From dab709d300f7b4166e856640257a71186b2e4129 Mon Sep 17 00:00:00 2001 From: Jarrod Norwell Date: Wed, 13 Mar 2024 03:27:27 +0800 Subject: [PATCH] Does this work? I hate Git sometimes --- CMakeModules/CopySudachiFFmpegDeps.cmake | 10 + CMakeModules/CopySudachiQt5Deps.cmake | 125 + CMakeModules/CopySudachiSDLDeps.cmake | 8 + dist/72-sudachi-input.rules | 19 + dist/org.sudachi_emu.sudachi.desktop | 16 + dist/org.sudachi_emu.sudachi.metainfo.xml | 62 + dist/org.sudachi_emu.sudachi.xml | 39 + .../default/icons/256x256/sudachi.png | Bin 0 -> 262282 bytes dist/sudachi.bmp | Bin 0 -> 262282 bytes dist/sudachi.desktop | 12 + dist/sudachi.icns | Bin 0 -> 722366 bytes dist/sudachi.ico | Bin 0 -> 9662 bytes dist/sudachi.manifest | 58 + externals/SDL | 2 +- externals/ffmpeg/ffmpeg | 2 +- hooks/pre-commit | 2 +- .../app/src/ea/res/drawable/ic_sudachi.xml | 22 + .../src/ea/res/drawable/ic_sudachi_full.xml | 12 + .../src/ea/res/drawable/ic_sudachi_title.xml | 24 + .../org/sudachi/sudachi_emu/NativeLibrary.kt | 462 ++ .../sudachi/sudachi_emu/SudachiApplication.kt | 55 + .../activities/EmulationActivity.kt | 509 ++ .../adapters/AbstractDiffAdapter.kt | 38 + .../adapters/AbstractListAdapter.kt | 98 + .../adapters/AbstractSingleSelectionList.kt | 105 + .../sudachi_emu/adapters/AddonAdapter.kt | 37 + .../sudachi_emu/adapters/AppletAdapter.kt | 74 + .../adapters/CabinetLauncherDialogAdapter.kt | 59 + .../sudachi_emu/adapters/DriverAdapter.kt | 59 + .../sudachi_emu/adapters/FolderAdapter.kt | 48 + .../sudachi_emu/adapters/GameAdapter.kt | 99 + .../adapters/GamePropertiesAdapter.kt | 115 + .../adapters/HomeSettingAdapter.kt | 84 + .../adapters/InstallableAdapter.kt | 35 + .../sudachi_emu/adapters/LicenseAdapter.kt | 39 + .../sudachi_emu/adapters/SetupAdapter.kt | 75 + .../applets/keyboard/SoftwareKeyboard.kt | 124 + .../keyboard/ui/KeyboardDialogFragment.kt | 100 + .../DiskShaderCacheProgress.kt | 51 + .../sudachi_emu/features/DocumentProvider.kt | 341 ++ .../sudachi_emu/features/input/NativeInput.kt | 416 ++ .../features/input/SudachiInputDevice.kt | 93 + .../features/input/SudachiVibrator.kt | 76 + .../features/input/model/AnalogDirection.kt | 11 + .../features/input/model/ButtonName.kt | 19 + .../features/input/model/InputType.kt | 13 + .../features/input/model/NativeAnalog.kt | 14 + .../features/input/model/NativeButton.kt | 38 + .../features/input/model/NativeTrigger.kt | 10 + .../features/input/model/NpadStyleIndex.kt | 30 + .../features/input/model/PlayerInput.kt | 83 + .../settings/model/AbstractBooleanSetting.kt | 9 + .../settings/model/AbstractByteSetting.kt | 9 + .../settings/model/AbstractFloatSetting.kt | 9 + .../settings/model/AbstractIntSetting.kt | 9 + .../settings/model/AbstractLongSetting.kt | 9 + .../settings/model/AbstractSetting.kt | 31 + .../settings/model/AbstractShortSetting.kt | 9 + .../settings/model/AbstractStringSetting.kt | 9 + .../features/settings/model/BooleanSetting.kt | 46 + .../features/settings/model/ByteSetting.kt | 25 + .../features/settings/model/FloatSetting.kt | 26 + .../features/settings/model/IntSetting.kt | 45 + .../features/settings/model/LongSetting.kt | 25 + .../features/settings/model/Settings.kt | 120 + .../features/settings/model/ShortSetting.kt | 25 + .../features/settings/model/StringSetting.kt | 26 + .../settings/model/view/AnalogInputSetting.kt | 31 + .../settings/model/view/ButtonInputSetting.kt | 29 + .../settings/model/view/DateTimeSetting.kt | 20 + .../settings/model/view/HeaderSetting.kt | 13 + .../model/view/InputProfileSetting.kt | 32 + .../settings/model/view/InputSetting.kt | 134 + .../model/view/IntSingleChoiceSetting.kt | 38 + .../model/view/ModifierInputSetting.kt | 31 + .../settings/model/view/RunnableSetting.kt | 19 + .../settings/model/view/SettingsItem.kt | 391 ++ .../model/view/SingleChoiceSetting.kt | 29 + .../settings/model/view/SliderSetting.kt | 42 + .../settings/model/view/StringInputSetting.kt | 22 + .../model/view/StringSingleChoiceSetting.kt | 35 + .../settings/model/view/SubmenuSetting.kt | 19 + .../settings/model/view/SwitchSetting.kt | 34 + .../settings/ui/InputDialogFragment.kt | 300 + .../settings/ui/InputProfileAdapter.kt | 68 + .../settings/ui/InputProfileDialogFragment.kt | 148 + .../ui/NewInputProfileDialogFragment.kt | 79 + .../features/settings/ui/SettingsActivity.kt | 171 + .../features/settings/ui/SettingsAdapter.kt | 434 ++ .../settings/ui/SettingsDialogFragment.kt | 301 + .../features/settings/ui/SettingsFragment.kt | 182 + .../settings/ui/SettingsFragmentPresenter.kt | 975 +++ .../settings/ui/SettingsSearchFragment.kt | 183 + .../features/settings/ui/SettingsViewModel.kt | 112 + .../ui/viewholder/DateTimeViewHolder.kt | 54 + .../ui/viewholder/HeaderViewHolder.kt | 30 + .../ui/viewholder/InputProfileViewHolder.kt | 34 + .../settings/ui/viewholder/InputViewHolder.kt | 60 + .../ui/viewholder/RunnableViewHolder.kt | 50 + .../ui/viewholder/SettingViewHolder.kt | 54 + .../ui/viewholder/SingleChoiceViewHolder.kt | 91 + .../ui/viewholder/SliderViewHolder.kt | 50 + .../ui/viewholder/StringInputViewHolder.kt | 45 + .../ui/viewholder/SubmenuViewHolder.kt | 46 + .../ui/viewholder/SwitchSettingViewHolder.kt | 51 + .../features/settings/utils/SettingsFile.kt | 29 + .../sudachi_emu/fragments/AboutFragment.kt | 124 + .../fragments/AddGameFolderDialogFragment.kt | 56 + .../sudachi_emu/fragments/AddonsFragment.kt | 205 + .../fragments/AppletLauncherFragment.kt | 106 + .../CabinetLauncherDialogFragment.kt | 41 + .../ContentTypeSelectionDialogFragment.kt | 68 + .../fragments/CoreErrorDialogFragment.kt | 47 + .../fragments/DriverManagerFragment.kt | 199 + .../fragments/DriversLoadingDialogFragment.kt | 50 + .../fragments/EarlyAccessFragment.kt | 87 + .../fragments/EmulationFragment.kt | 1048 ++++ .../GameFolderPropertiesDialogFragment.kt | 78 + .../fragments/GameFoldersFragment.kt | 116 + .../sudachi_emu/fragments/GameInfoFragment.kt | 179 + .../fragments/GamePropertiesFragment.kt | 424 ++ .../fragments/HomeSettingsFragment.kt | 437 ++ .../fragments/InstallableFragment.kt | 323 + .../fragments/LaunchGameDialogFragment.kt | 61 + .../LicenseBottomSheetDialogFragment.kt | 59 + .../sudachi_emu/fragments/LicensesFragment.kt | 132 + .../fragments/MessageDialogFragment.kt | 195 + .../PermissionDeniedDialogFragment.kt | 38 + .../fragments/ProgressDialogFragment.kt | 148 + .../fragments/ResetSettingsDialogFragment.kt | 30 + .../sudachi_emu/fragments/SearchFragment.kt | 218 + .../sudachi_emu/fragments/SetupFragment.kt | 396 ++ .../fragments/SetupWarningDialogFragment.kt | 86 + .../layout/AutofitGridLayoutManager.kt | 63 + .../org/sudachi/sudachi_emu/model/Addon.kt | 10 + .../sudachi_emu/model/AddonViewModel.kt | 97 + .../org/sudachi/sudachi_emu/model/Applet.kt | 55 + .../org/sudachi/sudachi_emu/model/Driver.kt | 27 + .../sudachi_emu/model/DriverViewModel.kt | 196 + .../sudachi_emu/model/EmulationViewModel.kt | 76 + .../org/sudachi/sudachi_emu/model/Game.kt | 103 + .../org/sudachi/sudachi_emu/model/GameDir.kt | 13 + .../sudachi_emu/model/GameProperties.kt | 36 + .../model/GameVerificationResult.kt | 15 + .../sudachi_emu/model/GamesViewModel.kt | 186 + .../sudachi/sudachi_emu/model/HomeSetting.kt | 18 + .../sudachi_emu/model/HomeViewModel.kt | 76 + .../sudachi_emu/model/InstallResult.kt | 15 + .../sudachi/sudachi_emu/model/Installable.kt | 13 + .../org/sudachi/sudachi_emu/model/License.kt | 16 + .../model/MessageDialogViewModel.kt | 16 + .../sudachi_emu/model/MinimalDocumentFile.kt | 11 + .../org/sudachi/sudachi_emu/model/Patch.kt | 16 + .../sudachi/sudachi_emu/model/PatchType.kt | 14 + .../sudachi_emu/model/SelectableItem.kt | 9 + .../sudachi_emu/model/SettingsViewModel.kt | 71 + .../sudachi/sudachi_emu/model/SetupPage.kt | 29 + .../sudachi_emu/model/TaskViewModel.kt | 83 + .../sudachi_emu/overlay/InputOverlay.kt | 1049 ++++ .../overlay/InputOverlayDrawableButton.kt | 151 + .../overlay/InputOverlayDrawableDpad.kt | 266 + .../overlay/InputOverlayDrawableJoystick.kt | 292 + .../overlay/model/OverlayControl.kt | 188 + .../overlay/model/OverlayControlData.kt | 19 + .../overlay/model/OverlayControlDefault.kt | 13 + .../overlay/model/OverlayLayout.kt | 10 + .../sudachi/sudachi_emu/ui/GamesFragment.kt | 160 + .../sudachi_emu/ui/main/MainActivity.kt | 692 +++ .../sudachi_emu/ui/main/ThemeProvider.kt | 11 + .../sudachi/sudachi_emu/utils/AddonUtil.kt | 8 + .../utils/DirectoryInitialization.kt | 213 + .../sudachi_emu/utils/DocumentsTree.kt | 137 + .../org/sudachi/sudachi_emu/utils/FileUtil.kt | 503 ++ .../sudachi/sudachi_emu/utils/GameHelper.kt | 152 + .../sudachi_emu/utils/GameIconUtils.kt | 109 + .../sudachi/sudachi_emu/utils/GameMetadata.kt | 22 + .../sudachi_emu/utils/GpuDriverHelper.kt | 229 + .../sudachi_emu/utils/GpuDriverMetadata.kt | 119 + .../sudachi/sudachi_emu/utils/InputHandler.kt | 94 + .../sudachi/sudachi_emu/utils/InsetsHelper.kt | 25 + .../sudachi_emu/utils/LifecycleUtils.kt | 38 + .../java/org/sudachi/sudachi_emu/utils/Log.kt | 31 + .../sudachi/sudachi_emu/utils/MemoryUtil.kt | 111 + .../sudachi/sudachi_emu/utils/NativeConfig.kt | 186 + .../sudachi/sudachi_emu/utils/NfcReader.kt | 171 + .../sudachi/sudachi_emu/utils/ParamPackage.kt | 141 + .../sudachi_emu/utils/PreferenceUtil.kt | 37 + .../sudachi_emu/utils/SerializableHelper.kt | 44 + .../sudachi/sudachi_emu/utils/ThemeHelper.kt | 105 + .../sudachi/sudachi_emu/utils/ViewUtils.kt | 93 + .../viewholder/AbstractViewHolder.kt | 18 + .../views/FixedRatioSurfaceView.kt | 64 + .../app/src/main/res/drawable/ic_sudachi.xml | 22 + .../src/main/res/drawable/ic_sudachi_full.xml | 12 + .../main/res/drawable/ic_sudachi_title.xml | 24 + .../main/res/values-night/sudachi_colors.xml | 37 + .../src/main/res/values/sudachi_colors.xml | 37 + src/dedicated_room/sudachi_room.cpp | 403 ++ src/dedicated_room/sudachi_room.rc | 20 + src/sudachi/CMakeLists.txt | 480 ++ src/sudachi/Info.plist | 48 + src/sudachi/about_dialog.cpp | 32 + src/sudachi/about_dialog.h | 22 + src/sudachi/aboutdialog.ui | 204 + src/sudachi/applets/qt_amiibo_settings.cpp | 274 + src/sudachi/applets/qt_amiibo_settings.h | 85 + src/sudachi/applets/qt_amiibo_settings.ui | 494 ++ src/sudachi/applets/qt_controller.cpp | 778 +++ src/sudachi/applets/qt_controller.h | 183 + src/sudachi/applets/qt_controller.ui | 2699 +++++++++ src/sudachi/applets/qt_error.cpp | 68 + src/sudachi/applets/qt_error.h | 34 + src/sudachi/applets/qt_profile_select.cpp | 260 + src/sudachi/applets/qt_profile_select.h | 87 + src/sudachi/applets/qt_software_keyboard.cpp | 1674 ++++++ src/sudachi/applets/qt_software_keyboard.h | 287 + src/sudachi/applets/qt_software_keyboard.ui | 3541 +++++++++++ src/sudachi/applets/qt_web_browser.cpp | 449 ++ src/sudachi/applets/qt_web_browser.h | 220 + src/sudachi/applets/qt_web_browser_scripts.h | 198 + src/sudachi/bootmanager.cpp | 1140 ++++ src/sudachi/bootmanager.h | 280 + src/sudachi/breakpad.cpp | 77 + src/sudachi/breakpad.h | 10 + src/sudachi/compatdb.cpp | 210 + src/sudachi/compatdb.h | 43 + src/sudachi/compatdb.ui | 398 ++ src/sudachi/compatibility_list.cpp | 17 + src/sudachi/compatibility_list.h | 16 + src/sudachi/configuration/config.cpp | 1309 ++++ src/sudachi/configuration/config.h | 179 + .../configuration/configuration_shared.cpp | 19 + .../configuration/configuration_shared.h | 27 + src/sudachi/configuration/configure.ui | 117 + .../configuration/configure_applets.cpp | 84 + src/sudachi/configuration/configure_applets.h | 48 + .../configuration/configure_applets.ui | 65 + src/sudachi/configuration/configure_audio.cpp | 278 + src/sudachi/configuration/configure_audio.h | 66 + src/sudachi/configuration/configure_audio.ui | 67 + .../configuration/configure_camera.cpp | 163 + src/sudachi/configuration/configure_camera.h | 56 + src/sudachi/configuration/configure_camera.ui | 170 + src/sudachi/configuration/configure_cpu.cpp | 114 + src/sudachi/configuration/configure_cpu.h | 55 + src/sudachi/configuration/configure_cpu.ui | 151 + .../configuration/configure_cpu_debug.cpp | 78 + .../configuration/configure_cpu_debug.h | 35 + .../configuration/configure_cpu_debug.ui | 223 + src/sudachi/configuration/configure_debug.cpp | 130 + src/sudachi/configuration/configure_debug.h | 37 + src/sudachi/configuration/configure_debug.ui | 576 ++ .../configure_debug_controller.cpp | 44 + .../configure_debug_controller.h | 45 + .../configure_debug_controller.ui | 77 + .../configuration/configure_debug_tab.cpp | 45 + .../configuration/configure_debug_tab.h | 41 + .../configuration/configure_debug_tab.ui | 31 + .../configuration/configure_dialog.cpp | 213 + src/sudachi/configuration/configure_dialog.h | 94 + .../configuration/configure_filesystem.cpp | 155 + .../configuration/configure_filesystem.h | 43 + .../configuration/configure_filesystem.ui | 244 + .../configuration/configure_general.cpp | 128 + src/sudachi/configuration/configure_general.h | 55 + .../configuration/configure_general.ui | 134 + .../configuration/configure_graphics.cpp | 552 ++ .../configuration/configure_graphics.h | 116 + .../configuration/configure_graphics.ui | 151 + .../configure_graphics_advanced.cpp | 82 + .../configure_graphics_advanced.h | 49 + .../configure_graphics_advanced.ui | 68 + .../configuration/configure_hotkeys.cpp | 423 ++ src/sudachi/configuration/configure_hotkeys.h | 74 + .../configuration/configure_hotkeys.ui | 76 + src/sudachi/configuration/configure_input.cpp | 309 + src/sudachi/configuration/configure_input.h | 81 + src/sudachi/configuration/configure_input.ui | 548 ++ .../configure_input_advanced.cpp | 204 + .../configuration/configure_input_advanced.h | 53 + .../configuration/configure_input_advanced.ui | 2821 +++++++++ .../configure_input_per_game.cpp | 115 + .../configuration/configure_input_per_game.h | 46 + .../configuration/configure_input_per_game.ui | 333 + .../configuration/configure_input_player.cpp | 1670 ++++++ .../configuration/configure_input_player.h | 228 + .../configuration/configure_input_player.ui | 3323 ++++++++++ .../configure_input_player_widget.cpp | 3007 ++++++++++ .../configure_input_player_widget.h | 230 + .../configure_input_profile_dialog.cpp | 39 + .../configure_input_profile_dialog.h | 43 + .../configure_input_profile_dialog.ui | 71 + .../configuration/configure_linux_tab.cpp | 75 + .../configuration/configure_linux_tab.h | 44 + .../configuration/configure_linux_tab.ui | 53 + .../configuration/configure_motion_touch.cpp | 326 + .../configuration/configure_motion_touch.h | 93 + .../configuration/configure_motion_touch.ui | 297 + .../configuration/configure_mouse_panning.cpp | 90 + .../configuration/configure_mouse_panning.h | 36 + .../configuration/configure_mouse_panning.ui | 212 + .../configuration/configure_network.cpp | 48 + src/sudachi/configuration/configure_network.h | 30 + .../configuration/configure_network.ui | 60 + .../configuration/configure_per_game.cpp | 204 + .../configuration/configure_per_game.h | 91 + .../configuration/configure_per_game.ui | 299 + .../configure_per_game_addons.cpp | 143 + .../configuration/configure_per_game_addons.h | 58 + .../configure_per_game_addons.ui | 41 + .../configure_profile_manager.cpp | 372 ++ .../configuration/configure_profile_manager.h | 90 + .../configure_profile_manager.ui | 181 + .../configuration/configure_ringcon.cpp | 497 ++ src/sudachi/configuration/configure_ringcon.h | 94 + .../configuration/configure_ringcon.ui | 374 ++ .../configuration/configure_system.cpp | 206 + src/sudachi/configuration/configure_system.h | 63 + src/sudachi/configuration/configure_system.ui | 105 + src/sudachi/configuration/configure_tas.cpp | 81 + src/sudachi/configuration/configure_tas.h | 39 + src/sudachi/configuration/configure_tas.ui | 182 + .../configure_touch_from_button.cpp | 617 ++ .../configure_touch_from_button.h | 86 + .../configure_touch_from_button.ui | 221 + .../configuration/configure_touch_widget.h | 61 + .../configure_touchscreen_advanced.cpp | 50 + .../configure_touchscreen_advanced.h | 32 + .../configure_touchscreen_advanced.ui | 162 + src/sudachi/configuration/configure_ui.cpp | 354 ++ src/sudachi/configuration/configure_ui.h | 58 + src/sudachi/configuration/configure_ui.ui | 268 + .../configuration/configure_vibration.cpp | 133 + .../configuration/configure_vibration.h | 51 + .../configuration/configure_vibration.ui | 553 ++ src/sudachi/configuration/configure_web.cpp | 180 + src/sudachi/configuration/configure_web.h | 39 + src/sudachi/configuration/configure_web.ui | 210 + src/sudachi/configuration/input_profiles.cpp | 134 + src/sudachi/configuration/input_profiles.h | 36 + src/sudachi/configuration/qt_config.cpp | 560 ++ src/sudachi/configuration/qt_config.h | 55 + .../configuration/shared_translation.cpp | 537 ++ .../configuration/shared_translation.h | 68 + src/sudachi/configuration/shared_widget.cpp | 802 +++ src/sudachi/configuration/shared_widget.h | 178 + src/sudachi/debugger/console.cpp | 49 + src/sudachi/debugger/console.h | 13 + src/sudachi/debugger/controller.cpp | 116 + src/sudachi/debugger/controller.h | 56 + src/sudachi/debugger/profiler.cpp | 229 + src/sudachi/debugger/profiler.h | 27 + src/sudachi/debugger/wait_tree.cpp | 431 ++ src/sudachi/debugger/wait_tree.h | 188 + src/sudachi/discord.h | 24 + src/sudachi/discord_impl.cpp | 117 + src/sudachi/discord_impl.h | 32 + src/sudachi/game_list.cpp | 970 +++ src/sudachi/game_list.h | 204 + src/sudachi/game_list_p.h | 408 ++ src/sudachi/game_list_worker.cpp | 485 ++ src/sudachi/game_list_worker.h | 94 + src/sudachi/hotkeys.cpp | 214 + src/sudachi/hotkeys.h | 127 + src/sudachi/install_dialog.cpp | 69 + src/sudachi/install_dialog.h | 34 + src/sudachi/loading_screen.cpp | 202 + src/sudachi/loading_screen.h | 94 + src/sudachi/loading_screen.ui | 164 + src/sudachi/main.cpp | 5343 +++++++++++++++++ src/sudachi/main.h | 580 ++ src/sudachi/main.ui | 483 ++ src/sudachi/mini_dump.cpp | 202 + src/sudachi/mini_dump.h | 19 + src/sudachi/multiplayer/chat_room.cpp | 508 ++ src/sudachi/multiplayer/chat_room.h | 76 + src/sudachi/multiplayer/chat_room.ui | 59 + src/sudachi/multiplayer/client_room.cpp | 115 + src/sudachi/multiplayer/client_room.h | 39 + src/sudachi/multiplayer/client_room.ui | 80 + src/sudachi/multiplayer/direct_connect.cpp | 137 + src/sudachi/multiplayer/direct_connect.h | 49 + src/sudachi/multiplayer/direct_connect.ui | 165 + src/sudachi/multiplayer/host_room.cpp | 264 + src/sudachi/multiplayer/host_room.h | 80 + src/sudachi/multiplayer/host_room.ui | 207 + src/sudachi/multiplayer/lobby.cpp | 439 ++ src/sudachi/multiplayer/lobby.h | 138 + src/sudachi/multiplayer/lobby.ui | 130 + src/sudachi/multiplayer/lobby_p.h | 268 + src/sudachi/multiplayer/message.cpp | 85 + src/sudachi/multiplayer/message.h | 72 + src/sudachi/multiplayer/moderation_dialog.cpp | 112 + src/sudachi/multiplayer/moderation_dialog.h | 43 + src/sudachi/multiplayer/moderation_dialog.ui | 84 + src/sudachi/multiplayer/state.cpp | 336 ++ src/sudachi/multiplayer/state.h | 111 + src/sudachi/multiplayer/validation.h | 67 + src/sudachi/play_time.cpp | 177 + src/sudachi/play_time.h | 68 + src/sudachi/play_time_manager.cpp | 182 + src/sudachi/play_time_manager.h | 50 + src/sudachi/precompiled_headers.h | 6 + src/sudachi/qt_common.cpp | 60 + src/sudachi/qt_common.h | 15 + src/sudachi/startup_checks.cpp | 197 + src/sudachi/startup_checks.h | 24 + src/sudachi/sudachi.qrc | 10 + src/sudachi/sudachi.rc | 22 + src/sudachi/uisettings.cpp | 112 + src/sudachi/uisettings.h | 279 + src/sudachi/util/clickable_label.cpp | 11 + src/sudachi/util/clickable_label.h | 21 + src/sudachi/util/controller_navigation.cpp | 179 + src/sudachi/util/controller_navigation.h | 50 + src/sudachi/util/limitable_input_dialog.cpp | 88 + src/sudachi/util/limitable_input_dialog.h | 40 + src/sudachi/util/overlay_dialog.cpp | 268 + src/sudachi/util/overlay_dialog.h | 108 + src/sudachi/util/overlay_dialog.ui | 404 ++ .../util/sequence_dialog/sequence_dialog.cpp | 38 + .../util/sequence_dialog/sequence_dialog.h | 23 + src/sudachi/util/url_request_interceptor.cpp | 33 + src/sudachi/util/url_request_interceptor.h | 29 + src/sudachi/util/util.cpp | 152 + src/sudachi/util/util.h | 29 + src/sudachi/vk_device_info.cpp | 67 + src/sudachi/vk_device_info.h | 36 + src/sudachi_cmd/CMakeLists.txt | 65 + src/sudachi_cmd/config.cpp | 279 + src/sudachi_cmd/config.h | 38 + src/sudachi_cmd/default_ini.h | 553 ++ .../emu_window/emu_window_sdl2.cpp | 254 + src/sudachi_cmd/emu_window/emu_window_sdl2.h | 95 + .../emu_window/emu_window_sdl2_gl.cpp | 153 + .../emu_window/emu_window_sdl2_gl.h | 37 + .../emu_window/emu_window_sdl2_null.cpp | 51 + .../emu_window/emu_window_sdl2_null.h | 26 + .../emu_window/emu_window_sdl2_vk.cpp | 93 + .../emu_window/emu_window_sdl2_vk.h | 26 + src/sudachi_cmd/precompiled_headers.h | 6 + src/sudachi_cmd/sdl_config.cpp | 262 + src/sudachi_cmd/sdl_config.h | 49 + src/sudachi_cmd/sudachi.cpp | 459 ++ src/sudachi_cmd/sudachi.rc | 20 + 445 files changed, 85216 insertions(+), 3 deletions(-) create mode 100644 CMakeModules/CopySudachiFFmpegDeps.cmake create mode 100644 CMakeModules/CopySudachiQt5Deps.cmake create mode 100644 CMakeModules/CopySudachiSDLDeps.cmake create mode 100644 dist/72-sudachi-input.rules create mode 100644 dist/org.sudachi_emu.sudachi.desktop create mode 100644 dist/org.sudachi_emu.sudachi.metainfo.xml create mode 100644 dist/org.sudachi_emu.sudachi.xml create mode 100644 dist/qt_themes/default/icons/256x256/sudachi.png create mode 100644 dist/sudachi.bmp create mode 100644 dist/sudachi.desktop create mode 100644 dist/sudachi.icns create mode 100644 dist/sudachi.ico create mode 100644 dist/sudachi.manifest create mode 100644 src/android/app/src/ea/res/drawable/ic_sudachi.xml create mode 100644 src/android/app/src/ea/res/drawable/ic_sudachi_full.xml create mode 100644 src/android/app/src/ea/res/drawable/ic_sudachi_title.xml create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/NativeLibrary.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/SudachiApplication.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/activities/EmulationActivity.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/adapters/AbstractDiffAdapter.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/adapters/AbstractListAdapter.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/adapters/AbstractSingleSelectionList.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/adapters/AddonAdapter.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/adapters/AppletAdapter.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/adapters/CabinetLauncherDialogAdapter.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/adapters/DriverAdapter.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/adapters/FolderAdapter.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/adapters/GameAdapter.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/adapters/GamePropertiesAdapter.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/adapters/HomeSettingAdapter.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/adapters/InstallableAdapter.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/adapters/LicenseAdapter.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/adapters/SetupAdapter.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/applets/keyboard/SoftwareKeyboard.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/applets/keyboard/ui/KeyboardDialogFragment.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/disk_shader_cache/DiskShaderCacheProgress.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/features/DocumentProvider.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/features/input/NativeInput.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/features/input/SudachiInputDevice.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/features/input/SudachiVibrator.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/features/input/model/AnalogDirection.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/features/input/model/ButtonName.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/features/input/model/InputType.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/features/input/model/NativeAnalog.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/features/input/model/NativeButton.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/features/input/model/NativeTrigger.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/features/input/model/NpadStyleIndex.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/features/input/model/PlayerInput.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/model/AbstractBooleanSetting.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/model/AbstractByteSetting.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/model/AbstractFloatSetting.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/model/AbstractIntSetting.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/model/AbstractLongSetting.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/model/AbstractSetting.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/model/AbstractShortSetting.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/model/AbstractStringSetting.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/model/BooleanSetting.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/model/ByteSetting.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/model/FloatSetting.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/model/IntSetting.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/model/LongSetting.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/model/Settings.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/model/ShortSetting.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/model/StringSetting.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/model/view/AnalogInputSetting.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/model/view/ButtonInputSetting.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/model/view/DateTimeSetting.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/model/view/HeaderSetting.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/model/view/InputProfileSetting.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/model/view/InputSetting.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/model/view/IntSingleChoiceSetting.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/model/view/ModifierInputSetting.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/model/view/RunnableSetting.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/model/view/SettingsItem.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/model/view/SingleChoiceSetting.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/model/view/SliderSetting.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/model/view/StringInputSetting.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/model/view/StringSingleChoiceSetting.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/model/view/SubmenuSetting.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/model/view/SwitchSetting.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/InputDialogFragment.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/InputProfileAdapter.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/InputProfileDialogFragment.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/NewInputProfileDialogFragment.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/SettingsActivity.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/SettingsAdapter.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/SettingsDialogFragment.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/SettingsFragment.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/SettingsFragmentPresenter.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/SettingsSearchFragment.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/SettingsViewModel.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/viewholder/DateTimeViewHolder.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/viewholder/HeaderViewHolder.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/viewholder/InputProfileViewHolder.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/viewholder/InputViewHolder.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/viewholder/RunnableViewHolder.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/viewholder/SettingViewHolder.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/viewholder/SingleChoiceViewHolder.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/viewholder/SliderViewHolder.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/viewholder/StringInputViewHolder.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/viewholder/SubmenuViewHolder.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/viewholder/SwitchSettingViewHolder.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/utils/SettingsFile.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/fragments/AboutFragment.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/fragments/AddGameFolderDialogFragment.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/fragments/AddonsFragment.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/fragments/AppletLauncherFragment.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/fragments/CabinetLauncherDialogFragment.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/fragments/ContentTypeSelectionDialogFragment.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/fragments/CoreErrorDialogFragment.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/fragments/DriverManagerFragment.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/fragments/DriversLoadingDialogFragment.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/fragments/EarlyAccessFragment.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/fragments/EmulationFragment.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/fragments/GameFolderPropertiesDialogFragment.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/fragments/GameFoldersFragment.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/fragments/GameInfoFragment.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/fragments/GamePropertiesFragment.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/fragments/HomeSettingsFragment.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/fragments/InstallableFragment.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/fragments/LaunchGameDialogFragment.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/fragments/LicenseBottomSheetDialogFragment.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/fragments/LicensesFragment.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/fragments/MessageDialogFragment.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/fragments/PermissionDeniedDialogFragment.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/fragments/ProgressDialogFragment.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/fragments/ResetSettingsDialogFragment.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/fragments/SearchFragment.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/fragments/SetupFragment.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/fragments/SetupWarningDialogFragment.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/layout/AutofitGridLayoutManager.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/model/Addon.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/model/AddonViewModel.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/model/Applet.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/model/Driver.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/model/DriverViewModel.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/model/EmulationViewModel.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/model/Game.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/model/GameDir.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/model/GameProperties.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/model/GameVerificationResult.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/model/GamesViewModel.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/model/HomeSetting.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/model/HomeViewModel.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/model/InstallResult.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/model/Installable.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/model/License.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/model/MessageDialogViewModel.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/model/MinimalDocumentFile.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/model/Patch.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/model/PatchType.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/model/SelectableItem.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/model/SettingsViewModel.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/model/SetupPage.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/model/TaskViewModel.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/overlay/InputOverlay.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/overlay/InputOverlayDrawableButton.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/overlay/InputOverlayDrawableDpad.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/overlay/InputOverlayDrawableJoystick.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/overlay/model/OverlayControl.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/overlay/model/OverlayControlData.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/overlay/model/OverlayControlDefault.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/overlay/model/OverlayLayout.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/ui/GamesFragment.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/ui/main/MainActivity.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/ui/main/ThemeProvider.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/utils/AddonUtil.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/utils/DirectoryInitialization.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/utils/DocumentsTree.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/utils/FileUtil.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/utils/GameHelper.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/utils/GameIconUtils.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/utils/GameMetadata.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/utils/GpuDriverHelper.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/utils/GpuDriverMetadata.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/utils/InputHandler.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/utils/InsetsHelper.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/utils/LifecycleUtils.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/utils/Log.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/utils/MemoryUtil.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/utils/NativeConfig.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/utils/NfcReader.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/utils/ParamPackage.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/utils/PreferenceUtil.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/utils/SerializableHelper.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/utils/ThemeHelper.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/utils/ViewUtils.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/viewholder/AbstractViewHolder.kt create mode 100644 src/android/app/src/main/java/org/sudachi/sudachi_emu/views/FixedRatioSurfaceView.kt create mode 100644 src/android/app/src/main/res/drawable/ic_sudachi.xml create mode 100644 src/android/app/src/main/res/drawable/ic_sudachi_full.xml create mode 100644 src/android/app/src/main/res/drawable/ic_sudachi_title.xml create mode 100644 src/android/app/src/main/res/values-night/sudachi_colors.xml create mode 100644 src/android/app/src/main/res/values/sudachi_colors.xml create mode 100644 src/dedicated_room/sudachi_room.cpp create mode 100644 src/dedicated_room/sudachi_room.rc create mode 100644 src/sudachi/CMakeLists.txt create mode 100644 src/sudachi/Info.plist create mode 100644 src/sudachi/about_dialog.cpp create mode 100644 src/sudachi/about_dialog.h create mode 100644 src/sudachi/aboutdialog.ui create mode 100644 src/sudachi/applets/qt_amiibo_settings.cpp create mode 100644 src/sudachi/applets/qt_amiibo_settings.h create mode 100644 src/sudachi/applets/qt_amiibo_settings.ui create mode 100644 src/sudachi/applets/qt_controller.cpp create mode 100644 src/sudachi/applets/qt_controller.h create mode 100644 src/sudachi/applets/qt_controller.ui create mode 100644 src/sudachi/applets/qt_error.cpp create mode 100644 src/sudachi/applets/qt_error.h create mode 100644 src/sudachi/applets/qt_profile_select.cpp create mode 100644 src/sudachi/applets/qt_profile_select.h create mode 100644 src/sudachi/applets/qt_software_keyboard.cpp create mode 100644 src/sudachi/applets/qt_software_keyboard.h create mode 100644 src/sudachi/applets/qt_software_keyboard.ui create mode 100644 src/sudachi/applets/qt_web_browser.cpp create mode 100644 src/sudachi/applets/qt_web_browser.h create mode 100644 src/sudachi/applets/qt_web_browser_scripts.h create mode 100644 src/sudachi/bootmanager.cpp create mode 100644 src/sudachi/bootmanager.h create mode 100644 src/sudachi/breakpad.cpp create mode 100644 src/sudachi/breakpad.h create mode 100644 src/sudachi/compatdb.cpp create mode 100644 src/sudachi/compatdb.h create mode 100644 src/sudachi/compatdb.ui create mode 100644 src/sudachi/compatibility_list.cpp create mode 100644 src/sudachi/compatibility_list.h create mode 100644 src/sudachi/configuration/config.cpp create mode 100644 src/sudachi/configuration/config.h create mode 100644 src/sudachi/configuration/configuration_shared.cpp create mode 100644 src/sudachi/configuration/configuration_shared.h create mode 100644 src/sudachi/configuration/configure.ui create mode 100644 src/sudachi/configuration/configure_applets.cpp create mode 100644 src/sudachi/configuration/configure_applets.h create mode 100644 src/sudachi/configuration/configure_applets.ui create mode 100644 src/sudachi/configuration/configure_audio.cpp create mode 100644 src/sudachi/configuration/configure_audio.h create mode 100644 src/sudachi/configuration/configure_audio.ui create mode 100644 src/sudachi/configuration/configure_camera.cpp create mode 100644 src/sudachi/configuration/configure_camera.h create mode 100644 src/sudachi/configuration/configure_camera.ui create mode 100644 src/sudachi/configuration/configure_cpu.cpp create mode 100644 src/sudachi/configuration/configure_cpu.h create mode 100644 src/sudachi/configuration/configure_cpu.ui create mode 100644 src/sudachi/configuration/configure_cpu_debug.cpp create mode 100644 src/sudachi/configuration/configure_cpu_debug.h create mode 100644 src/sudachi/configuration/configure_cpu_debug.ui create mode 100644 src/sudachi/configuration/configure_debug.cpp create mode 100644 src/sudachi/configuration/configure_debug.h create mode 100644 src/sudachi/configuration/configure_debug.ui create mode 100644 src/sudachi/configuration/configure_debug_controller.cpp create mode 100644 src/sudachi/configuration/configure_debug_controller.h create mode 100644 src/sudachi/configuration/configure_debug_controller.ui create mode 100644 src/sudachi/configuration/configure_debug_tab.cpp create mode 100644 src/sudachi/configuration/configure_debug_tab.h create mode 100644 src/sudachi/configuration/configure_debug_tab.ui create mode 100644 src/sudachi/configuration/configure_dialog.cpp create mode 100644 src/sudachi/configuration/configure_dialog.h create mode 100644 src/sudachi/configuration/configure_filesystem.cpp create mode 100644 src/sudachi/configuration/configure_filesystem.h create mode 100644 src/sudachi/configuration/configure_filesystem.ui create mode 100644 src/sudachi/configuration/configure_general.cpp create mode 100644 src/sudachi/configuration/configure_general.h create mode 100644 src/sudachi/configuration/configure_general.ui create mode 100644 src/sudachi/configuration/configure_graphics.cpp create mode 100644 src/sudachi/configuration/configure_graphics.h create mode 100644 src/sudachi/configuration/configure_graphics.ui create mode 100644 src/sudachi/configuration/configure_graphics_advanced.cpp create mode 100644 src/sudachi/configuration/configure_graphics_advanced.h create mode 100644 src/sudachi/configuration/configure_graphics_advanced.ui create mode 100644 src/sudachi/configuration/configure_hotkeys.cpp create mode 100644 src/sudachi/configuration/configure_hotkeys.h create mode 100644 src/sudachi/configuration/configure_hotkeys.ui create mode 100644 src/sudachi/configuration/configure_input.cpp create mode 100644 src/sudachi/configuration/configure_input.h create mode 100644 src/sudachi/configuration/configure_input.ui create mode 100644 src/sudachi/configuration/configure_input_advanced.cpp create mode 100644 src/sudachi/configuration/configure_input_advanced.h create mode 100644 src/sudachi/configuration/configure_input_advanced.ui create mode 100644 src/sudachi/configuration/configure_input_per_game.cpp create mode 100644 src/sudachi/configuration/configure_input_per_game.h create mode 100644 src/sudachi/configuration/configure_input_per_game.ui create mode 100644 src/sudachi/configuration/configure_input_player.cpp create mode 100644 src/sudachi/configuration/configure_input_player.h create mode 100644 src/sudachi/configuration/configure_input_player.ui create mode 100644 src/sudachi/configuration/configure_input_player_widget.cpp create mode 100644 src/sudachi/configuration/configure_input_player_widget.h create mode 100644 src/sudachi/configuration/configure_input_profile_dialog.cpp create mode 100644 src/sudachi/configuration/configure_input_profile_dialog.h create mode 100644 src/sudachi/configuration/configure_input_profile_dialog.ui create mode 100644 src/sudachi/configuration/configure_linux_tab.cpp create mode 100644 src/sudachi/configuration/configure_linux_tab.h create mode 100644 src/sudachi/configuration/configure_linux_tab.ui create mode 100644 src/sudachi/configuration/configure_motion_touch.cpp create mode 100644 src/sudachi/configuration/configure_motion_touch.h create mode 100644 src/sudachi/configuration/configure_motion_touch.ui create mode 100644 src/sudachi/configuration/configure_mouse_panning.cpp create mode 100644 src/sudachi/configuration/configure_mouse_panning.h create mode 100644 src/sudachi/configuration/configure_mouse_panning.ui create mode 100644 src/sudachi/configuration/configure_network.cpp create mode 100644 src/sudachi/configuration/configure_network.h create mode 100644 src/sudachi/configuration/configure_network.ui create mode 100644 src/sudachi/configuration/configure_per_game.cpp create mode 100644 src/sudachi/configuration/configure_per_game.h create mode 100644 src/sudachi/configuration/configure_per_game.ui create mode 100644 src/sudachi/configuration/configure_per_game_addons.cpp create mode 100644 src/sudachi/configuration/configure_per_game_addons.h create mode 100644 src/sudachi/configuration/configure_per_game_addons.ui create mode 100644 src/sudachi/configuration/configure_profile_manager.cpp create mode 100644 src/sudachi/configuration/configure_profile_manager.h create mode 100644 src/sudachi/configuration/configure_profile_manager.ui create mode 100644 src/sudachi/configuration/configure_ringcon.cpp create mode 100644 src/sudachi/configuration/configure_ringcon.h create mode 100644 src/sudachi/configuration/configure_ringcon.ui create mode 100644 src/sudachi/configuration/configure_system.cpp create mode 100644 src/sudachi/configuration/configure_system.h create mode 100644 src/sudachi/configuration/configure_system.ui create mode 100644 src/sudachi/configuration/configure_tas.cpp create mode 100644 src/sudachi/configuration/configure_tas.h create mode 100644 src/sudachi/configuration/configure_tas.ui create mode 100644 src/sudachi/configuration/configure_touch_from_button.cpp create mode 100644 src/sudachi/configuration/configure_touch_from_button.h create mode 100644 src/sudachi/configuration/configure_touch_from_button.ui create mode 100644 src/sudachi/configuration/configure_touch_widget.h create mode 100644 src/sudachi/configuration/configure_touchscreen_advanced.cpp create mode 100644 src/sudachi/configuration/configure_touchscreen_advanced.h create mode 100644 src/sudachi/configuration/configure_touchscreen_advanced.ui create mode 100644 src/sudachi/configuration/configure_ui.cpp create mode 100644 src/sudachi/configuration/configure_ui.h create mode 100644 src/sudachi/configuration/configure_ui.ui create mode 100644 src/sudachi/configuration/configure_vibration.cpp create mode 100644 src/sudachi/configuration/configure_vibration.h create mode 100644 src/sudachi/configuration/configure_vibration.ui create mode 100644 src/sudachi/configuration/configure_web.cpp create mode 100644 src/sudachi/configuration/configure_web.h create mode 100644 src/sudachi/configuration/configure_web.ui create mode 100644 src/sudachi/configuration/input_profiles.cpp create mode 100644 src/sudachi/configuration/input_profiles.h create mode 100644 src/sudachi/configuration/qt_config.cpp create mode 100644 src/sudachi/configuration/qt_config.h create mode 100644 src/sudachi/configuration/shared_translation.cpp create mode 100644 src/sudachi/configuration/shared_translation.h create mode 100644 src/sudachi/configuration/shared_widget.cpp create mode 100644 src/sudachi/configuration/shared_widget.h create mode 100644 src/sudachi/debugger/console.cpp create mode 100644 src/sudachi/debugger/console.h create mode 100644 src/sudachi/debugger/controller.cpp create mode 100644 src/sudachi/debugger/controller.h create mode 100644 src/sudachi/debugger/profiler.cpp create mode 100644 src/sudachi/debugger/profiler.h create mode 100644 src/sudachi/debugger/wait_tree.cpp create mode 100644 src/sudachi/debugger/wait_tree.h create mode 100644 src/sudachi/discord.h create mode 100644 src/sudachi/discord_impl.cpp create mode 100644 src/sudachi/discord_impl.h create mode 100644 src/sudachi/game_list.cpp create mode 100644 src/sudachi/game_list.h create mode 100644 src/sudachi/game_list_p.h create mode 100644 src/sudachi/game_list_worker.cpp create mode 100644 src/sudachi/game_list_worker.h create mode 100644 src/sudachi/hotkeys.cpp create mode 100644 src/sudachi/hotkeys.h create mode 100644 src/sudachi/install_dialog.cpp create mode 100644 src/sudachi/install_dialog.h create mode 100644 src/sudachi/loading_screen.cpp create mode 100644 src/sudachi/loading_screen.h create mode 100644 src/sudachi/loading_screen.ui create mode 100644 src/sudachi/main.cpp create mode 100644 src/sudachi/main.h create mode 100644 src/sudachi/main.ui create mode 100644 src/sudachi/mini_dump.cpp create mode 100644 src/sudachi/mini_dump.h create mode 100644 src/sudachi/multiplayer/chat_room.cpp create mode 100644 src/sudachi/multiplayer/chat_room.h create mode 100644 src/sudachi/multiplayer/chat_room.ui create mode 100644 src/sudachi/multiplayer/client_room.cpp create mode 100644 src/sudachi/multiplayer/client_room.h create mode 100644 src/sudachi/multiplayer/client_room.ui create mode 100644 src/sudachi/multiplayer/direct_connect.cpp create mode 100644 src/sudachi/multiplayer/direct_connect.h create mode 100644 src/sudachi/multiplayer/direct_connect.ui create mode 100644 src/sudachi/multiplayer/host_room.cpp create mode 100644 src/sudachi/multiplayer/host_room.h create mode 100644 src/sudachi/multiplayer/host_room.ui create mode 100644 src/sudachi/multiplayer/lobby.cpp create mode 100644 src/sudachi/multiplayer/lobby.h create mode 100644 src/sudachi/multiplayer/lobby.ui create mode 100644 src/sudachi/multiplayer/lobby_p.h create mode 100644 src/sudachi/multiplayer/message.cpp create mode 100644 src/sudachi/multiplayer/message.h create mode 100644 src/sudachi/multiplayer/moderation_dialog.cpp create mode 100644 src/sudachi/multiplayer/moderation_dialog.h create mode 100644 src/sudachi/multiplayer/moderation_dialog.ui create mode 100644 src/sudachi/multiplayer/state.cpp create mode 100644 src/sudachi/multiplayer/state.h create mode 100644 src/sudachi/multiplayer/validation.h create mode 100644 src/sudachi/play_time.cpp create mode 100644 src/sudachi/play_time.h create mode 100644 src/sudachi/play_time_manager.cpp create mode 100644 src/sudachi/play_time_manager.h create mode 100644 src/sudachi/precompiled_headers.h create mode 100644 src/sudachi/qt_common.cpp create mode 100644 src/sudachi/qt_common.h create mode 100644 src/sudachi/startup_checks.cpp create mode 100644 src/sudachi/startup_checks.h create mode 100644 src/sudachi/sudachi.qrc create mode 100644 src/sudachi/sudachi.rc create mode 100644 src/sudachi/uisettings.cpp create mode 100644 src/sudachi/uisettings.h create mode 100644 src/sudachi/util/clickable_label.cpp create mode 100644 src/sudachi/util/clickable_label.h create mode 100644 src/sudachi/util/controller_navigation.cpp create mode 100644 src/sudachi/util/controller_navigation.h create mode 100644 src/sudachi/util/limitable_input_dialog.cpp create mode 100644 src/sudachi/util/limitable_input_dialog.h create mode 100644 src/sudachi/util/overlay_dialog.cpp create mode 100644 src/sudachi/util/overlay_dialog.h create mode 100644 src/sudachi/util/overlay_dialog.ui create mode 100644 src/sudachi/util/sequence_dialog/sequence_dialog.cpp create mode 100644 src/sudachi/util/sequence_dialog/sequence_dialog.h create mode 100644 src/sudachi/util/url_request_interceptor.cpp create mode 100644 src/sudachi/util/url_request_interceptor.h create mode 100644 src/sudachi/util/util.cpp create mode 100644 src/sudachi/util/util.h create mode 100644 src/sudachi/vk_device_info.cpp create mode 100644 src/sudachi/vk_device_info.h create mode 100644 src/sudachi_cmd/CMakeLists.txt create mode 100644 src/sudachi_cmd/config.cpp create mode 100644 src/sudachi_cmd/config.h create mode 100644 src/sudachi_cmd/default_ini.h create mode 100644 src/sudachi_cmd/emu_window/emu_window_sdl2.cpp create mode 100644 src/sudachi_cmd/emu_window/emu_window_sdl2.h create mode 100644 src/sudachi_cmd/emu_window/emu_window_sdl2_gl.cpp create mode 100644 src/sudachi_cmd/emu_window/emu_window_sdl2_gl.h create mode 100644 src/sudachi_cmd/emu_window/emu_window_sdl2_null.cpp create mode 100644 src/sudachi_cmd/emu_window/emu_window_sdl2_null.h create mode 100644 src/sudachi_cmd/emu_window/emu_window_sdl2_vk.cpp create mode 100644 src/sudachi_cmd/emu_window/emu_window_sdl2_vk.h create mode 100644 src/sudachi_cmd/precompiled_headers.h create mode 100644 src/sudachi_cmd/sdl_config.cpp create mode 100644 src/sudachi_cmd/sdl_config.h create mode 100644 src/sudachi_cmd/sudachi.cpp create mode 100644 src/sudachi_cmd/sudachi.rc diff --git a/CMakeModules/CopySudachiFFmpegDeps.cmake b/CMakeModules/CopySudachiFFmpegDeps.cmake new file mode 100644 index 0000000..fddca9b --- /dev/null +++ b/CMakeModules/CopySudachiFFmpegDeps.cmake @@ -0,0 +1,10 @@ +# SPDX-FileCopyrightText: 2020 sudachi Emulator Project +# SPDX-License-Identifier: GPL-2.0-or-later + +function(copy_sudachi_FFmpeg_deps target_dir) + include(WindowsCopyFiles) + set(DLL_DEST "$/") + file(READ "${FFmpeg_PATH}/requirements.txt" FFmpeg_REQUIRED_DLLS) + string(STRIP "${FFmpeg_REQUIRED_DLLS}" FFmpeg_REQUIRED_DLLS) + windows_copy_files(${target_dir} ${FFmpeg_LIBRARY_DIR} ${DLL_DEST} ${FFmpeg_REQUIRED_DLLS}) +endfunction(copy_sudachi_FFmpeg_deps) diff --git a/CMakeModules/CopySudachiQt5Deps.cmake b/CMakeModules/CopySudachiQt5Deps.cmake new file mode 100644 index 0000000..992864c --- /dev/null +++ b/CMakeModules/CopySudachiQt5Deps.cmake @@ -0,0 +1,125 @@ +# SPDX-FileCopyrightText: 2016 Citra Emulator Project +# SPDX-License-Identifier: GPL-2.0-or-later + +function(copy_sudachi_Qt5_deps target_dir) + include(WindowsCopyFiles) + if (MSVC) + set(DLL_DEST "$/") + set(Qt5_DLL_DIR "${Qt5_DIR}/../../../bin") + else() + set(DLL_DEST "${CMAKE_BINARY_DIR}/bin/") + set(Qt5_DLL_DIR "${Qt5_DIR}/../../../lib/") + endif() + set(Qt5_PLATFORMS_DIR "${Qt5_DIR}/../../../plugins/platforms/") + set(Qt5_PLATFORMTHEMES_DIR "${Qt5_DIR}/../../../plugins/platformthemes/") + set(Qt5_PLATFORMINPUTCONTEXTS_DIR "${Qt5_DIR}/../../../plugins/platforminputcontexts/") + set(Qt5_MEDIASERVICE_DIR "${Qt5_DIR}/../../../plugins/mediaservice/") + set(Qt5_XCBGLINTEGRATIONS_DIR "${Qt5_DIR}/../../../plugins/xcbglintegrations/") + set(Qt5_STYLES_DIR "${Qt5_DIR}/../../../plugins/styles/") + set(Qt5_IMAGEFORMATS_DIR "${Qt5_DIR}/../../../plugins/imageformats/") + set(Qt5_RESOURCES_DIR "${Qt5_DIR}/../../../resources/") + set(PLATFORMS ${DLL_DEST}plugins/platforms/) + set(MEDIASERVICE ${DLL_DEST}mediaservice/) + set(STYLES ${DLL_DEST}plugins/styles/) + set(IMAGEFORMATS ${DLL_DEST}plugins/imageformats/) + if (MSVC) + windows_copy_files(${target_dir} ${Qt5_DLL_DIR} ${DLL_DEST} + Qt5Core$<$:d>.* + Qt5Gui$<$:d>.* + Qt5Widgets$<$:d>.* + Qt5Network$<$:d>.* + ) + if (SUDACHI_USE_QT_MULTIMEDIA) + windows_copy_files(${target_dir} ${Qt5_DLL_DIR} ${DLL_DEST} + Qt5Multimedia$<$:d>.* + ) + endif() + if (SUDACHI_USE_QT_WEB_ENGINE) + windows_copy_files(${target_dir} ${Qt5_DLL_DIR} ${DLL_DEST} + Qt5Network$<$:d>.* + Qt5Positioning$<$:d>.* + Qt5PrintSupport$<$:d>.* + Qt5Qml$<$:d>.* + Qt5QmlModels$<$:d>.* + Qt5Quick$<$:d>.* + Qt5QuickWidgets$<$:d>.* + Qt5WebChannel$<$:d>.* + Qt5WebEngineCore$<$:d>.* + Qt5WebEngineWidgets$<$:d>.* + QtWebEngineProcess$<$:d>.* + ) + + windows_copy_files(${target_dir} ${Qt5_RESOURCES_DIR} ${DLL_DEST} + icudtl.dat + qtwebengine_devtools_resources.pak + qtwebengine_resources.pak + qtwebengine_resources_100p.pak + qtwebengine_resources_200p.pak + ) + endif () + windows_copy_files(sudachi ${Qt5_PLATFORMS_DIR} ${PLATFORMS} qwindows$<$:d>.*) + windows_copy_files(sudachi ${Qt5_STYLES_DIR} ${STYLES} qwindowsvistastyle$<$:d>.*) + windows_copy_files(sudachi ${Qt5_IMAGEFORMATS_DIR} ${IMAGEFORMATS} + qjpeg$<$:d>.* + qgif$<$:d>.* + ) + windows_copy_files(sudachi ${Qt5_MEDIASERVICE_DIR} ${MEDIASERVICE} + dsengine$<$:d>.* + wmfengine$<$:d>.* + ) + else() + set(Qt5_DLLS + "${Qt5_DLL_DIR}libQt5Core.so.5" + "${Qt5_DLL_DIR}libQt5DBus.so.5" + "${Qt5_DLL_DIR}libQt5Gui.so.5" + "${Qt5_DLL_DIR}libQt5Widgets.so.5" + "${Qt5_DLL_DIR}libQt5XcbQpa.so.5" + "${Qt5_DLL_DIR}libicudata.so.60" + "${Qt5_DLL_DIR}libicui18n.so.60" + "${Qt5_DLL_DIR}libicuuc.so.60" + ) + set(Qt5_IMAGEFORMAT_DLLS + "${Qt5_IMAGEFORMATS_DIR}libqjpeg.so" + "${Qt5_IMAGEFORMATS_DIR}libqgif.so" + "${Qt5_IMAGEFORMATS_DIR}libqico.so" + ) + set(Qt5_PLATFORMTHEME_DLLS + "${Qt5_PLATFORMTHEMES_DIR}libqgtk3.so" + "${Qt5_PLATFORMTHEMES_DIR}libqxdgdesktopportal.so" + ) + set(Qt5_PLATFORM_DLLS + "${Qt5_PLATFORMS_DIR}libqxcb.so" + ) + set(Qt5_PLATFORMINPUTCONTEXT_DLLS + "${Qt5_PLATFORMINPUTCONTEXTS_DIR}libcomposeplatforminputcontextplugin.so" + "${Qt5_PLATFORMINPUTCONTEXTS_DIR}libibusplatforminputcontextplugin.so" + ) + set(Qt5_XCBGLINTEGRATION_DLLS + "${Qt5_XCBGLINTEGRATIONS_DIR}libqxcb-glx-integration.so" + ) + foreach(LIB ${Qt5_DLLS}) + file(COPY ${LIB} DESTINATION "${DLL_DEST}/lib" FOLLOW_SYMLINK_CHAIN) + endforeach() + foreach(LIB ${Qt5_IMAGEFORMAT_DLLS}) + file(COPY ${LIB} DESTINATION "${DLL_DEST}plugins/imageformats/" FOLLOW_SYMLINK_CHAIN) + endforeach() + foreach(LIB ${Qt5_PLATFORMTHEME_DLLS}) + file(COPY ${LIB} DESTINATION "${DLL_DEST}plugins/platformthemes/" FOLLOW_SYMLINK_CHAIN) + endforeach() + foreach(LIB ${Qt5_PLATFORM_DLLS}) + file(COPY ${LIB} DESTINATION "${DLL_DEST}plugins/platforms/" FOLLOW_SYMLINK_CHAIN) + endforeach() + foreach(LIB ${Qt5_PLATFORMINPUTCONTEXT_DLLS}) + file(COPY ${LIB} DESTINATION "${DLL_DEST}plugins/platforminputcontexts/" FOLLOW_SYMLINK_CHAIN) + endforeach() + foreach(LIB ${Qt5_XCBGLINTEGRATION_DLLS}) + file(COPY ${LIB} DESTINATION "${DLL_DEST}plugins/xcbglintegrations/" FOLLOW_SYMLINK_CHAIN) + endforeach() + + endif() + # Create an empty qt.conf file. Qt will detect that this file exists, and use the folder that its in as the root folder. + # This way it'll look for plugins in the root/plugins/ folder + add_custom_command(TARGET sudachi POST_BUILD + COMMAND ${CMAKE_COMMAND} -E touch ${DLL_DEST}qt.conf + ) +endfunction(copy_sudachi_Qt5_deps) diff --git a/CMakeModules/CopySudachiSDLDeps.cmake b/CMakeModules/CopySudachiSDLDeps.cmake new file mode 100644 index 0000000..71d4952 --- /dev/null +++ b/CMakeModules/CopySudachiSDLDeps.cmake @@ -0,0 +1,8 @@ +# SPDX-FileCopyrightText: 2016 Citra Emulator Project +# SPDX-License-Identifier: GPL-2.0-or-later + +function(copy_sudachi_SDL_deps target_dir) + include(WindowsCopyFiles) + set(DLL_DEST "$/") + windows_copy_files(${target_dir} ${SDL2_DLL_DIR} ${DLL_DEST} SDL2.dll) +endfunction(copy_sudachi_SDL_deps) diff --git a/dist/72-sudachi-input.rules b/dist/72-sudachi-input.rules new file mode 100644 index 0000000..57c8d59 --- /dev/null +++ b/dist/72-sudachi-input.rules @@ -0,0 +1,19 @@ +# SPDX-FileCopyrightText: 2023 sudachi Emulator Project +# SPDX-License-Identifier: GPL-2.0-or-later + +# Allow systemd-logind to manage user access to hidraw with this file +# On most systems, this file should be installed to /etc/udev/rules.d/72-sudachi-input.rules +# Consult your distro if this is not the case + +# Switch Pro Controller (USB/Bluetooth) +KERNEL=="hidraw*", ATTRS{idVendor}=="057e", ATTRS{idProduct}=="2009", MODE="0660", TAG+="uaccess" +KERNEL=="hidraw*", KERNELS=="*057e:2009*", MODE="0660", TAG+="uaccess" + +# Joy-Con L (Bluetooth) +KERNEL=="hidraw*", KERNELS=="*057e:2006*", MODE="0660", TAG+="uaccess" + +# Joy-Con R (Bluetooth) +KERNEL=="hidraw*", KERNELS=="*057e:2007*", MODE="0660", TAG+="uaccess" + +# Joy-Con Charging Grip (USB) +KERNEL=="hidraw*", ATTRS{idVendor}=="057e", ATTRS{idProduct}=="200e", MODE="0660", TAG+="uaccess" diff --git a/dist/org.sudachi_emu.sudachi.desktop b/dist/org.sudachi_emu.sudachi.desktop new file mode 100644 index 0000000..278ebd2 --- /dev/null +++ b/dist/org.sudachi_emu.sudachi.desktop @@ -0,0 +1,16 @@ +# SPDX-FileCopyrightText: 2018 sudachi Emulator Project +# SPDX-License-Identifier: GPL-2.0-or-later + +[Desktop Entry] +Version=1.0 +Type=Application +Name=sudachi +GenericName=Switch Emulator +Comment=Nintendo Switch video game console emulator +Icon=org.sudachi_emu.sudachi +TryExec=sudachi +Exec=sudachi %f +Categories=Game;Emulator;Qt; +MimeType=application/x-nx-nro;application/x-nx-nso;application/x-nx-nsp;application/x-nx-xci; +Keywords=Nintendo;Switch; +StartupWMClass=sudachi diff --git a/dist/org.sudachi_emu.sudachi.metainfo.xml b/dist/org.sudachi_emu.sudachi.metainfo.xml new file mode 100644 index 0000000..888da98 --- /dev/null +++ b/dist/org.sudachi_emu.sudachi.metainfo.xml @@ -0,0 +1,62 @@ + + + + + + org.sudachi_emu.sudachi + CC0-1.0 + sudachi + Nintendo Switch emulator + +

sudachi is the world's most popular, open-source, Nintendo Switch emulator — started by the creators of Citra.

+

The emulator is capable of running most commercial games at full speed, provided you meet the necessary hardware requirements.

+

For a full list of games sudachi support, please visit our Compatibility page.

+

Check out our website for the latest news on exciting features, monthly progress reports, and more!

+
+ + Game + Emulator + + + switch + emulator + + https://sudachi-emu.org/ + https://github.com/sudachi-emu/sudachi/issues + https://sudachi-emu.org/wiki/faq/ + https://sudachi-emu.org/wiki/home/ + https://sudachi-emu.org/donate/ + https://www.transifex.com/projects/p/sudachi + https://community.citra-emu.org/ + https://github.com/sudachi-emu/sudachi + https://sudachi-emu.org/wiki/contributing/ + org.sudachi_emu.sudachi.desktop + + sudachi + sudachi-cmd + + + pointing + keyboard + gamepad + + + 8192 + + + 16384 + + GPL-3.0-or-later + sudachi Emulator Team + + + https://raw.githubusercontent.com/sudachi-emu/sudachi-emu.github.io/master/images/screenshots/001-Super%20Mario%20Odyssey%20.png + https://raw.githubusercontent.com/sudachi-emu/sudachi-emu.github.io/master/images/screenshots/004-The%20Legend%20of%20Zelda%20Skyward%20Sword%20HD.png + https://raw.githubusercontent.com/sudachi-emu/sudachi-emu.github.io/master/images/screenshots/007-Pokemon%20Sword.png + https://raw.githubusercontent.com/sudachi-emu/sudachi-emu.github.io/master/images/screenshots/010-Hyrule%20Warriors%20Age%20of%20Calamity.png + https://raw.githubusercontent.com/sudachi-emu/sudachi-emu.github.io/master/images/screenshots/039-Pok%C3%A9mon%20Mystery%20Dungeon%20Rescue%20Team%20DX.png.png.png + +
diff --git a/dist/org.sudachi_emu.sudachi.xml b/dist/org.sudachi_emu.sudachi.xml new file mode 100644 index 0000000..a1839d7 --- /dev/null +++ b/dist/org.sudachi_emu.sudachi.xml @@ -0,0 +1,39 @@ + + + + + + + Nintendo Switch homebrew executable + NRO + + + + + + + Nintendo Switch homebrew executable + NSO + + + + + + + Nintendo Switch Package + NSP + + + + + + + Nintendo Switch Card Image + XCI + + + + diff --git a/dist/qt_themes/default/icons/256x256/sudachi.png b/dist/qt_themes/default/icons/256x256/sudachi.png new file mode 100644 index 0000000000000000000000000000000000000000..a8702dbb67e85843f71f0cb163ca80f21c1af36c GIT binary patch literal 262282 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 literal 0 HcmV?d00001 diff --git a/dist/sudachi.bmp b/dist/sudachi.bmp new file mode 100644 index 0000000000000000000000000000000000000000..a8702dbb67e85843f71f0cb163ca80f21c1af36c GIT binary patch literal 262282 zcmeFacf4N3bvEkn-uvJEX0N^9*5&Vh z&HwWn{=FOStqlAx@BY_ohQH?jW*qsi|9@X2PNMRkd(CV7KgL{ZSNCOEUTvZOD=BvQ zsakimhFp{SKpS=a8Z5DOK826@muT@!`(Lr833R0#?YqvA`3#A&1ZPqp(Sq5 z(bZmFm@;V{jVn$18rHnN`sL`9ORv_B)>A(%Z_{7Rb99y4#{Itnoxal7dg`bBD3_j=D~fAf`Wh}OS1qlt;}`66zc*NCKdcPU ze%U^1Sbg@7VbA~2|3jQf9jl^`?CC1YhPcE za({;7C$_H$`+jcDw6DR>l}qvj(@9$7|0KPr?5iUU^1l~jc{Pte;y*Xu-i^OEb9E-m zM*Qdc9`>D=l=k0m{Jq6=Z_0cAU)3wjoJl_TQ`34ikG~lIX97bnKaH0U|1W>v&jiob zI{t{|TNYNv=yf`7FaNqxKI#imo(9=?T_z*9-LX{e|EiBEtJ8R`p<`%Or=fieeyw%m z*pm8jTu6^)Oy6w2giG^Y&EwyNyFWUa_dbrlvDP)vHBaZU=KA@y+j9*1KX|Vuo{3}2 z)5z70WlGmLD_4+LbN@r#S8)6h!KOhxp^x$ zx3;{co2KhrYkKBK{0rY|x!(9nt6U!~EtXYh(=}{qX`1?)r#{ouu*TIdDr3{exN76i zV^{lPeQk?=8rHNiuKQN#0h9sovvOHq^7vOe+K$r6%4a;KWz;WM*V3?MHQmuK2fX<3br7VMYeU-PZcwl%K( zU|iGFG#f7}r}@^`a+;>?sjunuE&tnk8n(XHPkm1(Kd!Wl#%)>c2lF&NO|$W$a++^_ zEvISPw)&b*zw-Da_HS*Jve_7wmbRsFrLA%Gk^bH>ZcU*KKpEs^_}4m#k21yW_a)tr zy-8^LaHP#Nb#YzTMp7>Fv2CAaipn9as4XpvyjS!1v;DvChj{Wa2&i{8p{|{-b zm)2#TO|xOGSLACs_0uw%ZheN;rR94QSDLm?Y1*rM{JV~>bi0m1{|{?t{nD}K*U*;* zZzXlh&Ap{(!)bjR*K+FHJn|fQk7=4u-#S~qw_&Ae>y@Vcd5ymt`~4Nb=o z%zD{*D;FkR(&t$~>0A8UdX4WLU9uMW{H#Ak>6)hVZ{r#+^3(D~Wi*XtHQXCt>(PHT zkDu754Cu|+S=NqUeJ%CV^$GEE z`0sV!Qu?*@>6I*#_OD?4x-S!(eHuS$_QH7?06hckZ4df?Z|c`nmUG;TKK-f6zM9AH z^?&UDe~$B9W&I=m{~Y^%Ci+z4_;o*Ca~pK>Gtq91G_>9GPHd3#|0~({pHo}gkNEqK zKc_y`+Lu3T{8!<_|MvK=Vf@4%b#Y>`)-_Mdk$v}~nvMRZv&P1m^kN;~x{9d(wL z=2_q7SF59Ci^^Si?-ob?W1g0?ewwav^=(-ju9RQ2{;bP7>MSkIv%bx*R!7SgmE-t{ zJzxt7T;nzQQ zKl{xK?&mN5)cxYOKNt6#-~Y} zlva+;_M=PP-s7tU`{iUnxw>a6eeG>6?zWFy?>_m}r`&gc@@@CW|M)|tC4SlR{_DT~ zE7C3sGc9$jkfyy{@z}Y7Hq(BjaqW}R(75_q-sY!aEo*(PXVYp4EA3axCr^FvC*Khs zyY0i*;~9HXi99${{9KNXVxK-SN9>g=+w1fXV2@YF{hndqx8hrYU$ zCgYl>LdS(d(Lr>fBs?M{afxo|LZ?rDK;qEPAxq9KV?egUti~>>_WUk8UOL0 z{~>$L6JL15U3T}`dF-V8t8x13D)4p@?ue}`AKdNM9`HJL#J|$fHVj509nG`8hP8Yj zeT#qMm;GZu*RY>`q*<^}dE z&$v%N_l$eyYfnM{d(u7mPalDtdBi>V>?wEp(dbPZVvuU?v5vKamPP($UXS^6YileoOGu@d$+s$nZr^)E3fse zuQb#z%3~Vy)s-uAlRNRLBjVEjRqlh*_dds()z`2sqwV`V&e1*39FZ|U{Dphn1E0Ug z-TkRMDvaIFE5~E!mvhd!=Ni!Ix}5&>3HR`4?w2)t@{1ppHRM`yO}VyQ<8S}yTkiRv zeqYx9w|{u?jPMfm&Lbbc-L>B^>5TI|F?EGvKVT|{|1*rUa`-Ds-+s*N|29u$fXJ#d z(6^;G*SY&YeUJOaZ+_ORwffy3e}{Gc9@hO?_mO{m9P4wNd-ucFxU-L~cZ+YDUa(GM zk!Brr5{(D_MB-U`lz$>mSvHi5aiqO}tZTb|qT6t=&7FJCI+2eT+`9>P#!KANn`cYD z-xqw@xJQsibj)MimeH`y(=z^C=*#En`19RZx9b?}|KPIoUP?#nX?jLG>xcHi=Vi32 zl^r8v=N#}3nR^E3X2ZdD%+-W!+`0J%&9EMOk9T|hH@80MON=v|m5=KZ=`qi{B{$D> zdynLc^Rg&{m-sbxp1~#LhpK|!}Q9o_4@Jd@)P^r zolhSU-Xf2Y*Q)*f^0&Vb-X#x98= z1@+e+3}+Vj5qAaUit^re`b{E#E6wvCa3}MX?|ji6|Kwq}}55&HNIOk4y z!}15i8rN{<^A48JZ?ZCD1mBbkm)^JCU2@+xx8vwC=o>ThvLRql@j$#3g*C05Z|jvS zQ!ZWcUoPLK_qCl`>)CQkFjt&2&L17;SY#&Ghw}uVbFTN_e(tk1$;D-zZJt@at!>Sl-dF=Rxw}cIdAj|6|;$ zTj#m`*thtO)c(7Buzxi~Oj|5zxas|)bNLnnT86%+t8de6Ih$TfS#STpBG-rJGJQ(v zY25k@+qm}4rfZt-YkvGTPs81?Yvo*0f8yM7j&u0udWqAuNqk+$Dr=wdMtR()JjM9B z10BMPFZ}8!Ww$EcPk$135OOA{0k5@SChq?=&@1Y9O=&k&pNk#PLH6BU9{+5omhrIM zRVUAf4*$cl^Y~-r9mW~H^@j1~aeo8WzhF+X^<{o9Ts#LTpXGhxpXb*Lq1#`6|4!Mr zRKHgqPJOY#BvxqS889P$8aH{Qkt?flt)qULu5tP-W86->|3_D`ZKZ4K74}=p+4eM? z=4o7gEeAiHD~)qbsS|LHFFfhzJKO(tO%(f#YuKzc(+bw!@&?1|nrq;H<+ZjOC&>LI z&j;oHXiG*O*E&Uh30BfRgg7xL`hUvnW%$3SElDH2m*95XN&G|qtwLk%A)Eys!t)FG zMP&~^b({OczZXBZ|Ml;Fh5PX%uJuOXzDhe;KGvFZf$}Zz#p&9bco9FuH077L$0(nH z<_E^P&3AN09d*;84oNG;DBgoM{2jyOeYnQpTU@?U;hjg8yB%;ZZ+jSjg)zqQ7tK*Y zS}W##Bj#Gq1{)8x%e;FXAX^{7e*xCx+L}D_hNeee2|SYVQ*AhDziE>2DtWf(k33FZ zFQO~huYeI-hnU-O=dwaNzFfxtii51a#|7gu_5@R!){>-W^|QGU{NK|Z>6EKOnnf~z zn11rhA1<0IIRkv^xlg+F2Rb8t?Srn9#{F0f))nJrd_O1Iy6gITKkyNGB*A_Q?sF9P zyw5x5?sa(oX;p&p$~+(OpznCmFBewqXnqop?|L&`=~VMWSzs{y@I+lfj#`FcJr!>^Y0V^{oWn<0K9 z=fqfo1ZJhKA)pp``9%R_u3x&VaK5Tiuux4 zvzBK4SSI3L!-af7elae{fb|DkY-p@IK5Xr?$Bb zhj1s9$ZWrsSbJG(u4@Pf9`L+Vo@Z=+%KyrX%7+2>dP_|4BdVnDIq18gXr z%=h?>{2ln;3j-&zJRO zuwHjGy0<=duKU*?JXeVQuYdom*kds^>67+BecL}9X1*zdwe{l&KI37%OJo4k1@D0` z66~+OeSz#V7vmkLo&(KtMpZqYJh=N9_Wy)esn?3bcaU^Hq@Our#KvFw@OEII?oZQ? zqVa2*oxA*+4$=9kUkdIO>)3m?-mw_?_utU)cqb1lA19cO-|_AF9d3F!!8u&OPOUy&L7Ze-rLyxMo~C+V(_!Acud{Gx%4&h%z8zJ>@qW zFTznVKa!_E_T?uEd7k?qF;R+t+g3iD;or7bE^O;+8TuM7%C~9d!?OS9@L#?xaoCD^ z_@{4uzGNQwjD$9vnH(vgbCaeO^;`4c$8sjktfyUf>T9X4Pn(Y&$8gvDz%H?oq3xdD zL+9`h-Xjn4I~ut?l-MR8SHn1d3u2-C9)$N9uH=MXQif@19ZeHIiOz> zy&k;!J~;5>mP5opzoA>qzY7DOE3X&grVQ?E9g8Cg<993NcWrH5rZKL*=4ssc{0+!$ z2Z4Y7hIXhUxD0h-+pJ^i<@yowVp(l3@oUM~GW2udGBSbRF5hx!kzWU{g*coQpp((5 zyk2_qEcr&pCHHT`nE>{LRtJc8N&YNQh>s$^s*%?tZzko*>*V?L@5!%xsAdcZ{(-%6 zZRFCcU^rJc>1(w-!T-tH@PGK}+tMM6EaZG#^~i45a~Nl!UbfHjtwnW;)(vSY=k*Rm z_y3L?r{Fo}8h6?09gg>W%0J3W!dIeudfY4C$%8xPH*_-mD@K%$6<_{$s7J-$r(XCT z4bxBDyOVgDukl*S*)k@)71-Z;0Qfg)(hnPtVakEfr{C1tu){eTdgWL(6QreTmzm_%CQn%k{M{HPwr9Aj}E-5z~y!2|9(&Gv|Es z!LDrHxgK;P1F%l8W7PE%S&lP7#|@M5UV)eKT=S?41UVpkg5ej@8!OobP`n<4l5;y5bd~&S^{eM^xoxrA3|F}y)#<#H`U^1!#d0F(5jlAf zWW%+iWX?JFx(?#241gck&B}qWwmk=%Mc(g)4IO!aazORLk~^`$W0t3s&jkO07lj{F z3>e<^yuHWkxXQcmsq?3Qw1FjNgy#(ni-CxLZBxssujy5M_Pem}HPmB&b|&~&dY0xh z|NK-sM91eLZMWc_KWRUkCocn(j`~{Ow$pm^xF`$kI>m5&j_7qh?*FT9pXYcc+<(v7 zxaT(!_lo_nkHo#6yrp|QeeOrtr#9W*<+dMM=J79>&-k_~F5U@jy>lcS{lrl=q-n8i znK~uqm4+>!g*V^2$kCOyqxE|0XS91ovz#0ZGE?P))kiqzn{MxvdFNV))AcEo13{(- z8Nf5Zemox_50Dp#e>n%}oj7q+#zovC-|}I~n~c+CJdN{hNK<|$2EP9MS4;5miLX7G z!-kgE`rLCx{}1#uy;fi8+5T&oG%Dq5Ir_T)+cXW6&f6Zky5yY0_j$MAoh~g$U)yB3 z=ZNY_O}T#P*nGWlx%x`OuUR4wbWQ2wo_E`^#kkww={DZc?sdSx6MoP4dxzfd3H}3L z`Exz?AnZS?qi(#d3wLPCvc28%atU6E59>y`2E-fwt}hOC%hs!um$kL&mM%x<>7;ei zcvdgpZc#a=6{Tl17NGBx5i)+xL7Xef9G#;ef9#wx47tEL-*}tHzpMq<1g?>e>s2TN z!kHkfH~0OGhuei0$P@nAAD=D3mBD_V2Z@tXKJ@&VVn5HzT82Kc!1smiAK!m_+e2?E z%m2hb_g;$|OVg&?uq`9uTQG*yp-&}gWyx#t;+ z=L206uM0pv^GtAEq6_d$r}w#h&zWl<_5i-WN8Y&P^fuuYuLBqvknoqs6y!kUONO(! z=Utrlq044vD(|cR@GqZC;o^8lSX7^TF0ogX_e!Rf!~gPwbtO6t--%ieeXBS9>p6x-Tr&iTSu%4gI; z--Z1M{`#BS-Im*yWV(3RONEEaVf^jjadE)I+vzl}{s=b@>GQ7fPV;S==1H2+$jUNL z=`w#l=_oz*k+%MZ4slY}v^6jE$I}@p{mAI~xNU>|v-GpEyewR`;_tOaF&+!g5a1Fl_q9-s^mIY1pCF9Ue~-*U7I-`d+HJd-;IME>%7 zO@{wG?#bKh0?+ma`>eO|aF51K3b^OKVX?1!i}>WVaL-2lblr_@Zu2dRMTho#JNcG8UFtj*GyFsh2kcdi zzb(iF^|c=3)@eHZw5-Wn_kngt7wZ<4*ZQenRMyazv1p%6y2v6Ew_{d0rLxA$2+Bm2 z1ta1-bI#Y@*zV6$dS4)N0P90J5M=Q32FSh-UM0`UK3BxoObfgnF&4_*Q=l{7|CtjdI28N?d$~50CVg#J z)6#gFuKB(61^=gT{x|hHZ=YPU_x}jL$HH`5t~X(ksXDK>>U`@OsE={W8jW#nH;;Gc zoa}KIpWfmY-#R0~KJm}{VCsXwKlwq={LuMR?2CRG_L$sx9(#Sy4Q+1I&5QhA758ys zKf$?Tp^88EZ-zU%h)I_Jn-OlV)$W-9J^Oni( z!uvP5^G>bDo!=xm|Ep}by1$$Sf&F;b&-1>~_x*k!bbYz24(EH-@xg~Z*8~5!LsK5L z`&r~|@U&n?FbS*(#(WynYj+-dRm=Z%xpMjRp7*smy7Kh2y!y5FwNm-wJe`y1s~n(j z^aYg%n{HZ!xo^$iUGknl*2$mEph8`9!Im?6GGK=IEVq{NEOI2KB4SuTXC13F!aH{{P($rU)<|MSyOJDIXGT<2Q160O}4r636;!AIz;VyY#o4k|IdK1oSIREqhFY5iG`}=3X z5%=-D@9*`gJ4WnV+>^g}KV{CTYd_H9Hr~+Xc~p4T?}ZUle{C?susC2K`V5EoU%~H% zt1nlU^a}D~`H;W%x;95AWo;cBPUu7$NxHUIE#HqdjF;_1r*jj0BNr(XqAU%vfOEd~ z{Vk$r>3UdQfO0^d9Z(<0$pFas_z!d#R)Ce59R4c{D-tF z??2aeTfz^1_EfB+?I;cDSD>l%HBHO5-G=?&IMD;}Y%tRU&?l_N@`JN+Zrkpki|QSq zVxMxI*oX5rKF|ZX_bc{Q?=QiAxN{=zE%t+czXm+G;rh(vkCO7b+Hz74ay#%EerV4nqWuTcD}9l)-;mxH%< zxa9|CXEFfz@%Pi77X|l*Pc#0_Fr#VJ`2T^=-CKeqVoR|h_{ZL>bktY+MZT7yuTJR| zm9Lbhv=skZ_K7b(n(H^ekG$b%N3Z&+v@$=B|5nwvXdiGC_5oOjRfp!fD<0nIw%r9g zj);A~jwQN(xbs&V5x%1*XMTfy(Id%21$%zH!@_>Q=Dp2s{RcWT9Xj%C#@7+wii2|g zU!o43=G%CsFv}O!vvriFDZA!9EsieMwdFNzeXXl$Hm>xUm!_xXwQMq-3f{5aAwyU>UI z_jSj9q`LoCzxSn>m)5O4p5PxcmNG!+f7|gzg8j{RLeB91Pw@{whkdj6N8Mkr@AZDs z`JpTF+!*cha(lkNi_*Jo@?kge|Nf4+w@0j#cT@gW99Z9CC8XEES4V7WxbCeU=MqOa z#u*Of1j{C@>DE`8-S2L3bP~7pOnoiO`byjSl5X0!?XZ6uuQ&8<{hX{|8|j=eZsdVK zF983T^X}^c{>>dhe0BhRASVL?{^MCdWdQF3gAO3i1A~qj@o#vEcmG@N>;$jva^$(I zWdGq^ANVo1?^}M&WI*lw?(2L7*s*`amZvdJFh}fFqsRu%Ec|3{IoIN_?{B+%v3t{_d)=NBE93oa#6RSB z4*&AJAL||N{AJ${&-xbR_I|(bXZpUuJhgTjl6F7IHpzNd(9h%--tYH;V;-)f3#I_#64H` z|A<$%lhw^|ZPU|yjjM0_$~3KO(`^{PAs6vK&fWgh^|}7i)_2G8m7!nxdZn^J+tPMz zc@tLb!(VmBTzBOoyWHNBtHfV>c%iogl=bp!C-@ICU7z=5=l<}VzX1E$Jzp8@hx@zL z*EYJI_qLbrTZV^~zlj6u>fWLl@_5Vgh#$pJhDF7Z`gL#d<+L8_XxNuQKFVmCmKWTi zFRS0#>{h?48D+A5SsGei>#MKzwXBWj+6(>hbh0)gowDOMWz9TV-C!N&eARmz*{4X*9&uV#p zL)jhipFHp99dLT)r%j(e-;aC0v6s_5Ki*kmFIx3(;QzXSf8|x(&xlX(xbXj*hq*a$ zb-`yi=iY?4xS>8><1D9P>uY_cX&LoxISq$AX+NZ`{JSQ%@*Pc{zO8G+Nqy-H^KF@O zVb-;I8rS;4*Exwk=M8y&?u>q*^9!Az2hS~5y$Ab0)*!BjxgX5QfK&&79+0;W%I*P0 z2T0BUsspUOW1;YvypsZ++j6`^cyI-Hk$Zou@^Xpacm5}a?B6qAe+rlZ{(-qX{w=N$ zj(8$|QY>kILf!$E4|SrCJkqG*cHIm8pZG_)@BQ>UwyW+l|L0SozS6EvFKchCYX?ob zC?mqWE4!a34eLU;fYzw+8DZ_qXQGpEe${|KqIZ@BIDVpXvR; zK6FIlKicsr_PMW+x8NeLfd}Wpt$f!)+@ZC49Tt2WG5sdsSg~_8!dD~js-bSSxM5{{ z<|FS)TaNKiUeYx`q$y42Sw}j|V_l_Z>m$7UnuX$GzP7F93u!Cew694#v^7WjA$srh?=f3`bV@>|btV>uYwHc7J0Q7*o4uG?OJ`?2g z0NMwdbAZ?bQ3k~40Q_e40^u$4SbPQu89*K+FD^SYtAtM@|Mn779s?dT&jLgCPwoIf zFXF!nJ~KS^279%aRs1hHG{vRAg?JZ!l+m>JU2_QEdNcUv-45}u_kQK}{)qix!z<@Xi+k{z-dT|kSG;3^>wag8=T-2m z$9Ld!U|`l2!CekNx*sd6B1_;yZipO(Jm8!!$J}!bf2sqJm-y_DyhT2fcjw4+aJ+xpeG<tOYz-)FzmHPp0Mvq-;Tqk3;s`cyT5$+(p*0o zx$=Q?Vtq@emaw*`KIgULz(jZPLz~?>_pk9f4WDC%brAf^{mdA_{$&qrcijiG?>$EB zWB;eT&*R_j{p1zw_2iptV8bie_j`=Rz3R2gu9@#vy}e0zH1h9dL*2|vfdjfryqf`k z#>I4ljgRm}wYljvma9sS?FU+m-!jk9X}rWYWlQqX{%~w*S}k#z6P*{%4IJkx%$v-i z%B>(jDGxa3%dTk>uPI z@{;J3H+vgk;WgzuKH~##o^yJQ@Mg!siBWG=-c_G`&u}&Vx8<&QV1Eg&-umz*Za*+b z>?y4xzo;#nChZXS#O=;ouA6fso($m?_KAi2|3y+ts;6QWx|}b z9~ke>e*nMt^xy{9&T~L`wrcV3-~HuXjh6wJ;$6<}0}JFmuZaKmR>wYdM!WCJ^Cz4= zdEX_tzdF-_7ys2fxBRac3eF?G!S~ZI4DJH>bmtFp)6Oe!=OT@+C{NRDnQ~#4RXRn! z)-!&`74@#;$~xb+Nhh7DHm?0BYD3esyp7v>ny$VrAIj+*(U&>Xxx-wFYzeZ0GL>`A zxfeY_&kDRV2y(#c0hHtNE)jLQoDL9VKs*CL4+!|DzSw=B0q=}&b88?2$W!95$3*-G z9waZ4KQDZ6qwpxhf_Y+z{A=))hEqJH`JIO*x!?W!?{YGLSX2C`<fs*n)-k_+;eJ$yX?_zj=GP?I^Jnu-t+hmYZUJPxPF)1 zx6Q4_?{M=SKhgaw?fujd#jX$gKA-Wy?u2{3=$Jg)L+_*xI_nDT>DZIFM|Hlr&MkS% zeBsw=igDuSoPloY{>)7|d!Q4y5594eP1vS0%)CnZX?d-q=~~y*86fRz8q2g^GTXIX zGDrHO>FO(u)VKN7!pzq)6@AVP%8Ju@GIKZeoFI=VCxUEP@|Fgfd+G;rj^J4#oDbyQ zkavYV6JQPHEC4+~zB9nPfGRowd4)Vf-r@e?@BhGC*#GGw{)7JS@7M6VHsDS2D0x+I zuX|So9Mr=9#FG7c>T4er%n^Hvqd%3;{h#>%>i6@u3Sap4XU|yQHs90ku6lfzTXqTt@vr$Z5U%Uya{^y7=C$_}%#VIOF-d9_s#j@27Wu@p&J0e#Jib{D}Ku zIoxx9Coi^NG23-rF;}pz_!hq3JHSoe1GjsCo3?X+o3(AAtKT}%&EGuGE!Z^BE!;TJ zHEtN_n&6t((G3#61>u$+V6$hCxEAAUSn^O_;_9Ou7!GlzzjVtaN9XIY zUDKwPR~qmm{n!@jM4G0pm>=5IGBQpbFUG#!&jIIxbHcgd9L?J1=ZteFbGR3B1Wx4x zMCtD6i7A zGI7QK4}S4{u8$x2^5dm6EL|HW?KB?JRv(+^-t@#CxAx8j8TNUfiv6E`?f83cN%eQ}%nDO+~K@j^%)9T|2_42g4boD_v@9%r)q2`@sprcG{YXdYsn0S9hwSEiru*Jj2ggZ&*R z?g+ScVvis)z~2YxIY6EVaP9eCw>}F1{>dX3p4cq=zup1J{!cp~?*HI1-Ty`Z1P_uI z?YWw~N}laLHaE}Lmd9ySsoP(2 z-xeR9>aP6YPPhH!qU_#5_J2R;I{zXA1IN-WvG7ytMhcMdQo`~OR%{Zi!y`*7)_TWt3veBFot_9fxJ z;}Gss9^UBAd$1?3<5>J#9l+urx|-Mjv39_|Kl?+r-!#vie|)1`e#2am`SQ&^+z07> zpPcuxSI9XZ`$ydKF9>@KbWrNkD_aIdOnY1p0v}_~rvon4twLDcin>8=(aeFabH)JI zF@1n*hoj$xw3W!ya+Q42=$rw$gL-Y#GRHJaLuCVLF)V&)Lus)6lm_#(uGX_Ov~O&y z6a8jCTG0qP>22JJaB%N&K~F}3n)j|!KrQ{@*v0r%7dIN zfIL_SeXDalu#bBL(Fb@gfWF2x$Otzv)zt zq+!iZeU?Ys?)#RxcYge;lK$<#Z=I)`*0XWjrb+KWco*ni{=x08>yAm84n>`bjxvBU zmvgUY0M-31_QT%K^`zr-zZEymb?4o=!7ckhy{x}{s|$BS5&PIHW)b_iS0ew&ezI?n zYlB|Ds=;GiFhQ)qk>6L;4Rp)rc()AxGVn0@SltqY=@!i#;F>0%<>pQ3@8-ibPdUpi zo`t+hj&)5wF|rUe>c{tUbKyvjX|_xZhjwE;&|&$;$^C@}=`>cNVcNEI7J~-cW*_Ru z^>Yg+_0RW1`yKkAb3vc;!+B!3b?RAe!NmUJTBn^Q^T_$7oS;ly14muK$^(@Jsu!rg z8FFA_%MiD?2e>~w=mLD6h_%dQ0PX|uOjo`m67B+*e_*aV_t*yE4S5cTdm`fBzYk>g zf7+D`{&5D#-Tm=Sir6>!2XB*i$;0Gj!Ig#xj|6`xMwWNQ`kIgI|a}8~;J>KBn^r1a&)v>xTwje(R|J1bt z{(}tg`hU><3-I6ce%g1yg}eXd*UxqPkF0Y`ud8!B^OGNVSIlR7!dSt#5vPBR9Pr8BFIzKNuoSZSrG0lC<`L~sTV*7Y@r;$y+QjX$N;P% z&u&~(uC1H}_*{T80MB`sfCtD629sGfMDv>@&A!_^$y|idV(2^%+j%w!G#Sl_CBg_|o0E{{8lkFA6MIr)*E9|P4w$lk4}9r(#J`rc^%=HtrK4qI{4m<03vEWu zKVJCFk4xz(eWexZOWTI-c@N>t^vH%#e?0sY|0-{(1HdW%A&>R>Up)IO{$bNW`%bm_ zlW%+A+`stUv)#^{R=M`K&5(N{@(1~3-qt~G@m#;J^K7bTW%4QcR`E>CHcUKAbomtx zc9XbamtUM>hxIa&C3wM0d%?7I)d^4tMFMc6S-v zh3lH!qWaOO)30p3j9x#tqG7zdcmrv)yG!BdU$nm2Et@y4hWfs5OXrQ1I?|6o;}XzZ z3EEj(b{wUBTU0k%`tJMDA^qUk)-+ARxuZYM4RAOE$yo^cKj%?ppvs8l^G3M~);6Fo zP40r8MtA<&1@3}1^W3(MiSeGH8~T9C0Y3BKvxIm*5Oje}&;^=z_~!$1AAmc-jq@r_x^0J-}kPv&+1cJeJ8`Infw;F0x? zyq&|E^1b?HFv0r7l>PhYbB_u3a`;g?^eruhQZ^#YEj&r*k zq%P|-z8`+Wl%b~n8)I1OlYYyLk?uUo9?&@#by)u#$Rnnm+r7XooHjhxOZ%gASmr#G z6&mn8jRv7{-kJrjb>=9iWm6i{M)`itpFC9RmC^uR(CDli<7SNR=VpxQ=Vk^sYivK6 zPs*2ixaMg?-2PSbq~B*_E-730Vs7`XsCWBU*1HYOc-D^l1D+3fHsC!h?+9|bfZ%_q zoCl~2bZzp_ak$o8^Nwp~3J;JM_^m*}KhFT>T>$?*An50Q z{IC1J;w|v;Qt&i+dn0(fEM_aP2gP{_BvvHOn>3o>EK=RzQ;j-x2|ov+qQU)+tM}L zZR(sQZZpD6+XcC>Wc~y^|G<-iuqu#AZU1%hVO@tWheTv7Hy>Mrt#$q zCK(!8Kel2%cA_5~>!cC=;NIvai|gm6z@IwO&nxG5#e#_N8OP{BSVt0*1m33%Sh;bOTgSD2%T)hN0Cxer1M)fm?uca^kx4ycbwe+RR_S_TN@x*8*~5H z#QT5V<5K_U`~Fy4+52(t!~1^PhWIo8VAl-n6Zbr~a-SE>V;|!FM1CaJr+_~vgKvpf z;&?ppJ8jHB*V8(~t!zjlxC-;Iu5G4Ujk0kpm=n$q`$#u=)Brbj=o{U*;5e6b zlqHbAM`Z2C!j8X`&m9aP+eeS<8asy499t3s6P{g^8jVQ z%u5IR`#|sjd4YF<|CE;z_k14C0TyRR zDr4?z&*$xHiMca^f42J)-0suM+}D5fl^p)R@SV@}isptB_}zpL?{-U%O>>=;KO%#S z?8IEkeMZo&EdD8f<^Esx|DdDcuExLn>+$dH{zr?QC*{2C{dmql8}E(qJUDOtAnf-8 zWWTQ;f0pQ>GqBflk0<82S1I1fuVcaEip2@T&vHu}CcCb=6J6_^@vdp+Sl2iMdIx;Q zC4KfdH)7BmO5H%D!HpT(A3DM$w`iWFG1k*SUD9Z;8}CLAexsCUo#;px@zF#2xy7hY z8f{5GS^|xZ`U!4SN<;ev8q8x`i|0);G_rnlB9HlF&{iA|`x5%Vez1>hM`<+A;(Wki zKG=^=fKiQMWkAipbHZelJRh}omH%uGt=EKdN zIKs^sH_S~N2b;n1&@B*W`i${I-OvGVDC>p+H}j`LH$mAslRyJ?r{`#laL(~n1#CY(8v18BOLo%X*_Ia!Spdg zOUGhqu%E*RzES9q*6`p&rVaA*I~(I&XlUqIW=+7eyD8Aqr;oyV(f$DEG~CVbSp)5n z_$+bhY;T*fYVI(%v<}~Bs2}RO=fOq@w#jlg2t9z$30wByIS+Zj=m2sLh&urJjUa=6 z-T(F3pVv3)?c%3{?-mevFpZ>-(IgEYdg@1XO@Lz;|D-&!O z_y55EcYpS+T;HDgm#3_SXLVmEzyu|9Sk& z`Jecw?hkt}f9?k+_^l4uh|R~o!9Ab%c+*DpcayQ-PaNJ4`;*^?Mq{t{nD=`xd6hg% zyeS684jbU6jv3;{4Ik)64LQq=7>vCQ{s9a=(TENGI( z@In15)+hZ@Lof!=7>{;sKS*OTXpE?V2K(-53`%I=U7XQFu)p`O+K=G_agIknCX5{9 zMh^}1G59RUc_IytbqM-9#LS`2CFhm%H4<{5ga*bpMraInQ%A$j59>vFT?aY9GaGd{ z-r3TokTQUGgRRqt!ol_u&k+hIxBVFLyuqMgDH`6JLK)Xfj`&rOztE|$|GK{re-PqB22{ts z$_CP49nyG1pfRxj>mg_QMnn5Cu;1%@)sLa*2m917^y3Zi2Y^Oz`Y|-fynb)sSiBP& zMY0g{$@a22^|Ez1=AQFsX`J=O*F!G(b()Cvnlci1vZL`V2D%&XY8M6>z~{D0AOk1| zm`?dXy8_At+7(j|5c`0Y!7eE10D1c<}pM}*}otB@_D!Cf#oIjIt(q_rloJgSs9OSw(a}iF{*tjEysM| zAAR`Iukzn0{5alEw&Td^`Z_*7&RzJ}26y&DtMk|o^CSCzm^-Y(5LQ2xlH-Y`^pK>)h1*8rc(xt_2)P%15^*-xq$Zrd{#(XA@2WbE7-Yf z2%ZIan;`Ot+yn9q0N#;106a^oihtGp$)|d+r`V?spnNU(_BhxN-X{hW4|(6q1g*z+ z{_P(zN9=igW-=qAp=pY1i#_d6r93SY>K(Ij4SW3mm(0r%PdUc6t) zo=@DX%kZyqpuh)yhNz1?Tw0Det4Ob0e!27tZIx}Oh*nXW(yqm4eRW#CfM%`Z*SV)p zIZR!Q=NQTW>H(Ey0By4USwNo)=(~mX9$;hpU{{ZC57yy(0rD)6XMlizwg1uQ|7G`o ztNT~k`xT>$@0=_cAQm!wkoPSvJr;;5`}dPy|0v*J%cphHxTlkE2WiB0Imdn!{mczZ+Ej>)iO)Yv$mcsZ__nAWz105(^2Qn z2915)EpBz|Y@GK8MH!{#NJqo;HO%yhqwqd*+Z=cHnijVYZf*MNIdQBKH0R5QZ z&RN^+E?nQ{b}gUh+GmdvouBssZPUCR=Q4RV=-+RqjZm}`gdV_iz_P8tKhFSsF95v4 zZ-Vfh5&0G{_kZw|*#F4=Kkfna?*Cx0`&0bu-cLWb?*lW++r&-??>xQOUH-(jQXDAm z0uCPj;(ZqWVh%x82|AuKe&0 z*L7?{`Ep_0JkR*MK!bniSfU4D?&bL(?*M3%5o`dx{SThO;r^Cu$hG9XKfmKQ2j~77 zQ=s#YIxE@tiT!@qU&vqTi2r`BX%^l^-q7YQ-vT?N%6E_T<71%f3nCnzt}1^yMD z^=S+64D5pSttB+}tXe4depy|WJ4!3_>v8XWUQbI2jkCL(-1PCd+mG!hG>E6rm)Vm> zx^sb*bS%Wr9OS9I^R)c<^K@YkF>bhHn?hrAHXrQUTs&vUXr%SCeo$8IUDcTD$K{yQ z3)Z!`g)_$`WASsKXECl1*NHM~_ey-1o9jY5WvmTpTnc~X!ihNZ(_WjloYejC9v5u| z!@I@&Mv2}7691c8pabCjAo7UV0}B3C|Hpk&z`wUO1+Q8B2kgt;pWxnO-=D=ZtSjd2 zUPugF0W562uf04LfSCvX>E0a1h&f`<()v?`*@s{J_NTeN-TV1F(XV3Nq3g~G?y3*( zbem7Lmh`!8FEs2}q#vNM1$it}R9Dm3#>UP$1^w{$&fC#PJ_phAMLx&6u6-74io;l7-@7Nw z2W_2qE}Q2jV$O={GELWLP0LKNRo3~iG?>;reK?*C!iF2~GV$94ybt6v0NMxfS)kkl zG!4W4Pa9!;UkLXA@*IHnfVeNF&6u43bI<;<_tWN__y_Ni2W2nHo%@M@xWK;_mnA$7 zp5Jm`n_yzmofAtip>>HV`$x>35&Yi?Y?X7wqs=Rbvk$-g-Op{;oZw&UX`SG2ht5yk zU+8Kc%68E)o;uzC1OD|ckaMp17abtH2M}!l760MxZ!YXRsNYSSdzQ!kNIr`i1lyal z1p6bvlYI82yrq8Y?D4=|o!HWD=$HwKw7Gcpp3#^keenI5DKuznJOes_mM`*WBk&tJB|fd z6dPaI4O0gws;g;yj!&Ch_IV@vPa8ey2mDR6%|+fk=m1*2$miZqyXG7X)F&;au@L%F zQC&^r8HRS!IT~0Oq0#C4v8-W&tDi6c_8s`n0L}rl7v?!YzH@|U03QFtaSup4VSHkIKuTv#3tynopfTX7y>$2Y@r0+5iFneD7E8{&AnfbKUHVhWIo82E9_Ze6YH_7^266B4W&c7H`=_iyxRF?G^R-2OxU$} z`D5vDuk&a+AC?l zeSdH+;@pIO)ImPbrkS?9o`$zu6dJJ4?QWhf_I}!L9h1iC&xD+!tutxRc9L^Jm(iF8 zo4;}BM>c-xQ|JTJDMM)MoYBBo=6V{mzozZ+l6e#GTyTJ!r{{nfd|!Ze0r>71bb$4+ z3EZ%71kM2-|I`5*FCFUN3HrMc5&zUBOV0k>`}yqekjKA&-tYO8x~k~<%HQgz z{J-&3vtWZ5N%I&drtDwDzs1ZM46~nUKQv8!=I?oEx%*(`l?g0FIz_iccTKV7o=JQ+PU+?~AK=+?GqCf1{&%&J< z@IQL6*r$wyT?zGC;q%0&JsoXUX@g3;INHF`F-#t%P3(BsxfIpaG}^n-ZZ@US5NI%O z68fU$i~KpTC1kl4v}yan{*f;GNI6tgSJP&~rf!j;L7PL4g*0fdOuJo`Ls}>GX_G^l zL77Gyzakp!Yg*67Iab;Sw}J+BBg!1xk9Nr1`XI|}JsYNtlczD>(|~Lv4bq~lB#ovS zqhTL3z}1b14loJdDmFU6D!B(3;?}}GcwNI#xeFlvm+T(m-v_`QAbE#-h}iz&{4aO^ z_Prn3`_UHnBcuO&{0A9e_Sh2sPB3=aho}QA%HdA2lH#A3ll?!?&~l2kYQFY`_@&z$ z9BDGF=|#SlrGLren@jM2`isY7{YVRRufpDc(FZq{(3di7*Esfr_nUhjSSEH(v~}Y9 z4!iDKitnB-bvy4};&z;bJF&!Vzh^Oii+Pd!Ci~VqyWG}eU2gN?PPg%3yIXg2tLuJ$ zlUwnw1#b4@5!mERpNx@g$DSaG98-1hcw#GMH;}M_JcIow|dAJ zp<%~h!{`TX=SX8V>~kqAQW}&~EF*mgl`$G*UW< zkHT2z;VzSMg7QRZ&_&n&N=6e_R^#=8U5f|(Y{b=%ovCFu160DIsm?T zCU=2(9bnZwZyU_}z|Fw_`kv9kE999T@Xm$?U zapyeT?aq0q+wFglZnZo6^lG>7^eVUSez^NqxxJ@Wx;-aXxZNjKxLx-wce{=+cRP+P zbzARP>^9%l<oJKpJYlY|Cp{Wdh@rGn`k-2AvO219!VfpNMnb z)KLT6%(1ZXo`ByNkb3}r-+}i4{(HxKFM#I(enVu_Lfk2|4i#Qme?4#y-q{8oA}^7r zc7wObW8^jR++Oe=dGKuTB6)KE190F`@+x`uT=FlR$B^8Rim#KEI}J?P zKVpveXy*Y-TDBZrrLCCNILmk41-<(vILeU}|A#+)O9{=(KaA)74|J8Jm$p42mWgKo zK1)#jKimWA8Nj~_h_l%3yqAHKX8_dyp$ERtzw<|%t~of@ksrq5ygwT6+Y$TfxF0Eh z#W2&W=!d1jd@ZYOhk80r+TAnEHrY05(2*Yd7V8^2v2CSK8gLvt?Hr{a zmWF9t#?A8VN9vRY=J9oyPmYB)&z$Gj57t4s*oQzv#=~^dfIdMRN1YGaJkv&qIv($M z_@41}oB`&HgARr>Koh==)`5F~CA0bMa{nxV-xuXKhxwZen;`?5uD~52ct&ghz(eFE zo+;(opXmR1_dnkKd;Qj(UqdTDKd=qp*+%aH z;mh+>+En5mu$>NfFYy@w)+6E{XSF8c|GnPspU?hg?HvMspx%$YA2NXZo$wL%8}EBQ z<^Ge8Yr6M|9rAuw#+FaRjHhY(K1>mfv^?9?H1(_XBVR6GzjQ1dPc{~9FJCU7pU>l5 zWHi{e=4(HS(zJ~CiQ!@z%(rxuR_c2>pzGpw8R`)O@T>>FC4@7;EZ~1Gbby6;XShw? z2f}ZPgnPiA`TkiT_kZHQ^Sq%3|2Y2({<*h!9uw><{(1Hf{OF(gVXqSG)3+E>{3kNN z@;}0hj!y(u_6TMorjefF|3zSq*b_{WhSITN${icGWpvNi{66~Z!|(t7x2588o0N`Ktf8d|baf$yK z_!g|kKfXHw?2|`QM_rQVVo3+zrTMkQsk@R!xi)H{!S@DVG7ae0q(MD9w%MC8p)B=l z>4&9JFmI85K_1UH(hrQOQn_k*q`~t-wQ|ho-23|-kN>H_|ID#C|4$o+{eK9a1?JBH z#6Qpf{=0#L-SVAyFAzM#cgFbL_#FPBXDa^NdH<*Pe$-3Jo4l_k?&-LHMVtlPABSI# z|3mt|M^-vw#+Ez$Y<~a$9WYmd|0;AXy)zXq#6S9~^jDmi;okc3eU84SmGc+Hx#1l7 z`HJ_Lm`{DCB65~8m$H|(SHTvzm1h8c2Lv|2dHmzMcgqH5_y=E;x7G1^^Occnszl^ zY4O?n&J_zmV+s1P)Lp&}_ZN%j$vq*<oAfxE!%%NyL4+eu@Yy8<+}Eo~6H zT5TssCmj>wv{T-)WWKvXXej;(FH=+e>-)fR27oNqZvhGZv1SWZ|9{(XH>HXA_xDre4Y_y2 z-c&bvl-q}IWL}JKikZt)o8f{!pKe&)v^m)8zP#T%mz) z{|XJnw=JC)@BXxW=4Z5K;VyiCcVk9F`vDp|md%$t@~ob=oz*99Xq&Sa_QaQLY|W1a zGh2u0;oM8&j39uiT_{&2p)?2KWxl0yC1*zWaobHsCnL>;otMK@^&rwC$@+g zt1}nkAJ{48QP>}c7t`0oR5 zi8b54$DU71^K3bWZ9c>0@Xx%Br=e5J7>e6dTJ*ik=BSHv6wUyp_)l$tGFxE5KWu=w zX1f2+1^y@D9$4{D-q3S8?dE8|O1n790@{ty4vsc%{FX26)3kh%&u7oHgWZU-tdr7U z`U=>&71iZ=mg&@$)owLM1N~u~^j9OS<%?)hSLQg_XWG_r3^Eo&V-09=UoEQ7H0E(0 zHo<+>{b8XD8lkLmb+ zu(zG&+*j%c?Ev|`V9ty7gMA?l_H9!;`Zj+!?16{i+u`O8uo2%30nZ5j?fKu}U+n%A z|2+Grdw)Fl=l8Wt24q;WI8eN?oa~+VHwji#{PWI@*i%d@4Xv-frh9y47*EqRzqkJ0 zM~n4;(z)=7_3rFPR|IS;#xvjcJ)0LJ0~G&32he8$l(qRgfN&2e&j6DB|DAa62R7Zj z2c9_6^SJUl@07?RwB@B8Upu~=$!}-!o0GJ)V;+4zcPFpXPaS!X-|St6Z~U?>>*i^A z`&MErtt)Y)Ple7+e%JPByQVbQFVbcoiE-PH7zPdEiMGMZL!Y%DZRihaP>#{jPDaNc z>xc0X*X#rBiD{3Uqfzf^FrRIuV<9a%_L1#)8k`S57LJEDinJMK9_L=iPha~YKE|W= z)xIAI4ccB~zIacO($Ib|opm`U^l8Vq1FH{7iP{?~>(z>V?@V32FT zcfpB&?jbq-U+?{K_Rrn@8T`j{e>nRW@pg*8JQh4gh#g{xWy|5ew|Ffne-GL!>wv|w zdA{C0^s(grALsw2CnmeMKDF0%pPEyZhHryy87DdtbpT*7$Wplr4EKP7e?AA`9Uxph z1K_J0z13lS{BCl+WsOB+A&)lmTE57qy;u7>C+?{4ay+!ptHXCX zl?K0;Dc|5F4V2S5sn2)r*oTb9$Q%v!f%%nb;G4WCM>}QO;__}J>j&_JJksZw)Antg z-~QyB&^9?w1MhSQjd8MmwvG*Ru2@%T&{oj)gErLdbH?JGV94+KfPc9I^v?hr-{SB8 z6#ruTtMB{~`})4W-20pTU+??^_Nm84{FA>KuYxJfUk&WM^{IWp(iC_2Gx_^}Vs0Py zUM-vXwbRe)RFVNUuaq|XP=bH-b^Ak|?(&apE$E-6XTzjzoW*~T0TKU@0TKVa1K_)W zSQEJiRQ%(eO+LG6;QoK*FxY_%1kZ7Q@_Urnhk*xqr#%BUr{qVTKY5SL^FDFSJnnVA zY^aBF5(j?~2Mn_uZR>gO#kxv^apuwHSIfq_ratp#q7N)fnItrLM*&)%2Hso5ewm}e zwzV(8=eLJxPt3k^kC%Rc4#&nZ@*BERN9(Z9>*7@e2+1BRI}GY0#%Wp!b!O6Qt2J zeYjgXX9(Iw`@kE;2569L!Jc9Df8t;6{&>$E_I}y_MfVT)(|Yy~`<32x=kOnS z-12@J&Tw}5N4E-=it!KKx~wcwERnX-OMRPX`%qM_xNPD65By*9p^a|OBTGa7Y+s7$ zWNrtHZpi4A3)x?*GviNbzs%f9fvJ@J}qHj{ME<%<%h^wCkf!J2di1TDDxA zc;k03EDhSC_Lc_QO=(c4E!S?E&i01EmXF`c)3MMtmi4wd3Em zQweJ!?*z&hI^>_A(|ir9@6-1^R=WQm#qWXt<yWpedQAEXRv!O?y$qw)I4>$a|z*EHJ)rj3E^4zbIA@cY4hF3vJq zPU{JsGU==nX@C~{#r~xOrJ4fwCFj819d65fASFI(Y}YY3g9u0T<(c6Y$>-8{pjj54&>WpFEbk`{VQe zVDlsQ{-XDLo)zA;ybS)1JRY&7{7+x8rapc@92g>&h$;Ig`aiH&j48#M`j)na)z`LC zpZT^-8usyhkCmSPPk!mJy8?PkHlE_T`M!s_;e6>lDE=V>;ynOltk(f}=JN7aZGi>< zSR;N1NbG;8|Kkp)?n2@pcVxnIo+p&Qyw9}M`FF{(^fa#JRUT;E`jzsuoYJkOtkx;= zEgj7-N@Je3rM{N8zSh;WG;Y)Tl4t8PT&XWwwvw-XP=AQ5H_s2#@STSld|z`szV9<} zh-;WU#5GOBZ-)i^uYmrqzYoH90EqvN%ZUFG-u@5!KW+aN|DykM?}sb4_W{3S&+=z| z@U!J<!4=K#Pz*NQq@-2cxXhBF|~o;;V1kTWRxqBo9b z$j*6_VUJdaZ)eWN-PV|1)z`K<=4LebjT=p`*@v8>J;6HIC-&gGowQG=xvm*=3!bmj z#*s7_?hB13-0`tK`@%l;g$BR%$N5}`?}v6`ZmQD2_|r063)0})bYYEbKG$s;us>rQ zzWH1R|2+S%Z5ZY@HH~na_$?sFfKKR)DgNbmK1BD24iI$z9R9<(Kf7bk_`ig=BPO#l zrFivu#1b)O|MK{+PNS4|kO3!Aw~Sj{CSA%CugKr`!P5QzvFA>?HTTz-rOWy$ZN^LH zi1-gWfIQEvh<~q_QAYDUAbzt!-~X!v{-=)}EO$YT;I}2PgK4jyB=@~*2|edhBH{%qK-SsGV@))v^f@jk2;8nm6=yBape(hv4! zvDme4U)CUZbEPy&`$qY<6W=Y(XmC7>-L91jMK+ewD4ieP18#$TUrJ*S?4Q}k@-(vk z@E(w3r){yPkU#0{9Gl~BR`Td{2 zAIkIpwhsJW@#3j&Yuh-txe4C`!!y9ni*f$vH$eIPU-$oD^V^R1PQ>mHXaD%FkKX&s zz7K5i+^YPW>}|s9m9UlM5lh6B{fqbyWsC5X!!_lNp{spRUook4iqbXB`bGGE=xZkg z-?p66SD$${Y|BcX%Ey5Jr~?EUEAy^)09c3i!{Jdl#pZ2gB4UQ$F z!S8=s8d}$;G0e7TOMAZbBhbK{a6Y!-`=8Xav<}D5a2EIBwXomIXe`L}V<%*bJg*4# zviiWjN#opeEa$KF^Py|QesIkw>sXilnTg+EoIL^G_L>C!e@f8*f&Wdd6W#8mGu_tq ziEdN#Xtxo+7fu`Cu8R`{0%ZYOPgS%zhG{BazC2A}D(9LK)Z&|p85Mhm`~8tW-7_)3?$&`#2T zT`#{|N*g`)#nPbNP;Lwf9X{9FF807m!;eR4aGYIOJ7RwZ+$`XKj^KZ&TQCLlIc=!x znvHLnE}G#sw@-EJo5#8JjU(|rK;R$xf7eAQ$Nit*{{k%4%^i7>JW|fl-i`LA zwCknJpnV-3>(V}!wye}OOY8gJ@MZd9*sGBSZBUg4=?V?dB@Np6l+wuhMV*EAx1_;( zxqiT=l{AREN;HTm+7hQUXd~AhXbA2~<|7*m+sM&?Eg#218nmtDH-$a!vN~y>C}U_B zO*>o}i=_cuO4{pk&eD3+fi$fFXMRs(hFit?2sG%g1`XOkvtPtM_x@SH{+x-}|0ieo zUs6BPt#5_BcjH92x&e0x4I|t-{!TdXzXP)EaY6e!+O`p^4AYL3wyxAEq(19}dg6yTZBAK^<%I_7lMchA%RJhu z#k%Z|rG+@{*n|ek)3%O%;23C2M;f%3rCf{klKM=i?cTD`57NtMupi?=W0I7KV@dk~ z8nnwJu4u~}X@D+mB}s$d@MZr>`eFLRu~L3<{v!>Hh5h4P$#*^B3k~+6pdXam#5(07 z^*Pq%oU$LJp?1ThLE9?+o_HPgKjMEX_J8~?@S-^*-13HrZpqv+ZYgBJs(Hg)4{ZR6 z{|oK@kNbbs{{x@J{hxdo&ZOkcZ0`^FCl8+i%n?)eFYf=n#n%}wf5!12`{(h`J3)Cy zqWFh=?WCN&E5ZK}FP9_!@xHd~|G;+TpE$aRKrkq)T3EJ~9 zU;9CwSnFxq-uHc?U2#f-dn)Tt4pM&b``%jDjzz<4gEVL_ZTrDGtV9lL#yHDs+dj^*;CEb5Px?`Y2EVOL`mE1>3jQY%|Bzu*Ay20d zbDgsw-{%0^Ge=06w!nN3fcRg00q_srY5Kq@>?HyJwE302AA3#E{kwpF|BgR+lK7W9 zKgh7~Tu*e`$kPQnZ;g2`8~#s)K9%#+{;`~K`yMa7|Cjw=>1ll%H}yq-3GpSi4C;y8 zEWm$G2f(+MvNHhpf876b{|Em0+YPe+6aRSjLtT*fQG7ncyJ5jT@n6A78Sooz(fBPY z+LF=HK8E&kB{WbrrKe?Bp7oT*=s+VaUlb<|I@-7hjrh$hzSjgBWa1VU9Ol~L=EEMKc^WW}?}6}l0XE|I0Wxk_Q;iI`*C z+7Eumll)()p3Nf-+U3y>mfzbX9nv6OTdrD|c6px0YlRMJ(7uo3tX5a^Ie)YZWIy=b zPSW5!_l5>#A8F7&kTm35rjSP}hu9Y7MBSv}IQ!$b{iY0;?|`+;z}}Djf5{x&&rTmK zzZ2Gp?*gx$i{A%tz&FCF|I7X#zWqb|%X2@!_s9JoJV;$Mr~89n$+Jb+UvdiPjT-S_ zF;o`+eV}FOYPc_bV%X8$@ee-Z zbHAgwJL0}0_x{8_oZS0CXT=^Bv9Ei-@U!3dmCqG-wfZj?{+HYbtifqNZ2vURrfE6) zOh5Yx=>PxmTYEL(*jFuAL_4KRI^yo5Z1nO`+Kl-3av*mPP=tTp0q|X5)&IHwUyI-U zJ{S5Q?tjP!o|pW)u{?+JHz?Fk!y0G4cdVZ;&ob(4URs`UPmj82R!75WU0+t))w+!P zKG=HY%KG%Iz4Cp^`lJ1{^tEr=c3LJaoAoD~FReowTHgBFhSpU-P1inX-HdMOT=_Eo zuBRS*|2$y7VJhworW60eJ^t&4xs`MI8zA^yb^H#HxBrFyFZjn@N5Fq;4*x;#)aQQW zMZv$)PC<8C}w#K}xtW?M5u{}OZ zr0pfY>w58~cCkC?n1$a{n~nE>1OC^P;2-u9;2n>Dod3a7YV)VMKc7Fz-f!_QdOvl4 zICI|q|G9e)cTJM3%=1qGVZ@A(+E)ASshTL$KL%R>DSrDH{|)I{Ce4xd%AXCdkezCCg$MUxx!dd zeutj#(Sxx`wsP%xC4=qJoEqJV%-dV zPc)AI*#G=Q=D)rBtNH(X(%c_iv;AjV_X~4>)~fSZRq8F{JM-Go@q7GkqWm9oS-&Zn za823T;zF8E{(C<6L!O>;ERFYzRa>?4pEmZrKzjz@eL&rp%zy5Y@n7!%*%^Skhwy)z zxIay2;xJA}w|VicW=qRCk(?`ez`jF&d%<_;M`Y8wNbj}8^ljly!oUufvp30KV(9lF zVQ_Y5TfP{#eu2JaFMC?UkQ~??A`fz5Z_GFt)3=4OhcyhzKAh_kF*v)kEnkcqQW`K0 zMGVQ}a;(wWoiTk|7+L#aY!D~g^2Ipb<2l?%j!o(tV*0i)c9(~B-f*f*b#JH(w$pnD zZY>5i{;!@M`7fJb_WxM_ga3T{BlF+Je{4i+Vu^2zEO|eU(_wjANmk5NQWIO z_GsAQakeBrXK`{3ZckSpFBiNR=De=$%fOgwc@jqYmOYqYZ_*jVj$wYra2&?eZA)*{ zI|3~jviZYScZ<&TBpvy^PM+UoA`N!DoB>*s0}QnlWw7@j^E-y)BCBD@rq?mB3&d`a z_NK1Xxx}C@SthUu(`MK;V;8(xc;t^5JJeRmPN2OmU~}(w>kQ-)Lw1G8V{!<0*YaD7 z)iW+{=fCX#W=nUB|I#JSmU8}AYkye}eeKW2|BCzN^ESGSZo6-KK0e;H@?YE=KhMTj z?r8WIj{mZQi~RTYV*ccPfaX@oCa`MvCjVKVUHD6Ut0D4VXYq;urdyf+gt5uN78d(a zZ0oQY80C{|&gLpXex#@dLeDAbU~l*5C&5`1B3fvtqOB>CG3{NB!C zf9F1aQ>VrIq~}~4yL;XE|67v59rAd1jQ+t-b|qO6aQz3|1%~ltouJ>iky6O0(*AWp0EpG{}~%SY};;>4H|Pubj9+J zufrLJ(k6^_)~92z78Wtkzcw5&D35ZDGkWC#NuIH)R#_Q%X(Qh>|uoI?#HNJ=T^E?rQ zIQkFl5;@QG&8i1-fPvj0_pL3TW4KTM(S@-@`+q~z;QuAP{6DGZ|1SQU&7bC-aqTb6 z{TZvOb-6UAMcp?29(8=II7#}^_+Q6NdA-bDzD4ZR|5W^Q9wwi>j#KxEzor-&W&C%! zD*RsA;x#X01$`2Ifb(Cz&jD&{0New)v=5lpW^MeB{6D5KM`QoY8>KTEdzy}Mm$Pc) zcj&S8GaVwmbg`MI88=O1CTEB`2DYfz=zAm0bP512hG zwsG(!rmJB_*%XEs=)24DLvQ#2vx_S+V){n;UIu9?*W~~UE;Al`nwks|C*y;`xtrMb zQWr3&8+CMyX8jE_%BL{)y%oj}=se&z+C0fIYI1m9_w&7DUWZ+9lH&)8%YOVv_5Po1 zf2UqrthDjJ!GE3qv7&|lcGeeje>vm6^v-Nj8@9etXMO(fdfoIBzno)5{I0ur&C|GlZ87rcmifPAZNunaPA*rx zx4bqdp%19~(kB1o9H5o|dT)z9d?x(Y*pI&TbLb||0*sUW(pA^4=tt;!sN4o2HJ@tXG=%)-jrCM*mG;0tSA2d85lkKL7@Gnm7h~{b}!}9iv$n$w z&3)HBWesTdluL_6w`zUiw#&5l5C1PM$Nx<}{}=z4H~Ek4f4T3M`CrccvG>XQRB`NQ z>_VTT>)}_OZ^lhaQ*jJ#w(?)xt9V;a-^;&P4rx8ThvV;<*28|i{=1w1F@0Cqv0D6+ z|5YDQ@?YzKOQ*~x$g~3^-*e?5Z_oG~d-Eo~W3n|;>Kql-IV;a)=Z}&Y8AKN_Iz%ghWY!6Ax z^)}}??ql&&IQ%@Z)bl-{=3~@e&5IV&%R$9|4nDlhyQh+bdUe_bcwHH;c(9P zJM!BceO6n1Bd(MGF`f5+F|IA_>4MMvf1vdlAhmn752)udY=L!;;6LALqVJv~{?9(H zew=%+@APrT4SU?2rHNg5Is->~DvqacI#(y@S2~ks->O-~=`9P3_zii&rxdqF`j@_~ z=lRk(G_IfN+7-oaooU&HVSNDQOHWHV?03Yx(qzUSH_E}c>`!l5P+)^t$)9=WnBW?= z&dd_`yD+xuT2&YFsQeNmhxMDX3}f@W;)LvIv88o6IRCS%d&aQxtNh^72PcgAmftXh zLtXX^%?Nd=w}H1OWnfo9o1WS{zmj7k_hr6psb^Uqkhl;3wFi2O=3ci>Dwc@*%covm z42u7o=UrVKTBWlbm+JiA1y|_L z&H*?6|5U|)zSGIRGPWPsfnm$W-Z0~Pm*f2#Y=N=q?fDM9;`XhaVRQMe{KOB)9@a7T z*1lyw&2aaxnrUfV6HX@uw#q*>2 zSzpi@eq)Eq`Ly&G*phL^C5|w0+tr7l@1DN&4Krb^)qDfHSI59M4(Ay3L-Y}rHqI|4O1LVn`0 z3&fU~vq-TwY{Nj-QvN6hSaQ4{P+`>Tf*e~V2W?iXOU~rhQI4_3 zDNif^C9B~@94{mIgW)>ip0?@Be~tgOIY641(MNL@sJ#PZ{FjaHiOY2V_56n(I==JWF1r;Igolf5)@L=4)2 zxs_vpYSA2B#%m_85;<}BE1QWwME-l`tdhyH^5 zzJR`8&iFrdQn5n(UnBmnovU;+uP#>2m}KYwvIoHV|L}i~=8ksu7w`T%|I4#Jq<_r+ z9Uu*G=pCP^H>EwS% z>*Yk*JnT4*Z@$ZnZ+$@SPniRh&j3ySvks_d0G-KD=fBS0JfSlj#s7I41Lo*C(AI@C zE;xti6H4bgo8#yoSRP3p6ByW0(m$5v!GEp&EH?gMsky(-`xgIK-i{rB#)m1F=p4|C^e%99 z{;%<$vwt)H7fYw?ysrlTm;Z+5{`r~I*Zz$EzP}zg-{eG-f6d=9{aE-P(@!Y;k*a?h zBpiRMFDfxTUjE2ud)NPvwU#%eFEJusN7(wQCjZMmAU$*WGl1;_un)-i5C4Bj^FF<| z&GSFwKWo2pv;V0IIMWa5qR#`me8zt4a(`Ukq2}yO|AzdvvJnKs^O??e;GwJ7 z(D5zngz-a$ca`*&G21bs4_^FDe}!#d6N7W_7&Bwqt}yFi*!I;h^o?$0<62j~7|+-W z&#=e+_c}+E`n*XPv=K7Kw9T;Bi8|8<8HUd9Oc+0G_Pw+f`8;hC!+$fjbDko0+nfPv z7;pR$*>e7v^--PKp|F<0fHUEE?p8usw ziT~*t)1LeDzTdLU|I+5K9Q!q9I4bHUkU)(Es+DKpW&*Qtw<@$NJ zkXn-?#Y|g2VrxJyYuknA4 z_`hzZ&H=maEpmbXr}g~L_|MrN_U_M>>74Ih6Gm;{FOC1z*q`UCjQ{Aa^UisQevg|M z*A`>q{{(O+B1_D3Vfo+ai_~`U86ffB*8rUV>H~6LY|sDjU+1%O27rzK@c*Q2e>C@7 zBppB(lGAf6&$9O0!(;jFY13L$J-^4b;ds8L?|IyBUhn!%de0NXq;HG&xOlx89?Ngi z#B^iD^?Xg=^Sj@?-u0XGo+pM$Uyom`-%I3{8vkVj0{?Hn!k+)v&eT~WeD`biC3)@t zE%yE26(#?x@!!t*w{?H@0sido&;8i?`m=wS`&ax=ydRn<|6gob|8I`}6X5^R$JQ3- z)v~1dj`f1*%ui`qnsL!U&GH~*0cA>)^J1^ z*eEU2I(IEkmbVt?IQbgu>)0D12R`fawe+NG=pF1}Vt8z!A zv*_-TpwFh)6XYA5ihrEB4foE6yO#I!;+s2UBkVH9uwQG$b@G2SOurs|D|emB$BFxD zJ8Q$-2ORozZe#iY`2TyU53qXy|K&>l>z-|YpZNbv;{RRZv(5iB4oDa3?%IMgDev7o zAluyZE&6+8LwauKQrm;Br)}n~exu*yySs+5Oy8lrrBPm$r-{8wTuB%!iaU2L6V{62 zF4@&$Z`VxQ^_y>aV*^XxJCzq7nXv8a%F~R;Mv%4uCt)l%xxj9w?fQ*PGI^;_#MrGq zr7KS}p8flCvQ4gG>|J5~Kr?OEZ|n%p?pSIVv?XnEpD+%uoss!}d9gwK-_#iYjsMae zug#Bf zgv0N_k7~|+9`4ZRTAu$kR~awn&_^{tGKV~b^Rs|I1N=^2Ptl%gxeuT@7wa#+255Fb zS_9-PP@dWB48Wh!Gu)|3HuqaPU2{O4`NEijzA!$a(CZfm`^cYMYFlg! zkoh#3L+{23h=zOL3%xU+^Rumgr`wsoG`giED zBZb@OQkP?%ykB;x*r4vx91}Y~?C7u=>H9TvC8Az%nC#2c?uId z+np<>^~C^ZkH*#tBi98v3}c$?eW#C#u}k)|`+MX7<2I9HkL2uE7q0D4pITu=Ib>(A zwx+I}yVef}eq&F}8KCLh(cGqzV-GgIYIAIs`eAI<+3VPCa|WnmaBr|11mnQav|_{T zNtyqbi2s+!_NRLOcm8vp1a@U5|FJuByPy2rpPv1tpLxw6eKp1<8Or7?!HV6kIeEt@uM*M>A^;QKe^ zv2*qGZ9W*-nx->A>lloK#B-)@nK$#j{4E_GRGHYkVz)}UE(dmSjzJmddzsI1vd^!*yEOUQ5~^g z#CDlBr_L!a^0-XyYfdE$?4_~sWB$Z_p)S}tVmA#2ZJ*8sO?_x7OKubHU5TN5O^nTp zZqoZd*!?p8Xa48$f8dP1moD9546*fp`0wxgJOA1DcmA{Q7q~y5Ie@K`NA8<`*ZJUl zYWncYImWg3ic|5A=YPh3u6erXd)TkX-(FT*oL?KOf9SE6`F|h&cjIR6=RTnM3_xG% za{%YR+XBXC0Pc~$18Czv_wcmry~Y3K;{S5#JUWU)2hl0U5cZnczrvo(_p#7R_~+@{ zd@#^)KSwlSu-B;hhcJofJ(RY*=qvH?3BBew`&Lm7_P3DF?0dye$3eDOmv)RSN5)`{ zle2Ffqv>VW7PdWb&{O5gA8~@>d zUibI8e;fbHy?mSdUu$#Ua%}T4E{w0;oKW7Ju9g4do?pLkK0K_-9W8v}_)nSjKEUQO z`57SE0&tFy_W|i$Al(=GWZfJ1&-ia=05Jd0{9kdq_&-B@gG1S&@8~FJ_VQghY{{@| zvyNwIS?o`pPB-h_iaJnPY<#pVo*@>}-$oiM6$(P_tUpE&fZU3E4{w$%BnAb>w-ut$fGh8s%V{hYg-%a3(43gN-3_k}l#jf752zOMv0u zTm|EnjKTh4%*-@M*7w;Wz^dt zm0iVYZlL;7Z)}~hE2KRxd$Z(_og%im)U{dvDu0DXpHstl%a57;BN()Kg;C|H$6aTA z5H?qp9FCzgzNSqo*6W?WHvY%?KV!+__gz`O?_*;>c7L_GzjI&n)WH2T$Cj?UZkyh> z=|4Paaw7K;;!-F7`|_-fd(Aj6v&)a^n`y-V&v(rKd*q6Fn_+%8T<#;S4}ky8KEP}N z^BSP@UvmI#Gkf_jdvEc7U}ll=bX)mf!!nX@V4}A zarCRy|BXLFo4%p=QMn&i8#5larEiOa#T8JhW<@$t9k9Si?GpZl>KkL&Q&Zk_yR4&ZwL zf&Z)lw();(X5v53eCP;1-`a7-HQl?Gko8zWOSZW)Hg zK`*Co-hjpUIwNCnW+h{(W0ZbK?=nVw)*SfO0pB~HDGb&XNatzHZ`9x0!OLL2F;o2_ zb)kRcx92a@MmZXIq$LkrH4N2>y0zvFIOL1vFfXv@5y?T@(#Aa)Dm&(BULO#~9Np7t z`UdO_eFL2Lf7Z^rs#rHW@qZKZ{~rEp{qJY%{5QMb!2i*$1P%Ru3O;8~x0O-J6=kpEcVl=u_albaw~D)7_b|DYy8o+YUt6r6bxpB$HvCV||C{DquC>27 z{_9r3`+Pu@Mz+~4N^;kmzj=6CM1m)_uhrQf<%TYM-q^xg?==~k@>IVMf1zdmZ~Px$xz2z0%XVw<-{x1o4p4grsMY{E z2WXMr0kr3T_{}K{ru2QfvfL;Oc=5Nw8)biUYRe+ovkjPi-to-~ zY(LCo?^JuJ&uT1(@7}Bf1yZD&D(y&@iysDCJds*|S(VTYD_hfz{&wn%VIGYCZdF z?SIZBjs5y=y3YRGyX2~3=ln~H?K=N+;Epi<^X_lvzs>(M|IH4qAOBfLcm9hwf6V7NU2$bRZATl%K7f6|4_D)V^$Z~X zV+*7{ntR1}fEMW7mU%y?b>6dj*F*Q7y=1;2yGwR3!>ebN`_oB3(2=x%U;R$HgZ=wm zvd86H^!_b-z9D~Z#}e(?-<(< z(Ay))u}tqQ+}y&+@ELu@Z8VdmFaGtJ4c<5VdoKk zsrQY(g7{YcYwY!W5!1t6*tI;rr*flVMN*EUn8J|8+)#y#rYC|4QRO=T>p<9kwIbmF<&`tTN8)j4%1@u{RxhdVIrN z`(FG(oi&L)8ICZvq=(ndNqHLi=}R0FJp}_hSiU`9$B><`Y>g?`@?;rem}_fg^K#5E z^o~-%;QULzZ+}$R7z>;7v*+(|*yEHKp)QWWHRdCp9=|CEc2dZTZE{_X+f5EIm>b0U zmpGw~j;P(R4K@s&B}$th2j`@Mf!$P;qYMX(Lu+SO82P?92JOwgTR8`tA#9IuI(KZ2 z#(_E4mi#|BbW5?HGeDPIZ|DDRpLaKC7Zwe?5{D& z_@8ZOd1g)gug2z5zg^eeN8fX}g;(uBGcrimg ztpB3_^zomzE%yPMa{%3wd?pa@0sA|^^x;d+>ikUI_tiS<&CiI$E^L?jfc4TvIR^dLCY71wNauEPjX6LQgWrzBGYq!Q*cTfHa!?oX1lvK*17_~rhT(C{!^ zw2onN2n!6_6nke+-{v#l!7jRnk=w*EXfv0)>4V9<3tQ(JhT>>fYzL78o9U{2-FxwW zt@L1B=KtpTn)46Hu6VKRKoz$0f9E#%&)FYl`>W@F_-}K6*&b-^x!Uv3<9~kcuedMW zb&k0|UOwdXqw;@(xD)1SF0YmUue_^1_D@i@8vnfyppE718Gt!Jxel26WbV;YozVdQ z7w8NC&Tn=8GY_ne{77wt}v1vPr^v` z_4cc`3->CW-5fEnKjdBMv|p0?dD=Z;Ox9Y1WHWcY@qhi?Yl^K4^xoA1ox?o$+O!s+ zeSj@K|04E zzkgGYbGx`!$`Q*BIF3P?lwD$Ie+^t@G>n$=T^_EpHwXs%la7JiA-29?@SM@Czvm|& z47i4k05*PL;K0Csfqhgz6TEB_uM@uH`{j`^JcYjiKeS_R#Z_`V9ELZtnHPdhvaO_|N#yzR*>EHjl0P8|z* zv316#kZ*z`3vyudM_aMZ8gZ;2DC?wasw;L%j=}eN9Rr(V?0aeVCPtE{il_g;*7-)s zQDVq8FkxI~V}Fu`JbG_vp7=ix{$E#Yn0HOFN$>t}7SO_5E-RMZc1b$_*UtYA{Ac`U z{*UdC)|Y+HznuF!|MPRdJ@dnV?d{p~zUy-4->M06qLu#|doL7^CyH~S_)oj^w2il2 zvkxfO0n*xwKLgktK=){=&H{}5Uz+(pq&Y8~hXdGuGnbA!))hwIv7yBl5BogaC9-M7 zMiyI>u6!{bT}>E2X8HM^J$TsrVzV04c7?GeB>kmt4srf#vmtubv^m^x$I%Qw?G$Ivj|^u}URKFhQ#UyL8r z^9KC}c7~2|5g4)==5zlLvpK%p*95rn?vRpmRI) zuf0d(zn=Z|?Z3_QB(u){SbUSd`KR-L*Uy0eZ?5<+9m1Z}Y=5L%Y28ou*lzO&|Kr%N zJ*$;1V?TObxxg#uLzkbq(8m8GUE@Dkj^|uz`|at*=o^O{X^(tqbK#{Wh-lc!G{ z*sDINHp*$rexNo7U_C%Cw>N#S*4X=9`Y>xQ;M+d52&K8=8(ZJ@HIbT|=ME##Ij|Zb!e#dXhrXI-BBZudS<+^NsE&H^m&!fsViD@DR@&4O=#j$=_Vl?Y- zSUQWr`9ELp{m#GEzWvF!e{J1=9&3N;{a^O~2k+GQFWp)AU#@8IU*msi^OK+bo&R~w z&-nj)8vmVp@=dqt1A>l^#))_xPQ^ca{@2(W(-3z4G~?s%W}3vk+-ErdGFH6S41;;3 zZTx@rT_#5?Cx%@X$LsNFH{|g4qYrRf$!rr~eeOrn8UTHO*#huB0ONnQ0m25@_reAC~S@;xp4X`^9y+Ag29 zan2OM_cSh}`yL&doQi+&AMU}Ew)Bx>p2p+cZ_78{xFcU~ng0j=_r?2^ zWXIL&;C*E8bAZ`P*Z8lt#~vs=17Hh;jVAUQ&j01cf6e`*0~;2i3({qHk=M8~LoCahc>} zi-3LPBPu`PtSRnM-Kb-&J+joUF9!*SftINZZCTNjlW zZ&SP6w|7~wfACiA`CqNE|4MOxQn3yG%l4<_|6LmY-#y86i1$g%?w7THAN$STuXpZm z>wb*=j9HogY0R(my43fe|IX3!TI7V%b@9J7eauU^hhu?rF2e-hW>1R#Fm}Z}RXAPa%)C3L>$UID3xjXjVy;F6e+=zi()G?jQ6?bxS^J&PnDr z4C=T|c0cvHcz*Cw7+It*!VA9LZ{NRe}3kSw!V4IFX<<` zD(7RraWU((>2~JhIQh}ae~rBp<=KVG_qy?4ZB_AKeL-FaDBqdVvzD*Bl>BEMfM)>h z0W8set-k}b{O+q{=caj|#(wy0PI`xq9p+4U*_~obh7BEOMdI@tyIRinjCsm1IKtpJ z_ORHowqan;I;?NedzrQTK^Ei~>A(Q{;F?*L93h{}050~hU~u*(=WNEhfH9)$W%?|8 zO)kYz4l?gw=@{Uo_AonPFk<XPJe4B9-FS*r&)a-0#0jW!tAJ<=Z31^Y$fj;y_1{J$!U|9bbQ!GHViSKxoO?#I}Z z#($gp+g^WKr!M)Q$1oeud|VqR@13WQzWVcV`fqD)%)R~kzmPyUvbaX(iPd1ao(f!!X0+7yJRQIc*VTd_lsD|Vt%<*wtwiG zr*HEabLlTgOCHvp+3&{ZH}-=(U$o^V4n5^L9i8-fehou%fx(%%jzJy|yH6eHe_Rgg zP|5*@uA3aPKlVKMH7s4D96v|2#87>BmS#SVoM1(aCQcK>_uw6aITv+}`CJZs9Ca}{ z6h@AOk=FjOp@zq_eLp#s;f-*J|F+{QvS|`@9DKe_>Mk?w`gI z+xsu){?)o)wcnR?vp(-n^K9m{?k399M_+rL`2T6icpk2m|F3EOKS9jZpH^Gpod4=C zZ5}`$P|g9=)~vNM@1hT24aU|1((@TM+NBM!&ToPLtov+%|LDIt+3!n#;M$(V^OqW4`640Njv15D!KZ&!Ze(R2ErA(fZp(D%Z#9F9S{emH4vulH|- zf$Xs^oC}EEBkO0zyIg;7ha?-<(s{Y8rDNmVhJl>)SH1N~?P`64WMzL9IoO-@y0B-6 z9JCj8?!};;ZnpjH2Y=W^%L=GHsVDrrTU^^Fy z|MOe;f4AoU`Q4vn`>Q=a_#f?k(LHpKH7Sk%uAequ*}R{*ex=Xo^+b8m$^UWiZ1p(O zBB#q6^TjY}y7*5zPwReL-9269=k`)tm3=^N%i27kHV4psfdA|V>I`P%KQ=(T1E702 zApUPz#Cw0zE$KKqibFrxKgJ#a+tK=ddSE7?MLadXB-lx4a8Ty0-ilr)%)V zFtmq0Rp(|p1~z%@%_C=D47Drvj@U(FPY4Dw;K0CE7ygchLBGtIWngfAC$@qSgZ^nW z4CWiyU6mN69G96K%k(XX?F+AN@c&|4{~x;hQu~G^c4XL-@y?&m{mb|KjQirhulF-P zF-|qPk1l7||=1;5_S ze@|Osd%2#s-LKlp`X8GIqj1V62>;Xj!0Z8H1GM0mbXMGbR~K6s-za@a zV;p+pj`0ZFSL{czX~RbEYT2b>&x*b=cl5MfK6~KU;Bqcyi6PrSeskt6>r`ENJsw*` z!%(@DkDVX(ghqY;mak2GNp0>+pT@VcHl)WyoO3QZZutEfg+HTmYR==__v_8y8vno2`~3gD zufDqlFXoM5$Id=&hD?pNvo=+~+S&&&5A(eMYyq?e==|p$pn(U}$BX~$|885F=G)9a zag2MMlXtP~MmYnN@5pmbCEt)o$4G;oZEJBJ&zyp@>9Bz%FBo85EDY@X+VXmwW8ib% z9(l<7?;JyNG5>9*H~Eo?dIZ^cj|bVK9N5HSn-|krzDC_#7RF3$`#8_kG~26Gd*>FFH<-@>6jysLqI@SFeLj~d3mSG!=dJYAeFF+zI79Z+wi`6CdDI{A2t-3ja&qrgW(c55uX5+6V{K7vo6ewZdM(TW6Bk8);Ab+AI6En zJKMLZEwPoRZ8=xixIYFQ*8V7u_l&`~To~71{-$ElE$V|7Uaz^ozW?a_pC9=z`@j3e z|NkP}KWu;U-e23;U%um`vETOM;l5lsewBQ7j=7#kpRoV-bkV=A_|VsTzyEvW@l_3; zMGOx&v15EJW3;g6BV83f+A{us>>IzXG!d^#U+=GokJn-`gAeLCZz=oZ>C6t!;XpUcC#-d(&BK4;U8ax5vfnIb7y# zrt`AhZ(fg>IZaKLm|o%Xn$nixm?ppgJom$Y z#w(taYaDQWjyfO1e!bm?Q}NHne{ruG-`n%mW$^OcSDw$FpKIg)@BPV#imji&y&D_O zkEPtue%_|Eukqje0M-G0Eilgku+L--aEb2S!uN>(k6csi5dU{+zlXhK_WU?ErpM{K zkUeoB9ed8$v|$^_w<-Ij?YhRfSJ>iU+j2m52VLp<#k0?keGQJi_I_!*u8pjzFh;cJ z-<7UkJY}C%J&@y&>=*i_?YcI+YNoXXHb=B?SGs=j*l5t6wCmCJb6YU<%}?=vhxm^k z>{z0`gT*%#JH-DTTL0Uz;EH1B!YhhBizXGj=3i3m;QY^r#sBwc{Fe@)OZM#V&;9H@ zp>s_;pFH-razE<(SoJ^W+4}hz1)Pe1CI4%Q(e z=K{@5`s5q)HR}T;r>#5Y{?+>c`rTj;U~>TV0oVhxUc)=vi+)A?f7{i?&Sly&UU8dj zLS%m;J;UCHZ`zKGaD^qif9nt~k|Bk@_Lqj(fhgRNH>|J_Iv1`F)#g4g}|37l6 z?H`qE|5{h7pXnLw{22H1*pHs}@c%q{aO8mTbP)-~FKlFe7N^#F~ zyDtp?DJ#qatbLvTJ_k@g>~jG60DB)u`!%)(s6D{Jcf$WX{>ugo+Y)Tt7)P;1O*)nR z%*9t0?--t^h9_OeRs}oOWAr=9XJtFahB*Z`tqB7e zqJu-}H+;b+H(|^&`#P6{IBaX{a^(7C9wHO+8HUQlE)E+#$Dn>(^E$X+)n%%#C3z_8 zcmu=EAQcAnO#N9^e!{ALgLxv`1#L|2vo5Alskoijkq4ijkGFzgl*Ev1`$l#ZK7(!T*7G=$&Bp z{`4;C3ikfAw`}~+V}G>qvpGLwzs>oXTi3_oHGwY^;TT+kQ}NIEud#O`c(pvAldMhW z@)F++$F#cE#{c(y?cK#AFCHp3J*KlH$?tg_s~P9lqaV|Hm^n-CU(4r!{Ora3us%TF z4B|P>_5ieQ!#y1Mk5@}~b>1iTUYY}-kJy5u`{)Sz+U1xVV=KB_@2%|A*yU>m=pnp5 zBpzWC*Oj06gPJEBhSD<@IR;^HDT6*~Gz{!-U8kEEl)*DeKMe9?2i%3hbzDCT%Al-- z!C9Yr&ek)w*M)j=y&neS`yti2!cd!J4Dw;W%)RM{L7Q9Kt4-+}e9Zwl$d~%~Sqa1X zKAj1=T>RG@VCT{sjsKkadvHkSo-UiBX8}DUE|^s8&^tgj|Nr0MKWl&DzxI=`F|+S@ z*!a_*e43C68e@%i=d_-S5gb z&iFR|Kl;_5FYbJ1dvW+HD=Y4g6SuX^ke#-r51_xrneWNNiQfFOH_y0+q4G$N z-I`%&O`0&c;J0H`a_vWsjDam|lw-^ol9PHPM~UHb=uAFr=0=snF|c)xa;R-MOS!_R z>hJB~K4ol^9N0x94AloYgn|757^B@6!_a-iP5}EVZ&U1~z(_X4@xH)+Iq`p&_`hq( z;cYA`~NF`Qs0fx{t>q1to<4PW#{L5R@(VwJv8pe=X%$7 z>1?UX9ybmj4xJO19{pOc{V&`r`;l>AHuFUJVz`;E?RP8xCF}6x%hEGIAGy4&Hh<0M zbcSm)FZ};3^Q$!1vN?d-KkWyk=P_(CSfk|`z}5h@2e9I&6aTsY?Ej-1a0bUX!Z_}Z zF$w3t8Begy!G@GQZERw3OJqC78CKZKIY!JI!;DX)V;?SIfOCz7!Q~py&9U5=A6a@a zkfXvt#+WB!k)Pj;i%AZhgJ$;5L5|zij@qvzP0ZVd3D>aU!!{Y)1mwWJ7F$X%khLwJ z02OI===}81FZ9Z+5cbu(>gbjwSTSsv7U@hF~96T@0V_xj#lF=I^3hj zS+705=|`S1_aAFz;^s+*QS%70myW z-5=v0JYeiWk6c%KeCa9kTXY=TTlkxx(svhyVSPv=Pn3na(pOX%)!DsZEL8vM{Ezu!m^L3!|BH=qI)^x6aF#H3O4#}H zOp)@aKNA1xw_>BYF% z;!;24GPL@4^hT(E#4EniVIhyxHXMm_5=beItk?xJjA^T9SN2;6}~V6@Kv#sBlczYuwkb&UUD@A>kA;=vd9pTCT`A1LPn z^Z{`Wp#FDd=mVIiF<;}kjrE(w@4Z6bd13#L@t@~K-UUwY%d$??>eLT-JZpW7+1JP( zA6s_p>aj_OkLanTi+(e#-;BMSA?o(<;DEY!I3*6@=#UW%=`r&Kw}*EO><&we zCQi=lbzwZjzW3Vvet^GcKpfXRT`5m&pDYt)QKn-g`~DxdahH0Qd1D=1u8c>!VN=+| zV186$c>Y+22F`%)0cQYHk1KU{Fz;V*wlnuEwWs$%Q>6p?{t^6Nev|ruDaCH_f7jw` ziVd?T6|1J}9gCS4+u6U{=ZgPYH@3AufA%*Ue~tZN+^^RA8TTvxmt%f|&*SCWgD)O1 z4w0{o|8Q@-n4RfApPvK9Df6+X+UNgP&gX9s|Ea_L3m~Vy-xm5;Ywx@kz?vZMS(bJ| zvI$_mhRufA0cs6!&w4%U>swDe2Qn_O#*A})Z-$%KNypig=o@yetRZ?HPwRe#d3(Ja z=`LrG4a`g!ti^d+e8(FtZj;u{kc07>wL{PA`Q3M%_}jz0H;--npkyKJ@ewP=HL!x3o8HKzq_Aw2kL=zfA_e=05krFw`bsSYH^*vUG#$mooqN2LA6B z|936CQttrBCRpcxZrA*O>x#F?c2xZTjjL=O$>#oX-2YTsf3me`#-S$njYC?$dJH!) zUhRE+p>fCaxAI@y^Xq;6e%$8TdAL^o|NhUHn8%-9Q=EHtTlsrJWm!9>wygSq-0tN( zfIdL?Mg23+XY2#`8o-kOs(szPH|or{+ob2(U((q&tRIivHPUM>zCCXvT`b4*!1JE6 z+v0K=p3n1;KgKy;j3cf8)^+ye>^E^SeVHbg-Ejw#Q&WOG-qJ%Pv`$^*>+KS_Sf1U zHvj07J@XsyHTV3qbPuO-hxNX4Uh4By=e@?i3F*7|c<#Av1>ExdkBU?AkNv-R-P3)) z{n7Zp`HQy|Z+&^B*d%`@PoPO7z1=J5PA1j{&v=?a406G(#=eX@B zrxXW<^quFGQ{@sT;02B|sjxXo_IbUMo#j-~9F1{s6jSuAS1&<{^X^zRJ=&qfMb#IocPCA!x)9hm3@+n&XZ|DEU!xyJ# zf9C#q-QV{4rC({z&+{kTPh*hJ_f0S3ct62$6|TJPW!cXJ&it>+e>i{cxvj|KU~X96ET9Jq+h-!aMX8 z2VbyFMdwK~ngfF~DdGPK6Z*uEZIJ+ACqWU(f6Kv|ST} z^E_#5&N=jS{rppE1MUO;8McD-$)`Bqb+h__WjAR2mu;Bl|2t*(o5ugQ6zg>M2k-px z{D=-Q_S@W2x>dff#<*k8qv``9=NbFUF>7LTYq$bu;&^#M`TvC=7vVNX-_;i1h&%T5 z>f(F<^7nCSQXp&0yX*0}#ltThEY^KNHXdWdCI8wli2vE8HkC}YH5lIJb`R(W694I! ztq;(1Kzas9b^z=C_v?)R2gUz`;{U2vt0NIBb@yx4!;OMv28H_dHGS_3?H%()}v;sXiyh>||FQeuIqzb#{ay7RF1LOEwC#wNck|eV{zfj0rvK}{IK{XEXJWo4|G)Py-!0%?Ow$u?a;^Eh z!DaHVZHeo_4QY<)`F=G1lmEUK_7sPn9Oy4^z;#)i|K|IAAhtQ-wmv}Xfwm8zGg_C+ z7I5V+UZZ<{tGHb89~;wy(hb&&*t2Gz-W>L#`c1s?U%KSF-Sn|t#}1XX^BRV*d>?!? z4CF|5#2pyI9u32IA%4cXVE;mWSPMr!-g#=uAAiGn$|*6F&)dXpdf;!&(-s~LgSuMV z_i5LJk@iiHzmg-_U0a)R|3<-J?JwOI?38&2IK2nBTXuk)|IIr;yBA%d_x>{fIs4yfh*1Hz5M@sbV3d@~(3b&y+_2h=) z|pWvO+4#;q)quVmjBimi5%?h)iAOgU|{!) zJ*>%~^oCXDPk8i0)CCM=k99E&?Gv!)X7AdN*PYcZ$;LJI?wT01iDMwAVbC5WMm>zI zoDmx3NEpf189t`>ppc_#OKTsiS9)K9^L*McIP;S}!RK75T^uXdP4(j5u{V$~bart5 z1|Z*p*s^GH!8?G)f4%#=YyPFhPObgzT6~FgWl%b^+H?mUI`MeYD|E}8bPb&|SL&j8 zB;6dhJ~#OH&`ZPO|Lz_fI2Hfk9Gp7e@)*CFM}=ux`wi!~aF0LZc&m;7 zcRjy7lt;O#>?)n7$M29!b`i(S=^kl~6dNRSvWMnfN!~r?Y?MP^Tv;4^Y^XT!sTIZE zk1Z<>oRsd0|A)l?L-1cZf-bWEg$+42w1cu6#YP5unsthUQ|NoA6L;BzBR_eDl;1Gq z8;14_+0X9GpRqanit;K9z7d!(z(&r|FtB$kF;s^r2N;Z@qhXK+`}rO@)Hc{nz^BnL zkQI#8bEc&4@@apMx^Vs|?>x}IjD|r!!+tAv!rTw?8;0cIJpt|+eMmnX?ww)SeMx(r zLo=@}R?p`9-#R~Zq3k!7$ToZ-@BJtKZy%8@1-c@gIry0RfiJBzT|%dhO1I2qePiuA zxvY;3?y&b&>2;;+m0nkIq=Orbkrf9k{r|`}-XH02oP&E++Q^$&ZjLt|CO%ejtuEGn zQP0?4)cl~;ajoC=>#cs@{g=P1$@YOi{N+}>!%qzs_r18M-unA!Q78~xZ@4P|0*SN7-5Ik zw)wd($T1)c?}uD&_bq?s5^3NW^|!h>hHQ1fNaJo*rss7md}M9Izy=Zw>e{S-S&!VV zv>Cila-_OYAIG2%@iM&){kQwvyM)1g(YpatuhJQ0+S8w*Z6G5#NWW-R>e;x_TlyQc4XJihxk{np?8_wPi}Ox6n9%ekRg_a&`uygXd& zdty;*9jo^6JnpCVDfsATRkG!+)o> zL%3lW%3EPvZSTfad1mRmHD0!3rQdLodLTP>s4%Xw_i}o<8uKSvNV7_PVZ;E3x*%VL z;WG5r1zCAFrws#}NuC>GeX2Hb%;YnNNElaHU62ENfBI>j`+M`bELFX*2jbi2^YrcY zc{l5OzPDUaET5(`*=6^?X9@hjGVxz)e>-LOH}uPTUsUh&-#ejO)(- z8s}YKBX2wo-{UL)z>`ai8|%KLd6r%GbSYnx|8VZO@!v5#J-GdGvPNra`u@`q%>jnh2dvezA@B8}W1N-G^D{cin)g^7 zV-32^`#3n>4?=%iF~r|COxCF3*euxzI|e+0b3N%JMhL?T-ocxhG4Pqo^Nies5$hH( zNK2j)L-k=i2fHsuz(Z#GKCcV)!QPNM(tma0gln`N^LO6w@pk3?0@}JWe~{sb^7D*G zA28>pNy53jSa6HlW2WZ(+WX(9wZA=D``f+X(qh-Vi;8WVFESl5dmkJ7^^FkLma{%F z&a6epY&}i-*~NWy+d0_Kf7AOKr;_eJpt1kZ(*uDEb^V7^@h|azO+9@Lm;BDFjAhp& z$NYMXtCw5xG{$?r8;ax4t||VXfBtV#w8Hpb^4sM|WzfGp^vZ!^_^G9mFJgMwvEBE0 z<_|gC=m%)))DNV4WAg#;1N0nVc0uaTnFDP5iOI#$HMh!5Esn08E{9I*+!^T&;}Uyj z=pDbKL*M*&411jWa0L58=A<6i(+3nEAMFWa2VUTv0!x7$1ub-+9jrm z;nC!veD>X_Q!{N>{hR4%OV&`^Fq&!OZ}7Rd^zkFB^d5k0|MuxUkl{huf-b+I*r#WI z&ivc0Xa8OEH1~h!<@TPx?e}Bf=W~8^spP%%65gYO?i0?sS?Z|ks`*Lx6CZo^`oL35 ziici4ApO?ZUvjpduE~Em2lv|3*Yj3sT+Vo%usg2DYo5N_M~3hG^WW6?|Iu%JurJQR zrgBZ#Q)pG{b|z4 z+vUWw_2Reoy`#f!tQmiNqxQBpROwP0?aw#zdcNLx_Tdvo+qD!|$r95L_TTJ{c-gdKE>(VSU<|iC~S7l)zk=r%ihroZgWH#xy1i^x_%g~*S?(dyN?Wn<<@22eusF&$=8wLZ~y79YW)BB zH-EFLOT@@waPNP4Z*fG=0ws3Dk9lIe!sLx@O*qZ9qOU&8_dK!1vOYlX2g#0$wZL8P zoKhTHd#l{k;@GF|F13f>Dhme?0$C7zo^*wR*n6v`AJ6@FVGp=&o6l|or>H?7qf#8 zW!QBWy~O$NHec5dKQm}tsBtZ(%^1J?&EKkV>~H_+uM_`C+bm1JTk)FdLOI4O{f_yX zVgDV|!vDYd$G@z}_US+QSmJ2|FZL;WpIT5n^6J52!{a&wDx{0pG3@OaVSoxcp1MIk^IKK9_;`q90a<>=9#sA|QX31$BsX3k3E#98qoa{$W zXzXUp!wwD(&9$60Js87PE=SdiqT&X34*~!HT%>hoVn<_WGII;eAadM^{Hm;Uvk~kCQhe(uFnZWoEOh3jEo~p*08}inaeA&8o#kw=Ne@uIg;HBVK7eWIKJ)HP#C1)daQrUpD*sYjWYexV`lZy5H04P7jkl7MvU)L6UBEXC$(!om z#7bjp!eE^T+Z($!8eqE1DRrdAb}Y z)c5gRcU1j5dp?JC#{bBG-UC~9jrc#Q*t_u3V*ALYrVHqV?e!b?rGq|?%-k=>{HSZ^ z5k}|HeK=s8a~fHY;9NQWdtR5P`MY1dmp4Hl8UFOU--x0WPyEFf5{JitdGh&n z#XT?Y8gqK<8%kWwxd?mxf?x4pyw{qh-B0!lb$^)$uqU|ogVz-&cHUN;-Y~5=wefa2 z>Hj8;#~L>nf6y_;G2ThRrghJNY{_NYvP)-n?^YOlS~$SGmv;rb9Pizbjx#>7Ya_p7 z5XZHyydK|;p)>k2#!1QR>CJD*#XB>Uh1_*H5(eK!;a$BNMx$O8hR)WdE@gXU47C&U z@qQRwJFrskT_`Q>=X`GIUQ>}N3r`@d87ER zJ;-HO75f)!-EXDNP5TYa`#&L@K%V(C_j%5T@0AWUbnNjk*C$-+qtcBre6MsK-5+au zI2HfC_1$kK{*SS|v6eI5bnyQhfB%P3wBnh+e0ejYe9U<|Weog} zeFFEj96YHJ+89ytdf-%ae;Uo2JX%u6IIb%FWU<_8dJ=&$DN0_`r9r(A8bi zvmMLzzJTog@%c^s8LfNuIKAuYalDJWXOOe`r(53b%Q&xI=Yk4jr{ea2L3+>Qe!%4$ zA&y}(7=~~hL-@QK>t&W$0ppb3r!@?(M+0NGFi6vu*Ktp4jR1Mewh+cPa7+&Mi^4$G z{um4E?O9^zn;*GvrOmud^IO_v-;nILRTuJ5{tn$2!rmVq)3e-3@t-xGqpM{vI5b84 zzqS}&dZpI>bavw}$Sz7cU^YH^&Tr#{kNHub;y81joI}^qd33*1mm6iksrdJezx(=y z<^Na4-giZrk3YYr`1=3*S`@8#@o%1~>J@QgIMi?d(~F8nUK=SkKOy^d;ycpCay`6W z>2dzM^z*%|J>w9^y;VP8Yl7HGKo^v%WDEqZr;i@s;KRcq{9=V%>kp58rQVCzeK zCupC(Q@2aHy zpZs=B7<*UFRC>LiqrRhwabWeV7CD-7Ew*~}U@T4i*(dx_<&d0|MLX2wP@QO#y&Bhf zXMZ%DgKEd3i(EO!uO`#&A=YbqyUy*U|Y>S6kB6 zuIW0Qihpnp?v05nO_^dHxON^68D9I&3sJP--^a2K;8{TL#e8u=aqp9Ri(@}IySRIY&YRjXx43)fJh}OD3w8b!--g){zZiz+iN5Er z$DP%;M~Bzyn}fpGIVhV&VVv8QAnoF;l60kU+XnIyWOEV z+?h?918kUD9A9&Dadf5L{Tk4@;_tkvxbLaGcF#-hckv$G31bVolw75Au6twgho4D_XPH;ryGw5Od^zlG7RkeVeed^f+5OzD zv;FScxj^>lsYy?#jde6Q|(Ip6oOA^Xnr zq(9Dk`A=kh8mE44mQKTcbUf6#e)Y|6%UWBHvj$uD;1aAee#=*YaVz1fmIg1e|S~#(8!wN;X`YR2Zh6L zPZRxm-U^2_31hWkJk$px;x)tf53iIQqhq)n?k60tOT<{yQx~tZ$sF3kWdRdMn?5um zC!aP>?X7ci)c)Lud-uxzaL*F)f1&uV=QcgV-LYBgp&PWfyY{By$QC_!|4Mq+w>5sv z_qpefyb_+nzk+`Fc%L|4jrUonq*tz!u5mf^o+S`VME5C1>*tq;`r|BwIUdp-ThSj*Y;$Dg-Cyt5_dl~p`>%fgbze0H#7@B01wW_r>_0lWxa0mg#RK~m$}K7$*uPl* z68TG$JFvWXz}yP?1I0tbgVN8H3J*y~2a5;9pNF%~5`KVd=C6{Uo%HzucgFj~H`1Z! zq@y2zhn6R#wS3?-;*7KEKS&REdN9b-bP+?>ocFC5q$PhNPZ^i5ImUzfo#dcB)USD6 zB1R1}x0Bi+^`(^O?J=Y_mQ!0laDcvdxt#iA^~?9FpT1|;{NgS>zn$AUv$$iE_H;G& zA3Uq?fqvNSd9@eJIv?}>$7KVoH8|4&>4NJ;ZM-k_r+0kp(&xxQPjlq?LDOk;+vCRT z|AROc|KJ?l8!yg&{ctb$ zKdbxx)k6h+K-tGjA9zM!zrgkfuoKW+;J|yP755)oR6KM*?`YwMmu9z2zV>kr;#L$7 z4$}`LcmICrruqb&>FbEbLGk|~d~KYVDFFjC)fpZX8RIDGmN%d6`#eOuTt zR1SG?Hcsa7gI0dTsFxq{NNf2shT}Xa?omce+Zm1+))q-7>iF;>ods3a-R1DMk#Dl% zs}IcWct35ccBRjK5T~}czF2+o{d@I%rf0Q#cFrm8+O9R(Et>c5(YM}zL1zVVeg}Jd zd5w?zyI%L&?>jp1HtE8FXBSJ)x^#y4iRmFaj4tnyPEQnL+f&nf_yy-K2>+i{z0Sj( zcyUefGhe^2N4`5=&>a3eaUXhZf3fjNo!fREI4N&+U%Ah%_dCxG7#FM`kj;>tl_=d= z|A`xlqyKqk@!)Z--yB?K?qU3qeDqYtotfW9F22M-<`PDfjj1MT$SJRq-nTrS?kgm-<%tUb+K%fAql8;=%ozk7;gp|6bN0VlWAUJL_{58=&kO6!3tM~m_0wO! zN4OgQ&r`IZG`TqNoVc&P z*!p^pr_U#x`~D42=sWg22TEV;nLxT@{lMqd4}5ZRarl>J6!+b|xOnTK<=L%}pWGwz zlN*pPHzMB3z4b6|uy~ssesV+dhZL9mM-FL@sI(6?9C0|Vlh)H$7~u8C05f95avd-E zgI120i67;03@@*eA(hW>aJ(+f{Eqdo>f#urcNr31Y9HF~;gJ==sh`zc>*0gUznF_H z7XQWleXP-F@8Oir2zd7`#kx=S^4_>l|K1$m{l23cj0@6@Vd)6EhfBIM@$nJeMTZmC z#Bu)5uf3y(U-!SZ=XK(Ll#@1yzk9>*|Ln_~ihuYY->u2`Yk%~MqvjnAd&^U-nGP36 zUs!qGSO=cdJP+>o++V-X*>@eu^MSH2aJwP1AJiFkAJ(4IFV8COf9LYz?I)%8M+S>UmRq!mvE_H#u*@OTX(FRoRssNd}XH z{AFL2!&w$Duks^SkfSDp*M;_pb;)@xPGNl7(E3d8TbHXZWxmDSYiaQ?{6D;;xa+M8 zi;;KBrs{WYv^l(%+O#2tM>a~$a|Iy{#2 z{nEVu|Ng}qw^$1}`|{=s!~dsLzo%5s()Td&4M$rv;u8Ni7vKJe`a3|M`?F7^v@Lm> z7*_WNuBX}k?406lzkjIM``rAVvOUdc{(IGQ%rW1ga8EwZpMBEyq!IVu zA@1X>4>+o4sx#VieQ;56_CM-fjCai{4!lo%|L{%L=0~1bX=^oZ-_3oo{yOu1!;@3W zvAi13)7WnJ7T!1X!TNx#D?NJEsmpcF?0a^8@pfULyZv;##P7sSe1l8z53ZH`@8C^| zU0$!t*n7Q~+f|yFH-@$U$N2xsrs8Ygc_oS{d&Q?a`AVCoJ||*%9CE~X*=@X_x%Tgm z6x*NCH+akX^pm%`R&xJ&t?!h16MpmF$KTBTOh?cU!s-XyrEx`N=o#VIv#X2K&uvKW zifHVqokOT+2F{V#@Tn=qmXF_5?D(+0H}l*24(*4g6$gGp-ynOx>{NeE@9zDo&Zo-m z(EDZ-hac5Dy=ptG{9Z z>0Vjt*P`4WmQGtc++aTGlWdg_7i+u-X-I$arM1TYFa1UR{p3IY-k;WF^EyYY7>=@f ze3a+ZOY4eD#ue6J?|5;O z#vQE}$l3U#ameo^D>zeCmZQ%9JKF?Ue zm2JpcOm@CMN1LbhZ|SGsmxiwP+`oL?b=q}eL(pm019T?oPJPbkdGI|iiN4wDux#lJ6q`*X(slMVhmu8*Jo8{cug9G7{+(`r9+;(QKU z+Lm~~Uipdt=hPQ{;2ZVx$^YqJ|FPKgwEFQ9CznH8H_B`OO?pq8^4|U0_Tt{xb`~3- z*1XPf4X>V#e8X`64-)swv<*DF?~W6%xgPm7|J~xBd{OvJy zi{4k{e#7jN%049ZC1srJKiAvB#5J!uu3z)(A^z0!>#KKALwsu)`MULU4a_pmWedLb zhx+aPqmS36FWkiG?B{l)&8+QcKlp9V=krtY{C90_Y>N6`-Seb7t-niMBu%A5uK&aI z0n(x27t{y1F4X)g4?2l%-u>!!OPesN>t+73%;vS`?n6HDZv~o%sK98voz^+8s3+`SHLjheO&bPipHfOi$PAZ&F)4 z@YOxVov&_b!L8DBztVlvT5c(8b-g`q{PKX4=0D=Um($ZnNM}9H^_brtcK@lQ+iC7f z*d1xpwO+r0lQC-878kJioztCuX+wbnH*qXqD{&h6kfW5HYf-jl*k$u{{=37EZDcYk zUFj3fF1Isd4sBnyySM*@{r3Am{6fDzpvr?zqMPVwlBv2@myvjPs?#X)KKSa<9$vje zTs!xw>i@F%KT7`dyWUlYN^ax7WZCk}t;K);*MF|b%ADZ*%CE|C9Ljn4_tPB9MDuQCISJ zmYLFiKlQ(VA27K%{mOdNNpy4c_GqbVt-b2kfBhTpsc{O9QO*Ug|1&Dr9rAb>zxms* zDIWft{wwZV-~Hp7tk3@S6ZO1ZIF2_KUl=={WsPOHIPvo8N>0UB_etY^#A++o#{B z<>fc{9xvZybU&9JuY39^b6GD>-{F_(LV2bBdmgTNxaqfD^Ek(hdHkBk>36GcDIOg} z7q>q%Kcncc{_)%OHaX9- zqAa_fn^nB+4@QckFAZ85t@q~wmKV#8;aL9|zu_6p9lj6cpHCSj|6dn5U*G!o=>O|d zhkBn<>6q37R35sA4(@zzX3M?4F!ldyaVY*h@mF84vi>dV-==F(|9|C=em06){7?NK zdvRS$+tk~&4Rp8J&3Z!`p5YH6~eDC|1Zov?rO(>EBzmS zF|7wAof|3kJU{1k({H!~hvFX`qfIVU{Wm@v?o$kpg-1$%@|9}-Km5Yd;$Q#gzeaJ3 z4}SB#z3oFe(NUKF_OO>vyoZ~uTZ%Im~M z=XLh9?t6T*-u~Oux$p7KeDONr{M-2ds^nd0763zWHd4 zOK@xWg=JRf3swIfS+Y#lE-qgqE?zVK>s~m{x4!qyn#^DO&dcRBPZRTdT$kT3t@XuU zKUAD}WmWO^uj^UhWjzZtWbXP6Za?g{YaZu5*v=ar*VI*X~&u_;+_%r zaUK`_Haw4WpY$<2T7JLYmB+6~zbkF2|M`A*rHfcGO!|I#+O83Yj-hKw=d?Gd`9Q?= za9jSdT=O*ea3}u3Ex&%D>W`e!fggX57Iyvjbf5Y2k42GfPhh|HP`KU?J6`iTI%b>i z+{fqoo)_j8Z?pTdx|Ba+MvSg_()7C)F+AL?e?N?V*zp?S_}j}Guiu5?@y`E<>(^r3 zXyIm_=5Np670>l}jd1+!`J?apALE+gE{v{t^7p%T;+55=V|%4*q#b@q&jK2MTnD}U zE{v}DX1Sz0_~MG5{j|^g`NvD$zF_r#ZhUETUOQIfKR-V9tq+#h zJ)QZ@vg2c9}T;C zJ^uEv`=S1|GR7%y%xnCAZNs?h95MQaqwH;RHm}Fu9(KP`|8rm6T%3A!&H2k7@!Mp~ zai?EhSG@HPhKpS<%&L`H_5lrDw00Q{yLmnQ{@9;>sE0=nfAv^d_64m!4V( zDjTi9%m5-1U_gw*3?QfrDoD26-*nv##w{_5sOYBRV|R^f`ASj-fqB0lFvDvGkcT{l z^qx6s`KA!31Q(j-<{P*NQUW`0)$XnzRa%!IBmQSZtPWp7Rc(cE}$sCiK(OHEeZd9SfzM*=ecp$E#~pQ`^=-g^9j#a|8H<A0}+!=VtWR$qUC#gX~3GzGm@beyVtE{rAZI-@*99_;wwFZNIs1Yf8@5^Ys(8 z@7VFllQ(PE470NP9`n?o{GHFX?^k4-SRopUKO{e$__qFK5139q<@IHiu*k)C2e=5V#%^oY3*ywr5*A&dSp!=?EGn2wlzkm9g06BKb`pXU;lFcS5RYFI>-^^ z3UVe(E^CZ?fW51h;osu6ADgegIh82?lNU~yd-up^jt)+t{%aq^cDs3UV3AqV+h`hg-&{2w?PtYLndwGnMRR$r)<5Id z>oR-yO*S`>Bghp!R!JK<8^5pS%-x+wlKwTeH4U*MOX51aq87Fjrdn)pbEX50NIyUpx9H>UKm z<}1raaV+gK=b03;3)}y}z7MSHn``&{qJFVH^5jFF`2E=0PA%K{;)hSA>pzW^jwO#w zE#oU-h4SCGW?Xkt#DH^tZQmT51IUGRT(FZTw$d_LIgJ_^IN6h`k3Ua-9Pp_&@yGG* z5j;FHCzjg2#6*6Al20nY6`8^E;{K>@sChXXG8j|<@ z&L4X4w0~^7HpvIYm^?Nv%0;}$*T%M6>^t8^`Tjt2=bg$I%4K|d3gMDZs{L|#QD5Vtt^0}Ga z!m##;1vAXYU&6k)*1--Nnl%-wDle&BthjJQ+JnE7b_^bP7+uSS86~}$FpfjiKTe8NDcC8EhJ!NT& z6nwNvUB2#9hFI)VhB(-FWDkgQ{08&Z*voF|+ZsWwr01CU1>8#FFUCY4i5a3r-wSh* z)=THV*3s=5X4}3OQt{$FXZzR-c3v<)MLXK3Bu+67@b#E(Q$Hk=l$|4WkNV#z>$9xu zCu;49Lz>hpC;nnS5et<5C2^DU;Eu6RUE-0?zQ!kyGB=pkxc)ijQp#$+%ggG!W598F z==0HbbUUq6%zr(m+te>$V@(Tte51iyr~RP3rXjp68Lgw+1s(1I+sA&J`dxVx$E*9c zh;JGnZ5K!GKk@#j$8?(*;AuYFq$1ZA$%5)O{D^rXAGeGA6}@u*7hmEr%8+soMPJ&V zPJH0m11{P_LXho0#o6}wrJC(30cNeT$hx-m4_TArt@v}NCssBE?|H~bqI-IHUFB>f9_DDaX8gI5U20fgAYvlfK$2hKhu-L*U4k+%}&x|}Idq5w;PkrJ0 zArEx@ERF*h)Tgg;-)9p%@*Uf;p6es~N{@CIcm2R@7`$KZkiRrbyMI)~7M)7sXAGH? zenft`e#d=B&SHW`zEH}7vJE*~|8fUN_@VT`*YX-OrK4>|KF2bS4>8}B(|!68@e+Or zZ%w~^{wKMoeOlTr|0hn?2y2BkvvwTYE>6l9+Kp&J-WiK^!RMo6h}y_ye81cGg>w+K zfSQQ$phsH~M#2x_rQ(m|7;~&IIMfAYJGMo?9XiYD$MHFQoBy&Fp>dbLr2B{VUcIFI z4l}poHq&uD{Vsrc!p}oIKfF0Nu4{+=ym3&T)Tj4AkNjU+d5=VXaUUo*e&Sy)>WhQ6 z-gO<-LF*~8dJA%|HWRUd7-wheabYn%e`na{XJ037ykC? z&SRg;ykJe$9(x7b4U$_A_Q?O=9$suV3@tQ|4b00O|8$9geq_}@Wu_}d{$uXf4=s?- z^%vVaKXPfh_}eT$SNO%z&r(18%6;I|$q$V7w@@3+vKPobAV+@Zt^Yh&vpXuUtrdN! z|AwN^0W4qbYu~nwZSnqm0Okw76SZUiR@2mROVLiLISTlpZN}|#WFxx`9oPrt@4w4F zfV}|i#tzB93b0~7vSKP1H-~&{zkL3O`+pAETt348T4L$>h;SQZj=BG0?(wblZ5X=WY#NdO zMbLMTnb&#SwDB)1UR(dN2V}`imodyK=62Iab3ETG`{eyUBcCqk80!92Im0H;9nQ>O zOwr~drT@0q_G{aAhzD*3xmdQy*_pEU0t zeA6^{-xah=ys4%-{zcv2H|qxHm|u@B6FOI7mb=_E$xojjsQ*>dy#5#W{J_rTX33#- z19QwTWsVWcbjklka#kZ3k4jF4zVLf}e>(D!L2moj!Q~ZeCY(Q4H2%)wond9a-2Vgh z&p4WM*8XgT`^?ZfbKq?9|DL<^d*N^AP0z^=`)=?n`5eNX$0_S8KYj7f>$=T=zj0)- zc}kuZY0vNaPHF!X?v(ki_9N7OMfG2@MnLErO<`Q$N=j2M}|3mWg z1aFMLWO`1t2j5wls|U}Hn|~d8G~v6BBhgm#gqq1ZH)|F@Y|7IRQCov$wB!7DbgciQ z(Yf!8<+ah+DeqG9-hc9&6P(T<#bxUPZhx>kJ?(O<}>sQHnK&=Mmt{i_uvaJJB|MKLWqmVBle#ov*9&>`Z!5m?( zFlRXX8#?8)e9s)L8I=2Pzuf;lV=ga`A0e5h&Y6-+%gpxi7j14K$K3zP|L^kpqkG%C?Cit`Gxh? zSF`i0&+TUrpUXSjr;{I=KTf=B-aqt~tn2IM?SrqF|2puB z`0=8B-Z{L@{6U^q#{WI{^XmA2n70qTWd3LJ4fDZ~9p?QB;J$3YF9)Yu+20@fo%#Kt zmoKZ$SI2)Nv2K-^x7*k;2bha(`+rlGADDB@J=Xzi^0qwh9(qI8YKQsb;dkxYVGaLs z^b@7ytwDK z>xF!sx8#9_9ygb@zHS%vxd));PRNc!09eCdZyoupB=S% zFnRutoJbrdW)^xzGE!G%ojWz$|*!wmIkPFX^KW5fTZqzEzfHPJ3kJ?Agq4uiv zcje&Srqbu}k@>M7m15Mp9_9e+P>*q}AGy!wd_DTUVf223xvxjxYvD8E#~x7a95i&x zU7&)exxesP-#^P5V4tf5<7&{sTGng)tA=KoRYUUqzY5m>O6y?GG51&6k1J&#@uT+Q z&)X|q!=4*WqdX1z0hW&s85y&+zfV4kls&-a|8P|Q#1I*asc_KeV{Kt98c*1cFsM%* z+mGzWZP#8v*Tr}B-9G(F@gw`h!+1T%kJ`tRic_r{V~wd=vxJ`FWB%-z!a<*p#lo1@ ziPnyGs81c+kLDlkMpA|_JpY|F)-jffKO_sikZy>tv9j9GN6xj#`LE?l;c}dm zx^AC*(5x{n=gLAaq#NS9c%eS@b^NSJ>;)km?O&z#n+KxW7fL%i|H-?SXg$ZjYIv5x z9#D_#W%FOoo_h4X7CyW2FJ*0(*aLFe?p0?#+VWWZF}Y`;_9Gr>BLBgcy~&P~YM#>5 z7_oeoPSzN4A*1D7+wK_0sYj~$8k=oJ>pA|_Qva)l3-|v-UX&4kYVA13q{LElmRftt zWa&6#kBrFqkNVfRkkN9)rHnhqaq5w3ewMBtBeqM4McEM5wtYIpjmCWOdu!CuOUzfu z7l-e~jbvJe<^C^gQ3>wVpi|HBuO5-}U-p1{T!%+S8q@Xfq^%O0ZmZadEZ3>jDFYU{I>q(9iSf9W9_Ki|8f3Zqj{Gn{=7Bp&S5!v$bb0a zV&v&JP0`vBmz2J^ee$cJ^&J0|uK#M*BP;)jOUm_i`{Z9KT8%$><1g0o9VTQCe2zi> z*W>azKkodhKCMiD;ZCCETSf}?PkrjJPwMi~ZxTE_9J9R0mn=QXkwTU@wjKG+HYw#n z^`pl%U$?EyRTe+%znb_tN5Ehklzmc;>3%iqzjjnU|CjS0c=qwEO+1$24}H~s(N|4F zHYnrjvrp=iyIk=vvo$TB2MpaAuS@JtRDxIq|!?<@8-UZa)hP=U?lH)W4HMwdj}QM>Y0bJ8nM< zyWIGfN$sPyL4CNT#(g^AVGY*qZS-PdyquUqHl{Pbp5qVIe=X;Gs@5MlH&yk2wZtE))73cr8TK`qX&pqJ(0o*t@-T(jq literal 0 HcmV?d00001 diff --git a/dist/sudachi.desktop b/dist/sudachi.desktop new file mode 100644 index 0000000..a0fcd48 --- /dev/null +++ b/dist/sudachi.desktop @@ -0,0 +1,12 @@ +[Desktop Entry] +Version=1.0 +Type=Application +Name=sudachi +GenericName=Switch Emulator +Comment=Nintendo Switch video game console emulator +Icon=sudachi +TryExec=sudachi +Exec=sudachi %f +Categories=Game;Emulator;Qt; +MimeType=application/x-nx-nro;application/x-nx-nso;application/x-nx-nsp;application/x-nx-xci; +Keywords=Switch;Nintendo; \ No newline at end of file diff --git a/dist/sudachi.icns b/dist/sudachi.icns new file mode 100644 index 0000000000000000000000000000000000000000..8cb83f9daa0f27be8ba43357afe5ab48dc13e05e GIT binary patch literal 722366 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%0AmM-#U6p)V1bQtK}k^B2Y4vX~K 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

mG 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�} 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

G&Kjt*1GidI0U4Z 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%0AMmMo%MS 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≪+Y-DP0KO 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

Mrzif{628lb#-2g4#9RjUE%MKL8V`Trcg=Cxwc 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 L(EscIZ#?i{QJ+Q` literal 0 HcmV?d00001 diff --git a/dist/sudachi.manifest b/dist/sudachi.manifest new file mode 100644 index 0000000..0236ad1 --- /dev/null +++ b/dist/sudachi.manifest @@ -0,0 +1,58 @@ + + + + + + + + + + true/pm + + + + PerMonitorV2 + + + + true + + + true + + + + + + + + + + + + + + + + + + diff --git a/externals/SDL b/externals/SDL index ee87132..d79f865 160000 --- a/externals/SDL +++ b/externals/SDL @@ -1 +1 @@ -Subproject commit ee87132385014449c4cd33236c661d57539071c1 +Subproject commit d79f8652510b8bd1f89c90be2ab65fc8940056eb diff --git a/externals/ffmpeg/ffmpeg b/externals/ffmpeg/ffmpeg index 53a952a..d65908c 160000 --- a/externals/ffmpeg/ffmpeg +++ b/externals/ffmpeg/ffmpeg @@ -1 +1 @@ -Subproject commit 53a952a7313f2c78d93a4f6805abe570fe35f96b +Subproject commit d65908c3d416e331e075c3a5ffe7bc670112a018 diff --git a/hooks/pre-commit b/hooks/pre-commit index 484eef1..13f9a6e 100644 --- a/hooks/pre-commit +++ b/hooks/pre-commit @@ -3,7 +3,7 @@ # SPDX-FileCopyrightText: 2015 Citra Emulator Project # SPDX-License-Identifier: GPL-2.0-or-later -# Enforce yuzu's whitespace policy +# Enforce sudachi's whitespace policy git config --local core.whitespace tab-in-indent,trailing-space paths_to_check="src/ CMakeLists.txt" diff --git a/src/android/app/src/ea/res/drawable/ic_sudachi.xml b/src/android/app/src/ea/res/drawable/ic_sudachi.xml new file mode 100644 index 0000000..deb8ba5 --- /dev/null +++ b/src/android/app/src/ea/res/drawable/ic_sudachi.xml @@ -0,0 +1,22 @@ + + + + diff --git a/src/android/app/src/ea/res/drawable/ic_sudachi_full.xml b/src/android/app/src/ea/res/drawable/ic_sudachi_full.xml new file mode 100644 index 0000000..4ef4728 --- /dev/null +++ b/src/android/app/src/ea/res/drawable/ic_sudachi_full.xml @@ -0,0 +1,12 @@ + + + + diff --git a/src/android/app/src/ea/res/drawable/ic_sudachi_title.xml b/src/android/app/src/ea/res/drawable/ic_sudachi_title.xml new file mode 100644 index 0000000..29d0cfc --- /dev/null +++ b/src/android/app/src/ea/res/drawable/ic_sudachi_title.xml @@ -0,0 +1,24 @@ + + + + + + + + diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/NativeLibrary.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/NativeLibrary.kt new file mode 100644 index 0000000..f321e35 --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/NativeLibrary.kt @@ -0,0 +1,462 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu + +import android.content.DialogInterface +import android.net.Uri +import android.text.Html +import android.text.method.LinkMovementMethod +import android.view.Surface +import android.view.View +import android.widget.TextView +import androidx.annotation.Keep +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import java.lang.ref.WeakReference +import org.sudachi.sudachi_emu.activities.EmulationActivity +import org.sudachi.sudachi_emu.fragments.CoreErrorDialogFragment +import org.sudachi.sudachi_emu.utils.DocumentsTree +import org.sudachi.sudachi_emu.utils.FileUtil +import org.sudachi.sudachi_emu.utils.Log +import org.sudachi.sudachi_emu.model.InstallResult +import org.sudachi.sudachi_emu.model.Patch +import org.sudachi.sudachi_emu.model.GameVerificationResult + +/** + * Class which contains methods that interact + * with the native side of the Sudachi code. + */ +object NativeLibrary { + @JvmField + var sEmulationActivity = WeakReference(null) + + init { + try { + System.loadLibrary("sudachi-android") + } catch (ex: UnsatisfiedLinkError) { + error("[NativeLibrary] $ex") + } + } + + @Keep + @JvmStatic + fun openContentUri(path: String?, openmode: String?): Int { + return if (DocumentsTree.isNativePath(path!!)) { + SudachiApplication.documentsTree!!.openContentUri(path, openmode) + } else { + FileUtil.openContentUri(path, openmode) + } + } + + @Keep + @JvmStatic + fun getSize(path: String?): Long { + return if (DocumentsTree.isNativePath(path!!)) { + SudachiApplication.documentsTree!!.getFileSize(path) + } else { + FileUtil.getFileSize(path) + } + } + + @Keep + @JvmStatic + fun exists(path: String?): Boolean { + return if (DocumentsTree.isNativePath(path!!)) { + SudachiApplication.documentsTree!!.exists(path) + } else { + FileUtil.exists(path, suppressLog = true) + } + } + + @Keep + @JvmStatic + fun isDirectory(path: String?): Boolean { + return if (DocumentsTree.isNativePath(path!!)) { + SudachiApplication.documentsTree!!.isDirectory(path) + } else { + FileUtil.isDirectory(path) + } + } + + @Keep + @JvmStatic + fun getParentDirectory(path: String): String = + if (DocumentsTree.isNativePath(path)) { + SudachiApplication.documentsTree!!.getParentDirectory(path) + } else { + path + } + + @Keep + @JvmStatic + fun getFilename(path: String): String = + if (DocumentsTree.isNativePath(path)) { + SudachiApplication.documentsTree!!.getFilename(path) + } else { + FileUtil.getFilename(Uri.parse(path)) + } + + external fun setAppDirectory(directory: String) + + /** + * Installs a nsp or xci file to nand + * @param filename String representation of file uri + * @return int representation of [InstallResult] + */ + external fun installFileToNand( + filename: String, + callback: (max: Long, progress: Long) -> Boolean + ): Int + + external fun doesUpdateMatchProgram(programId: String, updatePath: String): Boolean + + external fun initializeGpuDriver( + hookLibDir: String?, + customDriverDir: String?, + customDriverName: String?, + fileRedirectDir: String? + ) + + external fun reloadKeys(): Boolean + + external fun initializeSystem(reload: Boolean) + + /** + * Begins emulation. + */ + external fun run(path: String?, programIndex: Int, frontendInitiated: Boolean) + + // Surface Handling + external fun surfaceChanged(surf: Surface?) + + external fun surfaceDestroyed() + + /** + * Unpauses emulation from a paused state. + */ + external fun unpauseEmulation() + + /** + * Pauses emulation. + */ + external fun pauseEmulation() + + /** + * Stops emulation. + */ + external fun stopEmulation() + + /** + * Returns true if emulation is running (or is paused). + */ + external fun isRunning(): Boolean + + /** + * Returns true if emulation is paused. + */ + external fun isPaused(): Boolean + + /** + * Returns the performance stats for the current game + */ + external fun getPerfStats(): DoubleArray + + /** + * Returns the current CPU backend. + */ + external fun getCpuBackend(): String + + /** + * Returns the current GPU Driver. + */ + external fun getGpuDriver(): String + + external fun applySettings() + + external fun logSettings() + + enum class CoreError { + ErrorSystemFiles, + ErrorSavestate, + ErrorUnknown + } + + var coreErrorAlertResult = false + val coreErrorAlertLock = Object() + + private fun onCoreErrorImpl(title: String, message: String) { + val emulationActivity = sEmulationActivity.get() + if (emulationActivity == null) { + Log.error("[NativeLibrary] EmulationActivity not present") + return + } + + val fragment = CoreErrorDialogFragment.newInstance(title, message) + fragment.show(emulationActivity.supportFragmentManager, "coreError") + } + + /** + * Handles a core error. + * + * @return true: continue; false: abort + */ + fun onCoreError(error: CoreError?, details: String): Boolean { + val emulationActivity = sEmulationActivity.get() + if (emulationActivity == null) { + Log.error("[NativeLibrary] EmulationActivity not present") + return false + } + + val title: String + val message: String + when (error) { + CoreError.ErrorSystemFiles -> { + title = emulationActivity.getString(R.string.system_archive_not_found) + message = emulationActivity.getString( + R.string.system_archive_not_found_message, + details.ifEmpty { emulationActivity.getString(R.string.system_archive_general) } + ) + } + + CoreError.ErrorSavestate -> { + title = emulationActivity.getString(R.string.save_load_error) + message = details + } + + CoreError.ErrorUnknown -> { + title = emulationActivity.getString(R.string.fatal_error) + message = emulationActivity.getString(R.string.fatal_error_message) + } + + else -> { + return true + } + } + + // Show the AlertDialog on the main thread. + emulationActivity.runOnUiThread { onCoreErrorImpl(title, message) } + + // Wait for the lock to notify that it is complete. + synchronized(coreErrorAlertLock) { coreErrorAlertLock.wait() } + + return coreErrorAlertResult + } + + @Keep + @JvmStatic + fun exitEmulationActivity(resultCode: Int) { + val Success = 0 + val ErrorNotInitialized = 1 + val ErrorGetLoader = 2 + val ErrorSystemFiles = 3 + val ErrorSharedFont = 4 + val ErrorVideoCore = 5 + val ErrorUnknown = 6 + val ErrorLoader = 7 + + val captionId: Int + var descriptionId: Int + when (resultCode) { + ErrorVideoCore -> { + captionId = R.string.loader_error_video_core + descriptionId = R.string.loader_error_video_core_description + } + + else -> { + captionId = R.string.loader_error_encrypted + descriptionId = R.string.loader_error_encrypted_roms_description + if (!reloadKeys()) { + descriptionId = R.string.loader_error_encrypted_keys_description + } + } + } + + val emulationActivity = sEmulationActivity.get() + if (emulationActivity == null) { + Log.warning("[NativeLibrary] EmulationActivity is null, can't exit.") + return + } + + val builder = MaterialAlertDialogBuilder(emulationActivity) + .setTitle(captionId) + .setMessage( + Html.fromHtml( + emulationActivity.getString(descriptionId), + Html.FROM_HTML_MODE_LEGACY + ) + ) + .setPositiveButton(android.R.string.ok) { _: DialogInterface?, _: Int -> + emulationActivity.finish() + } + .setOnDismissListener { emulationActivity.finish() } + emulationActivity.runOnUiThread { + val alert = builder.create() + alert.show() + (alert.findViewById(android.R.id.message) as TextView).movementMethod = + LinkMovementMethod.getInstance() + } + } + + fun setEmulationActivity(emulationActivity: EmulationActivity?) { + Log.debug("[NativeLibrary] Registering EmulationActivity.") + sEmulationActivity = WeakReference(emulationActivity) + } + + fun clearEmulationActivity() { + Log.debug("[NativeLibrary] Unregistering EmulationActivity.") + sEmulationActivity.clear() + } + + @Keep + @JvmStatic + fun onEmulationStarted() { + sEmulationActivity.get()!!.onEmulationStarted() + } + + @Keep + @JvmStatic + fun onEmulationStopped(status: Int) { + sEmulationActivity.get()!!.onEmulationStopped(status) + } + + @Keep + @JvmStatic + fun onProgramChanged(programIndex: Int) { + sEmulationActivity.get()!!.onProgramChanged(programIndex) + } + + /** + * Logs the Sudachi version, Android version and, CPU. + */ + external fun logDeviceInfo() + + /** + * Submits inline keyboard text. Called on input for buttons that result text. + * @param text Text to submit to the inline software keyboard implementation. + */ + external fun submitInlineKeyboardText(text: String?) + + /** + * Submits inline keyboard input. Used to indicate keys pressed that are not text. + * @param key_code Android Key Code associated with the keyboard input. + */ + external fun submitInlineKeyboardInput(key_code: Int) + + /** + * Creates a generic user directory if it doesn't exist already + */ + external fun initializeEmptyUserDirectory() + + /** + * Gets the launch path for a given applet. It is the caller's responsibility to also + * set the system's current applet ID before trying to launch the nca given by this function. + * + * @param id The applet entry ID + * @return The applet's launch path + */ + external fun getAppletLaunchPath(id: Long): String + + /** + * Sets the system's current applet ID before launching. + * + * @param appletId One of the ids in the Service::AM::Applets::AppletId enum + */ + external fun setCurrentAppletId(appletId: Int) + + /** + * Sets the cabinet mode for launching the cabinet applet. + * + * @param cabinetMode One of the modes that corresponds to the enum in Service::NFP::CabinetMode + */ + external fun setCabinetMode(cabinetMode: Int) + + /** + * Checks whether NAND contents are available and valid. + * + * @return 'true' if firmware is available + */ + external fun isFirmwareAvailable(): Boolean + + /** + * Checks the PatchManager for any addons that are available + * + * @param path Path to game file. Can be a [Uri]. + * @param programId String representation of a game's program ID + * @return Array of available patches + */ + external fun getPatchesForFile(path: String, programId: String): Array? + + /** + * Removes an update for a given [programId] + * @param programId String representation of a game's program ID + */ + external fun removeUpdate(programId: String) + + /** + * Removes all DLC for a [programId] + * @param programId String representation of a game's program ID + */ + external fun removeDLC(programId: String) + + /** + * Removes a mod installed for a given [programId] + * @param programId String representation of a game's program ID + * @param name The name of a mod as given by [getPatchesForFile]. This corresponds with the name + * of the mod's directory in a game's load folder. + */ + external fun removeMod(programId: String, name: String) + + /** + * Verifies all installed content + * @param callback UI callback for verification progress. Return true in the callback to cancel. + * @return Array of content that failed verification. Successful if empty. + */ + external fun verifyInstalledContents( + callback: (max: Long, progress: Long) -> Boolean + ): Array + + /** + * Verifies the contents of a game + * @param path String path to a game + * @param callback UI callback for verification progress. Return true in the callback to cancel. + * @return Int that is meant to be converted to a [GameVerificationResult] + */ + external fun verifyGameContents( + path: String, + callback: (max: Long, progress: Long) -> Boolean + ): Int + + /** + * Gets the save location for a specific game + * + * @param programId String representation of a game's program ID + * @return Save data path that may not exist yet + */ + external fun getSavePath(programId: String): String + + /** + * Gets the root save directory for the default profile as either + * /user/save/account/ or /user/save/000...000/ + * + * @param future If true, returns the /user/save/account/... directory + * @return Save data path that may not exist yet + */ + external fun getDefaultProfileSaveDataRoot(future: Boolean): String + + /** + * Adds a file to the manual filesystem provider in our EmulationSession instance + * @param path Path to the file we're adding. Can be a string representation of a [Uri] or + * a normal path + */ + external fun addFileToFilesystemProvider(path: String) + + /** + * Clears all files added to the manual filesystem provider in our EmulationSession instance + */ + external fun clearFilesystemProvider() + + /** + * Checks if all necessary keys are present for decryption + */ + external fun areKeysPresent(): Boolean +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/SudachiApplication.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/SudachiApplication.kt new file mode 100644 index 0000000..858b6ef --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/SudachiApplication.kt @@ -0,0 +1,55 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu + +import android.app.Application +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.Context +import org.sudachi.sudachi_emu.features.input.NativeInput +import java.io.File +import org.sudachi.sudachi_emu.utils.DirectoryInitialization +import org.sudachi.sudachi_emu.utils.DocumentsTree +import org.sudachi.sudachi_emu.utils.GpuDriverHelper +import org.sudachi.sudachi_emu.utils.Log + +fun Context.getPublicFilesDir(): File = getExternalFilesDir(null) ?: filesDir + +class SudachiApplication : Application() { + private fun createNotificationChannels() { + val noticeChannel = NotificationChannel( + getString(R.string.notice_notification_channel_id), + getString(R.string.notice_notification_channel_name), + NotificationManager.IMPORTANCE_HIGH + ) + noticeChannel.description = getString(R.string.notice_notification_channel_description) + noticeChannel.setSound(null, null) + + // Register the channel with the system; you can't change the importance + // or other notification behaviors after this + val notificationManager = getSystemService(NotificationManager::class.java) + notificationManager.createNotificationChannel(noticeChannel) + } + + override fun onCreate() { + super.onCreate() + application = this + documentsTree = DocumentsTree() + DirectoryInitialization.start() + GpuDriverHelper.initializeDriverParameters() + NativeInput.reloadInputDevices() + NativeLibrary.logDeviceInfo() + Log.logDeviceInfo() + + createNotificationChannels() + } + + companion object { + var documentsTree: DocumentsTree? = null + lateinit var application: SudachiApplication + + val appContext: Context + get() = application.applicationContext + } +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/activities/EmulationActivity.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/activities/EmulationActivity.kt new file mode 100644 index 0000000..59a14ea --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/activities/EmulationActivity.kt @@ -0,0 +1,509 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.activities + +import android.annotation.SuppressLint +import android.app.PendingIntent +import android.app.PictureInPictureParams +import android.app.RemoteAction +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.content.res.Configuration +import android.graphics.Rect +import android.graphics.drawable.Icon +import android.hardware.Sensor +import android.hardware.SensorEvent +import android.hardware.SensorEventListener +import android.hardware.SensorManager +import android.os.Build +import android.os.Bundle +import android.util.Rational +import android.view.InputDevice +import android.view.KeyEvent +import android.view.MotionEvent +import android.view.Surface +import android.view.View +import android.view.inputmethod.InputMethodManager +import android.widget.Toast +import androidx.activity.viewModels +import androidx.appcompat.app.AppCompatActivity +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsControllerCompat +import androidx.navigation.fragment.NavHostFragment +import androidx.preference.PreferenceManager +import org.sudachi.sudachi_emu.NativeLibrary +import org.sudachi.sudachi_emu.R +import org.sudachi.sudachi_emu.SudachiApplication +import org.sudachi.sudachi_emu.databinding.ActivityEmulationBinding +import org.sudachi.sudachi_emu.features.input.NativeInput +import org.sudachi.sudachi_emu.features.settings.model.BooleanSetting +import org.sudachi.sudachi_emu.features.settings.model.IntSetting +import org.sudachi.sudachi_emu.features.settings.model.Settings +import org.sudachi.sudachi_emu.model.EmulationViewModel +import org.sudachi.sudachi_emu.model.Game +import org.sudachi.sudachi_emu.utils.InputHandler +import org.sudachi.sudachi_emu.utils.Log +import org.sudachi.sudachi_emu.utils.MemoryUtil +import org.sudachi.sudachi_emu.utils.NativeConfig +import org.sudachi.sudachi_emu.utils.NfcReader +import org.sudachi.sudachi_emu.utils.ParamPackage +import org.sudachi.sudachi_emu.utils.ThemeHelper +import java.text.NumberFormat +import kotlin.math.roundToInt + +class EmulationActivity : AppCompatActivity(), SensorEventListener { + private lateinit var binding: ActivityEmulationBinding + + var isActivityRecreated = false + private lateinit var nfcReader: NfcReader + + private val gyro = FloatArray(3) + private val accel = FloatArray(3) + private var motionTimestamp: Long = 0 + private var flipMotionOrientation: Boolean = false + + private val actionPause = "ACTION_EMULATOR_PAUSE" + private val actionPlay = "ACTION_EMULATOR_PLAY" + private val actionMute = "ACTION_EMULATOR_MUTE" + private val actionUnmute = "ACTION_EMULATOR_UNMUTE" + + private val emulationViewModel: EmulationViewModel by viewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + Log.gameLaunched = true + ThemeHelper.setTheme(this) + + super.onCreate(savedInstanceState) + + InputHandler.updateControllerData() + val players = NativeConfig.getInputSettings(true) + var hasConfiguredControllers = false + players.forEach { + if (it.hasMapping()) { + hasConfiguredControllers = true + } + } + if (!hasConfiguredControllers && InputHandler.androidControllers.isNotEmpty()) { + var params: ParamPackage? = null + for (controller in InputHandler.registeredControllers) { + if (controller.get("port", -1) == 0) { + params = controller + break + } + } + + if (params != null) { + NativeInput.updateMappingsWithDefault( + 0, + params, + params.get("display", getString(R.string.unknown)) + ) + NativeConfig.saveGlobalConfig() + } + } + + binding = ActivityEmulationBinding.inflate(layoutInflater) + setContentView(binding.root) + + val navHostFragment = + supportFragmentManager.findFragmentById(R.id.fragment_container) as NavHostFragment + navHostFragment.navController.setGraph(R.navigation.emulation_navigation, intent.extras) + + isActivityRecreated = savedInstanceState != null + + // Set these options now so that the SurfaceView the game renders into is the right size. + enableFullscreenImmersive() + + window.decorView.setBackgroundColor(getColor(android.R.color.black)) + + nfcReader = NfcReader(this) + nfcReader.initialize() + + val preferences = PreferenceManager.getDefaultSharedPreferences(SudachiApplication.appContext) + if (!preferences.getBoolean(Settings.PREF_MEMORY_WARNING_SHOWN, false)) { + if (MemoryUtil.isLessThan(MemoryUtil.REQUIRED_MEMORY, MemoryUtil.totalMemory)) { + Toast.makeText( + this, + getString( + R.string.device_memory_inadequate, + MemoryUtil.getDeviceRAM(), + getString( + R.string.memory_formatted, + NumberFormat.getInstance().format(MemoryUtil.REQUIRED_MEMORY), + getString(R.string.memory_gigabyte) + ) + ), + Toast.LENGTH_LONG + ).show() + preferences.edit() + .putBoolean(Settings.PREF_MEMORY_WARNING_SHOWN, true) + .apply() + } + } + } + + override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean { + if (event.action == KeyEvent.ACTION_DOWN) { + if (keyCode == KeyEvent.KEYCODE_ENTER) { + // Special case, we do not support multiline input, dismiss the keyboard. + val overlayView: View = + this.findViewById(R.id.surface_input_overlay) + val im = + overlayView.context.getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager + im.hideSoftInputFromWindow(overlayView.windowToken, 0) + } else { + val textChar = event.unicodeChar + if (textChar == 0) { + // No text, button input. + NativeLibrary.submitInlineKeyboardInput(keyCode) + } else { + // Text submitted. + NativeLibrary.submitInlineKeyboardText(textChar.toChar().toString()) + } + } + } + return super.onKeyDown(keyCode, event) + } + + override fun onResume() { + super.onResume() + nfcReader.startScanning() + startMotionSensorListener() + InputHandler.updateControllerData() + + buildPictureInPictureParams() + } + + override fun onPause() { + super.onPause() + nfcReader.stopScanning() + stopMotionSensorListener() + } + + override fun onUserLeaveHint() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { + if (BooleanSetting.PICTURE_IN_PICTURE.getBoolean() && !isInPictureInPictureMode) { + val pictureInPictureParamsBuilder = PictureInPictureParams.Builder() + .getPictureInPictureActionsBuilder().getPictureInPictureAspectBuilder() + enterPictureInPictureMode(pictureInPictureParamsBuilder.build()) + } + } + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + setIntent(intent) + nfcReader.onNewIntent(intent) + InputHandler.updateControllerData() + } + + override fun dispatchKeyEvent(event: KeyEvent): Boolean { + if (event.source and InputDevice.SOURCE_JOYSTICK != InputDevice.SOURCE_JOYSTICK && + event.source and InputDevice.SOURCE_GAMEPAD != InputDevice.SOURCE_GAMEPAD + ) { + return super.dispatchKeyEvent(event) + } + + if (emulationViewModel.drawerOpen.value) { + return super.dispatchKeyEvent(event) + } + + return InputHandler.dispatchKeyEvent(event) + } + + override fun dispatchGenericMotionEvent(event: MotionEvent): Boolean { + if (event.source and InputDevice.SOURCE_JOYSTICK != InputDevice.SOURCE_JOYSTICK && + event.source and InputDevice.SOURCE_GAMEPAD != InputDevice.SOURCE_GAMEPAD + ) { + return super.dispatchGenericMotionEvent(event) + } + + if (emulationViewModel.drawerOpen.value) { + return super.dispatchGenericMotionEvent(event) + } + + // Don't attempt to do anything if we are disconnecting a device. + if (event.actionMasked == MotionEvent.ACTION_CANCEL) { + return true + } + + return InputHandler.dispatchGenericMotionEvent(event) + } + + override fun onSensorChanged(event: SensorEvent) { + val rotation = this.display?.rotation + if (rotation == Surface.ROTATION_90) { + flipMotionOrientation = true + } + if (rotation == Surface.ROTATION_270) { + flipMotionOrientation = false + } + + if (event.sensor.type == Sensor.TYPE_ACCELEROMETER) { + if (flipMotionOrientation) { + accel[0] = event.values[1] / SensorManager.GRAVITY_EARTH + accel[1] = -event.values[0] / SensorManager.GRAVITY_EARTH + } else { + accel[0] = -event.values[1] / SensorManager.GRAVITY_EARTH + accel[1] = event.values[0] / SensorManager.GRAVITY_EARTH + } + accel[2] = -event.values[2] / SensorManager.GRAVITY_EARTH + } + if (event.sensor.type == Sensor.TYPE_GYROSCOPE) { + // Investigate why sensor value is off by 6x + if (flipMotionOrientation) { + gyro[0] = -event.values[1] / 6.0f + gyro[1] = event.values[0] / 6.0f + } else { + gyro[0] = event.values[1] / 6.0f + gyro[1] = -event.values[0] / 6.0f + } + gyro[2] = event.values[2] / 6.0f + } + + // Only update state on accelerometer data + if (event.sensor.type != Sensor.TYPE_ACCELEROMETER) { + return + } + val deltaTimestamp = (event.timestamp - motionTimestamp) / 1000 + motionTimestamp = event.timestamp + NativeInput.onDeviceMotionEvent( + NativeInput.Player1Device, + deltaTimestamp, + gyro[0], + gyro[1], + gyro[2], + accel[0], + accel[1], + accel[2] + ) + NativeInput.onDeviceMotionEvent( + NativeInput.ConsoleDevice, + deltaTimestamp, + gyro[0], + gyro[1], + gyro[2], + accel[0], + accel[1], + accel[2] + ) + } + + override fun onAccuracyChanged(sensor: Sensor, i: Int) {} + + private fun enableFullscreenImmersive() { + WindowCompat.setDecorFitsSystemWindows(window, false) + + WindowInsetsControllerCompat(window, window.decorView).let { controller -> + controller.hide(WindowInsetsCompat.Type.systemBars()) + controller.systemBarsBehavior = + WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + } + } + + private fun PictureInPictureParams.Builder.getPictureInPictureAspectBuilder(): + PictureInPictureParams.Builder { + val aspectRatio = when (IntSetting.RENDERER_ASPECT_RATIO.getInt()) { + 0 -> Rational(16, 9) + 1 -> Rational(4, 3) + 2 -> Rational(21, 9) + 3 -> Rational(16, 10) + else -> null // Best fit + } + return this.apply { aspectRatio?.let { setAspectRatio(it) } } + } + + private fun PictureInPictureParams.Builder.getPictureInPictureActionsBuilder(): + PictureInPictureParams.Builder { + val pictureInPictureActions: MutableList = mutableListOf() + val pendingFlags = PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + + if (NativeLibrary.isPaused()) { + val playIcon = Icon.createWithResource(this@EmulationActivity, R.drawable.ic_pip_play) + val playPendingIntent = PendingIntent.getBroadcast( + this@EmulationActivity, + R.drawable.ic_pip_play, + Intent(actionPlay), + pendingFlags + ) + val playRemoteAction = RemoteAction( + playIcon, + getString(R.string.play), + getString(R.string.play), + playPendingIntent + ) + pictureInPictureActions.add(playRemoteAction) + } else { + val pauseIcon = Icon.createWithResource(this@EmulationActivity, R.drawable.ic_pip_pause) + val pausePendingIntent = PendingIntent.getBroadcast( + this@EmulationActivity, + R.drawable.ic_pip_pause, + Intent(actionPause), + pendingFlags + ) + val pauseRemoteAction = RemoteAction( + pauseIcon, + getString(R.string.pause), + getString(R.string.pause), + pausePendingIntent + ) + pictureInPictureActions.add(pauseRemoteAction) + } + + if (BooleanSetting.AUDIO_MUTED.getBoolean()) { + val unmuteIcon = Icon.createWithResource( + this@EmulationActivity, + R.drawable.ic_pip_unmute + ) + val unmutePendingIntent = PendingIntent.getBroadcast( + this@EmulationActivity, + R.drawable.ic_pip_unmute, + Intent(actionUnmute), + pendingFlags + ) + val unmuteRemoteAction = RemoteAction( + unmuteIcon, + getString(R.string.unmute), + getString(R.string.unmute), + unmutePendingIntent + ) + pictureInPictureActions.add(unmuteRemoteAction) + } else { + val muteIcon = Icon.createWithResource(this@EmulationActivity, R.drawable.ic_pip_mute) + val mutePendingIntent = PendingIntent.getBroadcast( + this@EmulationActivity, + R.drawable.ic_pip_mute, + Intent(actionMute), + pendingFlags + ) + val muteRemoteAction = RemoteAction( + muteIcon, + getString(R.string.mute), + getString(R.string.mute), + mutePendingIntent + ) + pictureInPictureActions.add(muteRemoteAction) + } + + return this.apply { setActions(pictureInPictureActions) } + } + + fun buildPictureInPictureParams() { + val pictureInPictureParamsBuilder = PictureInPictureParams.Builder() + .getPictureInPictureActionsBuilder().getPictureInPictureAspectBuilder() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + val isEmulationActive = emulationViewModel.emulationStarted.value && + !emulationViewModel.isEmulationStopping.value + pictureInPictureParamsBuilder.setAutoEnterEnabled( + BooleanSetting.PICTURE_IN_PICTURE.getBoolean() && isEmulationActive + ) + } + setPictureInPictureParams(pictureInPictureParamsBuilder.build()) + } + + private var pictureInPictureReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent) { + if (intent.action == actionPlay) { + if (NativeLibrary.isPaused()) NativeLibrary.unpauseEmulation() + } else if (intent.action == actionPause) { + if (!NativeLibrary.isPaused()) NativeLibrary.pauseEmulation() + } + if (intent.action == actionUnmute) { + if (BooleanSetting.AUDIO_MUTED.getBoolean()) { + BooleanSetting.AUDIO_MUTED.setBoolean(false) + } + } else if (intent.action == actionMute) { + if (!BooleanSetting.AUDIO_MUTED.getBoolean()) { + BooleanSetting.AUDIO_MUTED.setBoolean(true) + } + } + buildPictureInPictureParams() + } + } + + @SuppressLint("UnspecifiedRegisterReceiverFlag") + override fun onPictureInPictureModeChanged( + isInPictureInPictureMode: Boolean, + newConfig: Configuration + ) { + super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig) + if (isInPictureInPictureMode) { + IntentFilter().apply { + addAction(actionPause) + addAction(actionPlay) + addAction(actionMute) + addAction(actionUnmute) + }.also { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + registerReceiver(pictureInPictureReceiver, it, RECEIVER_EXPORTED) + } else { + registerReceiver(pictureInPictureReceiver, it) + } + } + } else { + try { + unregisterReceiver(pictureInPictureReceiver) + } catch (ignored: Exception) { + } + // Always resume audio, since there is no UI button + if (BooleanSetting.AUDIO_MUTED.getBoolean()) { + BooleanSetting.AUDIO_MUTED.setBoolean(false) + } + } + } + + fun onEmulationStarted() { + emulationViewModel.setEmulationStarted(true) + } + + fun onEmulationStopped(status: Int) { + if (status == 0 && emulationViewModel.programChanged.value == -1) { + finish() + } + emulationViewModel.setEmulationStopped(true) + } + + fun onProgramChanged(programIndex: Int) { + emulationViewModel.setProgramChanged(programIndex) + } + + private fun startMotionSensorListener() { + val sensorManager = this.getSystemService(Context.SENSOR_SERVICE) as SensorManager + val gyroSensor = sensorManager.getDefaultSensor(Sensor.TYPE_GYROSCOPE) + val accelSensor = sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER) + sensorManager.registerListener(this, gyroSensor, SensorManager.SENSOR_DELAY_GAME) + sensorManager.registerListener(this, accelSensor, SensorManager.SENSOR_DELAY_GAME) + } + + private fun stopMotionSensorListener() { + val sensorManager = this.getSystemService(Context.SENSOR_SERVICE) as SensorManager + val gyroSensor = sensorManager.getDefaultSensor(Sensor.TYPE_GYROSCOPE) + val accelSensor = sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER) + + sensorManager.unregisterListener(this, gyroSensor) + sensorManager.unregisterListener(this, accelSensor) + } + + companion object { + const val EXTRA_SELECTED_GAME = "SelectedGame" + + fun launch(activity: AppCompatActivity, game: Game) { + val launcher = Intent(activity, EmulationActivity::class.java) + launcher.putExtra(EXTRA_SELECTED_GAME, game) + activity.startActivity(launcher) + } + + private fun areCoordinatesOutside(view: View?, x: Float, y: Float): Boolean { + if (view == null) { + return true + } + val viewBounds = Rect() + view.getGlobalVisibleRect(viewBounds) + return !viewBounds.contains(x.roundToInt(), y.roundToInt()) + } + } +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/adapters/AbstractDiffAdapter.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/adapters/AbstractDiffAdapter.kt new file mode 100644 index 0000000..b145080 --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/adapters/AbstractDiffAdapter.kt @@ -0,0 +1,38 @@ +// SPDX-FileCopyrightText: 2024 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.adapters + +import android.annotation.SuppressLint +import androidx.recyclerview.widget.AsyncDifferConfig +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import org.sudachi.sudachi_emu.viewholder.AbstractViewHolder +import androidx.recyclerview.widget.RecyclerView + +/** + * Generic adapter that implements an [AsyncDifferConfig] and covers some of the basic boilerplate + * code used in every [RecyclerView]. + * Type assigned to [Model] must inherit from [Object] in order to be compared properly. + * @param exact Decides whether each item will be compared by reference or by their contents + */ +abstract class AbstractDiffAdapter>( + exact: Boolean = true +) : ListAdapter(AsyncDifferConfig.Builder(DiffCallback(exact)).build()) { + override fun onBindViewHolder(holder: Holder, position: Int) = + holder.bind(currentList[position]) + + private class DiffCallback(val exact: Boolean) : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: Model & Any, newItem: Model & Any): Boolean { + if (exact) { + return oldItem === newItem + } + return oldItem == newItem + } + + @SuppressLint("DiffUtilEquals") + override fun areContentsTheSame(oldItem: Model & Any, newItem: Model & Any): Boolean { + return oldItem == newItem + } + } +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/adapters/AbstractListAdapter.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/adapters/AbstractListAdapter.kt new file mode 100644 index 0000000..2a99788 --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/adapters/AbstractListAdapter.kt @@ -0,0 +1,98 @@ +// SPDX-FileCopyrightText: 2024 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.adapters + +import android.annotation.SuppressLint +import androidx.recyclerview.widget.RecyclerView +import org.sudachi.sudachi_emu.viewholder.AbstractViewHolder + +/** + * Generic list class meant to take care of basic lists + * @param currentList The list to show initially + */ +abstract class AbstractListAdapter>( + open var currentList: List +) : RecyclerView.Adapter() { + override fun onBindViewHolder(holder: Holder, position: Int) = + holder.bind(currentList[position]) + + override fun getItemCount(): Int = currentList.size + + /** + * Adds an item to [currentList] and notifies the underlying adapter of the change. If no parameter + * is passed in for position, [item] is added to the end of the list. Invokes [callback] last. + * @param item The item to add to the list + * @param position Index where [item] will be added + * @param callback Lambda that's called at the end of the list changes and has the added list + * position passed in as a parameter + */ + open fun addItem(item: Model, position: Int = -1, callback: ((position: Int) -> Unit)? = null) { + val newList = currentList.toMutableList() + val positionToUpdate: Int + if (position == -1) { + newList.add(item) + currentList = newList + positionToUpdate = currentList.size - 1 + } else { + newList.add(position, item) + currentList = newList + positionToUpdate = position + } + onItemAdded(positionToUpdate, callback) + } + + protected fun onItemAdded(position: Int, callback: ((Int) -> Unit)? = null) { + notifyItemInserted(position) + callback?.invoke(position) + } + + /** + * Replaces the [item] at [position] in the [currentList] and notifies the underlying adapter + * of the change. Invokes [callback] last. + * @param item New list item + * @param position Index where [item] will replace the existing list item + * @param callback Lambda that's called at the end of the list changes and has the changed list + * position passed in as a parameter + */ + fun changeItem(item: Model, position: Int, callback: ((position: Int) -> Unit)? = null) { + val newList = currentList.toMutableList() + newList[position] = item + currentList = newList + onItemChanged(position, callback) + } + + protected fun onItemChanged(position: Int, callback: ((Int) -> Unit)? = null) { + notifyItemChanged(position) + callback?.invoke(position) + } + + /** + * Removes the list item at [position] in [currentList] and notifies the underlying adapter + * of the change. Invokes [callback] last. + * @param position Index where the list item will be removed + * @param callback Lambda that's called at the end of the list changes and has the removed list + * position passed in as a parameter + */ + fun removeItem(position: Int, callback: ((position: Int) -> Unit)? = null) { + val newList = currentList.toMutableList() + newList.removeAt(position) + currentList = newList + onItemRemoved(position, callback) + } + + protected fun onItemRemoved(position: Int, callback: ((Int) -> Unit)? = null) { + notifyItemRemoved(position) + callback?.invoke(position) + } + + /** + * Replaces [currentList] with [newList] and notifies the underlying adapter of the change. + * @param newList The new list to replace [currentList] + */ + @SuppressLint("NotifyDataSetChanged") + open fun replaceList(newList: List) { + currentList = newList + notifyDataSetChanged() + } +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/adapters/AbstractSingleSelectionList.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/adapters/AbstractSingleSelectionList.kt new file mode 100644 index 0000000..5122fa4 --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/adapters/AbstractSingleSelectionList.kt @@ -0,0 +1,105 @@ +// SPDX-FileCopyrightText: 2024 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.adapters + +import org.sudachi.sudachi_emu.model.SelectableItem +import org.sudachi.sudachi_emu.viewholder.AbstractViewHolder + +/** + * Generic list class meant to take care of single selection UI updates + * @param currentList The list to show initially + * @param defaultSelection The default selection to use if no list items are selected by + * [SelectableItem.selected] or if the currently selected item is removed from the list + */ +abstract class AbstractSingleSelectionList< + Model : SelectableItem, + Holder : AbstractViewHolder + >( + final override var currentList: List, + private val defaultSelection: DefaultSelection = DefaultSelection.Start +) : AbstractListAdapter(currentList) { + var selectedItem = getDefaultSelection() + + init { + findSelectedItem() + } + + /** + * Changes the selection state of the [SelectableItem] that was selected and the previously selected + * item and notifies the underlying adapter of the change for those items. Invokes [callback] last. + * Does nothing if [position] is the same as the currently selected item. + * @param position Index of the item that was selected + * @param callback Lambda that's called at the end of the list changes and has the selected list + * position passed in as a parameter + */ + fun selectItem(position: Int, callback: ((position: Int) -> Unit)? = null) { + if (position == selectedItem) { + return + } + + val previouslySelectedItem = selectedItem + selectedItem = position + if (currentList.indices.contains(selectedItem)) { + currentList[selectedItem].onSelectionStateChanged(true) + } + if (currentList.indices.contains(previouslySelectedItem)) { + currentList[previouslySelectedItem].onSelectionStateChanged(false) + } + onItemChanged(previouslySelectedItem) + onItemChanged(selectedItem) + callback?.invoke(position) + } + + /** + * Removes a given item from the list and notifies the underlying adapter of the change. If the + * currently selected item was the item that was removed, the item at the position provided + * by [defaultSelection] will be made the new selection. Invokes [callback] last. + * @param position Index of the item that was removed + * @param callback Lambda that's called at the end of the list changes and has the removed and + * selected list positions passed in as parameters + */ + fun removeSelectableItem( + position: Int, + callback: ((removedPosition: Int, selectedPosition: Int) -> Unit)? + ) { + removeItem(position) + if (position == selectedItem) { + selectedItem = getDefaultSelection() + currentList[selectedItem].onSelectionStateChanged(true) + onItemChanged(selectedItem) + } else if (position < selectedItem) { + selectedItem-- + } + callback?.invoke(position, selectedItem) + } + + override fun addItem(item: Model, position: Int, callback: ((Int) -> Unit)?) { + super.addItem(item, position, callback) + if (position <= selectedItem && position != -1) { + selectedItem++ + } + } + + override fun replaceList(newList: List) { + super.replaceList(newList) + findSelectedItem() + } + + private fun findSelectedItem() { + for (i in currentList.indices) { + if (currentList[i].selected) { + selectedItem = i + break + } + } + } + + private fun getDefaultSelection(): Int = + when (defaultSelection) { + DefaultSelection.Start -> currentList.indices.first + DefaultSelection.End -> currentList.indices.last + } + + enum class DefaultSelection { Start, End } +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/adapters/AddonAdapter.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/adapters/AddonAdapter.kt new file mode 100644 index 0000000..92824b2 --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/adapters/AddonAdapter.kt @@ -0,0 +1,37 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.adapters + +import android.view.LayoutInflater +import android.view.ViewGroup +import org.sudachi.sudachi_emu.databinding.ListItemAddonBinding +import org.sudachi.sudachi_emu.model.Patch +import org.sudachi.sudachi_emu.model.AddonViewModel +import org.sudachi.sudachi_emu.viewholder.AbstractViewHolder + +class AddonAdapter(val addonViewModel: AddonViewModel) : + AbstractDiffAdapter() { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AddonViewHolder { + ListItemAddonBinding.inflate(LayoutInflater.from(parent.context), parent, false) + .also { return AddonViewHolder(it) } + } + + inner class AddonViewHolder(val binding: ListItemAddonBinding) : + AbstractViewHolder(binding) { + override fun bind(model: Patch) { + binding.root.setOnClickListener { + binding.addonCheckbox.isChecked = !binding.addonCheckbox.isChecked + } + binding.title.text = model.name + binding.version.text = model.version + binding.addonCheckbox.setOnCheckedChangeListener { _, checked -> + model.enabled = checked + } + binding.addonCheckbox.isChecked = model.enabled + binding.buttonDelete.setOnClickListener { + addonViewModel.setAddonToDelete(model) + } + } + } +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/adapters/AppletAdapter.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/adapters/AppletAdapter.kt new file mode 100644 index 0000000..10db1f7 --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/adapters/AppletAdapter.kt @@ -0,0 +1,74 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.adapters + +import android.view.LayoutInflater +import android.view.ViewGroup +import android.widget.Toast +import androidx.core.content.res.ResourcesCompat +import androidx.fragment.app.FragmentActivity +import androidx.navigation.findNavController +import org.sudachi.sudachi_emu.HomeNavigationDirections +import org.sudachi.sudachi_emu.NativeLibrary +import org.sudachi.sudachi_emu.R +import org.sudachi.sudachi_emu.SudachiApplication +import org.sudachi.sudachi_emu.databinding.CardSimpleOutlinedBinding +import org.sudachi.sudachi_emu.model.Applet +import org.sudachi.sudachi_emu.model.AppletInfo +import org.sudachi.sudachi_emu.model.Game +import org.sudachi.sudachi_emu.viewholder.AbstractViewHolder + +class AppletAdapter(val activity: FragmentActivity, applets: List) : + AbstractListAdapter(applets) { + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int + ): AppletAdapter.AppletViewHolder { + CardSimpleOutlinedBinding.inflate(LayoutInflater.from(parent.context), parent, false) + .also { return AppletViewHolder(it) } + } + + inner class AppletViewHolder(val binding: CardSimpleOutlinedBinding) : + AbstractViewHolder(binding) { + override fun bind(model: Applet) { + binding.title.setText(model.titleId) + binding.description.setText(model.descriptionId) + binding.icon.setImageDrawable( + ResourcesCompat.getDrawable( + binding.icon.context.resources, + model.iconId, + binding.icon.context.theme + ) + ) + + binding.root.setOnClickListener { onClick(model) } + } + + fun onClick(applet: Applet) { + val appletPath = NativeLibrary.getAppletLaunchPath(applet.appletInfo.entryId) + if (appletPath.isEmpty()) { + Toast.makeText( + binding.root.context, + R.string.applets_error_applet, + Toast.LENGTH_SHORT + ).show() + return + } + + if (applet.appletInfo == AppletInfo.Cabinet) { + binding.root.findNavController() + .navigate(R.id.action_appletLauncherFragment_to_cabinetLauncherDialogFragment) + return + } + + NativeLibrary.setCurrentAppletId(applet.appletInfo.appletId) + val appletGame = Game( + title = SudachiApplication.appContext.getString(applet.titleId), + path = appletPath + ) + val action = HomeNavigationDirections.actionGlobalEmulationActivity(appletGame) + binding.root.findNavController().navigate(action) + } + } +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/adapters/CabinetLauncherDialogAdapter.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/adapters/CabinetLauncherDialogAdapter.kt new file mode 100644 index 0000000..c3d1298 --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/adapters/CabinetLauncherDialogAdapter.kt @@ -0,0 +1,59 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.adapters + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.core.content.res.ResourcesCompat +import androidx.fragment.app.Fragment +import androidx.navigation.fragment.findNavController +import org.sudachi.sudachi_emu.HomeNavigationDirections +import org.sudachi.sudachi_emu.NativeLibrary +import org.sudachi.sudachi_emu.R +import org.sudachi.sudachi_emu.SudachiApplication +import org.sudachi.sudachi_emu.databinding.DialogListItemBinding +import org.sudachi.sudachi_emu.model.CabinetMode +import org.sudachi.sudachi_emu.adapters.CabinetLauncherDialogAdapter.CabinetModeViewHolder +import org.sudachi.sudachi_emu.model.AppletInfo +import org.sudachi.sudachi_emu.model.Game +import org.sudachi.sudachi_emu.viewholder.AbstractViewHolder + +class CabinetLauncherDialogAdapter(val fragment: Fragment) : + AbstractListAdapter( + CabinetMode.values().copyOfRange(1, CabinetMode.entries.size).toList() + ) { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CabinetModeViewHolder { + DialogListItemBinding.inflate(LayoutInflater.from(parent.context), parent, false) + .also { return CabinetModeViewHolder(it) } + } + + inner class CabinetModeViewHolder(val binding: DialogListItemBinding) : + AbstractViewHolder(binding) { + override fun bind(model: CabinetMode) { + binding.icon.setImageDrawable( + ResourcesCompat.getDrawable( + binding.icon.context.resources, + model.iconId, + binding.icon.context.theme + ) + ) + binding.title.setText(model.titleId) + + binding.root.setOnClickListener { onClick(model) } + } + + private fun onClick(mode: CabinetMode) { + val appletPath = NativeLibrary.getAppletLaunchPath(AppletInfo.Cabinet.entryId) + NativeLibrary.setCurrentAppletId(AppletInfo.Cabinet.appletId) + NativeLibrary.setCabinetMode(mode.id) + val appletGame = Game( + title = SudachiApplication.appContext.getString(R.string.cabinet_applet), + path = appletPath + ) + val action = HomeNavigationDirections.actionGlobalEmulationActivity(appletGame) + fragment.findNavController().navigate(action) + } + } +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/adapters/DriverAdapter.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/adapters/DriverAdapter.kt new file mode 100644 index 0000000..65ff222 --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/adapters/DriverAdapter.kt @@ -0,0 +1,59 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.adapters + +import android.view.LayoutInflater +import android.view.ViewGroup +import org.sudachi.sudachi_emu.R +import org.sudachi.sudachi_emu.databinding.CardDriverOptionBinding +import org.sudachi.sudachi_emu.features.settings.model.StringSetting +import org.sudachi.sudachi_emu.model.Driver +import org.sudachi.sudachi_emu.model.DriverViewModel +import org.sudachi.sudachi_emu.utils.ViewUtils.marquee +import org.sudachi.sudachi_emu.utils.ViewUtils.setVisible +import org.sudachi.sudachi_emu.viewholder.AbstractViewHolder + +class DriverAdapter(private val driverViewModel: DriverViewModel) : + AbstractSingleSelectionList( + driverViewModel.driverList.value + ) { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DriverViewHolder { + CardDriverOptionBinding.inflate(LayoutInflater.from(parent.context), parent, false) + .also { return DriverViewHolder(it) } + } + + inner class DriverViewHolder(val binding: CardDriverOptionBinding) : + AbstractViewHolder(binding) { + override fun bind(model: Driver) { + binding.apply { + radioButton.isChecked = model.selected + root.setOnClickListener { + selectItem(bindingAdapterPosition) { + driverViewModel.onDriverSelected(it) + driverViewModel.showClearButton(!StringSetting.DRIVER_PATH.global) + } + } + buttonDelete.setOnClickListener { + removeSelectableItem( + bindingAdapterPosition + ) { removedPosition: Int, selectedPosition: Int -> + driverViewModel.onDriverRemoved(removedPosition, selectedPosition) + driverViewModel.showClearButton(!StringSetting.DRIVER_PATH.global) + } + } + + // Delay marquee by 3s + title.marquee() + version.marquee() + description.marquee() + title.text = model.title + version.text = model.version + description.text = model.description + buttonDelete.setVisible( + model.title != binding.root.context.getString(R.string.system_gpu_driver) + ) + } + } + } +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/adapters/FolderAdapter.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/adapters/FolderAdapter.kt new file mode 100644 index 0000000..703280d --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/adapters/FolderAdapter.kt @@ -0,0 +1,48 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.adapters + +import android.net.Uri +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.fragment.app.FragmentActivity +import org.sudachi.sudachi_emu.databinding.CardFolderBinding +import org.sudachi.sudachi_emu.fragments.GameFolderPropertiesDialogFragment +import org.sudachi.sudachi_emu.model.GameDir +import org.sudachi.sudachi_emu.model.GamesViewModel +import org.sudachi.sudachi_emu.utils.ViewUtils.marquee +import org.sudachi.sudachi_emu.viewholder.AbstractViewHolder + +class FolderAdapter(val activity: FragmentActivity, val gamesViewModel: GamesViewModel) : + AbstractDiffAdapter() { + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int + ): FolderAdapter.FolderViewHolder { + CardFolderBinding.inflate(LayoutInflater.from(parent.context), parent, false) + .also { return FolderViewHolder(it) } + } + + inner class FolderViewHolder(val binding: CardFolderBinding) : + AbstractViewHolder(binding) { + override fun bind(model: GameDir) { + binding.apply { + path.text = Uri.parse(model.uriString).path + path.marquee() + + buttonEdit.setOnClickListener { + GameFolderPropertiesDialogFragment.newInstance(model) + .show( + activity.supportFragmentManager, + GameFolderPropertiesDialogFragment.TAG + ) + } + + buttonDelete.setOnClickListener { + gamesViewModel.removeFolder(model) + } + } + } + } +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/adapters/GameAdapter.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/adapters/GameAdapter.kt new file mode 100644 index 0000000..b396326 --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/adapters/GameAdapter.kt @@ -0,0 +1,99 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.adapters + +import android.net.Uri +import android.view.LayoutInflater +import android.view.ViewGroup +import android.widget.ImageView +import android.widget.Toast +import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.pm.ShortcutInfoCompat +import androidx.core.content.pm.ShortcutManagerCompat +import androidx.documentfile.provider.DocumentFile +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.lifecycleScope +import androidx.navigation.findNavController +import androidx.preference.PreferenceManager +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.sudachi.sudachi_emu.HomeNavigationDirections +import org.sudachi.sudachi_emu.R +import org.sudachi.sudachi_emu.SudachiApplication +import org.sudachi.sudachi_emu.databinding.CardGameBinding +import org.sudachi.sudachi_emu.model.Game +import org.sudachi.sudachi_emu.model.GamesViewModel +import org.sudachi.sudachi_emu.utils.GameIconUtils +import org.sudachi.sudachi_emu.utils.ViewUtils.marquee +import org.sudachi.sudachi_emu.viewholder.AbstractViewHolder + +class GameAdapter(private val activity: AppCompatActivity) : + AbstractDiffAdapter(exact = false) { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): GameViewHolder { + CardGameBinding.inflate(LayoutInflater.from(parent.context), parent, false) + .also { return GameViewHolder(it) } + } + + inner class GameViewHolder(val binding: CardGameBinding) : + AbstractViewHolder(binding) { + override fun bind(model: Game) { + binding.imageGameScreen.scaleType = ImageView.ScaleType.CENTER_CROP + GameIconUtils.loadGameIcon(model, binding.imageGameScreen) + + binding.textGameTitle.text = model.title.replace("[\\t\\n\\r]+".toRegex(), " ") + + binding.textGameTitle.marquee() + binding.cardGame.setOnClickListener { onClick(model) } + binding.cardGame.setOnLongClickListener { onLongClick(model) } + } + + fun onClick(game: Game) { + val gameExists = DocumentFile.fromSingleUri( + SudachiApplication.appContext, + Uri.parse(game.path) + )?.exists() == true + if (!gameExists) { + Toast.makeText( + SudachiApplication.appContext, + R.string.loader_error_file_not_found, + Toast.LENGTH_LONG + ).show() + + ViewModelProvider(activity)[GamesViewModel::class.java].reloadGames(true) + return + } + + val preferences = + PreferenceManager.getDefaultSharedPreferences(SudachiApplication.appContext) + preferences.edit() + .putLong( + game.keyLastPlayedTime, + System.currentTimeMillis() + ) + .apply() + + activity.lifecycleScope.launch { + withContext(Dispatchers.IO) { + val shortcut = + ShortcutInfoCompat.Builder(SudachiApplication.appContext, game.path) + .setShortLabel(game.title) + .setIcon(GameIconUtils.getShortcutIcon(activity, game)) + .setIntent(game.launchIntent) + .build() + ShortcutManagerCompat.pushDynamicShortcut(SudachiApplication.appContext, shortcut) + } + } + + val action = HomeNavigationDirections.actionGlobalEmulationActivity(game, true) + binding.root.findNavController().navigate(action) + } + + fun onLongClick(game: Game): Boolean { + val action = HomeNavigationDirections.actionGlobalPerGamePropertiesFragment(game) + binding.root.findNavController().navigate(action) + return true + } + } +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/adapters/GamePropertiesAdapter.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/adapters/GamePropertiesAdapter.kt new file mode 100644 index 0000000..79424bb --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/adapters/GamePropertiesAdapter.kt @@ -0,0 +1,115 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.adapters + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.core.content.res.ResourcesCompat +import androidx.lifecycle.LifecycleOwner +import org.sudachi.sudachi_emu.databinding.CardInstallableIconBinding +import org.sudachi.sudachi_emu.databinding.CardSimpleOutlinedBinding +import org.sudachi.sudachi_emu.model.GameProperty +import org.sudachi.sudachi_emu.model.InstallableProperty +import org.sudachi.sudachi_emu.model.SubmenuProperty +import org.sudachi.sudachi_emu.utils.ViewUtils.marquee +import org.sudachi.sudachi_emu.utils.ViewUtils.setVisible +import org.sudachi.sudachi_emu.utils.collect +import org.sudachi.sudachi_emu.viewholder.AbstractViewHolder + +class GamePropertiesAdapter( + private val viewLifecycle: LifecycleOwner, + private var properties: List +) : AbstractListAdapter>(properties) { + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int + ): AbstractViewHolder { + val inflater = LayoutInflater.from(parent.context) + return when (viewType) { + PropertyType.Submenu.ordinal -> { + SubmenuPropertyViewHolder( + CardSimpleOutlinedBinding.inflate( + inflater, + parent, + false + ) + ) + } + + else -> InstallablePropertyViewHolder( + CardInstallableIconBinding.inflate( + inflater, + parent, + false + ) + ) + } + } + + override fun getItemViewType(position: Int): Int { + return when (properties[position]) { + is SubmenuProperty -> PropertyType.Submenu.ordinal + else -> PropertyType.Installable.ordinal + } + } + + inner class SubmenuPropertyViewHolder(val binding: CardSimpleOutlinedBinding) : + AbstractViewHolder(binding) { + override fun bind(model: GameProperty) { + val submenuProperty = model as SubmenuProperty + + binding.root.setOnClickListener { + submenuProperty.action.invoke() + } + + binding.title.setText(submenuProperty.titleId) + binding.description.setText(submenuProperty.descriptionId) + binding.icon.setImageDrawable( + ResourcesCompat.getDrawable( + binding.icon.context.resources, + submenuProperty.iconId, + binding.icon.context.theme + ) + ) + + binding.details.marquee() + if (submenuProperty.details != null) { + binding.details.setVisible(true) + binding.details.text = submenuProperty.details.invoke() + } else if (submenuProperty.detailsFlow != null) { + binding.details.setVisible(true) + submenuProperty.detailsFlow.collect(viewLifecycle) { binding.details.text = it } + } else { + binding.details.setVisible(false) + } + } + } + + inner class InstallablePropertyViewHolder(val binding: CardInstallableIconBinding) : + AbstractViewHolder(binding) { + override fun bind(model: GameProperty) { + val installableProperty = model as InstallableProperty + + binding.title.setText(installableProperty.titleId) + binding.description.setText(installableProperty.descriptionId) + binding.icon.setImageDrawable( + ResourcesCompat.getDrawable( + binding.icon.context.resources, + installableProperty.iconId, + binding.icon.context.theme + ) + ) + + binding.buttonInstall.setVisible(installableProperty.install != null) + binding.buttonInstall.setOnClickListener { installableProperty.install?.invoke() } + binding.buttonExport.setVisible(installableProperty.export != null) + binding.buttonExport.setOnClickListener { installableProperty.export?.invoke() } + } + } + + enum class PropertyType { + Submenu, + Installable + } +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/adapters/HomeSettingAdapter.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/adapters/HomeSettingAdapter.kt new file mode 100644 index 0000000..6cdf533 --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/adapters/HomeSettingAdapter.kt @@ -0,0 +1,84 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.adapters + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.ContextCompat +import androidx.core.content.res.ResourcesCompat +import androidx.lifecycle.LifecycleOwner +import org.sudachi.sudachi_emu.R +import org.sudachi.sudachi_emu.databinding.CardHomeOptionBinding +import org.sudachi.sudachi_emu.fragments.MessageDialogFragment +import org.sudachi.sudachi_emu.model.HomeSetting +import org.sudachi.sudachi_emu.utils.ViewUtils.marquee +import org.sudachi.sudachi_emu.utils.ViewUtils.setVisible +import org.sudachi.sudachi_emu.utils.collect +import org.sudachi.sudachi_emu.viewholder.AbstractViewHolder + +class HomeSettingAdapter( + private val activity: AppCompatActivity, + private val viewLifecycle: LifecycleOwner, + options: List +) : AbstractListAdapter(options) { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): HomeOptionViewHolder { + CardHomeOptionBinding.inflate(LayoutInflater.from(parent.context), parent, false) + .also { return HomeOptionViewHolder(it) } + } + + inner class HomeOptionViewHolder(val binding: CardHomeOptionBinding) : + AbstractViewHolder(binding) { + override fun bind(model: HomeSetting) { + binding.optionTitle.text = activity.resources.getString(model.titleId) + binding.optionDescription.text = activity.resources.getString(model.descriptionId) + binding.optionIcon.setImageDrawable( + ResourcesCompat.getDrawable( + activity.resources, + model.iconId, + activity.theme + ) + ) + + when (model.titleId) { + R.string.get_early_access -> + binding.optionLayout.background = + ContextCompat.getDrawable( + binding.optionCard.context, + R.drawable.premium_background + ) + } + + if (!model.isEnabled.invoke()) { + binding.optionTitle.alpha = 0.5f + binding.optionDescription.alpha = 0.5f + binding.optionIcon.alpha = 0.5f + } + + model.details.collect(viewLifecycle) { updateOptionDetails(it) } + binding.optionDetail.marquee() + + binding.root.setOnClickListener { onClick(model) } + } + + private fun onClick(model: HomeSetting) { + if (model.isEnabled.invoke()) { + model.onClick.invoke() + } else { + MessageDialogFragment.newInstance( + activity, + titleId = model.disabledTitleId, + descriptionId = model.disabledMessageId + ).show(activity.supportFragmentManager, MessageDialogFragment.TAG) + } + } + + private fun updateOptionDetails(detailString: String) { + if (detailString.isNotEmpty()) { + binding.optionDetail.text = detailString + binding.optionDetail.setVisible(true) + } + } + } +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/adapters/InstallableAdapter.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/adapters/InstallableAdapter.kt new file mode 100644 index 0000000..38fa196 --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/adapters/InstallableAdapter.kt @@ -0,0 +1,35 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.adapters + +import android.view.LayoutInflater +import android.view.ViewGroup +import org.sudachi.sudachi_emu.databinding.CardInstallableBinding +import org.sudachi.sudachi_emu.model.Installable +import org.sudachi.sudachi_emu.utils.ViewUtils.setVisible +import org.sudachi.sudachi_emu.viewholder.AbstractViewHolder + +class InstallableAdapter(installables: List) : + AbstractListAdapter(installables) { + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int + ): InstallableAdapter.InstallableViewHolder { + CardInstallableBinding.inflate(LayoutInflater.from(parent.context), parent, false) + .also { return InstallableViewHolder(it) } + } + + inner class InstallableViewHolder(val binding: CardInstallableBinding) : + AbstractViewHolder(binding) { + override fun bind(model: Installable) { + binding.title.setText(model.titleId) + binding.description.setText(model.descriptionId) + + binding.buttonInstall.setVisible(model.install != null) + binding.buttonInstall.setOnClickListener { model.install?.invoke() } + binding.buttonExport.setVisible(model.export != null) + binding.buttonExport.setOnClickListener { model.export?.invoke() } + } + } +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/adapters/LicenseAdapter.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/adapters/LicenseAdapter.kt new file mode 100644 index 0000000..dd06d2e --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/adapters/LicenseAdapter.kt @@ -0,0 +1,39 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.adapters + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.appcompat.app.AppCompatActivity +import org.sudachi.sudachi_emu.databinding.ListItemSettingBinding +import org.sudachi.sudachi_emu.fragments.LicenseBottomSheetDialogFragment +import org.sudachi.sudachi_emu.model.License +import org.sudachi.sudachi_emu.utils.ViewUtils.setVisible +import org.sudachi.sudachi_emu.viewholder.AbstractViewHolder + +class LicenseAdapter(private val activity: AppCompatActivity, licenses: List) : + AbstractListAdapter(licenses) { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): LicenseViewHolder { + ListItemSettingBinding.inflate(LayoutInflater.from(parent.context), parent, false) + .also { return LicenseViewHolder(it) } + } + + inner class LicenseViewHolder(val binding: ListItemSettingBinding) : + AbstractViewHolder(binding) { + override fun bind(model: License) { + binding.apply { + textSettingName.text = root.context.getString(model.titleId) + textSettingDescription.text = root.context.getString(model.descriptionId) + textSettingValue.setVisible(false) + + root.setOnClickListener { onClick(model) } + } + } + + private fun onClick(license: License) { + LicenseBottomSheetDialogFragment.newInstance(license) + .show(activity.supportFragmentManager, LicenseBottomSheetDialogFragment.TAG) + } + } +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/adapters/SetupAdapter.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/adapters/SetupAdapter.kt new file mode 100644 index 0000000..313ae21 --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/adapters/SetupAdapter.kt @@ -0,0 +1,75 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.adapters + +import android.text.Html +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.res.ResourcesCompat +import androidx.lifecycle.ViewModelProvider +import com.google.android.material.button.MaterialButton +import org.sudachi.sudachi_emu.databinding.PageSetupBinding +import org.sudachi.sudachi_emu.model.HomeViewModel +import org.sudachi.sudachi_emu.model.SetupCallback +import org.sudachi.sudachi_emu.model.SetupPage +import org.sudachi.sudachi_emu.model.StepState +import org.sudachi.sudachi_emu.utils.ViewUtils +import org.sudachi.sudachi_emu.utils.ViewUtils.setVisible +import org.sudachi.sudachi_emu.viewholder.AbstractViewHolder + +class SetupAdapter(val activity: AppCompatActivity, pages: List) : + AbstractListAdapter(pages) { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SetupPageViewHolder { + PageSetupBinding.inflate(LayoutInflater.from(parent.context), parent, false) + .also { return SetupPageViewHolder(it) } + } + + inner class SetupPageViewHolder(val binding: PageSetupBinding) : + AbstractViewHolder(binding), SetupCallback { + override fun bind(model: SetupPage) { + if (model.stepCompleted.invoke() == StepState.COMPLETE) { + binding.buttonAction.setVisible(visible = false, gone = false) + binding.textConfirmation.setVisible(true) + } + + binding.icon.setImageDrawable( + ResourcesCompat.getDrawable( + activity.resources, + model.iconId, + activity.theme + ) + ) + binding.textTitle.text = activity.resources.getString(model.titleId) + binding.textDescription.text = + Html.fromHtml(activity.resources.getString(model.descriptionId), 0) + + binding.buttonAction.apply { + text = activity.resources.getString(model.buttonTextId) + if (model.buttonIconId != 0) { + icon = ResourcesCompat.getDrawable( + activity.resources, + model.buttonIconId, + activity.theme + ) + } + iconGravity = + if (model.leftAlignedIcon) { + MaterialButton.ICON_GRAVITY_START + } else { + MaterialButton.ICON_GRAVITY_END + } + setOnClickListener { + model.buttonAction.invoke(this@SetupPageViewHolder) + } + } + } + + override fun onStepCompleted() { + ViewUtils.hideView(binding.buttonAction, 200) + ViewUtils.showView(binding.textConfirmation, 200) + ViewModelProvider(activity)[HomeViewModel::class.java].setShouldPageForward(true) + } + } +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/applets/keyboard/SoftwareKeyboard.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/applets/keyboard/SoftwareKeyboard.kt new file mode 100644 index 0000000..390eca6 --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/applets/keyboard/SoftwareKeyboard.kt @@ -0,0 +1,124 @@ +// SPDX-FileCopyrightText: Copyright 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.applets.keyboard + +import android.content.Context +import android.os.Handler +import android.os.Looper +import android.view.KeyEvent +import android.view.View +import android.view.WindowInsets +import android.view.inputmethod.InputMethodManager +import androidx.annotation.Keep +import androidx.core.view.ViewCompat +import java.io.Serializable +import org.sudachi.sudachi_emu.NativeLibrary +import org.sudachi.sudachi_emu.R +import org.sudachi.sudachi_emu.applets.keyboard.ui.KeyboardDialogFragment + +@Keep +object SoftwareKeyboard { + lateinit var data: KeyboardData + val dataLock = Object() + + private fun executeNormalImpl(config: KeyboardConfig) { + val emulationActivity = NativeLibrary.sEmulationActivity.get() + data = KeyboardData(SwkbdResult.Cancel.ordinal, "") + val fragment = KeyboardDialogFragment.newInstance(config) + fragment.show(emulationActivity!!.supportFragmentManager, KeyboardDialogFragment.TAG) + } + + private fun executeInlineImpl(config: KeyboardConfig) { + val emulationActivity = NativeLibrary.sEmulationActivity.get() + + val overlayView = emulationActivity!!.findViewById(R.id.surface_input_overlay) + val im = + overlayView.context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager + im.showSoftInput(overlayView, InputMethodManager.SHOW_FORCED) + + // There isn't a good way to know that the IMM is dismissed, so poll every 500ms to submit inline keyboard result. + val handler = Handler(Looper.myLooper()!!) + val delayMs = 500 + handler.postDelayed( + object : Runnable { + override fun run() { + val insets = ViewCompat.getRootWindowInsets(overlayView) + val isKeyboardVisible = insets!!.isVisible(WindowInsets.Type.ime()) + if (isKeyboardVisible) { + handler.postDelayed(this, delayMs.toLong()) + return + } + + // No longer visible, submit the result. + NativeLibrary.submitInlineKeyboardInput(KeyEvent.KEYCODE_ENTER) + } + }, + delayMs.toLong() + ) + } + + @JvmStatic + fun executeNormal(config: KeyboardConfig): KeyboardData { + NativeLibrary.sEmulationActivity.get()!!.runOnUiThread { executeNormalImpl(config) } + synchronized(dataLock) { + dataLock.wait() + } + return data + } + + @JvmStatic + fun executeInline(config: KeyboardConfig) { + NativeLibrary.sEmulationActivity.get()!!.runOnUiThread { executeInlineImpl(config) } + } + + // Corresponds to Service::AM::Applets::SwkbdType + enum class SwkbdType { + Normal, + NumberPad, + Qwerty, + Unknown3, + Latin, + SimplifiedChinese, + TraditionalChinese, + Korean + } + + // Corresponds to Service::AM::Applets::SwkbdPasswordMode + enum class SwkbdPasswordMode { + Disabled, + Enabled + } + + // Corresponds to Service::AM::Applets::SwkbdResult + enum class SwkbdResult { + Ok, + Cancel + } + + @Keep + data class KeyboardConfig( + var ok_text: String? = null, + var header_text: String? = null, + var sub_text: String? = null, + var guide_text: String? = null, + var initial_text: String? = null, + var left_optional_symbol_key: Short = 0, + var right_optional_symbol_key: Short = 0, + var max_text_length: Int = 0, + var min_text_length: Int = 0, + var initial_cursor_position: Int = 0, + var type: Int = 0, + var password_mode: Int = 0, + var text_draw_type: Int = 0, + var key_disable_flags: Int = 0, + var use_blur_background: Boolean = false, + var enable_backspace_button: Boolean = false, + var enable_return_button: Boolean = false, + var disable_cancel_button: Boolean = false + ) : Serializable + + // Corresponds to Frontend::KeyboardData + @Keep + data class KeyboardData(var result: Int, var text: String) +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/applets/keyboard/ui/KeyboardDialogFragment.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/applets/keyboard/ui/KeyboardDialogFragment.kt new file mode 100644 index 0000000..16fbc48 --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/applets/keyboard/ui/KeyboardDialogFragment.kt @@ -0,0 +1,100 @@ +// SPDX-FileCopyrightText: Copyright 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.applets.keyboard.ui + +import android.app.Dialog +import android.content.DialogInterface +import android.os.Bundle +import android.text.InputFilter +import android.text.InputType +import androidx.fragment.app.DialogFragment +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import org.sudachi.sudachi_emu.R +import org.sudachi.sudachi_emu.applets.keyboard.SoftwareKeyboard +import org.sudachi.sudachi_emu.applets.keyboard.SoftwareKeyboard.KeyboardConfig +import org.sudachi.sudachi_emu.databinding.DialogEditTextBinding +import org.sudachi.sudachi_emu.utils.SerializableHelper.serializable + +class KeyboardDialogFragment : DialogFragment() { + private lateinit var binding: DialogEditTextBinding + private lateinit var config: KeyboardConfig + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + binding = DialogEditTextBinding.inflate(layoutInflater) + config = requireArguments().serializable(CONFIG)!! + + // Set up the input + binding.editText.hint = config.initial_text + binding.editText.isSingleLine = !config.enable_return_button + binding.editText.filters = + arrayOf(InputFilter.LengthFilter(config.max_text_length)) + + // Handle input type + var inputType: Int + when (config.type) { + SoftwareKeyboard.SwkbdType.Normal.ordinal, + SoftwareKeyboard.SwkbdType.Qwerty.ordinal, + SoftwareKeyboard.SwkbdType.Unknown3.ordinal, + SoftwareKeyboard.SwkbdType.Latin.ordinal, + SoftwareKeyboard.SwkbdType.SimplifiedChinese.ordinal, + SoftwareKeyboard.SwkbdType.TraditionalChinese.ordinal, + SoftwareKeyboard.SwkbdType.Korean.ordinal -> { + inputType = InputType.TYPE_CLASS_TEXT + if (config.password_mode == SoftwareKeyboard.SwkbdPasswordMode.Enabled.ordinal) { + inputType = inputType or InputType.TYPE_TEXT_VARIATION_PASSWORD + } + } + SoftwareKeyboard.SwkbdType.NumberPad.ordinal -> { + inputType = InputType.TYPE_CLASS_NUMBER + if (config.password_mode == SoftwareKeyboard.SwkbdPasswordMode.Enabled.ordinal) { + inputType = inputType or InputType.TYPE_NUMBER_VARIATION_PASSWORD + } + } + else -> { + inputType = InputType.TYPE_CLASS_TEXT + if (config.password_mode == SoftwareKeyboard.SwkbdPasswordMode.Enabled.ordinal) { + inputType = inputType or InputType.TYPE_TEXT_VARIATION_PASSWORD + } + } + } + binding.editText.inputType = inputType + + val headerText = + config.header_text!!.ifEmpty { resources.getString(R.string.software_keyboard) } + val okText = + config.ok_text!!.ifEmpty { resources.getString(R.string.submit) } + + return MaterialAlertDialogBuilder(requireContext()) + .setTitle(headerText) + .setView(binding.root) + .setPositiveButton(okText) { _, _ -> + SoftwareKeyboard.data.result = SoftwareKeyboard.SwkbdResult.Ok.ordinal + SoftwareKeyboard.data.text = binding.editText.text.toString() + } + .setNegativeButton(resources.getString(android.R.string.cancel)) { _, _ -> + SoftwareKeyboard.data.result = SoftwareKeyboard.SwkbdResult.Cancel.ordinal + } + .create() + } + + override fun onDismiss(dialog: DialogInterface) { + super.onDismiss(dialog) + synchronized(SoftwareKeyboard.dataLock) { + SoftwareKeyboard.dataLock.notifyAll() + } + } + + companion object { + const val TAG = "KeyboardDialogFragment" + const val CONFIG = "keyboard_config" + + fun newInstance(config: KeyboardConfig?): KeyboardDialogFragment { + val frag = KeyboardDialogFragment() + val args = Bundle() + args.putSerializable(CONFIG, config) + frag.arguments = args + return frag + } + } +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/disk_shader_cache/DiskShaderCacheProgress.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/disk_shader_cache/DiskShaderCacheProgress.kt new file mode 100644 index 0000000..eacb965 --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/disk_shader_cache/DiskShaderCacheProgress.kt @@ -0,0 +1,51 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.disk_shader_cache + +import androidx.annotation.Keep +import androidx.lifecycle.ViewModelProvider +import org.sudachi.sudachi_emu.NativeLibrary +import org.sudachi.sudachi_emu.R +import org.sudachi.sudachi_emu.activities.EmulationActivity +import org.sudachi.sudachi_emu.model.EmulationViewModel +import org.sudachi.sudachi_emu.utils.Log + +@Keep +object DiskShaderCacheProgress { + private lateinit var emulationViewModel: EmulationViewModel + + private fun prepareViewModel() { + emulationViewModel = + ViewModelProvider( + NativeLibrary.sEmulationActivity.get() as EmulationActivity + )[EmulationViewModel::class.java] + } + + @JvmStatic + fun loadProgress(stage: Int, progress: Int, max: Int) { + val emulationActivity = NativeLibrary.sEmulationActivity.get() + if (emulationActivity == null) { + Log.error("[DiskShaderCacheProgress] EmulationActivity not present") + return + } + + emulationActivity.runOnUiThread { + when (LoadCallbackStage.entries[stage]) { + LoadCallbackStage.Prepare -> prepareViewModel() + LoadCallbackStage.Build -> emulationViewModel.updateProgress( + emulationActivity.getString(R.string.building_shaders), + progress, + max + ) + + LoadCallbackStage.Complete -> {} + } + } + } + + // Equivalent to VideoCore::LoadCallbackStage + enum class LoadCallbackStage { + Prepare, Build, Complete + } +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/DocumentProvider.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/DocumentProvider.kt new file mode 100644 index 0000000..883c19f --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/DocumentProvider.kt @@ -0,0 +1,341 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +// SPDX-License-Identifier: MPL-2.0 +// Copyright © 2023 Skyline Team and Contributors (https://github.com/skyline-emu/) + +package org.sudachi.sudachi_emu.features + +import android.database.Cursor +import android.database.MatrixCursor +import android.os.CancellationSignal +import android.os.ParcelFileDescriptor +import android.provider.DocumentsContract +import android.provider.DocumentsProvider +import android.webkit.MimeTypeMap +import java.io.* +import org.sudachi.sudachi_emu.BuildConfig +import org.sudachi.sudachi_emu.R +import org.sudachi.sudachi_emu.SudachiApplication +import org.sudachi.sudachi_emu.getPublicFilesDir + +class DocumentProvider : DocumentsProvider() { + private val baseDirectory: File + get() = File(SudachiApplication.application.getPublicFilesDir().canonicalPath) + + companion object { + private val DEFAULT_ROOT_PROJECTION: Array = arrayOf( + DocumentsContract.Root.COLUMN_ROOT_ID, + DocumentsContract.Root.COLUMN_MIME_TYPES, + DocumentsContract.Root.COLUMN_FLAGS, + DocumentsContract.Root.COLUMN_ICON, + DocumentsContract.Root.COLUMN_TITLE, + DocumentsContract.Root.COLUMN_SUMMARY, + DocumentsContract.Root.COLUMN_DOCUMENT_ID, + DocumentsContract.Root.COLUMN_AVAILABLE_BYTES + ) + + private val DEFAULT_DOCUMENT_PROJECTION: Array = arrayOf( + DocumentsContract.Document.COLUMN_DOCUMENT_ID, + DocumentsContract.Document.COLUMN_MIME_TYPE, + DocumentsContract.Document.COLUMN_DISPLAY_NAME, + DocumentsContract.Document.COLUMN_LAST_MODIFIED, + DocumentsContract.Document.COLUMN_FLAGS, + DocumentsContract.Document.COLUMN_SIZE + ) + + const val AUTHORITY: String = BuildConfig.APPLICATION_ID + ".user" + const val ROOT_ID: String = "root" + } + + override fun onCreate(): Boolean { + return true + } + + /** + * @return The [File] that corresponds to the document ID supplied by [getDocumentId] + */ + private fun getFile(documentId: String): File { + if (documentId.startsWith(ROOT_ID)) { + val file = baseDirectory.resolve(documentId.drop(ROOT_ID.length + 1)) + if (!file.exists()) { + throw FileNotFoundException( + "${file.absolutePath} ($documentId) not found" + ) + } + return file + } else { + throw FileNotFoundException("'$documentId' is not in any known root") + } + } + + /** + * @return A unique ID for the provided [File] + */ + private fun getDocumentId(file: File): String { + return "$ROOT_ID/${file.toRelativeString(baseDirectory)}" + } + + override fun queryRoots(projection: Array?): Cursor { + val cursor = MatrixCursor(projection ?: DEFAULT_ROOT_PROJECTION) + + cursor.newRow().apply { + add(DocumentsContract.Root.COLUMN_ROOT_ID, ROOT_ID) + add(DocumentsContract.Root.COLUMN_SUMMARY, null) + add( + DocumentsContract.Root.COLUMN_FLAGS, + DocumentsContract.Root.FLAG_SUPPORTS_CREATE or + DocumentsContract.Root.FLAG_SUPPORTS_IS_CHILD + ) + add(DocumentsContract.Root.COLUMN_TITLE, context!!.getString(R.string.app_name)) + add(DocumentsContract.Root.COLUMN_DOCUMENT_ID, getDocumentId(baseDirectory)) + add(DocumentsContract.Root.COLUMN_MIME_TYPES, "*/*") + add(DocumentsContract.Root.COLUMN_AVAILABLE_BYTES, baseDirectory.freeSpace) + add(DocumentsContract.Root.COLUMN_ICON, R.drawable.ic_sudachi) + } + + return cursor + } + + override fun queryDocument(documentId: String?, projection: Array?): Cursor { + val cursor = MatrixCursor(projection ?: DEFAULT_DOCUMENT_PROJECTION) + return includeFile(cursor, documentId, null) + } + + override fun isChildDocument(parentDocumentId: String?, documentId: String?): Boolean { + return documentId?.startsWith(parentDocumentId!!) ?: false + } + + /** + * @return A new [File] with a unique name based off the supplied [name], not conflicting with any existing file + */ + private fun File.resolveWithoutConflict(name: String): File { + var file = resolve(name) + if (file.exists()) { + var noConflictId = + 1 // Makes sure two files don't have the same name by adding a number to the end + val extension = name.substringAfterLast('.') + val baseName = name.substringBeforeLast('.') + while (file.exists()) + file = resolve("$baseName (${noConflictId++}).$extension") + } + return file + } + + override fun createDocument( + parentDocumentId: String?, + mimeType: String?, + displayName: String + ): String { + val parentFile = getFile(parentDocumentId!!) + val newFile = parentFile.resolveWithoutConflict(displayName) + + try { + if (DocumentsContract.Document.MIME_TYPE_DIR == mimeType) { + if (!newFile.mkdir()) { + throw IOException("Failed to create directory") + } + } else { + if (!newFile.createNewFile()) { + throw IOException("Failed to create file") + } + } + } catch (e: IOException) { + throw FileNotFoundException("Couldn't create document '${newFile.path}': ${e.message}") + } + + return getDocumentId(newFile) + } + + override fun deleteDocument(documentId: String?) { + val file = getFile(documentId!!) + if (!file.delete()) { + throw FileNotFoundException("Couldn't delete document with ID '$documentId'") + } + } + + override fun removeDocument(documentId: String, parentDocumentId: String?) { + val parent = getFile(parentDocumentId!!) + val file = getFile(documentId) + + if (parent == file || file.parentFile == null || file.parentFile!! == parent) { + if (!file.delete()) { + throw FileNotFoundException("Couldn't delete document with ID '$documentId'") + } + } else { + throw FileNotFoundException("Couldn't delete document with ID '$documentId'") + } + } + + override fun renameDocument(documentId: String?, displayName: String?): String { + if (displayName == null) { + throw FileNotFoundException( + "Couldn't rename document '$documentId' as the new name is null" + ) + } + + val sourceFile = getFile(documentId!!) + val sourceParentFile = sourceFile.parentFile + ?: throw FileNotFoundException( + "Couldn't rename document '$documentId' as it has no parent" + ) + val destFile = sourceParentFile.resolve(displayName) + + try { + if (!sourceFile.renameTo(destFile)) { + throw FileNotFoundException( + "Couldn't rename document from '${sourceFile.name}' to '${destFile.name}'" + ) + } + } catch (e: Exception) { + throw FileNotFoundException( + "Couldn't rename document from '${sourceFile.name}' to '${destFile.name}': " + + "${e.message}" + ) + } + + return getDocumentId(destFile) + } + + private fun copyDocument( + sourceDocumentId: String, + sourceParentDocumentId: String, + targetParentDocumentId: String? + ): String { + if (!isChildDocument(sourceParentDocumentId, sourceDocumentId)) { + throw FileNotFoundException( + "Couldn't copy document '$sourceDocumentId' as its parent is not " + + "'$sourceParentDocumentId'" + ) + } + + return copyDocument(sourceDocumentId, targetParentDocumentId) + } + + override fun copyDocument(sourceDocumentId: String, targetParentDocumentId: String?): String { + val parent = getFile(targetParentDocumentId!!) + val oldFile = getFile(sourceDocumentId) + val newFile = parent.resolveWithoutConflict(oldFile.name) + + try { + if (!( + newFile.createNewFile() && newFile.setWritable(true) && + newFile.setReadable(true) + ) + ) { + throw IOException("Couldn't create new file") + } + + FileInputStream(oldFile).use { inStream -> + FileOutputStream(newFile).use { outStream -> + inStream.copyTo(outStream) + } + } + } catch (e: IOException) { + throw FileNotFoundException("Couldn't copy document '$sourceDocumentId': ${e.message}") + } + + return getDocumentId(newFile) + } + + override fun moveDocument( + sourceDocumentId: String, + sourceParentDocumentId: String?, + targetParentDocumentId: String? + ): String { + try { + val newDocumentId = copyDocument( + sourceDocumentId, + sourceParentDocumentId!!, + targetParentDocumentId + ) + removeDocument(sourceDocumentId, sourceParentDocumentId) + return newDocumentId + } catch (e: FileNotFoundException) { + throw FileNotFoundException("Couldn't move document '$sourceDocumentId'") + } + } + + private fun includeFile(cursor: MatrixCursor, documentId: String?, file: File?): MatrixCursor { + val localDocumentId = documentId ?: file?.let { getDocumentId(it) } + val localFile = file ?: getFile(documentId!!) + + var flags = 0 + if (localFile.isDirectory && localFile.canWrite()) { + flags = DocumentsContract.Document.FLAG_DIR_SUPPORTS_CREATE + } else if (localFile.canWrite()) { + flags = DocumentsContract.Document.FLAG_SUPPORTS_WRITE + flags = flags or DocumentsContract.Document.FLAG_SUPPORTS_DELETE + + flags = flags or DocumentsContract.Document.FLAG_SUPPORTS_REMOVE + flags = flags or DocumentsContract.Document.FLAG_SUPPORTS_MOVE + flags = flags or DocumentsContract.Document.FLAG_SUPPORTS_COPY + flags = flags or DocumentsContract.Document.FLAG_SUPPORTS_RENAME + } + + cursor.newRow().apply { + add(DocumentsContract.Document.COLUMN_DOCUMENT_ID, localDocumentId) + add( + DocumentsContract.Document.COLUMN_DISPLAY_NAME, + if (localFile == baseDirectory) { + context!!.getString(R.string.app_name) + } else { + localFile.name + } + ) + add(DocumentsContract.Document.COLUMN_SIZE, localFile.length()) + add(DocumentsContract.Document.COLUMN_MIME_TYPE, getTypeForFile(localFile)) + add(DocumentsContract.Document.COLUMN_LAST_MODIFIED, localFile.lastModified()) + add(DocumentsContract.Document.COLUMN_FLAGS, flags) + if (localFile == baseDirectory) { + add(DocumentsContract.Root.COLUMN_ICON, R.drawable.ic_sudachi) + } + } + + return cursor + } + + private fun getTypeForFile(file: File): Any { + return if (file.isDirectory) { + DocumentsContract.Document.MIME_TYPE_DIR + } else { + getTypeForName(file.name) + } + } + + private fun getTypeForName(name: String): Any { + val lastDot = name.lastIndexOf('.') + if (lastDot >= 0) { + val extension = name.substring(lastDot + 1) + val mime = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension) + if (mime != null) { + return mime + } + } + return "application/octect-stream" + } + + override fun queryChildDocuments( + parentDocumentId: String?, + projection: Array?, + sortOrder: String? + ): Cursor { + var cursor = MatrixCursor(projection ?: DEFAULT_DOCUMENT_PROJECTION) + + val parent = getFile(parentDocumentId!!) + for (file in parent.listFiles()!!) + cursor = includeFile(cursor, null, file) + + return cursor + } + + override fun openDocument( + documentId: String?, + mode: String?, + signal: CancellationSignal? + ): ParcelFileDescriptor { + val file = documentId?.let { getFile(it) } + val accessMode = ParcelFileDescriptor.parseMode(mode) + return ParcelFileDescriptor.open(file, accessMode) + } +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/input/NativeInput.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/input/NativeInput.kt new file mode 100644 index 0000000..52288d9 --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/input/NativeInput.kt @@ -0,0 +1,416 @@ +// SPDX-FileCopyrightText: 2024 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.features.input + +import org.sudachi.sudachi_emu.features.input.model.NativeButton +import org.sudachi.sudachi_emu.features.input.model.NativeAnalog +import org.sudachi.sudachi_emu.features.input.model.InputType +import org.sudachi.sudachi_emu.features.input.model.ButtonName +import org.sudachi.sudachi_emu.features.input.model.NpadStyleIndex +import org.sudachi.sudachi_emu.utils.NativeConfig +import org.sudachi.sudachi_emu.utils.ParamPackage +import android.view.InputDevice + +object NativeInput { + /** + * Default controller id for each device + */ + const val Player1Device = 0 + const val Player2Device = 1 + const val Player3Device = 2 + const val Player4Device = 3 + const val Player5Device = 4 + const val Player6Device = 5 + const val Player7Device = 6 + const val Player8Device = 7 + const val ConsoleDevice = 8 + + /** + * Button states + */ + object ButtonState { + const val RELEASED = 0 + const val PRESSED = 1 + } + + /** + * Returns true if pro controller isn't available and handheld is. + * Intended to check where the input overlay should direct its inputs. + */ + external fun isHandheldOnly(): Boolean + + /** + * Handles button press events for a gamepad. + * @param guid 32 character hexadecimal string consisting of the controller's PID+VID. + * @param port Port determined by controller connection order. + * @param buttonId The Android Keycode corresponding to this event. + * @param action Mask identifying which action is happening (button pressed down, or button released). + */ + external fun onGamePadButtonEvent( + guid: String, + port: Int, + buttonId: Int, + action: Int + ) + + /** + * Handles axis movement events. + * @param guid 32 character hexadecimal string consisting of the controller's PID+VID. + * @param port Port determined by controller connection order. + * @param axis The axis ID. + * @param value Value along the given axis. + */ + external fun onGamePadAxisEvent(guid: String, port: Int, axis: Int, value: Float) + + /** + * Handles motion events. + * @param guid 32 character hexadecimal string consisting of the controller's PID+VID. + * @param port Port determined by controller connection order. + * @param deltaTimestamp The finger id corresponding to this event. + * @param xGyro The value of the x-axis for the gyroscope. + * @param yGyro The value of the y-axis for the gyroscope. + * @param zGyro The value of the z-axis for the gyroscope. + * @param xAccel The value of the x-axis for the accelerometer. + * @param yAccel The value of the y-axis for the accelerometer. + * @param zAccel The value of the z-axis for the accelerometer. + */ + external fun onGamePadMotionEvent( + guid: String, + port: Int, + deltaTimestamp: Long, + xGyro: Float, + yGyro: Float, + zGyro: Float, + xAccel: Float, + yAccel: Float, + zAccel: Float + ) + + /** + * Signals and load a nfc tag + * @param data Byte array containing all the data from a nfc tag. + */ + external fun onReadNfcTag(data: ByteArray?) + + /** + * Removes current loaded nfc tag. + */ + external fun onRemoveNfcTag() + + /** + * Handles touch press events. + * @param fingerId The finger id corresponding to this event. + * @param xAxis The value of the x-axis on the touchscreen. + * @param yAxis The value of the y-axis on the touchscreen. + */ + external fun onTouchPressed(fingerId: Int, xAxis: Float, yAxis: Float) + + /** + * Handles touch movement. + * @param fingerId The finger id corresponding to this event. + * @param xAxis The value of the x-axis on the touchscreen. + * @param yAxis The value of the y-axis on the touchscreen. + */ + external fun onTouchMoved(fingerId: Int, xAxis: Float, yAxis: Float) + + /** + * Handles touch release events. + * @param fingerId The finger id corresponding to this event + */ + external fun onTouchReleased(fingerId: Int) + + /** + * Sends a button input to the global virtual controllers. + * @param port Port determined by controller connection order. + * @param button The [NativeButton] corresponding to this event. + * @param action Mask identifying which action is happening (button pressed down, or button released). + */ + fun onOverlayButtonEvent(port: Int, button: NativeButton, action: Int) = + onOverlayButtonEventImpl(port, button.int, action) + + private external fun onOverlayButtonEventImpl(port: Int, buttonId: Int, action: Int) + + /** + * Sends a joystick input to the global virtual controllers. + * @param port Port determined by controller connection order. + * @param stick The [NativeAnalog] corresponding to this event. + * @param xAxis Value along the X axis. + * @param yAxis Value along the Y axis. + */ + fun onOverlayJoystickEvent(port: Int, stick: NativeAnalog, xAxis: Float, yAxis: Float) = + onOverlayJoystickEventImpl(port, stick.int, xAxis, yAxis) + + private external fun onOverlayJoystickEventImpl( + port: Int, + stickId: Int, + xAxis: Float, + yAxis: Float + ) + + /** + * Handles motion events for the global virtual controllers. + * @param port Port determined by controller connection order + * @param deltaTimestamp The finger id corresponding to this event. + * @param xGyro The value of the x-axis for the gyroscope. + * @param yGyro The value of the y-axis for the gyroscope. + * @param zGyro The value of the z-axis for the gyroscope. + * @param xAccel The value of the x-axis for the accelerometer. + * @param yAccel The value of the y-axis for the accelerometer. + * @param zAccel The value of the z-axis for the accelerometer. + */ + external fun onDeviceMotionEvent( + port: Int, + deltaTimestamp: Long, + xGyro: Float, + yGyro: Float, + zGyro: Float, + xAccel: Float, + yAccel: Float, + zAccel: Float + ) + + /** + * Reloads all input devices from the currently loaded Settings::values.players into HID Core + */ + external fun reloadInputDevices() + + /** + * Registers a controller to be used with mapping + * @param device An [InputDevice] or the input overlay wrapped with [SudachiInputDevice] + */ + external fun registerController(device: SudachiInputDevice) + + /** + * Gets the names of input devices that have been registered with the input subsystem via [registerController] + */ + external fun getInputDevices(): Array + + /** + * Reads all input profiles from disk. Must be called before creating a profile picker. + */ + external fun loadInputProfiles() + + /** + * Gets the names of each available input profile. + */ + external fun getInputProfileNames(): Array + + /** + * Checks if the user-provided name for an input profile is valid. + * @param name User-provided name for an input profile. + * @return Whether [name] is valid or not. + */ + external fun isProfileNameValid(name: String): Boolean + + /** + * Creates a new input profile. + * @param name The new profile's name. + * @param playerIndex Index of the player that's currently being edited. Used to write the profile + * name to this player's config. + * @return Whether creating the profile was successful or not. + */ + external fun createProfile(name: String, playerIndex: Int): Boolean + + /** + * Deletes an input profile. + * @param name Name of the profile to delete. + * @param playerIndex Index of the player that's currently being edited. Used to remove the profile + * name from this player's config if they have it loaded. + * @return Whether deleting this profile was successful or not. + */ + external fun deleteProfile(name: String, playerIndex: Int): Boolean + + /** + * Loads an input profile. + * @param name Name of the input profile to load. + * @param playerIndex Index of the player that will have this profile loaded. + * @return Whether loading this profile was successful or not. + */ + external fun loadProfile(name: String, playerIndex: Int): Boolean + + /** + * Saves an input profile. + * @param name Name of the profile to save. + * @param playerIndex Index of the player that's currently being edited. Used to write the profile + * name to this player's config. + * @return Whether saving the profile was successful or not. + */ + external fun saveProfile(name: String, playerIndex: Int): Boolean + + /** + * Intended to be used immediately before a call to [NativeConfig.saveControlPlayerValues] + * Must be used while per-game config is loaded. + */ + external fun loadPerGameConfiguration( + playerIndex: Int, + selectedIndex: Int, + selectedProfileName: String + ) + + /** + * Tells the input subsystem to start listening for inputs to map. + * @param type Type of input to map as shown by the int property in each [InputType]. + */ + external fun beginMapping(type: Int) + + /** + * Gets an input's [ParamPackage] as a serialized string. Used for input verification before mapping. + * Must be run after [beginMapping] and before [stopMapping]. + */ + external fun getNextInput(): String + + /** + * Tells the input subsystem to stop listening for inputs to map. + */ + external fun stopMapping() + + /** + * Updates a controller's mappings with auto-mapping params. + * @param playerIndex Index of the player to auto-map. + * @param deviceParams [ParamPackage] representing the device to auto-map as received + * from [getInputDevices]. + * @param displayName Name of the device to auto-map as received from the "display" param in [deviceParams]. + * Intended to be a way to provide a default name for a controller if the "display" param is empty. + */ + fun updateMappingsWithDefault( + playerIndex: Int, + deviceParams: ParamPackage, + displayName: String + ) = updateMappingsWithDefaultImpl(playerIndex, deviceParams.serialize(), displayName) + + private external fun updateMappingsWithDefaultImpl( + playerIndex: Int, + deviceParams: String, + displayName: String + ) + + /** + * Gets the params for a specific button. + * @param playerIndex Index of the player to get params from. + * @param button The [NativeButton] to get params for. + * @return A [ParamPackage] representing a player's specific button. + */ + fun getButtonParam(playerIndex: Int, button: NativeButton): ParamPackage = + ParamPackage(getButtonParamImpl(playerIndex, button.int)) + + private external fun getButtonParamImpl(playerIndex: Int, buttonId: Int): String + + /** + * Sets the params for a specific button. + * @param playerIndex Index of the player to set params for. + * @param button The [NativeButton] to set params for. + * @param param A [ParamPackage] to set. + */ + fun setButtonParam(playerIndex: Int, button: NativeButton, param: ParamPackage) = + setButtonParamImpl(playerIndex, button.int, param.serialize()) + + private external fun setButtonParamImpl(playerIndex: Int, buttonId: Int, param: String) + + /** + * Gets the params for a specific stick. + * @param playerIndex Index of the player to get params from. + * @param stick The [NativeAnalog] to get params for. + * @return A [ParamPackage] representing a player's specific stick. + */ + fun getStickParam(playerIndex: Int, stick: NativeAnalog): ParamPackage = + ParamPackage(getStickParamImpl(playerIndex, stick.int)) + + private external fun getStickParamImpl(playerIndex: Int, stickId: Int): String + + /** + * Sets the params for a specific stick. + * @param playerIndex Index of the player to set params for. + * @param stick The [NativeAnalog] to set params for. + * @param param A [ParamPackage] to set. + */ + fun setStickParam(playerIndex: Int, stick: NativeAnalog, param: ParamPackage) = + setStickParamImpl(playerIndex, stick.int, param.serialize()) + + private external fun setStickParamImpl(playerIndex: Int, stickId: Int, param: String) + + /** + * Gets the int representation of a [ButtonName]. Tells you what to show as the mapped input for + * a button/analog/other. + * @param param A [ParamPackage] that represents a specific button's params. + * @return The [ButtonName] for [param]. + */ + fun getButtonName(param: ParamPackage): ButtonName = + ButtonName.from(getButtonNameImpl(param.serialize())) + + private external fun getButtonNameImpl(param: String): Int + + /** + * Gets each supported [NpadStyleIndex] for a given player. + * @param playerIndex Index of the player to get supported indexes for. + * @return List of each supported [NpadStyleIndex]. + */ + fun getSupportedStyleTags(playerIndex: Int): List = + getSupportedStyleTagsImpl(playerIndex).map { NpadStyleIndex.from(it) } + + private external fun getSupportedStyleTagsImpl(playerIndex: Int): IntArray + + /** + * Gets the [NpadStyleIndex] for a given player. + * @param playerIndex Index of the player to get an [NpadStyleIndex] from. + * @return The [NpadStyleIndex] for a given player. + */ + fun getStyleIndex(playerIndex: Int): NpadStyleIndex = + NpadStyleIndex.from(getStyleIndexImpl(playerIndex)) + + private external fun getStyleIndexImpl(playerIndex: Int): Int + + /** + * Sets the [NpadStyleIndex] for a given player. + * @param playerIndex Index of the player to change. + * @param style The new style to set. + */ + fun setStyleIndex(playerIndex: Int, style: NpadStyleIndex) = + setStyleIndexImpl(playerIndex, style.int) + + private external fun setStyleIndexImpl(playerIndex: Int, styleIndex: Int) + + /** + * Checks if a device is a controller. + * @param params [ParamPackage] for an input device retrieved from [getInputDevices] + * @return Whether the device is a controller or not. + */ + fun isController(params: ParamPackage): Boolean = isControllerImpl(params.serialize()) + + private external fun isControllerImpl(params: String): Boolean + + /** + * Checks if a controller is connected + * @param playerIndex Index of the player to check. + * @return Whether the player is connected or not. + */ + external fun getIsConnected(playerIndex: Int): Boolean + + /** + * Connects/disconnects a controller and ensures that connection order stays in-tact. + * @param playerIndex Index of the player to connect/disconnect. + * @param connected Whether to connect or disconnect this controller. + */ + fun connectControllers(playerIndex: Int, connected: Boolean = true) { + val connectedControllers = mutableListOf().apply { + if (connected) { + for (i in 0 until 8) { + add(i <= playerIndex) + } + } else { + for (i in 0 until 8) { + add(i < playerIndex) + } + } + } + connectControllersImpl(connectedControllers.toBooleanArray()) + } + + private external fun connectControllersImpl(connected: BooleanArray) + + /** + * Resets all of the button and analog mappings for a player. + * @param playerIndex Index of the player that will have its mappings reset. + */ + external fun resetControllerMappings(playerIndex: Int) +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/input/SudachiInputDevice.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/input/SudachiInputDevice.kt new file mode 100644 index 0000000..d985eaf --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/input/SudachiInputDevice.kt @@ -0,0 +1,93 @@ +// SPDX-FileCopyrightText: 2024 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.features.input + +import android.view.InputDevice +import androidx.annotation.Keep +import org.sudachi.sudachi_emu.SudachiApplication +import org.sudachi.sudachi_emu.R +import org.sudachi.sudachi_emu.utils.InputHandler.getGUID + +@Keep +interface SudachiInputDevice { + fun getName(): String + + fun getGUID(): String + + fun getPort(): Int + + fun getSupportsVibration(): Boolean + + fun vibrate(intensity: Float) + + fun getAxes(): Array = arrayOf() + fun hasKeys(keys: IntArray): BooleanArray = BooleanArray(0) +} + +class SudachiPhysicalDevice( + private val device: InputDevice, + private val port: Int, + useSystemVibrator: Boolean +) : SudachiInputDevice { + private val vibrator = if (useSystemVibrator) { + SudachiVibrator.getSystemVibrator() + } else { + SudachiVibrator.getControllerVibrator(device) + } + + override fun getName(): String { + return device.name + } + + override fun getGUID(): String { + return device.getGUID() + } + + override fun getPort(): Int { + return port + } + + override fun getSupportsVibration(): Boolean { + return vibrator.supportsVibration() + } + + override fun vibrate(intensity: Float) { + vibrator.vibrate(intensity) + } + + override fun getAxes(): Array = device.motionRanges.map { it.axis }.toTypedArray() + override fun hasKeys(keys: IntArray): BooleanArray = device.hasKeys(*keys) +} + +class SudachiInputOverlayDevice( + private val vibration: Boolean, + private val port: Int +) : SudachiInputDevice { + private val vibrator = SudachiVibrator.getSystemVibrator() + + override fun getName(): String { + return SudachiApplication.appContext.getString(R.string.input_overlay) + } + + override fun getGUID(): String { + return "00000000000000000000000000000000" + } + + override fun getPort(): Int { + return port + } + + override fun getSupportsVibration(): Boolean { + if (vibration) { + return vibrator.supportsVibration() + } + return false + } + + override fun vibrate(intensity: Float) { + if (vibration) { + vibrator.vibrate(intensity) + } + } +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/input/SudachiVibrator.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/input/SudachiVibrator.kt new file mode 100644 index 0000000..4f50bb0 --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/input/SudachiVibrator.kt @@ -0,0 +1,76 @@ +// SPDX-FileCopyrightText: 2024 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.features.input + +import android.content.Context +import android.os.Build +import android.os.CombinedVibration +import android.os.VibrationEffect +import android.os.Vibrator +import android.os.VibratorManager +import android.view.InputDevice +import androidx.annotation.Keep +import androidx.annotation.RequiresApi +import org.sudachi.sudachi_emu.SudachiApplication + +@Keep +@Suppress("DEPRECATION") +interface SudachiVibrator { + fun supportsVibration(): Boolean + + fun vibrate(intensity: Float) + + companion object { + fun getControllerVibrator(device: InputDevice): SudachiVibrator = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + SudachiVibratorManager(device.vibratorManager) + } else { + SudachiVibratorManagerCompat(device.vibrator) + } + + fun getSystemVibrator(): SudachiVibrator = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + val vibratorManager = SudachiApplication.appContext + .getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager + SudachiVibratorManager(vibratorManager) + } else { + val vibrator = SudachiApplication.appContext + .getSystemService(Context.VIBRATOR_SERVICE) as Vibrator + SudachiVibratorManagerCompat(vibrator) + } + + fun getVibrationEffect(intensity: Float): VibrationEffect? { + if (intensity > 0f) { + return VibrationEffect.createOneShot( + 50, + (255.0 * intensity).toInt().coerceIn(1, 255) + ) + } + return null + } + } +} + +@RequiresApi(Build.VERSION_CODES.S) +class SudachiVibratorManager(private val vibratorManager: VibratorManager) : SudachiVibrator { + override fun supportsVibration(): Boolean { + return vibratorManager.vibratorIds.isNotEmpty() + } + + override fun vibrate(intensity: Float) { + val vibration = SudachiVibrator.getVibrationEffect(intensity) ?: return + vibratorManager.vibrate(CombinedVibration.createParallel(vibration)) + } +} + +class SudachiVibratorManagerCompat(private val vibrator: Vibrator) : SudachiVibrator { + override fun supportsVibration(): Boolean { + return vibrator.hasVibrator() + } + + override fun vibrate(intensity: Float) { + val vibration = SudachiVibrator.getVibrationEffect(intensity) ?: return + vibrator.vibrate(vibration) + } +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/input/model/AnalogDirection.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/input/model/AnalogDirection.kt new file mode 100644 index 0000000..45d92d1 --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/input/model/AnalogDirection.kt @@ -0,0 +1,11 @@ +// SPDX-FileCopyrightText: 2024 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.features.input.model + +enum class AnalogDirection(val int: Int, val param: String) { + Up(0, "up"), + Down(1, "down"), + Left(2, "left"), + Right(3, "right") +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/input/model/ButtonName.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/input/model/ButtonName.kt new file mode 100644 index 0000000..2d8ff80 --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/input/model/ButtonName.kt @@ -0,0 +1,19 @@ +// SPDX-FileCopyrightText: 2024 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.features.input.model + +// Loosely matches the enum in common/input.h +enum class ButtonName(val int: Int) { + Invalid(1), + + // This will display the engine name instead of the button name + Engine(2), + + // This will display the button by value instead of the button name + Value(3); + + companion object { + fun from(int: Int): ButtonName = entries.firstOrNull { it.int == int } ?: Invalid + } +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/input/model/InputType.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/input/model/InputType.kt new file mode 100644 index 0000000..11f263a --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/input/model/InputType.kt @@ -0,0 +1,13 @@ +// SPDX-FileCopyrightText: 2024 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.features.input.model + +// Must match the corresponding enum in input_common/main.h +enum class InputType(val int: Int) { + None(0), + Button(1), + Stick(2), + Motion(3), + Touch(4) +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/input/model/NativeAnalog.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/input/model/NativeAnalog.kt new file mode 100644 index 0000000..88ef792 --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/input/model/NativeAnalog.kt @@ -0,0 +1,14 @@ +// SPDX-FileCopyrightText: 2024 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.features.input.model + +// Must match enum in src/common/settings_input.h +enum class NativeAnalog(val int: Int) { + LStick(0), + RStick(1); + + companion object { + fun from(int: Int): NativeAnalog = entries.firstOrNull { it.int == int } ?: LStick + } +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/input/model/NativeButton.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/input/model/NativeButton.kt new file mode 100644 index 0000000..d26e008 --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/input/model/NativeButton.kt @@ -0,0 +1,38 @@ +// SPDX-FileCopyrightText: 2024 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.features.input.model + +// Must match enum in src/common/settings_input.h +enum class NativeButton(val int: Int) { + A(0), + B(1), + X(2), + Y(3), + LStick(4), + RStick(5), + L(6), + R(7), + ZL(8), + ZR(9), + Plus(10), + Minus(11), + + DLeft(12), + DUp(13), + DRight(14), + DDown(15), + + SLLeft(16), + SRLeft(17), + + Home(18), + Capture(19), + + SLRight(20), + SRRight(21); + + companion object { + fun from(int: Int): NativeButton = entries.firstOrNull { it.int == int } ?: A + } +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/input/model/NativeTrigger.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/input/model/NativeTrigger.kt new file mode 100644 index 0000000..de97a45 --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/input/model/NativeTrigger.kt @@ -0,0 +1,10 @@ +// SPDX-FileCopyrightText: 2024 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.features.input.model + +// Must match enum in src/common/settings_input.h +enum class NativeTrigger(val int: Int) { + LTrigger(0), + RTrigger(1) +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/input/model/NpadStyleIndex.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/input/model/NpadStyleIndex.kt new file mode 100644 index 0000000..4d938fa --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/input/model/NpadStyleIndex.kt @@ -0,0 +1,30 @@ +// SPDX-FileCopyrightText: 2024 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.features.input.model + +import androidx.annotation.StringRes +import org.sudachi.sudachi_emu.R + +// Must match enum in src/core/hid/hid_types.h +enum class NpadStyleIndex(val int: Int, @StringRes val nameId: Int = 0) { + None(0), + Fullkey(3, R.string.pro_controller), + Handheld(4, R.string.handheld), + HandheldNES(4), + JoyconDual(5, R.string.dual_joycons), + JoyconLeft(6, R.string.left_joycon), + JoyconRight(7, R.string.right_joycon), + GameCube(8, R.string.gamecube_controller), + Pokeball(9), + NES(10), + SNES(12), + N64(13), + SegaGenesis(14), + SystemExt(32), + System(33); + + companion object { + fun from(int: Int): NpadStyleIndex = entries.firstOrNull { it.int == int } ?: None + } +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/input/model/PlayerInput.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/input/model/PlayerInput.kt new file mode 100644 index 0000000..bd1328c --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/input/model/PlayerInput.kt @@ -0,0 +1,83 @@ +// SPDX-FileCopyrightText: 2024 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.features.input.model + +import androidx.annotation.Keep + +@Keep +data class PlayerInput( + var connected: Boolean, + var buttons: Array, + var analogs: Array, + var motions: Array, + + var vibrationEnabled: Boolean, + var vibrationStrength: Int, + + var bodyColorLeft: Long, + var bodyColorRight: Long, + var buttonColorLeft: Long, + var buttonColorRight: Long, + var profileName: String, + + var useSystemVibrator: Boolean +) { + // It's recommended to use the generated equals() and hashCode() methods + // when using arrays in a data class + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as PlayerInput + + if (connected != other.connected) return false + if (!buttons.contentEquals(other.buttons)) return false + if (!analogs.contentEquals(other.analogs)) return false + if (!motions.contentEquals(other.motions)) return false + if (vibrationEnabled != other.vibrationEnabled) return false + if (vibrationStrength != other.vibrationStrength) return false + if (bodyColorLeft != other.bodyColorLeft) return false + if (bodyColorRight != other.bodyColorRight) return false + if (buttonColorLeft != other.buttonColorLeft) return false + if (buttonColorRight != other.buttonColorRight) return false + if (profileName != other.profileName) return false + return useSystemVibrator == other.useSystemVibrator + } + + override fun hashCode(): Int { + var result = connected.hashCode() + result = 31 * result + buttons.contentHashCode() + result = 31 * result + analogs.contentHashCode() + result = 31 * result + motions.contentHashCode() + result = 31 * result + vibrationEnabled.hashCode() + result = 31 * result + vibrationStrength + result = 31 * result + bodyColorLeft.hashCode() + result = 31 * result + bodyColorRight.hashCode() + result = 31 * result + buttonColorLeft.hashCode() + result = 31 * result + buttonColorRight.hashCode() + result = 31 * result + profileName.hashCode() + result = 31 * result + useSystemVibrator.hashCode() + return result + } + + fun hasMapping(): Boolean { + var hasMapping = false + buttons.forEach { + if (it != "[empty]" && it.isNotEmpty()) { + hasMapping = true + } + } + analogs.forEach { + if (it != "[empty]" && it.isNotEmpty()) { + hasMapping = true + } + } + motions.forEach { + if (it != "[empty]" && it.isNotEmpty()) { + hasMapping = true + } + } + return hasMapping + } +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/model/AbstractBooleanSetting.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/model/AbstractBooleanSetting.kt new file mode 100644 index 0000000..df4673b --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/model/AbstractBooleanSetting.kt @@ -0,0 +1,9 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.features.settings.model + +interface AbstractBooleanSetting : AbstractSetting { + fun getBoolean(needsGlobal: Boolean = false): Boolean + fun setBoolean(value: Boolean) +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/model/AbstractByteSetting.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/model/AbstractByteSetting.kt new file mode 100644 index 0000000..dd7a678 --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/model/AbstractByteSetting.kt @@ -0,0 +1,9 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.features.settings.model + +interface AbstractByteSetting : AbstractSetting { + fun getByte(needsGlobal: Boolean = false): Byte + fun setByte(value: Byte) +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/model/AbstractFloatSetting.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/model/AbstractFloatSetting.kt new file mode 100644 index 0000000..7dd7c0f --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/model/AbstractFloatSetting.kt @@ -0,0 +1,9 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.features.settings.model + +interface AbstractFloatSetting : AbstractSetting { + fun getFloat(needsGlobal: Boolean = false): Float + fun setFloat(value: Float) +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/model/AbstractIntSetting.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/model/AbstractIntSetting.kt new file mode 100644 index 0000000..bc01b75 --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/model/AbstractIntSetting.kt @@ -0,0 +1,9 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.features.settings.model + +interface AbstractIntSetting : AbstractSetting { + fun getInt(needsGlobal: Boolean = false): Int + fun setInt(value: Int) +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/model/AbstractLongSetting.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/model/AbstractLongSetting.kt new file mode 100644 index 0000000..a6da258 --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/model/AbstractLongSetting.kt @@ -0,0 +1,9 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.features.settings.model + +interface AbstractLongSetting : AbstractSetting { + fun getLong(needsGlobal: Boolean = false): Long + fun setLong(value: Long) +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/model/AbstractSetting.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/model/AbstractSetting.kt new file mode 100644 index 0000000..62c86fb --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/model/AbstractSetting.kt @@ -0,0 +1,31 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.features.settings.model + +import org.sudachi.sudachi_emu.utils.NativeConfig + +interface AbstractSetting { + val key: String + val defaultValue: Any + + val isRuntimeModifiable: Boolean + get() = NativeConfig.getIsRuntimeModifiable(key) + + val pairedSettingKey: String + get() = NativeConfig.getPairedSettingKey(key) + + val isSwitchable: Boolean + get() = NativeConfig.getIsSwitchable(key) + + var global: Boolean + get() = NativeConfig.usingGlobal(key) + set(value) = NativeConfig.setGlobal(key, value) + + val isSaveable: Boolean + get() = NativeConfig.getIsSaveable(key) + + fun getValueAsString(needsGlobal: Boolean = false): String + + fun reset() +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/model/AbstractShortSetting.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/model/AbstractShortSetting.kt new file mode 100644 index 0000000..c568fdf --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/model/AbstractShortSetting.kt @@ -0,0 +1,9 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.features.settings.model + +interface AbstractShortSetting : AbstractSetting { + fun getShort(needsGlobal: Boolean = false): Short + fun setShort(value: Short) +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/model/AbstractStringSetting.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/model/AbstractStringSetting.kt new file mode 100644 index 0000000..88213f7 --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/model/AbstractStringSetting.kt @@ -0,0 +1,9 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.features.settings.model + +interface AbstractStringSetting : AbstractSetting { + fun getString(needsGlobal: Boolean = false): String + fun setString(value: String) +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/model/BooleanSetting.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/model/BooleanSetting.kt new file mode 100644 index 0000000..5af0d38 --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/model/BooleanSetting.kt @@ -0,0 +1,46 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.features.settings.model + +import org.sudachi.sudachi_emu.utils.NativeConfig + +enum class BooleanSetting(override val key: String) : AbstractBooleanSetting { + AUDIO_MUTED("audio_muted"), + CPU_DEBUG_MODE("cpu_debug_mode"), + FASTMEM("cpuopt_fastmem"), + FASTMEM_EXCLUSIVES("cpuopt_fastmem_exclusives"), + RENDERER_USE_SPEED_LIMIT("use_speed_limit"), + USE_DOCKED_MODE("use_docked_mode"), + RENDERER_USE_DISK_SHADER_CACHE("use_disk_shader_cache"), + RENDERER_FORCE_MAX_CLOCK("force_max_clock"), + RENDERER_ASYNCHRONOUS_SHADERS("use_asynchronous_shaders"), + RENDERER_REACTIVE_FLUSHING("use_reactive_flushing"), + RENDERER_DEBUG("debug"), + PICTURE_IN_PICTURE("picture_in_picture"), + USE_CUSTOM_RTC("custom_rtc_enabled"), + BLACK_BACKGROUNDS("black_backgrounds"), + JOYSTICK_REL_CENTER("joystick_rel_center"), + DPAD_SLIDE("dpad_slide"), + HAPTIC_FEEDBACK("haptic_feedback"), + SHOW_PERFORMANCE_OVERLAY("show_performance_overlay"), + SHOW_INPUT_OVERLAY("show_input_overlay"), + TOUCHSCREEN("touchscreen"), + SHOW_THERMAL_OVERLAY("show_thermal_overlay"); + + override fun getBoolean(needsGlobal: Boolean): Boolean = + NativeConfig.getBoolean(key, needsGlobal) + + override fun setBoolean(value: Boolean) { + if (NativeConfig.isPerGameConfigLoaded()) { + global = false + } + NativeConfig.setBoolean(key, value) + } + + override val defaultValue: Boolean by lazy { NativeConfig.getDefaultToString(key).toBoolean() } + + override fun getValueAsString(needsGlobal: Boolean): String = getBoolean(needsGlobal).toString() + + override fun reset() = NativeConfig.setBoolean(key, defaultValue) +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/model/ByteSetting.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/model/ByteSetting.kt new file mode 100644 index 0000000..49b9459 --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/model/ByteSetting.kt @@ -0,0 +1,25 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.features.settings.model + +import org.sudachi.sudachi_emu.utils.NativeConfig + +enum class ByteSetting(override val key: String) : AbstractByteSetting { + AUDIO_VOLUME("volume"); + + override fun getByte(needsGlobal: Boolean): Byte = NativeConfig.getByte(key, needsGlobal) + + override fun setByte(value: Byte) { + if (NativeConfig.isPerGameConfigLoaded()) { + global = false + } + NativeConfig.setByte(key, value) + } + + override val defaultValue: Byte by lazy { NativeConfig.getDefaultToString(key).toByte() } + + override fun getValueAsString(needsGlobal: Boolean): String = getByte(needsGlobal).toString() + + override fun reset() = NativeConfig.setByte(key, defaultValue) +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/model/FloatSetting.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/model/FloatSetting.kt new file mode 100644 index 0000000..e7e1c29 --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/model/FloatSetting.kt @@ -0,0 +1,26 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.features.settings.model + +import org.sudachi.sudachi_emu.utils.NativeConfig + +enum class FloatSetting(override val key: String) : AbstractFloatSetting { + // No float settings currently exist + EMPTY_SETTING(""); + + override fun getFloat(needsGlobal: Boolean): Float = NativeConfig.getFloat(key, false) + + override fun setFloat(value: Float) { + if (NativeConfig.isPerGameConfigLoaded()) { + global = false + } + NativeConfig.setFloat(key, value) + } + + override val defaultValue: Float by lazy { NativeConfig.getDefaultToString(key).toFloat() } + + override fun getValueAsString(needsGlobal: Boolean): String = getFloat(needsGlobal).toString() + + override fun reset() = NativeConfig.setFloat(key, defaultValue) +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/model/IntSetting.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/model/IntSetting.kt new file mode 100644 index 0000000..5aad3d4 --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/model/IntSetting.kt @@ -0,0 +1,45 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.features.settings.model + +import org.sudachi.sudachi_emu.utils.NativeConfig + +enum class IntSetting(override val key: String) : AbstractIntSetting { + CPU_BACKEND("cpu_backend"), + CPU_ACCURACY("cpu_accuracy"), + REGION_INDEX("region_index"), + LANGUAGE_INDEX("language_index"), + RENDERER_BACKEND("backend"), + RENDERER_ACCURACY("gpu_accuracy"), + RENDERER_RESOLUTION("resolution_setup"), + RENDERER_VSYNC("use_vsync"), + RENDERER_SCALING_FILTER("scaling_filter"), + RENDERER_ANTI_ALIASING("anti_aliasing"), + RENDERER_SCREEN_LAYOUT("screen_layout"), + RENDERER_ASPECT_RATIO("aspect_ratio"), + AUDIO_OUTPUT_ENGINE("output_engine"), + MAX_ANISOTROPY("max_anisotropy"), + THEME("theme"), + THEME_MODE("theme_mode"), + OVERLAY_SCALE("control_scale"), + OVERLAY_OPACITY("control_opacity"), + LOCK_DRAWER("lock_drawer"), + VERTICAL_ALIGNMENT("vertical_alignment"), + FSR_SHARPENING_SLIDER("fsr_sharpening_slider"); + + override fun getInt(needsGlobal: Boolean): Int = NativeConfig.getInt(key, needsGlobal) + + override fun setInt(value: Int) { + if (NativeConfig.isPerGameConfigLoaded()) { + global = false + } + NativeConfig.setInt(key, value) + } + + override val defaultValue: Int by lazy { NativeConfig.getDefaultToString(key).toInt() } + + override fun getValueAsString(needsGlobal: Boolean): String = getInt(needsGlobal).toString() + + override fun reset() = NativeConfig.setInt(key, defaultValue) +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/model/LongSetting.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/model/LongSetting.kt new file mode 100644 index 0000000..677d0e9 --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/model/LongSetting.kt @@ -0,0 +1,25 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.features.settings.model + +import org.sudachi.sudachi_emu.utils.NativeConfig + +enum class LongSetting(override val key: String) : AbstractLongSetting { + CUSTOM_RTC("custom_rtc"); + + override fun getLong(needsGlobal: Boolean): Long = NativeConfig.getLong(key, needsGlobal) + + override fun setLong(value: Long) { + if (NativeConfig.isPerGameConfigLoaded()) { + global = false + } + NativeConfig.setLong(key, value) + } + + override val defaultValue: Long by lazy { NativeConfig.getDefaultToString(key).toLong() } + + override fun getValueAsString(needsGlobal: Boolean): String = getLong(needsGlobal).toString() + + override fun reset() = NativeConfig.setLong(key, defaultValue) +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/model/Settings.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/model/Settings.kt new file mode 100644 index 0000000..4965f73 --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/model/Settings.kt @@ -0,0 +1,120 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.features.settings.model + +import org.sudachi.sudachi_emu.R +import org.sudachi.sudachi_emu.SudachiApplication + +object Settings { + enum class MenuTag(val titleId: Int = 0) { + SECTION_ROOT(R.string.advanced_settings), + SECTION_SYSTEM(R.string.preferences_system), + SECTION_RENDERER(R.string.preferences_graphics), + SECTION_AUDIO(R.string.preferences_audio), + SECTION_INPUT(R.string.preferences_controls), + SECTION_INPUT_PLAYER_ONE, + SECTION_INPUT_PLAYER_TWO, + SECTION_INPUT_PLAYER_THREE, + SECTION_INPUT_PLAYER_FOUR, + SECTION_INPUT_PLAYER_FIVE, + SECTION_INPUT_PLAYER_SIX, + SECTION_INPUT_PLAYER_SEVEN, + SECTION_INPUT_PLAYER_EIGHT, + SECTION_THEME(R.string.preferences_theme), + SECTION_DEBUG(R.string.preferences_debug); + } + + fun getPlayerString(player: Int): String = + SudachiApplication.appContext.getString(R.string.preferences_player, player) + + const val PREF_FIRST_APP_LAUNCH = "FirstApplicationLaunch" + const val PREF_MEMORY_WARNING_SHOWN = "MemoryWarningShown" + + // Deprecated input overlay preference keys + const val PREF_CONTROL_SCALE = "controlScale" + const val PREF_CONTROL_OPACITY = "controlOpacity" + const val PREF_TOUCH_ENABLED = "isTouchEnabled" + const val PREF_BUTTON_A = "buttonToggle0" + const val PREF_BUTTON_B = "buttonToggle1" + const val PREF_BUTTON_X = "buttonToggle2" + const val PREF_BUTTON_Y = "buttonToggle3" + const val PREF_BUTTON_L = "buttonToggle4" + const val PREF_BUTTON_R = "buttonToggle5" + const val PREF_BUTTON_ZL = "buttonToggle6" + const val PREF_BUTTON_ZR = "buttonToggle7" + const val PREF_BUTTON_PLUS = "buttonToggle8" + const val PREF_BUTTON_MINUS = "buttonToggle9" + const val PREF_BUTTON_DPAD = "buttonToggle10" + const val PREF_STICK_L = "buttonToggle11" + const val PREF_STICK_R = "buttonToggle12" + const val PREF_BUTTON_STICK_L = "buttonToggle13" + const val PREF_BUTTON_STICK_R = "buttonToggle14" + const val PREF_BUTTON_HOME = "buttonToggle15" + const val PREF_BUTTON_SCREENSHOT = "buttonToggle16" + const val PREF_MENU_SETTINGS_JOYSTICK_REL_CENTER = "EmulationMenuSettings_JoystickRelCenter" + const val PREF_MENU_SETTINGS_DPAD_SLIDE = "EmulationMenuSettings_DpadSlideEnable" + const val PREF_MENU_SETTINGS_HAPTICS = "EmulationMenuSettings_Haptics" + const val PREF_MENU_SETTINGS_SHOW_FPS = "EmulationMenuSettings_ShowFps" + const val PREF_MENU_SETTINGS_SHOW_OVERLAY = "EmulationMenuSettings_ShowOverlay" + val overlayPreferences = listOf( + PREF_BUTTON_A, + PREF_BUTTON_B, + PREF_BUTTON_X, + PREF_BUTTON_Y, + PREF_BUTTON_L, + PREF_BUTTON_R, + PREF_BUTTON_ZL, + PREF_BUTTON_ZR, + PREF_BUTTON_PLUS, + PREF_BUTTON_MINUS, + PREF_BUTTON_DPAD, + PREF_STICK_L, + PREF_STICK_R, + PREF_BUTTON_HOME, + PREF_BUTTON_SCREENSHOT, + PREF_BUTTON_STICK_L, + PREF_BUTTON_STICK_R + ) + + // Deprecated layout preference keys + const val PREF_LANDSCAPE_SUFFIX = "_Landscape" + const val PREF_PORTRAIT_SUFFIX = "_Portrait" + const val PREF_FOLDABLE_SUFFIX = "_Foldable" + val overlayLayoutSuffixes = listOf( + PREF_LANDSCAPE_SUFFIX, + PREF_PORTRAIT_SUFFIX, + PREF_FOLDABLE_SUFFIX + ) + + // Deprecated theme preference keys + const val PREF_THEME = "Theme" + const val PREF_THEME_MODE = "ThemeMode" + const val PREF_BLACK_BACKGROUNDS = "BlackBackgrounds" + + enum class EmulationOrientation(val int: Int) { + Unspecified(0), + SensorLandscape(5), + Landscape(1), + ReverseLandscape(2), + SensorPortrait(6), + Portrait(4), + ReversePortrait(3); + + companion object { + fun from(int: Int): EmulationOrientation = + entries.firstOrNull { it.int == int } ?: Unspecified + } + } + + enum class EmulationVerticalAlignment(val int: Int) { + Top(1), + Center(0), + Bottom(2); + + companion object { + fun from(int: Int): EmulationVerticalAlignment = + entries.firstOrNull { it.int == int } ?: Center + } + } +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/model/ShortSetting.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/model/ShortSetting.kt new file mode 100644 index 0000000..6526c6d --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/model/ShortSetting.kt @@ -0,0 +1,25 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.features.settings.model + +import org.sudachi.sudachi_emu.utils.NativeConfig + +enum class ShortSetting(override val key: String) : AbstractShortSetting { + RENDERER_SPEED_LIMIT("speed_limit"); + + override fun getShort(needsGlobal: Boolean): Short = NativeConfig.getShort(key, needsGlobal) + + override fun setShort(value: Short) { + if (NativeConfig.isPerGameConfigLoaded()) { + global = false + } + NativeConfig.setShort(key, value) + } + + override val defaultValue: Short by lazy { NativeConfig.getDefaultToString(key).toShort() } + + override fun getValueAsString(needsGlobal: Boolean): String = getShort(needsGlobal).toString() + + override fun reset() = NativeConfig.setShort(key, defaultValue) +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/model/StringSetting.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/model/StringSetting.kt new file mode 100644 index 0000000..29aea99 --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/model/StringSetting.kt @@ -0,0 +1,26 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.features.settings.model + +import org.sudachi.sudachi_emu.utils.NativeConfig + +enum class StringSetting(override val key: String) : AbstractStringSetting { + DRIVER_PATH("driver_path"), + DEVICE_NAME("device_name"); + + override fun getString(needsGlobal: Boolean): String = NativeConfig.getString(key, needsGlobal) + + override fun setString(value: String) { + if (NativeConfig.isPerGameConfigLoaded()) { + global = false + } + NativeConfig.setString(key, value) + } + + override val defaultValue: String by lazy { NativeConfig.getDefaultToString(key) } + + override fun getValueAsString(needsGlobal: Boolean): String = getString(needsGlobal) + + override fun reset() = NativeConfig.setString(key, defaultValue) +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/model/view/AnalogInputSetting.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/model/view/AnalogInputSetting.kt new file mode 100644 index 0000000..1e5b91d --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/model/view/AnalogInputSetting.kt @@ -0,0 +1,31 @@ +// SPDX-FileCopyrightText: 2024 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.features.settings.model.view + +import androidx.annotation.StringRes +import org.sudachi.sudachi_emu.features.input.NativeInput +import org.sudachi.sudachi_emu.features.input.model.AnalogDirection +import org.sudachi.sudachi_emu.features.input.model.InputType +import org.sudachi.sudachi_emu.features.input.model.NativeAnalog +import org.sudachi.sudachi_emu.utils.ParamPackage + +class AnalogInputSetting( + override val playerIndex: Int, + val nativeAnalog: NativeAnalog, + val analogDirection: AnalogDirection, + @StringRes titleId: Int = 0, + titleString: String = "" +) : InputSetting(titleId, titleString) { + override val type = TYPE_INPUT + override val inputType = InputType.Stick + + override fun getSelectedValue(): String { + val params = NativeInput.getStickParam(playerIndex, nativeAnalog) + val analog = analogToText(params, analogDirection.param) + return getDisplayString(params, analog) + } + + override fun setSelectedValue(param: ParamPackage) = + NativeInput.setStickParam(playerIndex, nativeAnalog, param) +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/model/view/ButtonInputSetting.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/model/view/ButtonInputSetting.kt new file mode 100644 index 0000000..edd563e --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/model/view/ButtonInputSetting.kt @@ -0,0 +1,29 @@ +// SPDX-FileCopyrightText: 2024 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.features.settings.model.view + +import androidx.annotation.StringRes +import org.sudachi.sudachi_emu.utils.ParamPackage +import org.sudachi.sudachi_emu.features.input.NativeInput +import org.sudachi.sudachi_emu.features.input.model.InputType +import org.sudachi.sudachi_emu.features.input.model.NativeButton + +class ButtonInputSetting( + override val playerIndex: Int, + val nativeButton: NativeButton, + @StringRes titleId: Int = 0, + titleString: String = "" +) : InputSetting(titleId, titleString) { + override val type = TYPE_INPUT + override val inputType = InputType.Button + + override fun getSelectedValue(): String { + val params = NativeInput.getButtonParam(playerIndex, nativeButton) + val button = buttonToText(params) + return getDisplayString(params, button) + } + + override fun setSelectedValue(param: ParamPackage) = + NativeInput.setButtonParam(playerIndex, nativeButton, param) +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/model/view/DateTimeSetting.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/model/view/DateTimeSetting.kt new file mode 100644 index 0000000..5a53371 --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/model/view/DateTimeSetting.kt @@ -0,0 +1,20 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.features.settings.model.view + +import androidx.annotation.StringRes +import org.sudachi.sudachi_emu.features.settings.model.AbstractLongSetting + +class DateTimeSetting( + private val longSetting: AbstractLongSetting, + @StringRes titleId: Int = 0, + titleString: String = "", + @StringRes descriptionId: Int = 0, + descriptionString: String = "" +) : SettingsItem(longSetting, titleId, titleString, descriptionId, descriptionString) { + override val type = TYPE_DATETIME_SETTING + + fun getValue(needsGlobal: Boolean = false): Long = longSetting.getLong(needsGlobal) + fun setValue(value: Long) = (setting as AbstractLongSetting).setLong(value) +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/model/view/HeaderSetting.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/model/view/HeaderSetting.kt new file mode 100644 index 0000000..8a68451 --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/model/view/HeaderSetting.kt @@ -0,0 +1,13 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.features.settings.model.view + +import androidx.annotation.StringRes + +class HeaderSetting( + @StringRes titleId: Int = 0, + titleString: String = "" +) : SettingsItem(emptySetting, titleId, titleString, 0, "") { + override val type = TYPE_HEADER +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/model/view/InputProfileSetting.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/model/view/InputProfileSetting.kt new file mode 100644 index 0000000..f80c173 --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/model/view/InputProfileSetting.kt @@ -0,0 +1,32 @@ +// SPDX-FileCopyrightText: 2024 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.features.settings.model.view + +import org.sudachi.sudachi_emu.R +import org.sudachi.sudachi_emu.features.input.NativeInput +import org.sudachi.sudachi_emu.utils.NativeConfig + +class InputProfileSetting(private val playerIndex: Int) : + SettingsItem(emptySetting, R.string.profile, "", 0, "") { + override val type = TYPE_INPUT_PROFILE + + fun getCurrentProfile(): String = + NativeConfig.getInputSettings(true)[playerIndex].profileName + + fun getProfileNames(): Array = NativeInput.getInputProfileNames() + + fun isProfileNameValid(name: String): Boolean = NativeInput.isProfileNameValid(name) + + fun createProfile(name: String): Boolean = NativeInput.createProfile(name, playerIndex) + + fun deleteProfile(name: String): Boolean = NativeInput.deleteProfile(name, playerIndex) + + fun loadProfile(name: String): Boolean { + val result = NativeInput.loadProfile(name, playerIndex) + NativeInput.reloadInputDevices() + return result + } + + fun saveProfile(name: String): Boolean = NativeInput.saveProfile(name, playerIndex) +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/model/view/InputSetting.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/model/view/InputSetting.kt new file mode 100644 index 0000000..094a00d --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/model/view/InputSetting.kt @@ -0,0 +1,134 @@ +// SPDX-FileCopyrightText: 2024 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.features.settings.model.view + +import androidx.annotation.StringRes +import org.sudachi.sudachi_emu.R +import org.sudachi.sudachi_emu.SudachiApplication +import org.sudachi.sudachi_emu.features.input.NativeInput +import org.sudachi.sudachi_emu.features.input.model.ButtonName +import org.sudachi.sudachi_emu.features.input.model.InputType +import org.sudachi.sudachi_emu.utils.ParamPackage + +sealed class InputSetting( + @StringRes titleId: Int, + titleString: String +) : SettingsItem(emptySetting, titleId, titleString, 0, "") { + override val type = TYPE_INPUT + abstract val inputType: InputType + abstract val playerIndex: Int + + protected val context get() = SudachiApplication.appContext + + abstract fun getSelectedValue(): String + + abstract fun setSelectedValue(param: ParamPackage) + + protected fun getDisplayString(params: ParamPackage, control: String): String { + val deviceName = params.get("display", "") + deviceName.ifEmpty { + return context.getString(R.string.not_set) + } + return "$deviceName: $control" + } + + private fun getDirectionName(direction: String): String = + when (direction) { + "up" -> context.getString(R.string.up) + "down" -> context.getString(R.string.down) + "left" -> context.getString(R.string.left) + "right" -> context.getString(R.string.right) + else -> direction + } + + protected fun buttonToText(param: ParamPackage): String { + if (!param.has("engine")) { + return context.getString(R.string.not_set) + } + + val toggle = if (param.get("toggle", false)) "~" else "" + val inverted = if (param.get("inverted", false)) "!" else "" + val invert = if (param.get("invert", "+") == "-") "-" else "" + val turbo = if (param.get("turbo", false)) "$" else "" + val commonButtonName = NativeInput.getButtonName(param) + + if (commonButtonName == ButtonName.Invalid) { + return context.getString(R.string.invalid) + } + + if (commonButtonName == ButtonName.Engine) { + return param.get("engine", "") + } + + if (commonButtonName == ButtonName.Value) { + if (param.has("hat")) { + val hat = getDirectionName(param.get("direction", "")) + return context.getString(R.string.qualified_hat, turbo, toggle, inverted, hat) + } + if (param.has("axis")) { + val axis = param.get("axis", "") + return context.getString( + R.string.qualified_button_stick_axis, + toggle, + inverted, + invert, + axis + ) + } + if (param.has("button")) { + val button = param.get("button", "") + return context.getString(R.string.qualified_button, turbo, toggle, inverted, button) + } + } + + return context.getString(R.string.unknown) + } + + protected fun analogToText(param: ParamPackage, direction: String): String { + if (!param.has("engine")) { + return context.getString(R.string.not_set) + } + + if (param.get("engine", "") == "analog_from_button") { + return buttonToText(ParamPackage(param.get(direction, ""))) + } + + if (!param.has("axis_x") || !param.has("axis_y")) { + return context.getString(R.string.unknown) + } + + val xAxis = param.get("axis_x", "") + val yAxis = param.get("axis_y", "") + val xInvert = param.get("invert_x", "+") == "-" + val yInvert = param.get("invert_y", "+") == "-" + + if (direction == "modifier") { + return context.getString(R.string.unused) + } + + when (direction) { + "up" -> { + val yInvertString = if (yInvert) "+" else "-" + return context.getString(R.string.qualified_axis, yAxis, yInvertString) + } + + "down" -> { + val yInvertString = if (yInvert) "-" else "+" + return context.getString(R.string.qualified_axis, yAxis, yInvertString) + } + + "left" -> { + val xInvertString = if (xInvert) "+" else "-" + return context.getString(R.string.qualified_axis, xAxis, xInvertString) + } + + "right" -> { + val xInvertString = if (xInvert) "-" else "+" + return context.getString(R.string.qualified_axis, xAxis, xInvertString) + } + } + + return context.getString(R.string.unknown) + } +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/model/view/IntSingleChoiceSetting.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/model/view/IntSingleChoiceSetting.kt new file mode 100644 index 0000000..13b4917 --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/model/view/IntSingleChoiceSetting.kt @@ -0,0 +1,38 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.features.settings.model.view + +import androidx.annotation.StringRes +import org.sudachi.sudachi_emu.features.settings.model.AbstractIntSetting + +class IntSingleChoiceSetting( + private val intSetting: AbstractIntSetting, + @StringRes titleId: Int = 0, + titleString: String = "", + @StringRes descriptionId: Int = 0, + descriptionString: String = "", + val choices: Array, + val values: Array +) : SettingsItem(intSetting, titleId, titleString, descriptionId, descriptionString) { + override val type = TYPE_INT_SINGLE_CHOICE + + fun getValueAt(index: Int): Int = + if (values.indices.contains(index)) values[index] else -1 + + fun getChoiceAt(index: Int): String = + if (choices.indices.contains(index)) choices[index] else "" + + fun getSelectedValue(needsGlobal: Boolean = false) = intSetting.getInt(needsGlobal) + fun setSelectedValue(value: Int) = intSetting.setInt(value) + + val selectedValueIndex: Int + get() { + for (i in values.indices) { + if (values[i] == getSelectedValue()) { + return i + } + } + return -1 + } +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/model/view/ModifierInputSetting.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/model/view/ModifierInputSetting.kt new file mode 100644 index 0000000..3b77c6a --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/model/view/ModifierInputSetting.kt @@ -0,0 +1,31 @@ +// SPDX-FileCopyrightText: 2024 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.features.settings.model.view + +import androidx.annotation.StringRes +import org.sudachi.sudachi_emu.features.input.NativeInput +import org.sudachi.sudachi_emu.features.input.model.InputType +import org.sudachi.sudachi_emu.features.input.model.NativeAnalog +import org.sudachi.sudachi_emu.utils.ParamPackage + +class ModifierInputSetting( + override val playerIndex: Int, + val nativeAnalog: NativeAnalog, + @StringRes titleId: Int = 0, + titleString: String = "" +) : InputSetting(titleId, titleString) { + override val inputType = InputType.Button + + override fun getSelectedValue(): String { + val analogParam = NativeInput.getStickParam(playerIndex, nativeAnalog) + val modifierParam = ParamPackage(analogParam.get("modifier", "")) + return buttonToText(modifierParam) + } + + override fun setSelectedValue(param: ParamPackage) { + val newParam = NativeInput.getStickParam(playerIndex, nativeAnalog) + newParam.set("modifier", param.serialize()) + NativeInput.setStickParam(playerIndex, nativeAnalog, newParam) + } +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/model/view/RunnableSetting.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/model/view/RunnableSetting.kt new file mode 100644 index 0000000..242bebf --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/model/view/RunnableSetting.kt @@ -0,0 +1,19 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.features.settings.model.view + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes + +class RunnableSetting( + @StringRes titleId: Int = 0, + titleString: String = "", + @StringRes descriptionId: Int = 0, + descriptionString: String = "", + val isRunnable: Boolean, + @DrawableRes val iconId: Int = 0, + val runnable: () -> Unit +) : SettingsItem(emptySetting, titleId, titleString, descriptionId, descriptionString) { + override val type = TYPE_RUNNABLE +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/model/view/SettingsItem.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/model/view/SettingsItem.kt new file mode 100644 index 0000000..4a17199 --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/model/view/SettingsItem.kt @@ -0,0 +1,391 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.features.settings.model.view + +import androidx.annotation.StringRes +import org.sudachi.sudachi_emu.NativeLibrary +import org.sudachi.sudachi_emu.R +import org.sudachi.sudachi_emu.SudachiApplication +import org.sudachi.sudachi_emu.features.input.NativeInput +import org.sudachi.sudachi_emu.features.input.model.NpadStyleIndex +import org.sudachi.sudachi_emu.features.settings.model.AbstractBooleanSetting +import org.sudachi.sudachi_emu.features.settings.model.AbstractSetting +import org.sudachi.sudachi_emu.features.settings.model.BooleanSetting +import org.sudachi.sudachi_emu.features.settings.model.ByteSetting +import org.sudachi.sudachi_emu.features.settings.model.IntSetting +import org.sudachi.sudachi_emu.features.settings.model.LongSetting +import org.sudachi.sudachi_emu.features.settings.model.ShortSetting +import org.sudachi.sudachi_emu.features.settings.model.StringSetting +import org.sudachi.sudachi_emu.utils.NativeConfig + +/** + * ViewModel abstraction for an Item in the RecyclerView powering SettingsFragments. + * Each one corresponds to a [AbstractSetting] object, so this class's subclasses + * should vaguely correspond to those subclasses. There are a few with multiple analogues + * and a few with none (Headers, for example, do not correspond to anything in the ini + * file.) + */ +abstract class SettingsItem( + val setting: AbstractSetting, + @StringRes val titleId: Int, + val titleString: String, + @StringRes val descriptionId: Int, + val descriptionString: String +) { + abstract val type: Int + + val title: String by lazy { + if (titleId != 0) { + return@lazy SudachiApplication.appContext.getString(titleId) + } + return@lazy titleString + } + + val description: String by lazy { + if (descriptionId != 0) { + return@lazy SudachiApplication.appContext.getString(descriptionId) + } + return@lazy descriptionString + } + + val isEditable: Boolean + get() { + // Can't change docked mode toggle when using handheld mode + if (setting.key == BooleanSetting.USE_DOCKED_MODE.key) { + return NativeInput.getStyleIndex(0) != NpadStyleIndex.Handheld + } + + // Can't edit settings that aren't saveable in per-game config even if they are switchable + if (NativeConfig.isPerGameConfigLoaded() && !setting.isSaveable) { + return false + } + + if (!NativeLibrary.isRunning()) return true + + // Prevent editing settings that were modified in per-game config while editing global + // config + if (!NativeConfig.isPerGameConfigLoaded() && !setting.global) { + return false + } + + return setting.isRuntimeModifiable + } + + val needsRuntimeGlobal: Boolean + get() = NativeLibrary.isRunning() && !setting.global && + !NativeConfig.isPerGameConfigLoaded() + + val clearable: Boolean + get() = !setting.global && NativeConfig.isPerGameConfigLoaded() + + companion object { + const val TYPE_HEADER = 0 + const val TYPE_SWITCH = 1 + const val TYPE_SINGLE_CHOICE = 2 + const val TYPE_SLIDER = 3 + const val TYPE_SUBMENU = 4 + const val TYPE_STRING_SINGLE_CHOICE = 5 + const val TYPE_DATETIME_SETTING = 6 + const val TYPE_RUNNABLE = 7 + const val TYPE_INPUT = 8 + const val TYPE_INT_SINGLE_CHOICE = 9 + const val TYPE_INPUT_PROFILE = 10 + const val TYPE_STRING_INPUT = 11 + + const val FASTMEM_COMBINED = "fastmem_combined" + + val emptySetting = object : AbstractSetting { + override val key: String = "" + override val defaultValue: Any = false + override val isSaveable = true + override fun getValueAsString(needsGlobal: Boolean): String = "" + override fun reset() {} + } + + // Extension for putting SettingsItems into a hashmap without repeating yourself + fun HashMap.put(item: SettingsItem) { + put(item.setting.key, item) + } + + // List of all general + val settingsItems = HashMap().apply { + put(StringInputSetting(StringSetting.DEVICE_NAME, titleId = R.string.device_name)) + put( + SwitchSetting( + BooleanSetting.RENDERER_USE_SPEED_LIMIT, + titleId = R.string.frame_limit_enable, + descriptionId = R.string.frame_limit_enable_description + ) + ) + put( + SliderSetting( + ShortSetting.RENDERER_SPEED_LIMIT, + titleId = R.string.frame_limit_slider, + descriptionId = R.string.frame_limit_slider_description, + min = 1, + max = 400, + units = "%" + ) + ) + put( + SingleChoiceSetting( + IntSetting.CPU_BACKEND, + titleId = R.string.cpu_backend, + choicesId = R.array.cpuBackendArm64Names, + valuesId = R.array.cpuBackendArm64Values + ) + ) + put( + SingleChoiceSetting( + IntSetting.CPU_ACCURACY, + titleId = R.string.cpu_accuracy, + choicesId = R.array.cpuAccuracyNames, + valuesId = R.array.cpuAccuracyValues + ) + ) + put( + SwitchSetting( + BooleanSetting.PICTURE_IN_PICTURE, + titleId = R.string.picture_in_picture, + descriptionId = R.string.picture_in_picture_description + ) + ) + + val dockedModeSetting = object : AbstractBooleanSetting { + override val key = BooleanSetting.USE_DOCKED_MODE.key + + override fun getBoolean(needsGlobal: Boolean): Boolean { + if (NativeInput.getStyleIndex(0) == NpadStyleIndex.Handheld) { + return false + } + return BooleanSetting.USE_DOCKED_MODE.getBoolean(needsGlobal) + } + + override fun setBoolean(value: Boolean) = + BooleanSetting.USE_DOCKED_MODE.setBoolean(value) + + override val defaultValue = BooleanSetting.USE_DOCKED_MODE.defaultValue + + override fun getValueAsString(needsGlobal: Boolean): String = + BooleanSetting.USE_DOCKED_MODE.getValueAsString(needsGlobal) + + override fun reset() = BooleanSetting.USE_DOCKED_MODE.reset() + } + put( + SwitchSetting( + dockedModeSetting, + titleId = R.string.use_docked_mode, + descriptionId = R.string.use_docked_mode_description + ) + ) + + put( + SingleChoiceSetting( + IntSetting.REGION_INDEX, + titleId = R.string.emulated_region, + choicesId = R.array.regionNames, + valuesId = R.array.regionValues + ) + ) + put( + SingleChoiceSetting( + IntSetting.LANGUAGE_INDEX, + titleId = R.string.emulated_language, + choicesId = R.array.languageNames, + valuesId = R.array.languageValues + ) + ) + put( + SwitchSetting( + BooleanSetting.USE_CUSTOM_RTC, + titleId = R.string.use_custom_rtc, + descriptionId = R.string.use_custom_rtc_description + ) + ) + put(DateTimeSetting(LongSetting.CUSTOM_RTC, titleId = R.string.set_custom_rtc)) + put( + SingleChoiceSetting( + IntSetting.RENDERER_ACCURACY, + titleId = R.string.renderer_accuracy, + choicesId = R.array.rendererAccuracyNames, + valuesId = R.array.rendererAccuracyValues + ) + ) + put( + SingleChoiceSetting( + IntSetting.RENDERER_RESOLUTION, + titleId = R.string.renderer_resolution, + choicesId = R.array.rendererResolutionNames, + valuesId = R.array.rendererResolutionValues + ) + ) + put( + SingleChoiceSetting( + IntSetting.RENDERER_VSYNC, + titleId = R.string.renderer_vsync, + choicesId = R.array.rendererVSyncNames, + valuesId = R.array.rendererVSyncValues + ) + ) + put( + SingleChoiceSetting( + IntSetting.RENDERER_SCALING_FILTER, + titleId = R.string.renderer_scaling_filter, + choicesId = R.array.rendererScalingFilterNames, + valuesId = R.array.rendererScalingFilterValues + ) + ) + put( + SliderSetting( + IntSetting.FSR_SHARPENING_SLIDER, + titleId = R.string.fsr_sharpness, + descriptionId = R.string.fsr_sharpness_description, + units = "%" + ) + ) + put( + SingleChoiceSetting( + IntSetting.RENDERER_ANTI_ALIASING, + titleId = R.string.renderer_anti_aliasing, + choicesId = R.array.rendererAntiAliasingNames, + valuesId = R.array.rendererAntiAliasingValues + ) + ) + put( + SingleChoiceSetting( + IntSetting.RENDERER_SCREEN_LAYOUT, + titleId = R.string.renderer_screen_layout, + choicesId = R.array.rendererScreenLayoutNames, + valuesId = R.array.rendererScreenLayoutValues + ) + ) + put( + SingleChoiceSetting( + IntSetting.RENDERER_ASPECT_RATIO, + titleId = R.string.renderer_aspect_ratio, + choicesId = R.array.rendererAspectRatioNames, + valuesId = R.array.rendererAspectRatioValues + ) + ) + put( + SingleChoiceSetting( + IntSetting.VERTICAL_ALIGNMENT, + titleId = R.string.vertical_alignment, + descriptionId = 0, + choicesId = R.array.verticalAlignmentEntries, + valuesId = R.array.verticalAlignmentValues + ) + ) + put( + SwitchSetting( + BooleanSetting.RENDERER_USE_DISK_SHADER_CACHE, + titleId = R.string.use_disk_shader_cache, + descriptionId = R.string.use_disk_shader_cache_description + ) + ) + put( + SwitchSetting( + BooleanSetting.RENDERER_FORCE_MAX_CLOCK, + titleId = R.string.renderer_force_max_clock, + descriptionId = R.string.renderer_force_max_clock_description + ) + ) + put( + SwitchSetting( + BooleanSetting.RENDERER_ASYNCHRONOUS_SHADERS, + titleId = R.string.renderer_asynchronous_shaders, + descriptionId = R.string.renderer_asynchronous_shaders_description + ) + ) + put( + SwitchSetting( + BooleanSetting.RENDERER_REACTIVE_FLUSHING, + titleId = R.string.renderer_reactive_flushing, + descriptionId = R.string.renderer_reactive_flushing_description + ) + ) + put( + SingleChoiceSetting( + IntSetting.MAX_ANISOTROPY, + titleId = R.string.anisotropic_filtering, + descriptionId = R.string.anisotropic_filtering_description, + choicesId = R.array.anisoEntries, + valuesId = R.array.anisoValues + ) + ) + put( + SingleChoiceSetting( + IntSetting.AUDIO_OUTPUT_ENGINE, + titleId = R.string.audio_output_engine, + choicesId = R.array.outputEngineEntries, + valuesId = R.array.outputEngineValues + ) + ) + put( + SliderSetting( + ByteSetting.AUDIO_VOLUME, + titleId = R.string.audio_volume, + descriptionId = R.string.audio_volume_description, + units = "%" + ) + ) + put( + SingleChoiceSetting( + IntSetting.RENDERER_BACKEND, + titleId = R.string.renderer_api, + choicesId = R.array.rendererApiNames, + valuesId = R.array.rendererApiValues + ) + ) + put( + SwitchSetting( + BooleanSetting.RENDERER_DEBUG, + titleId = R.string.renderer_debug, + descriptionId = R.string.renderer_debug_description + ) + ) + put( + SwitchSetting( + BooleanSetting.CPU_DEBUG_MODE, + titleId = R.string.cpu_debug_mode, + descriptionId = R.string.cpu_debug_mode_description + ) + ) + + val fastmem = object : AbstractBooleanSetting { + override fun getBoolean(needsGlobal: Boolean): Boolean = + BooleanSetting.FASTMEM.getBoolean() && + BooleanSetting.FASTMEM_EXCLUSIVES.getBoolean() + + override fun setBoolean(value: Boolean) { + BooleanSetting.FASTMEM.setBoolean(value) + BooleanSetting.FASTMEM_EXCLUSIVES.setBoolean(value) + } + + override val key: String = FASTMEM_COMBINED + override val isRuntimeModifiable: Boolean = false + override val pairedSettingKey = BooleanSetting.CPU_DEBUG_MODE.key + override val defaultValue: Boolean = true + override val isSwitchable: Boolean = true + override var global: Boolean + get() { + return BooleanSetting.FASTMEM.global && + BooleanSetting.FASTMEM_EXCLUSIVES.global + } + set(value) { + BooleanSetting.FASTMEM.global = value + BooleanSetting.FASTMEM_EXCLUSIVES.global = value + } + + override val isSaveable = true + + override fun getValueAsString(needsGlobal: Boolean): String = + getBoolean().toString() + + override fun reset() = setBoolean(defaultValue) + } + put(SwitchSetting(fastmem, R.string.fastmem)) + } + } +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/model/view/SingleChoiceSetting.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/model/view/SingleChoiceSetting.kt new file mode 100644 index 0000000..946f683 --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/model/view/SingleChoiceSetting.kt @@ -0,0 +1,29 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.features.settings.model.view + +import androidx.annotation.ArrayRes +import androidx.annotation.StringRes +import org.sudachi.sudachi_emu.features.settings.model.AbstractIntSetting +import org.sudachi.sudachi_emu.features.settings.model.AbstractSetting + +class SingleChoiceSetting( + setting: AbstractSetting, + @StringRes titleId: Int = 0, + titleString: String = "", + @StringRes descriptionId: Int = 0, + descriptionString: String = "", + @ArrayRes val choicesId: Int, + @ArrayRes val valuesId: Int +) : SettingsItem(setting, titleId, titleString, descriptionId, descriptionString) { + override val type = TYPE_SINGLE_CHOICE + + fun getSelectedValue(needsGlobal: Boolean = false) = + when (setting) { + is AbstractIntSetting -> setting.getInt(needsGlobal) + else -> -1 + } + + fun setSelectedValue(value: Int) = (setting as AbstractIntSetting).setInt(value) +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/model/view/SliderSetting.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/model/view/SliderSetting.kt new file mode 100644 index 0000000..012441e --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/model/view/SliderSetting.kt @@ -0,0 +1,42 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.features.settings.model.view + +import androidx.annotation.StringRes +import org.sudachi.sudachi_emu.features.settings.model.AbstractByteSetting +import org.sudachi.sudachi_emu.features.settings.model.AbstractFloatSetting +import org.sudachi.sudachi_emu.features.settings.model.AbstractIntSetting +import org.sudachi.sudachi_emu.features.settings.model.AbstractSetting +import org.sudachi.sudachi_emu.features.settings.model.AbstractShortSetting +import kotlin.math.roundToInt + +class SliderSetting( + setting: AbstractSetting, + @StringRes titleId: Int = 0, + titleString: String = "", + @StringRes descriptionId: Int = 0, + descriptionString: String = "", + val min: Int = 0, + val max: Int = 100, + val units: String = "" +) : SettingsItem(setting, titleId, titleString, descriptionId, descriptionString) { + override val type = TYPE_SLIDER + + fun getSelectedValue(needsGlobal: Boolean = false) = + when (setting) { + is AbstractByteSetting -> setting.getByte(needsGlobal).toInt() + is AbstractShortSetting -> setting.getShort(needsGlobal).toInt() + is AbstractIntSetting -> setting.getInt(needsGlobal) + is AbstractFloatSetting -> setting.getFloat(needsGlobal).roundToInt() + else -> -1 + } + + fun setSelectedValue(value: Int) = + when (setting) { + is AbstractByteSetting -> setting.setByte(value.toByte()) + is AbstractShortSetting -> setting.setShort(value.toShort()) + is AbstractFloatSetting -> setting.setFloat(value.toFloat()) + else -> (setting as AbstractIntSetting).setInt(value) + } +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/model/view/StringInputSetting.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/model/view/StringInputSetting.kt new file mode 100644 index 0000000..f488290 --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/model/view/StringInputSetting.kt @@ -0,0 +1,22 @@ +// SPDX-FileCopyrightText: 2024 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.features.settings.model.view + +import androidx.annotation.StringRes +import org.sudachi.sudachi_emu.features.settings.model.AbstractStringSetting + +class StringInputSetting( + setting: AbstractStringSetting, + @StringRes titleId: Int = 0, + titleString: String = "", + @StringRes descriptionId: Int = 0, + descriptionString: String = "" +) : SettingsItem(setting, titleId, titleString, descriptionId, descriptionString) { + override val type = TYPE_STRING_INPUT + + fun getSelectedValue(needsGlobal: Boolean = false) = setting.getValueAsString(needsGlobal) + + fun setSelectedValue(selection: String) = + (setting as AbstractStringSetting).setString(selection) +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/model/view/StringSingleChoiceSetting.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/model/view/StringSingleChoiceSetting.kt new file mode 100644 index 0000000..e035562 --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/model/view/StringSingleChoiceSetting.kt @@ -0,0 +1,35 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.features.settings.model.view + +import androidx.annotation.StringRes +import org.sudachi.sudachi_emu.features.settings.model.AbstractStringSetting + +class StringSingleChoiceSetting( + private val stringSetting: AbstractStringSetting, + @StringRes titleId: Int = 0, + titleString: String = "", + @StringRes descriptionId: Int = 0, + descriptionString: String = "", + val choices: Array, + val values: Array +) : SettingsItem(stringSetting, titleId, titleString, descriptionId, descriptionString) { + override val type = TYPE_STRING_SINGLE_CHOICE + + fun getValueAt(index: Int): String = + if (index >= 0 && index < values.size) values[index] else "" + + fun getSelectedValue(needsGlobal: Boolean = false) = stringSetting.getString(needsGlobal) + fun setSelectedValue(value: String) = stringSetting.setString(value) + + val selectedValueIndex: Int + get() { + for (i in values.indices) { + if (values[i] == getSelectedValue()) { + return i + } + } + return -1 + } +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/model/view/SubmenuSetting.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/model/view/SubmenuSetting.kt new file mode 100644 index 0000000..dcbd04b --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/model/view/SubmenuSetting.kt @@ -0,0 +1,19 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.features.settings.model.view + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import org.sudachi.sudachi_emu.features.settings.model.Settings + +class SubmenuSetting( + @StringRes titleId: Int = 0, + titleString: String = "", + @StringRes descriptionId: Int = 0, + descriptionString: String = "", + @DrawableRes val iconId: Int = 0, + val menuKey: Settings.MenuTag +) : SettingsItem(emptySetting, titleId, titleString, descriptionId, descriptionString) { + override val type = TYPE_SUBMENU +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/model/view/SwitchSetting.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/model/view/SwitchSetting.kt new file mode 100644 index 0000000..897935e --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/model/view/SwitchSetting.kt @@ -0,0 +1,34 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.features.settings.model.view + +import androidx.annotation.StringRes +import org.sudachi.sudachi_emu.features.settings.model.AbstractBooleanSetting +import org.sudachi.sudachi_emu.features.settings.model.AbstractIntSetting +import org.sudachi.sudachi_emu.features.settings.model.AbstractSetting + +class SwitchSetting( + setting: AbstractSetting, + @StringRes titleId: Int = 0, + titleString: String = "", + @StringRes descriptionId: Int = 0, + descriptionString: String = "" +) : SettingsItem(setting, titleId, titleString, descriptionId, descriptionString) { + override val type = TYPE_SWITCH + + fun getIsChecked(needsGlobal: Boolean = false): Boolean { + return when (setting) { + is AbstractIntSetting -> setting.getInt(needsGlobal) == 1 + is AbstractBooleanSetting -> setting.getBoolean(needsGlobal) + else -> false + } + } + + fun setChecked(value: Boolean) { + when (setting) { + is AbstractIntSetting -> setting.setInt(if (value) 1 else 0) + is AbstractBooleanSetting -> setting.setBoolean(value) + } + } +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/InputDialogFragment.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/InputDialogFragment.kt new file mode 100644 index 0000000..91fc566 --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/InputDialogFragment.kt @@ -0,0 +1,300 @@ +// SPDX-FileCopyrightText: 2024 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.features.settings.ui + +import android.app.Dialog +import android.graphics.drawable.Animatable2 +import android.graphics.drawable.AnimatedVectorDrawable +import android.graphics.drawable.Drawable +import android.os.Bundle +import android.view.InputDevice +import android.view.KeyEvent +import android.view.LayoutInflater +import android.view.MotionEvent +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.activityViewModels +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import org.sudachi.sudachi_emu.R +import org.sudachi.sudachi_emu.databinding.DialogMappingBinding +import org.sudachi.sudachi_emu.features.input.NativeInput +import org.sudachi.sudachi_emu.features.input.model.NativeAnalog +import org.sudachi.sudachi_emu.features.input.model.NativeButton +import org.sudachi.sudachi_emu.features.settings.model.view.AnalogInputSetting +import org.sudachi.sudachi_emu.features.settings.model.view.ButtonInputSetting +import org.sudachi.sudachi_emu.features.settings.model.view.InputSetting +import org.sudachi.sudachi_emu.features.settings.model.view.ModifierInputSetting +import org.sudachi.sudachi_emu.utils.InputHandler +import org.sudachi.sudachi_emu.utils.ParamPackage + +class InputDialogFragment : DialogFragment() { + private var inputAccepted = false + + private var position: Int = 0 + + private lateinit var inputSetting: InputSetting + + private lateinit var binding: DialogMappingBinding + + private val settingsViewModel: SettingsViewModel by activityViewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + if (settingsViewModel.clickedItem == null) dismiss() + + position = requireArguments().getInt(POSITION) + + InputHandler.updateControllerData() + } + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + inputSetting = settingsViewModel.clickedItem as InputSetting + binding = DialogMappingBinding.inflate(layoutInflater) + + val builder = MaterialAlertDialogBuilder(requireContext()) + .setPositiveButton(android.R.string.cancel) { _, _ -> + NativeInput.stopMapping() + dismiss() + } + .setView(binding.root) + + val playButtonMapAnimation = { twoDirections: Boolean -> + val stickAnimation: AnimatedVectorDrawable + val buttonAnimation: AnimatedVectorDrawable + binding.imageStickAnimation.apply { + val anim = if (twoDirections) { + R.drawable.stick_two_direction_anim + } else { + R.drawable.stick_one_direction_anim + } + setBackgroundResource(anim) + stickAnimation = background as AnimatedVectorDrawable + } + binding.imageButtonAnimation.apply { + setBackgroundResource(R.drawable.button_anim) + buttonAnimation = background as AnimatedVectorDrawable + } + stickAnimation.registerAnimationCallback(object : Animatable2.AnimationCallback() { + override fun onAnimationEnd(drawable: Drawable?) { + buttonAnimation.start() + } + }) + buttonAnimation.registerAnimationCallback(object : Animatable2.AnimationCallback() { + override fun onAnimationEnd(drawable: Drawable?) { + stickAnimation.start() + } + }) + stickAnimation.start() + } + + when (val setting = inputSetting) { + is AnalogInputSetting -> { + when (setting.nativeAnalog) { + NativeAnalog.LStick -> builder.setTitle( + getString(R.string.map_control, getString(R.string.left_stick)) + ) + + NativeAnalog.RStick -> builder.setTitle( + getString(R.string.map_control, getString(R.string.right_stick)) + ) + } + + builder.setMessage(R.string.stick_map_description) + + playButtonMapAnimation.invoke(true) + } + + is ModifierInputSetting -> { + builder.setTitle(getString(R.string.map_control, setting.title)) + .setMessage(R.string.button_map_description) + playButtonMapAnimation.invoke(false) + } + + is ButtonInputSetting -> { + if (setting.nativeButton == NativeButton.DUp || + setting.nativeButton == NativeButton.DDown || + setting.nativeButton == NativeButton.DLeft || + setting.nativeButton == NativeButton.DRight + ) { + builder.setTitle(getString(R.string.map_dpad_direction, setting.title)) + } else { + builder.setTitle(getString(R.string.map_control, setting.title)) + } + builder.setMessage(R.string.button_map_description) + playButtonMapAnimation.invoke(false) + } + } + + return builder.create() + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + view.requestFocus() + view.setOnFocusChangeListener { v, hasFocus -> if (!hasFocus) v.requestFocus() } + dialog?.setOnKeyListener { _, _, keyEvent -> onKeyEvent(keyEvent) } + binding.root.setOnGenericMotionListener { _, motionEvent -> onMotionEvent(motionEvent) } + NativeInput.beginMapping(inputSetting.inputType.int) + } + + private fun onKeyEvent(event: KeyEvent): Boolean { + if (event.source and InputDevice.SOURCE_JOYSTICK != InputDevice.SOURCE_JOYSTICK && + event.source and InputDevice.SOURCE_GAMEPAD != InputDevice.SOURCE_GAMEPAD + ) { + return false + } + + val action = when (event.action) { + KeyEvent.ACTION_DOWN -> NativeInput.ButtonState.PRESSED + KeyEvent.ACTION_UP -> NativeInput.ButtonState.RELEASED + else -> return false + } + val controllerData = + InputHandler.androidControllers[event.device.controllerNumber] ?: return false + NativeInput.onGamePadButtonEvent( + controllerData.getGUID(), + controllerData.getPort(), + event.keyCode, + action + ) + onInputReceived(event.device) + return true + } + + private fun onMotionEvent(event: MotionEvent): Boolean { + if (event.source and InputDevice.SOURCE_JOYSTICK != InputDevice.SOURCE_JOYSTICK && + event.source and InputDevice.SOURCE_GAMEPAD != InputDevice.SOURCE_GAMEPAD + ) { + return false + } + + // Temp workaround for DPads that give both axis and button input. The input system can't + // take in a specific axis direction for a binding so you lose half of the directions for a DPad. + + val controllerData = + InputHandler.androidControllers[event.device.controllerNumber] ?: return false + event.device.motionRanges.forEach { + NativeInput.onGamePadAxisEvent( + controllerData.getGUID(), + controllerData.getPort(), + it.axis, + event.getAxisValue(it.axis) + ) + onInputReceived(event.device) + } + return true + } + + private fun onInputReceived(device: InputDevice) { + val params = ParamPackage(NativeInput.getNextInput()) + if (params.has("engine") && isInputAcceptable(params) && !inputAccepted) { + inputAccepted = true + setResult(params, device) + } + } + + private fun setResult(params: ParamPackage, device: InputDevice) { + NativeInput.stopMapping() + params.set("display", "${device.name} ${params.get("port", 0)}") + when (val item = settingsViewModel.clickedItem as InputSetting) { + is ModifierInputSetting, + is ButtonInputSetting -> { + // Invert DPad up and left bindings by default + val tempSetting = inputSetting as? ButtonInputSetting + if (tempSetting != null) { + if (tempSetting.nativeButton == NativeButton.DUp || + tempSetting.nativeButton == NativeButton.DLeft && + params.has("axis") + ) { + params.set("invert", "-") + } + } + + item.setSelectedValue(params) + settingsViewModel.setAdapterItemChanged(position) + } + + is AnalogInputSetting -> { + var analogParam = NativeInput.getStickParam(item.playerIndex, item.nativeAnalog) + analogParam = adjustAnalogParam(params, analogParam, item.analogDirection.param) + + // Invert Y-Axis by default + analogParam.set("invert_y", "-") + + item.setSelectedValue(analogParam) + settingsViewModel.setReloadListAndNotifyDataset(true) + } + } + dismiss() + } + + private fun adjustAnalogParam( + inputParam: ParamPackage, + analogParam: ParamPackage, + buttonName: String + ): ParamPackage { + // The poller returned a complete axis, so set all the buttons + if (inputParam.has("axis_x") && inputParam.has("axis_y")) { + return inputParam + } + + // Check if the current configuration has either no engine or an axis binding. + // Clears out the old binding and adds one with analog_from_button. + if (!analogParam.has("engine") || analogParam.has("axis_x") || analogParam.has("axis_y")) { + analogParam.clear() + analogParam.set("engine", "analog_from_button") + } + analogParam.set(buttonName, inputParam.serialize()) + return analogParam + } + + private fun isInputAcceptable(params: ParamPackage): Boolean { + if (InputHandler.registeredControllers.size == 1) { + return true + } + + if (params.has("motion")) { + return true + } + + val currentDevice = settingsViewModel.getCurrentDeviceParams(params) + if (currentDevice.get("engine", "any") == "any") { + return true + } + + val guidMatch = params.get("guid", "") == currentDevice.get("guid", "") || + params.get("guid", "") == currentDevice.get("guid2", "") + return params.get("engine", "") == currentDevice.get("engine", "") && + guidMatch && + params.get("port", 0) == currentDevice.get("port", 0) + } + + companion object { + const val TAG = "InputDialogFragment" + + const val POSITION = "Position" + + fun newInstance( + inputMappingViewModel: SettingsViewModel, + setting: InputSetting, + position: Int + ): InputDialogFragment { + inputMappingViewModel.clickedItem = setting + val args = Bundle() + args.putInt(POSITION, position) + val fragment = InputDialogFragment() + fragment.arguments = args + return fragment + } + } +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/InputProfileAdapter.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/InputProfileAdapter.kt new file mode 100644 index 0000000..1275408 --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/InputProfileAdapter.kt @@ -0,0 +1,68 @@ +// SPDX-FileCopyrightText: 2024 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.features.settings.ui + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import org.sudachi.sudachi_emu.SudachiApplication +import org.sudachi.sudachi_emu.adapters.AbstractListAdapter +import org.sudachi.sudachi_emu.databinding.ListItemInputProfileBinding +import org.sudachi.sudachi_emu.viewholder.AbstractViewHolder +import org.sudachi.sudachi_emu.R + +class InputProfileAdapter(options: List) : + AbstractListAdapter>(options) { + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int + ): AbstractViewHolder { + ListItemInputProfileBinding.inflate(LayoutInflater.from(parent.context), parent, false) + .also { return InputProfileViewHolder(it) } + } + + inner class InputProfileViewHolder(val binding: ListItemInputProfileBinding) : + AbstractViewHolder(binding) { + override fun bind(model: ProfileItem) { + when (model) { + is ExistingProfileItem -> { + binding.title.text = model.name + binding.buttonNew.visibility = View.GONE + binding.buttonDelete.visibility = View.VISIBLE + binding.buttonDelete.setOnClickListener { model.deleteProfile.invoke() } + binding.buttonSave.visibility = View.VISIBLE + binding.buttonSave.setOnClickListener { model.saveProfile.invoke() } + binding.buttonLoad.visibility = View.VISIBLE + binding.buttonLoad.setOnClickListener { model.loadProfile.invoke() } + } + + is NewProfileItem -> { + binding.title.text = model.name + binding.buttonNew.visibility = View.VISIBLE + binding.buttonNew.setOnClickListener { model.createNewProfile.invoke() } + binding.buttonSave.visibility = View.GONE + binding.buttonDelete.visibility = View.GONE + binding.buttonLoad.visibility = View.GONE + } + } + } + } +} + +sealed interface ProfileItem { + val name: String +} + +data class NewProfileItem( + val createNewProfile: () -> Unit +) : ProfileItem { + override val name: String = SudachiApplication.appContext.getString(R.string.create_new_profile) +} + +data class ExistingProfileItem( + override val name: String, + val deleteProfile: () -> Unit, + val saveProfile: () -> Unit, + val loadProfile: () -> Unit +) : ProfileItem diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/InputProfileDialogFragment.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/InputProfileDialogFragment.kt new file mode 100644 index 0000000..4257ccf --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/InputProfileDialogFragment.kt @@ -0,0 +1,148 @@ +// SPDX-FileCopyrightText: 2024 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.features.settings.ui + +import android.app.Dialog +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Toast +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.activityViewModels +import androidx.recyclerview.widget.LinearLayoutManager +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import org.sudachi.sudachi_emu.R +import org.sudachi.sudachi_emu.databinding.DialogInputProfilesBinding +import org.sudachi.sudachi_emu.features.settings.model.view.InputProfileSetting +import org.sudachi.sudachi_emu.fragments.MessageDialogFragment +import org.sudachi.sudachi_emu.utils.collect + +class InputProfileDialogFragment : DialogFragment() { + private var position = 0 + + private val settingsViewModel: SettingsViewModel by activityViewModels() + + private lateinit var binding: DialogInputProfilesBinding + + private lateinit var setting: InputProfileSetting + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + position = requireArguments().getInt(POSITION) + } + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + binding = DialogInputProfilesBinding.inflate(layoutInflater) + + setting = settingsViewModel.clickedItem as InputProfileSetting + val options = mutableListOf().apply { + add( + NewProfileItem( + createNewProfile = { + NewInputProfileDialogFragment.newInstance( + settingsViewModel, + setting, + position + ).show(parentFragmentManager, NewInputProfileDialogFragment.TAG) + dismiss() + } + ) + ) + + val onActionDismiss = { + settingsViewModel.setReloadListAndNotifyDataset(true) + dismiss() + } + setting.getProfileNames().forEach { + add( + ExistingProfileItem( + it, + deleteProfile = { + settingsViewModel.setShouldShowDeleteProfileDialog(it) + }, + saveProfile = { + if (!setting.saveProfile(it)) { + Toast.makeText( + requireContext(), + R.string.failed_to_save_profile, + Toast.LENGTH_SHORT + ).show() + } + onActionDismiss.invoke() + }, + loadProfile = { + if (!setting.loadProfile(it)) { + Toast.makeText( + requireContext(), + R.string.failed_to_load_profile, + Toast.LENGTH_SHORT + ).show() + } + onActionDismiss.invoke() + } + ) + ) + } + } + binding.listProfiles.apply { + layoutManager = LinearLayoutManager(requireContext()) + adapter = InputProfileAdapter(options) + } + + return MaterialAlertDialogBuilder(requireContext()) + .setView(binding.root) + .create() + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + settingsViewModel.shouldShowDeleteProfileDialog.collect(viewLifecycleOwner) { + if (it.isNotEmpty()) { + MessageDialogFragment.newInstance( + activity = requireActivity(), + titleId = R.string.delete_input_profile, + descriptionId = R.string.delete_input_profile_description, + positiveAction = { + setting.deleteProfile(it) + settingsViewModel.setReloadListAndNotifyDataset(true) + }, + negativeAction = {}, + negativeButtonTitleId = android.R.string.cancel + ).show(parentFragmentManager, MessageDialogFragment.TAG) + settingsViewModel.setShouldShowDeleteProfileDialog("") + dismiss() + } + } + } + + companion object { + const val TAG = "InputProfileDialogFragment" + + const val POSITION = "Position" + + fun newInstance( + settingsViewModel: SettingsViewModel, + profileSetting: InputProfileSetting, + position: Int + ): InputProfileDialogFragment { + settingsViewModel.clickedItem = profileSetting + + val args = Bundle() + args.putInt(POSITION, position) + val fragment = InputProfileDialogFragment() + fragment.arguments = args + return fragment + } + } +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/NewInputProfileDialogFragment.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/NewInputProfileDialogFragment.kt new file mode 100644 index 0000000..dcf269a --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/NewInputProfileDialogFragment.kt @@ -0,0 +1,79 @@ +// SPDX-FileCopyrightText: 2024 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.features.settings.ui + +import android.app.Dialog +import android.os.Bundle +import android.widget.Toast +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.activityViewModels +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import org.sudachi.sudachi_emu.databinding.DialogEditTextBinding +import org.sudachi.sudachi_emu.features.settings.model.view.InputProfileSetting +import org.sudachi.sudachi_emu.R + +class NewInputProfileDialogFragment : DialogFragment() { + private var position = 0 + + private val settingsViewModel: SettingsViewModel by activityViewModels() + + private lateinit var binding: DialogEditTextBinding + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + position = requireArguments().getInt(POSITION) + } + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + binding = DialogEditTextBinding.inflate(layoutInflater) + + val setting = settingsViewModel.clickedItem as InputProfileSetting + return MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.enter_profile_name) + .setPositiveButton(android.R.string.ok) { _, _ -> + val profileName = binding.editText.text.toString() + if (!setting.isProfileNameValid(profileName)) { + Toast.makeText( + requireContext(), + R.string.invalid_profile_name, + Toast.LENGTH_SHORT + ).show() + return@setPositiveButton + } + + if (!setting.createProfile(profileName)) { + Toast.makeText( + requireContext(), + R.string.profile_name_already_exists, + Toast.LENGTH_SHORT + ).show() + } else { + settingsViewModel.setAdapterItemChanged(position) + } + } + .setNegativeButton(android.R.string.cancel, null) + .setView(binding.root) + .show() + } + + companion object { + const val TAG = "NewInputProfileDialogFragment" + + const val POSITION = "Position" + + fun newInstance( + settingsViewModel: SettingsViewModel, + profileSetting: InputProfileSetting, + position: Int + ): NewInputProfileDialogFragment { + settingsViewModel.clickedItem = profileSetting + + val args = Bundle() + args.putInt(POSITION, position) + val fragment = NewInputProfileDialogFragment() + fragment.arguments = args + return fragment + } + } +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/SettingsActivity.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/SettingsActivity.kt new file mode 100644 index 0000000..84f6bd0 --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/SettingsActivity.kt @@ -0,0 +1,171 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.features.settings.ui + +import android.os.Bundle +import android.view.View +import android.view.ViewGroup.MarginLayoutParams +import android.widget.Toast +import androidx.activity.OnBackPressedCallback +import androidx.activity.viewModels +import androidx.appcompat.app.AppCompatActivity +import androidx.core.view.ViewCompat +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import androidx.navigation.fragment.NavHostFragment +import androidx.navigation.navArgs +import com.google.android.material.color.MaterialColors +import org.sudachi.sudachi_emu.NativeLibrary +import java.io.IOException +import org.sudachi.sudachi_emu.R +import org.sudachi.sudachi_emu.databinding.ActivitySettingsBinding +import org.sudachi.sudachi_emu.features.input.NativeInput +import org.sudachi.sudachi_emu.features.settings.utils.SettingsFile +import org.sudachi.sudachi_emu.fragments.ResetSettingsDialogFragment +import org.sudachi.sudachi_emu.utils.* + +class SettingsActivity : AppCompatActivity() { + private lateinit var binding: ActivitySettingsBinding + + private val args by navArgs() + + private val settingsViewModel: SettingsViewModel by viewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + ThemeHelper.setTheme(this) + + super.onCreate(savedInstanceState) + + binding = ActivitySettingsBinding.inflate(layoutInflater) + setContentView(binding.root) + + if (!NativeConfig.isPerGameConfigLoaded() && args.game != null) { + SettingsFile.loadCustomConfig(args.game!!) + } + settingsViewModel.game = args.game + + val navHostFragment = + supportFragmentManager.findFragmentById(R.id.fragment_container) as NavHostFragment + navHostFragment.navController.setGraph(R.navigation.settings_navigation, intent.extras) + + WindowCompat.setDecorFitsSystemWindows(window, false) + + if (InsetsHelper.getSystemGestureType(applicationContext) != + InsetsHelper.GESTURE_NAVIGATION + ) { + binding.navigationBarShade.setBackgroundColor( + ThemeHelper.getColorWithOpacity( + MaterialColors.getColor( + binding.navigationBarShade, + com.google.android.material.R.attr.colorSurface + ), + ThemeHelper.SYSTEM_BAR_ALPHA + ) + ) + } + + settingsViewModel.shouldRecreate.collect( + this, + resetState = { settingsViewModel.setShouldRecreate(false) } + ) { if (it) recreate() } + settingsViewModel.shouldNavigateBack.collect( + this, + resetState = { settingsViewModel.setShouldNavigateBack(false) } + ) { if (it) navigateBack() } + settingsViewModel.shouldShowResetSettingsDialog.collect( + this, + resetState = { settingsViewModel.setShouldShowResetSettingsDialog(false) } + ) { + if (it) { + ResetSettingsDialogFragment().show( + supportFragmentManager, + ResetSettingsDialogFragment.TAG + ) + } + } + + onBackPressedDispatcher.addCallback( + this, + object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() = navigateBack() + } + ) + + setInsets() + } + + fun navigateBack() { + val navHostFragment = + supportFragmentManager.findFragmentById(R.id.fragment_container) as NavHostFragment + if (navHostFragment.childFragmentManager.backStackEntryCount > 0) { + navHostFragment.navController.popBackStack() + } else { + finish() + } + } + + override fun onStart() { + super.onStart() + if (!DirectoryInitialization.areDirectoriesReady) { + DirectoryInitialization.start() + } + } + + override fun onStop() { + super.onStop() + Log.info("[SettingsActivity] Settings activity stopping. Saving settings to INI...") + if (isFinishing) { + NativeInput.reloadInputDevices() + NativeLibrary.applySettings() + if (args.game == null) { + NativeConfig.saveGlobalConfig() + } else if (NativeConfig.isPerGameConfigLoaded()) { + NativeLibrary.logSettings() + NativeConfig.savePerGameConfig() + NativeConfig.unloadPerGameConfig() + } + } + } + + fun onSettingsReset() { + // Delete settings file because the user may have changed values that do not exist in the UI + if (args.game == null) { + NativeConfig.unloadGlobalConfig() + val settingsFile = SettingsFile.getSettingsFile(SettingsFile.FILE_NAME_CONFIG) + if (!settingsFile.delete()) { + throw IOException("Failed to delete $settingsFile") + } + NativeConfig.initializeGlobalConfig() + } else { + NativeConfig.unloadPerGameConfig() + val settingsFile = SettingsFile.getCustomSettingsFile(args.game!!) + if (!settingsFile.delete()) { + throw IOException("Failed to delete $settingsFile") + } + } + + Toast.makeText( + applicationContext, + getString(R.string.settings_reset), + Toast.LENGTH_LONG + ).show() + finish() + } + + private fun setInsets() { + ViewCompat.setOnApplyWindowInsetsListener( + binding.navigationBarShade + ) { _: View, windowInsets: WindowInsetsCompat -> + val barInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) + + // The only situation where we care to have a nav bar shade is when it's at the bottom + // of the screen where scrolling list elements can go behind it. + val mlpNavShade = binding.navigationBarShade.layoutParams as MarginLayoutParams + mlpNavShade.height = barInsets.bottom + binding.navigationBarShade.layoutParams = mlpNavShade + + windowInsets + } + } +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/SettingsAdapter.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/SettingsAdapter.kt new file mode 100644 index 0000000..e1b172e --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/SettingsAdapter.kt @@ -0,0 +1,434 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.features.settings.ui + +import android.content.Context +import android.icu.util.Calendar +import android.icu.util.TimeZone +import android.text.format.DateFormat +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.PopupMenu +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModelProvider +import androidx.navigation.findNavController +import androidx.recyclerview.widget.AsyncDifferConfig +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import com.google.android.material.datepicker.MaterialDatePicker +import com.google.android.material.timepicker.MaterialTimePicker +import com.google.android.material.timepicker.TimeFormat +import org.sudachi.sudachi_emu.R +import org.sudachi.sudachi_emu.SettingsNavigationDirections +import org.sudachi.sudachi_emu.databinding.ListItemSettingBinding +import org.sudachi.sudachi_emu.databinding.ListItemSettingInputBinding +import org.sudachi.sudachi_emu.databinding.ListItemSettingSwitchBinding +import org.sudachi.sudachi_emu.databinding.ListItemSettingsHeaderBinding +import org.sudachi.sudachi_emu.features.input.NativeInput +import org.sudachi.sudachi_emu.features.input.model.AnalogDirection +import org.sudachi.sudachi_emu.features.settings.model.AbstractIntSetting +import org.sudachi.sudachi_emu.features.settings.model.view.* +import org.sudachi.sudachi_emu.features.settings.ui.viewholder.* +import org.sudachi.sudachi_emu.utils.ParamPackage + +class SettingsAdapter( + private val fragment: Fragment, + private val context: Context +) : ListAdapter( + AsyncDifferConfig.Builder(DiffCallback()).build() +) { + private val settingsViewModel: SettingsViewModel + get() = ViewModelProvider(fragment.requireActivity())[SettingsViewModel::class.java] + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SettingViewHolder { + val inflater = LayoutInflater.from(parent.context) + return when (viewType) { + SettingsItem.TYPE_HEADER -> { + HeaderViewHolder(ListItemSettingsHeaderBinding.inflate(inflater), this) + } + + SettingsItem.TYPE_SWITCH -> { + SwitchSettingViewHolder(ListItemSettingSwitchBinding.inflate(inflater), this) + } + + SettingsItem.TYPE_SINGLE_CHOICE, SettingsItem.TYPE_STRING_SINGLE_CHOICE -> { + SingleChoiceViewHolder(ListItemSettingBinding.inflate(inflater), this) + } + + SettingsItem.TYPE_SLIDER -> { + SliderViewHolder(ListItemSettingBinding.inflate(inflater), this) + } + + SettingsItem.TYPE_SUBMENU -> { + SubmenuViewHolder(ListItemSettingBinding.inflate(inflater), this) + } + + SettingsItem.TYPE_DATETIME_SETTING -> { + DateTimeViewHolder(ListItemSettingBinding.inflate(inflater), this) + } + + SettingsItem.TYPE_RUNNABLE -> { + RunnableViewHolder(ListItemSettingBinding.inflate(inflater), this) + } + + SettingsItem.TYPE_INPUT -> { + InputViewHolder(ListItemSettingInputBinding.inflate(inflater), this) + } + + SettingsItem.TYPE_INT_SINGLE_CHOICE -> { + SingleChoiceViewHolder(ListItemSettingBinding.inflate(inflater), this) + } + + SettingsItem.TYPE_INPUT_PROFILE -> { + InputProfileViewHolder(ListItemSettingBinding.inflate(inflater), this) + } + + SettingsItem.TYPE_STRING_INPUT -> { + StringInputViewHolder(ListItemSettingBinding.inflate(inflater), this) + } + + else -> { + HeaderViewHolder(ListItemSettingsHeaderBinding.inflate(inflater), this) + } + } + } + + override fun onBindViewHolder(holder: SettingViewHolder, position: Int) { + holder.bind(currentList[position]) + } + + override fun getItemCount(): Int = currentList.size + + override fun getItemViewType(position: Int): Int { + return currentList[position].type + } + + fun onBooleanClick(item: SwitchSetting, checked: Boolean, position: Int) { + item.setChecked(checked) + notifyItemChanged(position) + settingsViewModel.setShouldReloadSettingsList(true) + } + + fun onSingleChoiceClick(item: SingleChoiceSetting, position: Int) { + SettingsDialogFragment.newInstance( + settingsViewModel, + item, + SettingsItem.TYPE_SINGLE_CHOICE, + position + ).show(fragment.childFragmentManager, SettingsDialogFragment.TAG) + } + + fun onStringSingleChoiceClick(item: StringSingleChoiceSetting, position: Int) { + SettingsDialogFragment.newInstance( + settingsViewModel, + item, + SettingsItem.TYPE_STRING_SINGLE_CHOICE, + position + ).show(fragment.childFragmentManager, SettingsDialogFragment.TAG) + } + + fun onIntSingleChoiceClick(item: IntSingleChoiceSetting, position: Int) { + SettingsDialogFragment.newInstance( + settingsViewModel, + item, + SettingsItem.TYPE_INT_SINGLE_CHOICE, + position + ).show(fragment.childFragmentManager, SettingsDialogFragment.TAG) + } + + fun onDateTimeClick(item: DateTimeSetting, position: Int) { + val storedTime = item.getValue() * 1000 + + // Helper to extract hour and minute from epoch time + val calendar: Calendar = Calendar.getInstance() + calendar.timeInMillis = storedTime + calendar.timeZone = TimeZone.getTimeZone("UTC") + + var timeFormat: Int = TimeFormat.CLOCK_12H + if (DateFormat.is24HourFormat(context)) { + timeFormat = TimeFormat.CLOCK_24H + } + + val datePicker: MaterialDatePicker = MaterialDatePicker.Builder.datePicker() + .setSelection(storedTime) + .setTitleText(R.string.select_rtc_date) + .build() + val timePicker: MaterialTimePicker = MaterialTimePicker.Builder() + .setTimeFormat(timeFormat) + .setHour(calendar.get(Calendar.HOUR_OF_DAY)) + .setMinute(calendar.get(Calendar.MINUTE)) + .setTitleText(R.string.select_rtc_time) + .build() + + datePicker.addOnPositiveButtonClickListener { + timePicker.show( + fragment.childFragmentManager, + "TimePicker" + ) + } + timePicker.addOnPositiveButtonClickListener { + var epochTime: Long = datePicker.selection!! / 1000 + epochTime += timePicker.hour.toLong() * 60 * 60 + epochTime += timePicker.minute.toLong() * 60 + if (item.getValue() != epochTime) { + notifyItemChanged(position) + item.setValue(epochTime) + } + } + datePicker.show( + fragment.childFragmentManager, + "DatePicker" + ) + } + + fun onSliderClick(item: SliderSetting, position: Int) { + SettingsDialogFragment.newInstance( + settingsViewModel, + item, + SettingsItem.TYPE_SLIDER, + position + ).show(fragment.childFragmentManager, SettingsDialogFragment.TAG) + } + + fun onSubmenuClick(item: SubmenuSetting) { + val action = SettingsNavigationDirections.actionGlobalSettingsFragment(item.menuKey, null) + fragment.view?.findNavController()?.navigate(action) + } + + fun onInputProfileClick(item: InputProfileSetting, position: Int) { + InputProfileDialogFragment.newInstance( + settingsViewModel, + item, + position + ).show(fragment.childFragmentManager, InputProfileDialogFragment.TAG) + } + + fun onInputClick(item: InputSetting, position: Int) { + InputDialogFragment.newInstance( + settingsViewModel, + item, + position + ).show(fragment.childFragmentManager, InputDialogFragment.TAG) + } + + fun onInputOptionsClick(anchor: View, item: InputSetting, position: Int) { + val popup = PopupMenu(context, anchor) + popup.menuInflater.inflate(R.menu.menu_input_options, popup.menu) + + popup.menu.apply { + val invertAxis = findItem(R.id.invert_axis) + val invertButton = findItem(R.id.invert_button) + val toggleButton = findItem(R.id.toggle_button) + val turboButton = findItem(R.id.turbo_button) + val setThreshold = findItem(R.id.set_threshold) + val toggleAxis = findItem(R.id.toggle_axis) + when (item) { + is AnalogInputSetting -> { + val params = NativeInput.getStickParam(item.playerIndex, item.nativeAnalog) + + invertAxis.isVisible = true + invertAxis.isCheckable = true + invertAxis.isChecked = when (item.analogDirection) { + AnalogDirection.Left, AnalogDirection.Right -> { + params.get("invert_x", "+") == "-" + } + + AnalogDirection.Up, AnalogDirection.Down -> { + params.get("invert_y", "+") == "-" + } + } + invertAxis.setOnMenuItemClickListener { + if (item.analogDirection == AnalogDirection.Left || + item.analogDirection == AnalogDirection.Right + ) { + val invertValue = params.get("invert_x", "+") == "-" + val invertString = if (invertValue) "+" else "-" + params.set("invert_x", invertString) + } else if ( + item.analogDirection == AnalogDirection.Up || + item.analogDirection == AnalogDirection.Down + ) { + val invertValue = params.get("invert_y", "+") == "-" + val invertString = if (invertValue) "+" else "-" + params.set("invert_y", invertString) + } + true + } + + popup.setOnDismissListener { + NativeInput.setStickParam(item.playerIndex, item.nativeAnalog, params) + settingsViewModel.setDatasetChanged(true) + } + } + + is ButtonInputSetting -> { + val params = NativeInput.getButtonParam(item.playerIndex, item.nativeButton) + if (params.has("code") || params.has("button") || params.has("hat")) { + val buttonInvert = params.get("inverted", false) + invertButton.isVisible = true + invertButton.isCheckable = true + invertButton.isChecked = buttonInvert + invertButton.setOnMenuItemClickListener { + params.set("inverted", !buttonInvert) + true + } + + val toggle = params.get("toggle", false) + toggleButton.isVisible = true + toggleButton.isCheckable = true + toggleButton.isChecked = toggle + toggleButton.setOnMenuItemClickListener { + params.set("toggle", !toggle) + true + } + + val turbo = params.get("turbo", false) + turboButton.isVisible = true + turboButton.isCheckable = true + turboButton.isChecked = turbo + turboButton.setOnMenuItemClickListener { + params.set("turbo", !turbo) + true + } + } else if (params.has("axis")) { + val axisInvert = params.get("invert", "+") == "-" + invertAxis.isVisible = true + invertAxis.isCheckable = true + invertAxis.isChecked = axisInvert + invertAxis.setOnMenuItemClickListener { + params.set("invert", if (!axisInvert) "-" else "+") + true + } + + val buttonInvert = params.get("inverted", false) + invertButton.isVisible = true + invertButton.isCheckable = true + invertButton.isChecked = buttonInvert + invertButton.setOnMenuItemClickListener { + params.set("inverted", !buttonInvert) + true + } + + setThreshold.isVisible = true + val thresholdSetting = object : AbstractIntSetting { + override val key = "" + + override fun getInt(needsGlobal: Boolean): Int = + (params.get("threshold", 0.5f) * 100).toInt() + + override fun setInt(value: Int) { + params.set("threshold", value.toFloat() / 100) + NativeInput.setButtonParam( + item.playerIndex, + item.nativeButton, + params + ) + } + + override val defaultValue = 50 + + override fun getValueAsString(needsGlobal: Boolean): String = + getInt(needsGlobal).toString() + + override fun reset() = setInt(defaultValue) + } + setThreshold.setOnMenuItemClickListener { + onSliderClick( + SliderSetting(thresholdSetting, R.string.set_threshold), + position + ) + true + } + + val axisToggle = params.get("toggle", false) + toggleAxis.isVisible = true + toggleAxis.isCheckable = true + toggleAxis.isChecked = axisToggle + toggleAxis.setOnMenuItemClickListener { + params.set("toggle", !axisToggle) + true + } + } + + popup.setOnDismissListener { + NativeInput.setButtonParam(item.playerIndex, item.nativeButton, params) + settingsViewModel.setAdapterItemChanged(position) + } + } + + is ModifierInputSetting -> { + val stickParams = NativeInput.getStickParam(item.playerIndex, item.nativeAnalog) + val modifierParams = ParamPackage(stickParams.get("modifier", "")) + + val invert = modifierParams.get("inverted", false) + invertButton.isVisible = true + invertButton.isCheckable = true + invertButton.isChecked = invert + invertButton.setOnMenuItemClickListener { + modifierParams.set("inverted", !invert) + stickParams.set("modifier", modifierParams.serialize()) + true + } + + val toggle = modifierParams.get("toggle", false) + toggleButton.isVisible = true + toggleButton.isCheckable = true + toggleButton.isChecked = toggle + toggleButton.setOnMenuItemClickListener { + modifierParams.set("toggle", !toggle) + stickParams.set("modifier", modifierParams.serialize()) + true + } + + popup.setOnDismissListener { + NativeInput.setStickParam( + item.playerIndex, + item.nativeAnalog, + stickParams + ) + settingsViewModel.setAdapterItemChanged(position) + } + } + } + } + popup.show() + } + + fun onStringInputClick(item: StringInputSetting, position: Int) { + SettingsDialogFragment.newInstance( + settingsViewModel, + item, + SettingsItem.TYPE_STRING_INPUT, + position + ).show(fragment.childFragmentManager, SettingsDialogFragment.TAG) + } + + fun onLongClick(item: SettingsItem, position: Int): Boolean { + SettingsDialogFragment.newInstance( + settingsViewModel, + item, + SettingsDialogFragment.TYPE_RESET_SETTING, + position + ).show(fragment.childFragmentManager, SettingsDialogFragment.TAG) + + return true + } + + fun onClearClick(item: SettingsItem, position: Int) { + item.setting.global = true + notifyItemChanged(position) + settingsViewModel.setShouldReloadSettingsList(true) + } + + private class DiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: SettingsItem, newItem: SettingsItem): Boolean { + return oldItem.setting.key == newItem.setting.key + } + + override fun areContentsTheSame(oldItem: SettingsItem, newItem: SettingsItem): Boolean { + return oldItem.setting.key == newItem.setting.key + } + } +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/SettingsDialogFragment.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/SettingsDialogFragment.kt new file mode 100644 index 0000000..5cc66d2 --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/SettingsDialogFragment.kt @@ -0,0 +1,301 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.features.settings.ui + +import android.app.Dialog +import android.content.DialogInterface +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.activityViewModels +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.slider.Slider +import org.sudachi.sudachi_emu.R +import org.sudachi.sudachi_emu.databinding.DialogEditTextBinding +import org.sudachi.sudachi_emu.databinding.DialogSliderBinding +import org.sudachi.sudachi_emu.features.input.NativeInput +import org.sudachi.sudachi_emu.features.input.model.AnalogDirection +import org.sudachi.sudachi_emu.features.settings.model.view.AnalogInputSetting +import org.sudachi.sudachi_emu.features.settings.model.view.ButtonInputSetting +import org.sudachi.sudachi_emu.features.settings.model.view.IntSingleChoiceSetting +import org.sudachi.sudachi_emu.features.settings.model.view.SettingsItem +import org.sudachi.sudachi_emu.features.settings.model.view.SingleChoiceSetting +import org.sudachi.sudachi_emu.features.settings.model.view.SliderSetting +import org.sudachi.sudachi_emu.features.settings.model.view.StringInputSetting +import org.sudachi.sudachi_emu.features.settings.model.view.StringSingleChoiceSetting +import org.sudachi.sudachi_emu.utils.ParamPackage +import org.sudachi.sudachi_emu.utils.collect + +class SettingsDialogFragment : DialogFragment(), DialogInterface.OnClickListener { + private var type = 0 + private var position = 0 + + private var defaultCancelListener = + DialogInterface.OnClickListener { _: DialogInterface?, _: Int -> closeDialog() } + + private val settingsViewModel: SettingsViewModel by activityViewModels() + + private lateinit var sliderBinding: DialogSliderBinding + private lateinit var stringInputBinding: DialogEditTextBinding + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + type = requireArguments().getInt(TYPE) + position = requireArguments().getInt(POSITION) + + if (settingsViewModel.clickedItem == null) dismiss() + } + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + return when (type) { + TYPE_RESET_SETTING -> { + MaterialAlertDialogBuilder(requireContext()) + .setMessage(R.string.reset_setting_confirmation) + .setPositiveButton(android.R.string.ok) { _: DialogInterface, _: Int -> + when (val item = settingsViewModel.clickedItem) { + is AnalogInputSetting -> { + val stickParam = NativeInput.getStickParam( + item.playerIndex, + item.nativeAnalog + ) + if (stickParam.get("engine", "") == "analog_from_button") { + when (item.analogDirection) { + AnalogDirection.Up -> stickParam.erase("up") + AnalogDirection.Down -> stickParam.erase("down") + AnalogDirection.Left -> stickParam.erase("left") + AnalogDirection.Right -> stickParam.erase("right") + } + NativeInput.setStickParam( + item.playerIndex, + item.nativeAnalog, + stickParam + ) + settingsViewModel.setAdapterItemChanged(position) + } else { + NativeInput.setStickParam( + item.playerIndex, + item.nativeAnalog, + ParamPackage() + ) + settingsViewModel.setDatasetChanged(true) + } + } + + is ButtonInputSetting -> { + NativeInput.setButtonParam( + item.playerIndex, + item.nativeButton, + ParamPackage() + ) + settingsViewModel.setAdapterItemChanged(position) + } + + else -> { + settingsViewModel.clickedItem!!.setting.reset() + settingsViewModel.setAdapterItemChanged(position) + } + } + } + .setNegativeButton(android.R.string.cancel, null) + .create() + } + + SettingsItem.TYPE_SINGLE_CHOICE -> { + val item = settingsViewModel.clickedItem as SingleChoiceSetting + val value = getSelectionForSingleChoiceValue(item) + MaterialAlertDialogBuilder(requireContext()) + .setTitle(item.title) + .setSingleChoiceItems(item.choicesId, value, this) + .create() + } + + SettingsItem.TYPE_SLIDER -> { + sliderBinding = DialogSliderBinding.inflate(layoutInflater) + val item = settingsViewModel.clickedItem as SliderSetting + + settingsViewModel.setSliderTextValue(item.getSelectedValue().toFloat(), item.units) + sliderBinding.slider.apply { + valueFrom = item.min.toFloat() + valueTo = item.max.toFloat() + value = settingsViewModel.sliderProgress.value.toFloat() + addOnChangeListener { _: Slider, value: Float, _: Boolean -> + settingsViewModel.setSliderTextValue(value, item.units) + } + } + + MaterialAlertDialogBuilder(requireContext()) + .setTitle(item.title) + .setView(sliderBinding.root) + .setPositiveButton(android.R.string.ok, this) + .setNegativeButton(android.R.string.cancel, defaultCancelListener) + .create() + } + + SettingsItem.TYPE_STRING_INPUT -> { + stringInputBinding = DialogEditTextBinding.inflate(layoutInflater) + val item = settingsViewModel.clickedItem as StringInputSetting + stringInputBinding.editText.setText(item.getSelectedValue()) + MaterialAlertDialogBuilder(requireContext()) + .setTitle(item.title) + .setView(stringInputBinding.root) + .setPositiveButton(android.R.string.ok, this) + .setNegativeButton(android.R.string.cancel, defaultCancelListener) + .create() + } + + SettingsItem.TYPE_STRING_SINGLE_CHOICE -> { + val item = settingsViewModel.clickedItem as StringSingleChoiceSetting + MaterialAlertDialogBuilder(requireContext()) + .setTitle(item.title) + .setSingleChoiceItems(item.choices, item.selectedValueIndex, this) + .create() + } + + SettingsItem.TYPE_INT_SINGLE_CHOICE -> { + val item = settingsViewModel.clickedItem as IntSingleChoiceSetting + MaterialAlertDialogBuilder(requireContext()) + .setTitle(item.title) + .setSingleChoiceItems(item.choices, item.selectedValueIndex, this) + .create() + } + + else -> super.onCreateDialog(savedInstanceState) + } + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + return when (type) { + SettingsItem.TYPE_SLIDER -> sliderBinding.root + SettingsItem.TYPE_STRING_INPUT -> stringInputBinding.root + else -> super.onCreateView(inflater, container, savedInstanceState) + } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + when (type) { + SettingsItem.TYPE_SLIDER -> { + settingsViewModel.sliderTextValue.collect(viewLifecycleOwner) { + sliderBinding.textValue.text = it + } + settingsViewModel.sliderProgress.collect(viewLifecycleOwner) { + sliderBinding.slider.value = it.toFloat() + } + } + } + } + + override fun onClick(dialog: DialogInterface, which: Int) { + when (settingsViewModel.clickedItem) { + is SingleChoiceSetting -> { + val scSetting = settingsViewModel.clickedItem as SingleChoiceSetting + val value = getValueForSingleChoiceSelection(scSetting, which) + scSetting.setSelectedValue(value) + } + + is StringSingleChoiceSetting -> { + val scSetting = settingsViewModel.clickedItem as StringSingleChoiceSetting + val value = scSetting.getValueAt(which) + scSetting.setSelectedValue(value) + } + + is IntSingleChoiceSetting -> { + val scSetting = settingsViewModel.clickedItem as IntSingleChoiceSetting + val value = scSetting.getValueAt(which) + scSetting.setSelectedValue(value) + } + + is SliderSetting -> { + val sliderSetting = settingsViewModel.clickedItem as SliderSetting + sliderSetting.setSelectedValue(settingsViewModel.sliderProgress.value) + } + + is StringInputSetting -> { + val stringInputSetting = settingsViewModel.clickedItem as StringInputSetting + stringInputSetting.setSelectedValue( + (stringInputBinding.editText.text ?: "").toString() + ) + } + } + closeDialog() + } + + private fun closeDialog() { + settingsViewModel.setAdapterItemChanged(position) + settingsViewModel.clickedItem = null + settingsViewModel.setSliderProgress(-1f) + dismiss() + } + + private fun getValueForSingleChoiceSelection(item: SingleChoiceSetting, which: Int): Int { + val valuesId = item.valuesId + return if (valuesId > 0) { + val valuesArray = requireContext().resources.getIntArray(valuesId) + valuesArray[which] + } else { + which + } + } + + private fun getSelectionForSingleChoiceValue(item: SingleChoiceSetting): Int { + val value = item.getSelectedValue() + val valuesId = item.valuesId + if (valuesId > 0) { + val valuesArray = requireContext().resources.getIntArray(valuesId) + for (index in valuesArray.indices) { + val current = valuesArray[index] + if (current == value) { + return index + } + } + } else { + return value + } + return -1 + } + + companion object { + const val TAG = "SettingsDialogFragment" + + const val TYPE_RESET_SETTING = -1 + + const val TITLE = "Title" + const val TYPE = "Type" + const val POSITION = "Position" + + fun newInstance( + settingsViewModel: SettingsViewModel, + clickedItem: SettingsItem, + type: Int, + position: Int + ): SettingsDialogFragment { + when (type) { + SettingsItem.TYPE_HEADER, + SettingsItem.TYPE_SWITCH, + SettingsItem.TYPE_SUBMENU, + SettingsItem.TYPE_DATETIME_SETTING, + SettingsItem.TYPE_RUNNABLE -> + throw IllegalArgumentException("[SettingsDialogFragment] Incompatible type!") + + SettingsItem.TYPE_SLIDER -> settingsViewModel.setSliderProgress( + (clickedItem as SliderSetting).getSelectedValue().toFloat() + ) + } + settingsViewModel.clickedItem = clickedItem + + val args = Bundle() + args.putInt(TYPE, type) + args.putInt(POSITION, position) + val fragment = SettingsDialogFragment() + fragment.arguments = args + return fragment + } + } +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/SettingsFragment.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/SettingsFragment.kt new file mode 100644 index 0000000..b1dde26 --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/SettingsFragment.kt @@ -0,0 +1,182 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.features.settings.ui + +import android.annotation.SuppressLint +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.updatePadding +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.navigation.findNavController +import androidx.navigation.fragment.navArgs +import androidx.recyclerview.widget.LinearLayoutManager +import com.google.android.material.transition.MaterialSharedAxis +import org.sudachi.sudachi_emu.R +import org.sudachi.sudachi_emu.databinding.FragmentSettingsBinding +import org.sudachi.sudachi_emu.features.input.NativeInput +import org.sudachi.sudachi_emu.features.settings.model.Settings +import org.sudachi.sudachi_emu.fragments.MessageDialogFragment +import org.sudachi.sudachi_emu.utils.ViewUtils.updateMargins +import org.sudachi.sudachi_emu.utils.collect + +class SettingsFragment : Fragment() { + private lateinit var presenter: SettingsFragmentPresenter + private var settingsAdapter: SettingsAdapter? = null + + private var _binding: FragmentSettingsBinding? = null + private val binding get() = _binding!! + + private val args by navArgs() + + private val settingsViewModel: SettingsViewModel by activityViewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enterTransition = MaterialSharedAxis(MaterialSharedAxis.X, true) + returnTransition = MaterialSharedAxis(MaterialSharedAxis.X, false) + reenterTransition = MaterialSharedAxis(MaterialSharedAxis.X, false) + exitTransition = MaterialSharedAxis(MaterialSharedAxis.X, true) + + val playerIndex = getPlayerIndex() + if (playerIndex != -1) { + NativeInput.loadInputProfiles() + NativeInput.reloadInputDevices() + } + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentSettingsBinding.inflate(layoutInflater) + return binding.root + } + + @SuppressLint("NotifyDataSetChanged") + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + settingsAdapter = SettingsAdapter(this, requireContext()) + presenter = SettingsFragmentPresenter( + settingsViewModel, + settingsAdapter!!, + args.menuTag + ) + + binding.toolbarSettingsLayout.title = if (args.menuTag == Settings.MenuTag.SECTION_ROOT && + args.game != null + ) { + args.game!!.title + } else { + when (args.menuTag) { + Settings.MenuTag.SECTION_INPUT_PLAYER_ONE -> Settings.getPlayerString(1) + Settings.MenuTag.SECTION_INPUT_PLAYER_TWO -> Settings.getPlayerString(2) + Settings.MenuTag.SECTION_INPUT_PLAYER_THREE -> Settings.getPlayerString(3) + Settings.MenuTag.SECTION_INPUT_PLAYER_FOUR -> Settings.getPlayerString(4) + Settings.MenuTag.SECTION_INPUT_PLAYER_FIVE -> Settings.getPlayerString(5) + Settings.MenuTag.SECTION_INPUT_PLAYER_SIX -> Settings.getPlayerString(6) + Settings.MenuTag.SECTION_INPUT_PLAYER_SEVEN -> Settings.getPlayerString(7) + Settings.MenuTag.SECTION_INPUT_PLAYER_EIGHT -> Settings.getPlayerString(8) + else -> getString(args.menuTag.titleId) + } + } + binding.listSettings.apply { + adapter = settingsAdapter + layoutManager = LinearLayoutManager(requireContext()) + } + + binding.toolbarSettings.setNavigationOnClickListener { + settingsViewModel.setShouldNavigateBack(true) + } + + settingsViewModel.shouldReloadSettingsList.collect( + viewLifecycleOwner, + resetState = { settingsViewModel.setShouldReloadSettingsList(false) } + ) { if (it) presenter.loadSettingsList() } + settingsViewModel.adapterItemChanged.collect( + viewLifecycleOwner, + resetState = { settingsViewModel.setAdapterItemChanged(-1) } + ) { if (it != -1) settingsAdapter?.notifyItemChanged(it) } + settingsViewModel.datasetChanged.collect( + viewLifecycleOwner, + resetState = { settingsViewModel.setDatasetChanged(false) } + ) { if (it) settingsAdapter?.notifyDataSetChanged() } + settingsViewModel.reloadListAndNotifyDataset.collect( + viewLifecycleOwner, + resetState = { settingsViewModel.setReloadListAndNotifyDataset(false) } + ) { if (it) presenter.loadSettingsList(true) } + settingsViewModel.shouldShowResetInputDialog.collect( + viewLifecycleOwner, + resetState = { settingsViewModel.setShouldShowResetInputDialog(false) } + ) { + if (it) { + MessageDialogFragment.newInstance( + activity = requireActivity(), + titleId = R.string.reset_mapping, + descriptionId = R.string.reset_mapping_description, + positiveAction = { + NativeInput.resetControllerMappings(getPlayerIndex()) + settingsViewModel.setReloadListAndNotifyDataset(true) + }, + negativeAction = {} + ).show(parentFragmentManager, MessageDialogFragment.TAG) + } + } + + if (args.menuTag == Settings.MenuTag.SECTION_ROOT) { + binding.toolbarSettings.inflateMenu(R.menu.menu_settings) + binding.toolbarSettings.setOnMenuItemClickListener { + when (it.itemId) { + R.id.action_search -> { + view.findNavController() + .navigate(R.id.action_settingsFragment_to_settingsSearchFragment) + true + } + + else -> false + } + } + } + + presenter.onViewCreated() + + setInsets() + } + + private fun getPlayerIndex(): Int = + when (args.menuTag) { + Settings.MenuTag.SECTION_INPUT_PLAYER_ONE -> 0 + Settings.MenuTag.SECTION_INPUT_PLAYER_TWO -> 1 + Settings.MenuTag.SECTION_INPUT_PLAYER_THREE -> 2 + Settings.MenuTag.SECTION_INPUT_PLAYER_FOUR -> 3 + Settings.MenuTag.SECTION_INPUT_PLAYER_FIVE -> 4 + Settings.MenuTag.SECTION_INPUT_PLAYER_SIX -> 5 + Settings.MenuTag.SECTION_INPUT_PLAYER_SEVEN -> 6 + Settings.MenuTag.SECTION_INPUT_PLAYER_EIGHT -> 7 + else -> -1 + } + + private fun setInsets() { + ViewCompat.setOnApplyWindowInsetsListener( + binding.root + ) { _: View, windowInsets: WindowInsetsCompat -> + val barInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) + val cutoutInsets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout()) + + val leftInsets = barInsets.left + cutoutInsets.left + val rightInsets = barInsets.right + cutoutInsets.right + + binding.listSettings.updateMargins(left = leftInsets, right = rightInsets) + binding.listSettings.updatePadding(bottom = barInsets.bottom) + + binding.appbarSettings.updateMargins(left = leftInsets, right = rightInsets) + windowInsets + } + } +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/SettingsFragmentPresenter.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/SettingsFragmentPresenter.kt new file mode 100644 index 0000000..725fe74 --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/SettingsFragmentPresenter.kt @@ -0,0 +1,975 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.features.settings.ui + +import android.annotation.SuppressLint +import android.os.Build +import android.widget.Toast +import org.sudachi.sudachi_emu.NativeLibrary +import org.sudachi.sudachi_emu.R +import org.sudachi.sudachi_emu.SudachiApplication +import org.sudachi.sudachi_emu.features.input.NativeInput +import org.sudachi.sudachi_emu.features.input.model.AnalogDirection +import org.sudachi.sudachi_emu.features.input.model.NativeAnalog +import org.sudachi.sudachi_emu.features.input.model.NativeButton +import org.sudachi.sudachi_emu.features.input.model.NpadStyleIndex +import org.sudachi.sudachi_emu.features.settings.model.AbstractBooleanSetting +import org.sudachi.sudachi_emu.features.settings.model.AbstractIntSetting +import org.sudachi.sudachi_emu.features.settings.model.BooleanSetting +import org.sudachi.sudachi_emu.features.settings.model.ByteSetting +import org.sudachi.sudachi_emu.features.settings.model.IntSetting +import org.sudachi.sudachi_emu.features.settings.model.LongSetting +import org.sudachi.sudachi_emu.features.settings.model.Settings +import org.sudachi.sudachi_emu.features.settings.model.Settings.MenuTag +import org.sudachi.sudachi_emu.features.settings.model.ShortSetting +import org.sudachi.sudachi_emu.features.settings.model.StringSetting +import org.sudachi.sudachi_emu.features.settings.model.view.* +import org.sudachi.sudachi_emu.utils.InputHandler +import org.sudachi.sudachi_emu.utils.NativeConfig + +class SettingsFragmentPresenter( + private val settingsViewModel: SettingsViewModel, + private val adapter: SettingsAdapter, + private var menuTag: MenuTag +) { + private var settingsList = ArrayList() + + private val context get() = SudachiApplication.appContext + + // Extension for altering settings list based on each setting's properties + fun ArrayList.add(key: String) { + val item = SettingsItem.settingsItems[key]!! + if (settingsViewModel.game != null && !item.setting.isSwitchable) { + return + } + + if (!NativeConfig.isPerGameConfigLoaded() && !NativeLibrary.isRunning()) { + item.setting.global = true + } + + val pairedSettingKey = item.setting.pairedSettingKey + if (pairedSettingKey.isNotEmpty()) { + val pairedSettingValue = NativeConfig.getBoolean( + pairedSettingKey, + if (NativeLibrary.isRunning() && !NativeConfig.isPerGameConfigLoaded()) { + !NativeConfig.usingGlobal(pairedSettingKey) + } else { + NativeConfig.usingGlobal(pairedSettingKey) + } + ) + if (!pairedSettingValue) return + } + add(item) + } + + // Allows you to show/hide abstract settings based on the paired setting key + fun ArrayList.addAbstract(item: SettingsItem) { + val pairedSettingKey = item.setting.pairedSettingKey + if (pairedSettingKey.isNotEmpty()) { + val pairedSettingsItem = + this.firstOrNull { it.setting.key == pairedSettingKey } ?: return + val pairedSetting = pairedSettingsItem.setting as AbstractBooleanSetting + if (!pairedSetting.getBoolean(!NativeConfig.isPerGameConfigLoaded())) return + } + add(item) + } + + fun onViewCreated() { + loadSettingsList() + } + + @SuppressLint("NotifyDataSetChanged") + fun loadSettingsList(notifyDataSetChanged: Boolean = false) { + val sl = ArrayList() + when (menuTag) { + MenuTag.SECTION_ROOT -> addConfigSettings(sl) + MenuTag.SECTION_SYSTEM -> addSystemSettings(sl) + MenuTag.SECTION_RENDERER -> addGraphicsSettings(sl) + MenuTag.SECTION_AUDIO -> addAudioSettings(sl) + MenuTag.SECTION_INPUT -> addInputSettings(sl) + MenuTag.SECTION_INPUT_PLAYER_ONE -> addInputPlayer(sl, 0) + MenuTag.SECTION_INPUT_PLAYER_TWO -> addInputPlayer(sl, 1) + MenuTag.SECTION_INPUT_PLAYER_THREE -> addInputPlayer(sl, 2) + MenuTag.SECTION_INPUT_PLAYER_FOUR -> addInputPlayer(sl, 3) + MenuTag.SECTION_INPUT_PLAYER_FIVE -> addInputPlayer(sl, 4) + MenuTag.SECTION_INPUT_PLAYER_SIX -> addInputPlayer(sl, 5) + MenuTag.SECTION_INPUT_PLAYER_SEVEN -> addInputPlayer(sl, 6) + MenuTag.SECTION_INPUT_PLAYER_EIGHT -> addInputPlayer(sl, 7) + MenuTag.SECTION_THEME -> addThemeSettings(sl) + MenuTag.SECTION_DEBUG -> addDebugSettings(sl) + } + settingsList = sl + adapter.submitList(settingsList) { + if (notifyDataSetChanged) { + adapter.notifyDataSetChanged() + } + } + } + + private fun addConfigSettings(sl: ArrayList) { + sl.apply { + add( + SubmenuSetting( + titleId = R.string.preferences_system, + descriptionId = R.string.preferences_system_description, + iconId = R.drawable.ic_system_settings, + menuKey = MenuTag.SECTION_SYSTEM + ) + ) + add( + SubmenuSetting( + titleId = R.string.preferences_graphics, + descriptionId = R.string.preferences_graphics_description, + iconId = R.drawable.ic_graphics, + menuKey = MenuTag.SECTION_RENDERER + ) + ) + add( + SubmenuSetting( + titleId = R.string.preferences_audio, + descriptionId = R.string.preferences_audio_description, + iconId = R.drawable.ic_audio, + menuKey = MenuTag.SECTION_AUDIO + ) + ) + add( + SubmenuSetting( + titleId = R.string.preferences_debug, + descriptionId = R.string.preferences_debug_description, + iconId = R.drawable.ic_code, + menuKey = MenuTag.SECTION_DEBUG + ) + ) + add( + RunnableSetting( + titleId = R.string.reset_to_default, + descriptionId = R.string.reset_to_default_description, + isRunnable = !NativeLibrary.isRunning(), + iconId = R.drawable.ic_restore + ) { settingsViewModel.setShouldShowResetSettingsDialog(true) } + ) + } + } + + private fun addSystemSettings(sl: ArrayList) { + sl.apply { + add(StringSetting.DEVICE_NAME.key) + add(BooleanSetting.RENDERER_USE_SPEED_LIMIT.key) + add(ShortSetting.RENDERER_SPEED_LIMIT.key) + add(BooleanSetting.USE_DOCKED_MODE.key) + add(IntSetting.REGION_INDEX.key) + add(IntSetting.LANGUAGE_INDEX.key) + add(BooleanSetting.USE_CUSTOM_RTC.key) + add(LongSetting.CUSTOM_RTC.key) + } + } + + private fun addGraphicsSettings(sl: ArrayList) { + sl.apply { + add(IntSetting.RENDERER_ACCURACY.key) + add(IntSetting.RENDERER_RESOLUTION.key) + add(IntSetting.RENDERER_VSYNC.key) + add(IntSetting.RENDERER_SCALING_FILTER.key) + add(IntSetting.FSR_SHARPENING_SLIDER.key) + add(IntSetting.RENDERER_ANTI_ALIASING.key) + add(IntSetting.MAX_ANISOTROPY.key) + add(IntSetting.RENDERER_SCREEN_LAYOUT.key) + add(IntSetting.RENDERER_ASPECT_RATIO.key) + add(IntSetting.VERTICAL_ALIGNMENT.key) + add(BooleanSetting.PICTURE_IN_PICTURE.key) + add(BooleanSetting.RENDERER_USE_DISK_SHADER_CACHE.key) + add(BooleanSetting.RENDERER_FORCE_MAX_CLOCK.key) + add(BooleanSetting.RENDERER_ASYNCHRONOUS_SHADERS.key) + add(BooleanSetting.RENDERER_REACTIVE_FLUSHING.key) + } + } + + private fun addAudioSettings(sl: ArrayList) { + sl.apply { + add(IntSetting.AUDIO_OUTPUT_ENGINE.key) + add(ByteSetting.AUDIO_VOLUME.key) + } + } + + private fun addInputSettings(sl: ArrayList) { + settingsViewModel.currentDevice = 0 + + if (NativeConfig.isPerGameConfigLoaded()) { + NativeInput.loadInputProfiles() + val profiles = NativeInput.getInputProfileNames().toMutableList() + profiles.add(0, "") + val prettyProfiles = profiles.toTypedArray() + prettyProfiles[0] = + context.getString(R.string.use_global_input_configuration) + sl.apply { + for (i in 0 until 8) { + add( + IntSingleChoiceSetting( + getPerGameProfileSetting(profiles, i), + titleString = getPlayerProfileString(i + 1), + choices = prettyProfiles, + values = IntArray(profiles.size) { it }.toTypedArray() + ) + ) + } + } + return + } + + val getConnectedIcon: (Int) -> Int = { playerIndex: Int -> + if (NativeInput.getIsConnected(playerIndex)) { + R.drawable.ic_controller + } else { + R.drawable.ic_controller_disconnected + } + } + + val inputSettings = NativeConfig.getInputSettings(true) + sl.apply { + add( + SubmenuSetting( + titleString = Settings.getPlayerString(1), + descriptionString = inputSettings[0].profileName, + menuKey = MenuTag.SECTION_INPUT_PLAYER_ONE, + iconId = getConnectedIcon(0) + ) + ) + add( + SubmenuSetting( + titleString = Settings.getPlayerString(2), + descriptionString = inputSettings[1].profileName, + menuKey = MenuTag.SECTION_INPUT_PLAYER_TWO, + iconId = getConnectedIcon(1) + ) + ) + add( + SubmenuSetting( + titleString = Settings.getPlayerString(3), + descriptionString = inputSettings[2].profileName, + menuKey = MenuTag.SECTION_INPUT_PLAYER_THREE, + iconId = getConnectedIcon(2) + ) + ) + add( + SubmenuSetting( + titleString = Settings.getPlayerString(4), + descriptionString = inputSettings[3].profileName, + menuKey = MenuTag.SECTION_INPUT_PLAYER_FOUR, + iconId = getConnectedIcon(3) + ) + ) + add( + SubmenuSetting( + titleString = Settings.getPlayerString(5), + descriptionString = inputSettings[4].profileName, + menuKey = MenuTag.SECTION_INPUT_PLAYER_FIVE, + iconId = getConnectedIcon(4) + ) + ) + add( + SubmenuSetting( + titleString = Settings.getPlayerString(6), + descriptionString = inputSettings[5].profileName, + menuKey = MenuTag.SECTION_INPUT_PLAYER_SIX, + iconId = getConnectedIcon(5) + ) + ) + add( + SubmenuSetting( + titleString = Settings.getPlayerString(7), + descriptionString = inputSettings[6].profileName, + menuKey = MenuTag.SECTION_INPUT_PLAYER_SEVEN, + iconId = getConnectedIcon(6) + ) + ) + add( + SubmenuSetting( + titleString = Settings.getPlayerString(8), + descriptionString = inputSettings[7].profileName, + menuKey = MenuTag.SECTION_INPUT_PLAYER_EIGHT, + iconId = getConnectedIcon(7) + ) + ) + } + } + + private fun getPlayerProfileString(player: Int): String = + context.getString(R.string.player_num_profile, player) + + private fun getPerGameProfileSetting( + profiles: List, + playerIndex: Int + ): AbstractIntSetting { + return object : AbstractIntSetting { + private val players + get() = NativeConfig.getInputSettings(false) + + override val key = "" + + override fun getInt(needsGlobal: Boolean): Int { + val currentProfile = players[playerIndex].profileName + profiles.forEachIndexed { i, profile -> + if (profile == currentProfile) { + return i + } + } + return 0 + } + + override fun setInt(value: Int) { + NativeInput.loadPerGameConfiguration(playerIndex, value, profiles[value]) + NativeInput.connectControllers(playerIndex) + NativeConfig.saveControlPlayerValues() + } + + override val defaultValue = 0 + + override fun getValueAsString(needsGlobal: Boolean): String = getInt().toString() + + override fun reset() = setInt(defaultValue) + + override var global = true + + override val isRuntimeModifiable = true + + override val isSaveable = true + } + } + + private fun addInputPlayer(sl: ArrayList, playerIndex: Int) { + sl.apply { + val connectedSetting = object : AbstractBooleanSetting { + override val key = "connected" + + override fun getBoolean(needsGlobal: Boolean): Boolean = + NativeInput.getIsConnected(playerIndex) + + override fun setBoolean(value: Boolean) = + NativeInput.connectControllers(playerIndex, value) + + override val defaultValue = playerIndex == 0 + + override fun getValueAsString(needsGlobal: Boolean): String = + getBoolean(needsGlobal).toString() + + override fun reset() = setBoolean(defaultValue) + } + add(SwitchSetting(connectedSetting, R.string.connected)) + + val styleTags = NativeInput.getSupportedStyleTags(playerIndex) + val npadType = object : AbstractIntSetting { + override val key = "npad_type" + override fun getInt(needsGlobal: Boolean): Int { + val styleIndex = NativeInput.getStyleIndex(playerIndex) + return styleTags.indexOfFirst { it == styleIndex } + } + + override fun setInt(value: Int) { + NativeInput.setStyleIndex(playerIndex, styleTags[value]) + settingsViewModel.setReloadListAndNotifyDataset(true) + } + + override val defaultValue = NpadStyleIndex.Fullkey.int + override fun getValueAsString(needsGlobal: Boolean): String = getInt().toString() + override fun reset() = setInt(defaultValue) + override val pairedSettingKey: String = "connected" + } + addAbstract( + IntSingleChoiceSetting( + npadType, + titleId = R.string.controller_type, + choices = styleTags.map { context.getString(it.nameId) } + .toTypedArray(), + values = IntArray(styleTags.size) { it }.toTypedArray() + ) + ) + + InputHandler.updateControllerData() + + val autoMappingSetting = object : AbstractIntSetting { + override val key = "auto_mapping_device" + + override fun getInt(needsGlobal: Boolean): Int = -1 + + override fun setInt(value: Int) { + val registeredController = InputHandler.registeredControllers[value + 1] + val displayName = registeredController.get( + "display", + context.getString(R.string.unknown) + ) + NativeInput.updateMappingsWithDefault( + playerIndex, + registeredController, + displayName + ) + Toast.makeText( + context, + context.getString(R.string.attempted_auto_map, displayName), + Toast.LENGTH_SHORT + ).show() + settingsViewModel.setReloadListAndNotifyDataset(true) + } + + override val defaultValue = -1 + + override fun getValueAsString(needsGlobal: Boolean) = getInt().toString() + + override fun reset() = setInt(defaultValue) + + override val isRuntimeModifiable: Boolean = true + } + + val unknownString = context.getString(R.string.unknown) + val prettyAutoMappingControllerList = InputHandler.registeredControllers.mapNotNull { + val port = it.get("port", -1) + return@mapNotNull if (port == 100 || port == -1) { + null + } else { + it.get("display", unknownString) + } + }.toTypedArray() + add( + IntSingleChoiceSetting( + autoMappingSetting, + titleId = R.string.auto_map, + descriptionId = R.string.auto_map_description, + choices = prettyAutoMappingControllerList, + values = IntArray(prettyAutoMappingControllerList.size) { it }.toTypedArray() + ) + ) + + val mappingFilterSetting = object : AbstractIntSetting { + override val key = "mapping_filter" + + override fun getInt(needsGlobal: Boolean): Int = settingsViewModel.currentDevice + + override fun setInt(value: Int) { + settingsViewModel.currentDevice = value + } + + override val defaultValue = 0 + + override fun getValueAsString(needsGlobal: Boolean) = getInt().toString() + + override fun reset() = setInt(defaultValue) + + override val isRuntimeModifiable: Boolean = true + } + + val prettyControllerList = InputHandler.registeredControllers.mapNotNull { + return@mapNotNull if (it.get("port", 0) == 100) { + null + } else { + it.get("display", unknownString) + } + }.toTypedArray() + add( + IntSingleChoiceSetting( + mappingFilterSetting, + titleId = R.string.input_mapping_filter, + descriptionId = R.string.input_mapping_filter_description, + choices = prettyControllerList, + values = IntArray(prettyControllerList.size) { it }.toTypedArray() + ) + ) + + add(InputProfileSetting(playerIndex)) + add( + RunnableSetting(titleId = R.string.reset_to_default, isRunnable = true) { + settingsViewModel.setShouldShowResetInputDialog(true) + } + ) + + val styleIndex = NativeInput.getStyleIndex(playerIndex) + + // Buttons + when (styleIndex) { + NpadStyleIndex.Fullkey, + NpadStyleIndex.Handheld, + NpadStyleIndex.JoyconDual -> { + add(HeaderSetting(R.string.buttons)) + add(ButtonInputSetting(playerIndex, NativeButton.A, R.string.button_a)) + add(ButtonInputSetting(playerIndex, NativeButton.B, R.string.button_b)) + add(ButtonInputSetting(playerIndex, NativeButton.X, R.string.button_x)) + add(ButtonInputSetting(playerIndex, NativeButton.Y, R.string.button_y)) + add(ButtonInputSetting(playerIndex, NativeButton.Plus, R.string.button_plus)) + add(ButtonInputSetting(playerIndex, NativeButton.Minus, R.string.button_minus)) + add(ButtonInputSetting(playerIndex, NativeButton.Home, R.string.button_home)) + add( + ButtonInputSetting( + playerIndex, + NativeButton.Capture, + R.string.button_capture + ) + ) + } + + NpadStyleIndex.JoyconLeft -> { + add(HeaderSetting(R.string.buttons)) + add(ButtonInputSetting(playerIndex, NativeButton.Minus, R.string.button_minus)) + add( + ButtonInputSetting( + playerIndex, + NativeButton.Capture, + R.string.button_capture + ) + ) + } + + NpadStyleIndex.JoyconRight -> { + add(HeaderSetting(R.string.buttons)) + add(ButtonInputSetting(playerIndex, NativeButton.A, R.string.button_a)) + add(ButtonInputSetting(playerIndex, NativeButton.B, R.string.button_b)) + add(ButtonInputSetting(playerIndex, NativeButton.X, R.string.button_x)) + add(ButtonInputSetting(playerIndex, NativeButton.Y, R.string.button_y)) + add(ButtonInputSetting(playerIndex, NativeButton.Plus, R.string.button_plus)) + add(ButtonInputSetting(playerIndex, NativeButton.Home, R.string.button_home)) + } + + NpadStyleIndex.GameCube -> { + add(HeaderSetting(R.string.buttons)) + add(ButtonInputSetting(playerIndex, NativeButton.A, R.string.button_a)) + add(ButtonInputSetting(playerIndex, NativeButton.B, R.string.button_b)) + add(ButtonInputSetting(playerIndex, NativeButton.X, R.string.button_x)) + add(ButtonInputSetting(playerIndex, NativeButton.Y, R.string.button_y)) + add(ButtonInputSetting(playerIndex, NativeButton.Plus, R.string.start_pause)) + } + + else -> { + // No-op + } + } + + when (styleIndex) { + NpadStyleIndex.Fullkey, + NpadStyleIndex.Handheld, + NpadStyleIndex.JoyconDual, + NpadStyleIndex.JoyconLeft -> { + add(HeaderSetting(R.string.dpad)) + add(ButtonInputSetting(playerIndex, NativeButton.DUp, R.string.up)) + add(ButtonInputSetting(playerIndex, NativeButton.DDown, R.string.down)) + add(ButtonInputSetting(playerIndex, NativeButton.DLeft, R.string.left)) + add(ButtonInputSetting(playerIndex, NativeButton.DRight, R.string.right)) + } + + else -> { + // No-op + } + } + + // Left stick + when (styleIndex) { + NpadStyleIndex.Fullkey, + NpadStyleIndex.Handheld, + NpadStyleIndex.JoyconDual, + NpadStyleIndex.JoyconLeft -> { + add(HeaderSetting(R.string.left_stick)) + addAll(getStickDirections(playerIndex, NativeAnalog.LStick)) + add(ButtonInputSetting(playerIndex, NativeButton.LStick, R.string.pressed)) + addAll(getExtraStickSettings(playerIndex, NativeAnalog.LStick)) + } + + NpadStyleIndex.GameCube -> { + add(HeaderSetting(R.string.control_stick)) + addAll(getStickDirections(playerIndex, NativeAnalog.LStick)) + addAll(getExtraStickSettings(playerIndex, NativeAnalog.LStick)) + } + + else -> { + // No-op + } + } + + // Right stick + when (styleIndex) { + NpadStyleIndex.Fullkey, + NpadStyleIndex.Handheld, + NpadStyleIndex.JoyconDual, + NpadStyleIndex.JoyconRight -> { + add(HeaderSetting(R.string.right_stick)) + addAll(getStickDirections(playerIndex, NativeAnalog.RStick)) + add(ButtonInputSetting(playerIndex, NativeButton.RStick, R.string.pressed)) + addAll(getExtraStickSettings(playerIndex, NativeAnalog.RStick)) + } + + NpadStyleIndex.GameCube -> { + add(HeaderSetting(R.string.c_stick)) + addAll(getStickDirections(playerIndex, NativeAnalog.RStick)) + addAll(getExtraStickSettings(playerIndex, NativeAnalog.RStick)) + } + + else -> { + // No-op + } + } + + // L/R, ZL/ZR, and SL/SR + when (styleIndex) { + NpadStyleIndex.Fullkey, + NpadStyleIndex.Handheld -> { + add(HeaderSetting(R.string.triggers)) + add(ButtonInputSetting(playerIndex, NativeButton.L, R.string.button_l)) + add(ButtonInputSetting(playerIndex, NativeButton.R, R.string.button_r)) + add(ButtonInputSetting(playerIndex, NativeButton.ZL, R.string.button_zl)) + add(ButtonInputSetting(playerIndex, NativeButton.ZR, R.string.button_zr)) + } + + NpadStyleIndex.JoyconDual -> { + add(HeaderSetting(R.string.triggers)) + add(ButtonInputSetting(playerIndex, NativeButton.L, R.string.button_l)) + add(ButtonInputSetting(playerIndex, NativeButton.R, R.string.button_r)) + add(ButtonInputSetting(playerIndex, NativeButton.ZL, R.string.button_zl)) + add(ButtonInputSetting(playerIndex, NativeButton.ZR, R.string.button_zr)) + add( + ButtonInputSetting( + playerIndex, + NativeButton.SLLeft, + R.string.button_sl_left + ) + ) + add( + ButtonInputSetting( + playerIndex, + NativeButton.SRLeft, + R.string.button_sr_left + ) + ) + add( + ButtonInputSetting( + playerIndex, + NativeButton.SLRight, + R.string.button_sl_right + ) + ) + add( + ButtonInputSetting( + playerIndex, + NativeButton.SRRight, + R.string.button_sr_right + ) + ) + } + + NpadStyleIndex.JoyconLeft -> { + add(HeaderSetting(R.string.triggers)) + add(ButtonInputSetting(playerIndex, NativeButton.L, R.string.button_l)) + add(ButtonInputSetting(playerIndex, NativeButton.ZL, R.string.button_zl)) + add( + ButtonInputSetting( + playerIndex, + NativeButton.SLLeft, + R.string.button_sl_left + ) + ) + add( + ButtonInputSetting( + playerIndex, + NativeButton.SRLeft, + R.string.button_sr_left + ) + ) + } + + NpadStyleIndex.JoyconRight -> { + add(HeaderSetting(R.string.triggers)) + add(ButtonInputSetting(playerIndex, NativeButton.R, R.string.button_r)) + add(ButtonInputSetting(playerIndex, NativeButton.ZR, R.string.button_zr)) + add( + ButtonInputSetting( + playerIndex, + NativeButton.SLRight, + R.string.button_sl_right + ) + ) + add( + ButtonInputSetting( + playerIndex, + NativeButton.SRRight, + R.string.button_sr_right + ) + ) + } + + NpadStyleIndex.GameCube -> { + add(HeaderSetting(R.string.triggers)) + add(ButtonInputSetting(playerIndex, NativeButton.R, R.string.button_z)) + add(ButtonInputSetting(playerIndex, NativeButton.ZL, R.string.button_l)) + add(ButtonInputSetting(playerIndex, NativeButton.ZR, R.string.button_r)) + } + + else -> { + // No-op + } + } + + add(HeaderSetting(R.string.vibration)) + val vibrationEnabledSetting = object : AbstractBooleanSetting { + override val key = "vibration" + + override fun getBoolean(needsGlobal: Boolean): Boolean = + NativeConfig.getInputSettings(true)[playerIndex].vibrationEnabled + + override fun setBoolean(value: Boolean) { + val settings = NativeConfig.getInputSettings(true) + settings[playerIndex].vibrationEnabled = value + NativeConfig.setInputSettings(settings, true) + } + + override val defaultValue = true + + override fun getValueAsString(needsGlobal: Boolean): String = + getBoolean(needsGlobal).toString() + + override fun reset() = setBoolean(defaultValue) + } + add(SwitchSetting(vibrationEnabledSetting, R.string.vibration)) + + val useSystemVibratorSetting = object : AbstractBooleanSetting { + override val key = "" + + override fun getBoolean(needsGlobal: Boolean): Boolean = + NativeConfig.getInputSettings(true)[playerIndex].useSystemVibrator + + override fun setBoolean(value: Boolean) { + val settings = NativeConfig.getInputSettings(true) + settings[playerIndex].useSystemVibrator = value + NativeConfig.setInputSettings(settings, true) + } + + override val defaultValue = playerIndex == 0 + + override fun getValueAsString(needsGlobal: Boolean): String = + getBoolean(needsGlobal).toString() + + override fun reset() = setBoolean(defaultValue) + + override val pairedSettingKey: String = "vibration" + } + addAbstract(SwitchSetting(useSystemVibratorSetting, R.string.use_system_vibrator)) + + val vibrationStrengthSetting = object : AbstractIntSetting { + override val key = "" + + override fun getInt(needsGlobal: Boolean): Int = + NativeConfig.getInputSettings(true)[playerIndex].vibrationStrength + + override fun setInt(value: Int) { + val settings = NativeConfig.getInputSettings(true) + settings[playerIndex].vibrationStrength = value + NativeConfig.setInputSettings(settings, true) + } + + override val defaultValue = 100 + + override fun getValueAsString(needsGlobal: Boolean): String = + getInt(needsGlobal).toString() + + override fun reset() = setInt(defaultValue) + + override val pairedSettingKey: String = "vibration" + } + addAbstract( + SliderSetting(vibrationStrengthSetting, R.string.vibration_strength, units = "%") + ) + } + } + + // Convenience function for creating AbstractIntSettings for modifier range/stick range/stick deadzones + private fun getStickIntSettingFromParam( + playerIndex: Int, + paramName: String, + stick: NativeAnalog, + defaultValue: Float + ): AbstractIntSetting = + object : AbstractIntSetting { + val params get() = NativeInput.getStickParam(playerIndex, stick) + + override val key = "" + + override fun getInt(needsGlobal: Boolean): Int = + (params.get(paramName, defaultValue) * 100).toInt() + + override fun setInt(value: Int) { + val tempParams = params + tempParams.set(paramName, value.toFloat() / 100) + NativeInput.setStickParam(playerIndex, stick, tempParams) + } + + override val defaultValue = (defaultValue * 100).toInt() + + override fun getValueAsString(needsGlobal: Boolean): String = + getInt(needsGlobal).toString() + + override fun reset() = setInt(this.defaultValue) + } + + private fun getExtraStickSettings( + playerIndex: Int, + nativeAnalog: NativeAnalog + ): List { + val stickIsController = + NativeInput.isController(NativeInput.getStickParam(playerIndex, nativeAnalog)) + val modifierRangeSetting = + getStickIntSettingFromParam(playerIndex, "modifier_scale", nativeAnalog, 0.5f) + val stickRangeSetting = + getStickIntSettingFromParam(playerIndex, "range", nativeAnalog, 0.95f) + val stickDeadzoneSetting = + getStickIntSettingFromParam(playerIndex, "deadzone", nativeAnalog, 0.15f) + + val out = mutableListOf().apply { + if (stickIsController) { + add(SliderSetting(stickRangeSetting, titleId = R.string.range, min = 25, max = 150)) + add(SliderSetting(stickDeadzoneSetting, R.string.deadzone)) + } else { + add(ModifierInputSetting(playerIndex, NativeAnalog.LStick, R.string.modifier)) + add(SliderSetting(modifierRangeSetting, R.string.modifier_range)) + } + } + return out + } + + private fun getStickDirections(player: Int, stick: NativeAnalog): List = + listOf( + AnalogInputSetting( + player, + stick, + AnalogDirection.Up, + R.string.up + ), + AnalogInputSetting( + player, + stick, + AnalogDirection.Down, + R.string.down + ), + AnalogInputSetting( + player, + stick, + AnalogDirection.Left, + R.string.left + ), + AnalogInputSetting( + player, + stick, + AnalogDirection.Right, + R.string.right + ) + ) + + private fun addThemeSettings(sl: ArrayList) { + sl.apply { + val theme: AbstractIntSetting = object : AbstractIntSetting { + override fun getInt(needsGlobal: Boolean): Int = IntSetting.THEME.getInt() + override fun setInt(value: Int) { + IntSetting.THEME.setInt(value) + settingsViewModel.setShouldRecreate(true) + } + + override val key: String = IntSetting.THEME.key + override val isRuntimeModifiable: Boolean = IntSetting.THEME.isRuntimeModifiable + override fun getValueAsString(needsGlobal: Boolean): String = + IntSetting.THEME.getValueAsString() + + override val defaultValue: Int = IntSetting.THEME.defaultValue + override fun reset() = IntSetting.THEME.setInt(defaultValue) + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + add( + SingleChoiceSetting( + theme, + titleId = R.string.change_app_theme, + choicesId = R.array.themeEntriesA12, + valuesId = R.array.themeValuesA12 + ) + ) + } else { + add( + SingleChoiceSetting( + theme, + titleId = R.string.change_app_theme, + choicesId = R.array.themeEntries, + valuesId = R.array.themeValues + ) + ) + } + + val themeMode: AbstractIntSetting = object : AbstractIntSetting { + override fun getInt(needsGlobal: Boolean): Int = IntSetting.THEME_MODE.getInt() + override fun setInt(value: Int) { + IntSetting.THEME_MODE.setInt(value) + settingsViewModel.setShouldRecreate(true) + } + + override val key: String = IntSetting.THEME_MODE.key + override val isRuntimeModifiable: Boolean = + IntSetting.THEME_MODE.isRuntimeModifiable + + override fun getValueAsString(needsGlobal: Boolean): String = + IntSetting.THEME_MODE.getValueAsString() + + override val defaultValue: Int = IntSetting.THEME_MODE.defaultValue + override fun reset() { + IntSetting.THEME_MODE.setInt(defaultValue) + settingsViewModel.setShouldRecreate(true) + } + } + + add( + SingleChoiceSetting( + themeMode, + titleId = R.string.change_theme_mode, + choicesId = R.array.themeModeEntries, + valuesId = R.array.themeModeValues + ) + ) + + val blackBackgrounds: AbstractBooleanSetting = object : AbstractBooleanSetting { + override fun getBoolean(needsGlobal: Boolean): Boolean = + BooleanSetting.BLACK_BACKGROUNDS.getBoolean() + + override fun setBoolean(value: Boolean) { + BooleanSetting.BLACK_BACKGROUNDS.setBoolean(value) + settingsViewModel.setShouldRecreate(true) + } + + override val key: String = BooleanSetting.BLACK_BACKGROUNDS.key + override val isRuntimeModifiable: Boolean = + BooleanSetting.BLACK_BACKGROUNDS.isRuntimeModifiable + + override fun getValueAsString(needsGlobal: Boolean): String = + BooleanSetting.BLACK_BACKGROUNDS.getValueAsString() + + override val defaultValue: Boolean = BooleanSetting.BLACK_BACKGROUNDS.defaultValue + override fun reset() { + BooleanSetting.BLACK_BACKGROUNDS + .setBoolean(BooleanSetting.BLACK_BACKGROUNDS.defaultValue) + settingsViewModel.setShouldRecreate(true) + } + } + + add( + SwitchSetting( + blackBackgrounds, + titleId = R.string.use_black_backgrounds, + descriptionId = R.string.use_black_backgrounds_description + ) + ) + } + } + + private fun addDebugSettings(sl: ArrayList) { + sl.apply { + add(HeaderSetting(R.string.gpu)) + add(IntSetting.RENDERER_BACKEND.key) + add(BooleanSetting.RENDERER_DEBUG.key) + + add(HeaderSetting(R.string.cpu)) + add(IntSetting.CPU_BACKEND.key) + add(IntSetting.CPU_ACCURACY.key) + add(BooleanSetting.CPU_DEBUG_MODE.key) + add(SettingsItem.FASTMEM_COMBINED) + } + } +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/SettingsSearchFragment.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/SettingsSearchFragment.kt new file mode 100644 index 0000000..529cd4f --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/SettingsSearchFragment.kt @@ -0,0 +1,183 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.features.settings.ui + +import android.content.Context +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.inputmethod.InputMethodManager +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.updatePadding +import androidx.core.widget.doOnTextChanged +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.recyclerview.widget.LinearLayoutManager +import com.google.android.material.divider.MaterialDividerItemDecoration +import com.google.android.material.transition.MaterialSharedAxis +import info.debatty.java.stringsimilarity.Cosine +import org.sudachi.sudachi_emu.R +import org.sudachi.sudachi_emu.databinding.FragmentSettingsSearchBinding +import org.sudachi.sudachi_emu.features.settings.model.view.SettingsItem +import org.sudachi.sudachi_emu.utils.NativeConfig +import org.sudachi.sudachi_emu.utils.ViewUtils.setVisible +import org.sudachi.sudachi_emu.utils.ViewUtils.updateMargins +import org.sudachi.sudachi_emu.utils.collect + +class SettingsSearchFragment : Fragment() { + private var _binding: FragmentSettingsSearchBinding? = null + private val binding get() = _binding!! + + private var settingsAdapter: SettingsAdapter? = null + + private val settingsViewModel: SettingsViewModel by activityViewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enterTransition = MaterialSharedAxis(MaterialSharedAxis.X, true) + returnTransition = MaterialSharedAxis(MaterialSharedAxis.X, false) + reenterTransition = MaterialSharedAxis(MaterialSharedAxis.X, false) + exitTransition = MaterialSharedAxis(MaterialSharedAxis.X, true) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentSettingsSearchBinding.inflate(layoutInflater) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + if (savedInstanceState != null) { + binding.searchText.setText(savedInstanceState.getString(SEARCH_TEXT)) + } + + settingsAdapter = SettingsAdapter(this, requireContext()) + + val dividerDecoration = MaterialDividerItemDecoration( + requireContext(), + LinearLayoutManager.VERTICAL + ) + dividerDecoration.isLastItemDecorated = false + binding.settingsList.apply { + adapter = settingsAdapter + layoutManager = LinearLayoutManager(requireContext()) + addItemDecoration(dividerDecoration) + } + + focusSearch() + + binding.backButton.setOnClickListener { settingsViewModel.setShouldNavigateBack(true) } + binding.searchBackground.setOnClickListener { focusSearch() } + binding.clearButton.setOnClickListener { binding.searchText.setText("") } + binding.searchText.doOnTextChanged { _, _, _, _ -> + search() + binding.settingsList.smoothScrollToPosition(0) + } + settingsViewModel.shouldReloadSettingsList.collect(viewLifecycleOwner) { + if (it) { + settingsViewModel.setShouldReloadSettingsList(false) + search() + } + } + + search() + + setInsets() + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + outState.putString(SEARCH_TEXT, binding.searchText.text.toString()) + } + + private fun search() { + val searchTerm = binding.searchText.text.toString().lowercase() + binding.clearButton.setVisible(visible = searchTerm.isNotEmpty(), gone = false) + if (searchTerm.isEmpty()) { + binding.noResultsView.setVisible(visible = false, gone = false) + settingsAdapter?.submitList(emptyList()) + return + } + + val baseList = SettingsItem.settingsItems + val similarityAlgorithm = if (searchTerm.length > 2) Cosine() else Cosine(1) + val sortedList: List = baseList.mapNotNull { item -> + val title = item.value.title.lowercase() + val similarity = similarityAlgorithm.similarity(searchTerm, title) + if (similarity > 0.08) { + Pair(similarity, item) + } else { + null + } + }.sortedByDescending { it.first }.mapNotNull { + val item = it.second.value + val pairedSettingKey = item.setting.pairedSettingKey + val optionalSetting: SettingsItem? = if (pairedSettingKey.isNotEmpty()) { + val pairedSettingValue = NativeConfig.getBoolean(pairedSettingKey, false) + if (pairedSettingValue) it.second.value else null + } else { + it.second.value + } + optionalSetting + } + settingsAdapter?.submitList(sortedList) + binding.noResultsView.setVisible(visible = sortedList.isEmpty(), gone = false) + } + + private fun focusSearch() { + binding.searchText.requestFocus() + val imm = requireActivity() + .getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager? + imm?.showSoftInput(binding.searchText, InputMethodManager.SHOW_IMPLICIT) + } + + private fun setInsets() = + ViewCompat.setOnApplyWindowInsetsListener( + binding.root + ) { _: View, windowInsets: WindowInsetsCompat -> + val extraListSpacing = resources.getDimensionPixelSize(R.dimen.spacing_med) + val sideMargin = resources.getDimensionPixelSize(R.dimen.spacing_medlarge) + val topMargin = resources.getDimensionPixelSize(R.dimen.spacing_chip) + + val barInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) + val cutoutInsets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout()) + + val leftInsets = barInsets.left + cutoutInsets.left + val rightInsets = barInsets.right + cutoutInsets.right + + binding.settingsList.updatePadding(bottom = barInsets.bottom + extraListSpacing) + binding.frameSearch.updatePadding( + left = leftInsets + sideMargin, + top = barInsets.top + topMargin, + right = rightInsets + sideMargin + ) + binding.noResultsView.updatePadding( + left = leftInsets, + right = rightInsets, + bottom = barInsets.bottom + ) + + binding.settingsList.updateMargins( + left = leftInsets + sideMargin, + right = rightInsets + sideMargin + ) + binding.divider.updateMargins( + left = leftInsets + sideMargin, + right = rightInsets + sideMargin + ) + + windowInsets + } + + companion object { + const val SEARCH_TEXT = "SearchText" + } +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/SettingsViewModel.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/SettingsViewModel.kt new file mode 100644 index 0000000..9f03729 --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/SettingsViewModel.kt @@ -0,0 +1,112 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.features.settings.ui + +import androidx.lifecycle.ViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import org.sudachi.sudachi_emu.R +import org.sudachi.sudachi_emu.SudachiApplication +import org.sudachi.sudachi_emu.features.settings.model.view.SettingsItem +import org.sudachi.sudachi_emu.model.Game +import org.sudachi.sudachi_emu.utils.InputHandler +import org.sudachi.sudachi_emu.utils.ParamPackage + +class SettingsViewModel : ViewModel() { + var game: Game? = null + + var clickedItem: SettingsItem? = null + + var currentDevice = 0 + + val shouldRecreate: StateFlow get() = _shouldRecreate + private val _shouldRecreate = MutableStateFlow(false) + + val shouldNavigateBack: StateFlow get() = _shouldNavigateBack + private val _shouldNavigateBack = MutableStateFlow(false) + + val shouldShowResetSettingsDialog: StateFlow get() = _shouldShowResetSettingsDialog + private val _shouldShowResetSettingsDialog = MutableStateFlow(false) + + val shouldReloadSettingsList: StateFlow get() = _shouldReloadSettingsList + private val _shouldReloadSettingsList = MutableStateFlow(false) + + val sliderProgress: StateFlow get() = _sliderProgress + private val _sliderProgress = MutableStateFlow(-1) + + val sliderTextValue: StateFlow get() = _sliderTextValue + private val _sliderTextValue = MutableStateFlow("") + + val adapterItemChanged: StateFlow get() = _adapterItemChanged + private val _adapterItemChanged = MutableStateFlow(-1) + + private val _datasetChanged = MutableStateFlow(false) + val datasetChanged = _datasetChanged.asStateFlow() + + private val _reloadListAndNotifyDataset = MutableStateFlow(false) + val reloadListAndNotifyDataset = _reloadListAndNotifyDataset.asStateFlow() + + private val _shouldShowDeleteProfileDialog = MutableStateFlow("") + val shouldShowDeleteProfileDialog = _shouldShowDeleteProfileDialog.asStateFlow() + + private val _shouldShowResetInputDialog = MutableStateFlow(false) + val shouldShowResetInputDialog = _shouldShowResetInputDialog.asStateFlow() + + fun setShouldRecreate(value: Boolean) { + _shouldRecreate.value = value + } + + fun setShouldNavigateBack(value: Boolean) { + _shouldNavigateBack.value = value + } + + fun setShouldShowResetSettingsDialog(value: Boolean) { + _shouldShowResetSettingsDialog.value = value + } + + fun setShouldReloadSettingsList(value: Boolean) { + _shouldReloadSettingsList.value = value + } + + fun setSliderTextValue(value: Float, units: String) { + _sliderProgress.value = value.toInt() + _sliderTextValue.value = String.format( + SudachiApplication.appContext.getString(R.string.value_with_units), + value.toInt().toString(), + units + ) + } + + fun setSliderProgress(value: Float) { + _sliderProgress.value = value.toInt() + } + + fun setAdapterItemChanged(value: Int) { + _adapterItemChanged.value = value + } + + fun setDatasetChanged(value: Boolean) { + _datasetChanged.value = value + } + + fun setReloadListAndNotifyDataset(value: Boolean) { + _reloadListAndNotifyDataset.value = value + } + + fun setShouldShowDeleteProfileDialog(profile: String) { + _shouldShowDeleteProfileDialog.value = profile + } + + fun setShouldShowResetInputDialog(value: Boolean) { + _shouldShowResetInputDialog.value = value + } + + fun getCurrentDeviceParams(defaultParams: ParamPackage): ParamPackage = + try { + InputHandler.registeredControllers[currentDevice] + } catch (e: IndexOutOfBoundsException) { + defaultParams + } +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/viewholder/DateTimeViewHolder.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/viewholder/DateTimeViewHolder.kt new file mode 100644 index 0000000..6305e70 --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/viewholder/DateTimeViewHolder.kt @@ -0,0 +1,54 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.features.settings.ui.viewholder + +import android.view.View +import java.time.Instant +import java.time.ZoneId +import java.time.ZonedDateTime +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import org.sudachi.sudachi_emu.databinding.ListItemSettingBinding +import org.sudachi.sudachi_emu.features.settings.model.view.DateTimeSetting +import org.sudachi.sudachi_emu.features.settings.model.view.SettingsItem +import org.sudachi.sudachi_emu.features.settings.ui.SettingsAdapter +import org.sudachi.sudachi_emu.utils.ViewUtils.setVisible + +class DateTimeViewHolder(val binding: ListItemSettingBinding, adapter: SettingsAdapter) : + SettingViewHolder(binding.root, adapter) { + private lateinit var setting: DateTimeSetting + + override fun bind(item: SettingsItem) { + setting = item as DateTimeSetting + binding.textSettingName.text = item.title + binding.textSettingDescription.setVisible(item.description.isNotEmpty()) + binding.textSettingDescription.text = item.description + binding.textSettingValue.setVisible(true) + val epochTime = setting.getValue() + val instant = Instant.ofEpochMilli(epochTime * 1000) + val zonedTime = ZonedDateTime.ofInstant(instant, ZoneId.of("UTC")) + val dateFormatter = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM) + binding.textSettingValue.text = dateFormatter.format(zonedTime) + + binding.buttonClear.setVisible(setting.clearable) + binding.buttonClear.setOnClickListener { + adapter.onClearClick(setting, bindingAdapterPosition) + } + + setStyle(setting.isEditable, binding) + } + + override fun onClick(clicked: View) { + if (setting.isEditable) { + adapter.onDateTimeClick(setting, bindingAdapterPosition) + } + } + + override fun onLongClick(clicked: View): Boolean { + if (setting.isEditable) { + return adapter.onLongClick(setting, bindingAdapterPosition) + } + return false + } +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/viewholder/HeaderViewHolder.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/viewholder/HeaderViewHolder.kt new file mode 100644 index 0000000..b8a4629 --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/viewholder/HeaderViewHolder.kt @@ -0,0 +1,30 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.features.settings.ui.viewholder + +import android.view.View +import org.sudachi.sudachi_emu.databinding.ListItemSettingsHeaderBinding +import org.sudachi.sudachi_emu.features.settings.model.view.SettingsItem +import org.sudachi.sudachi_emu.features.settings.ui.SettingsAdapter + +class HeaderViewHolder(val binding: ListItemSettingsHeaderBinding, adapter: SettingsAdapter) : + SettingViewHolder(binding.root, adapter) { + + init { + itemView.setOnClickListener(null) + } + + override fun bind(item: SettingsItem) { + binding.textHeaderName.text = item.title + } + + override fun onClick(clicked: View) { + // no-op + } + + override fun onLongClick(clicked: View): Boolean { + // no-op + return true + } +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/viewholder/InputProfileViewHolder.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/viewholder/InputProfileViewHolder.kt new file mode 100644 index 0000000..693b8d5 --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/viewholder/InputProfileViewHolder.kt @@ -0,0 +1,34 @@ +// SPDX-FileCopyrightText: 2024 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.features.settings.ui.viewholder + +import android.view.View +import org.sudachi.sudachi_emu.databinding.ListItemSettingBinding +import org.sudachi.sudachi_emu.features.settings.model.view.InputProfileSetting +import org.sudachi.sudachi_emu.features.settings.model.view.SettingsItem +import org.sudachi.sudachi_emu.features.settings.ui.SettingsAdapter +import org.sudachi.sudachi_emu.R +import org.sudachi.sudachi_emu.utils.ViewUtils.setVisible + +class InputProfileViewHolder(val binding: ListItemSettingBinding, adapter: SettingsAdapter) : + SettingViewHolder(binding.root, adapter) { + private lateinit var setting: InputProfileSetting + + override fun bind(item: SettingsItem) { + setting = item as InputProfileSetting + binding.textSettingName.text = setting.title + binding.textSettingValue.text = + setting.getCurrentProfile().ifEmpty { binding.root.context.getString(R.string.not_set) } + + binding.textSettingDescription.setVisible(false) + binding.buttonClear.setVisible(false) + binding.icon.setVisible(false) + binding.buttonClear.setVisible(false) + } + + override fun onClick(clicked: View) = + adapter.onInputProfileClick(setting, bindingAdapterPosition) + + override fun onLongClick(clicked: View): Boolean = false +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/viewholder/InputViewHolder.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/viewholder/InputViewHolder.kt new file mode 100644 index 0000000..5163473 --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/viewholder/InputViewHolder.kt @@ -0,0 +1,60 @@ +// SPDX-FileCopyrightText: 2024 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.features.settings.ui.viewholder + +import android.view.View +import org.sudachi.sudachi_emu.databinding.ListItemSettingInputBinding +import org.sudachi.sudachi_emu.features.input.NativeInput +import org.sudachi.sudachi_emu.features.settings.model.view.AnalogInputSetting +import org.sudachi.sudachi_emu.features.settings.model.view.ButtonInputSetting +import org.sudachi.sudachi_emu.features.settings.model.view.InputSetting +import org.sudachi.sudachi_emu.features.settings.model.view.ModifierInputSetting +import org.sudachi.sudachi_emu.features.settings.model.view.SettingsItem +import org.sudachi.sudachi_emu.features.settings.ui.SettingsAdapter +import org.sudachi.sudachi_emu.utils.ViewUtils.setVisible + +class InputViewHolder(val binding: ListItemSettingInputBinding, adapter: SettingsAdapter) : + SettingViewHolder(binding.root, adapter) { + private lateinit var setting: InputSetting + + override fun bind(item: SettingsItem) { + setting = item as InputSetting + binding.textSettingName.text = setting.title + binding.textSettingValue.text = setting.getSelectedValue() + + when (item) { + is AnalogInputSetting -> { + val param = NativeInput.getStickParam(item.playerIndex, item.nativeAnalog) + binding.buttonOptions.setVisible( + param.get("engine", "") == "analog_from_button" || + param.has("axis_x") || param.has("axis_y") + ) + } + + is ButtonInputSetting -> { + val param = NativeInput.getButtonParam(item.playerIndex, item.nativeButton) + binding.buttonOptions.setVisible( + param.has("code") || param.has("button") || param.has("hat") || + param.has("axis") + ) + } + + is ModifierInputSetting -> { + val params = NativeInput.getStickParam(item.playerIndex, item.nativeAnalog) + binding.buttonOptions.setVisible(params.has("modifier")) + } + } + + binding.buttonOptions.setOnClickListener(null) + binding.buttonOptions.setOnClickListener { + adapter.onInputOptionsClick(binding.buttonOptions, setting, bindingAdapterPosition) + } + } + + override fun onClick(clicked: View) = + adapter.onInputClick(setting, bindingAdapterPosition) + + override fun onLongClick(clicked: View): Boolean = + adapter.onLongClick(setting, bindingAdapterPosition) +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/viewholder/RunnableViewHolder.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/viewholder/RunnableViewHolder.kt new file mode 100644 index 0000000..18677d1 --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/viewholder/RunnableViewHolder.kt @@ -0,0 +1,50 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.features.settings.ui.viewholder + +import android.view.View +import androidx.core.content.res.ResourcesCompat +import org.sudachi.sudachi_emu.databinding.ListItemSettingBinding +import org.sudachi.sudachi_emu.features.settings.model.view.RunnableSetting +import org.sudachi.sudachi_emu.features.settings.model.view.SettingsItem +import org.sudachi.sudachi_emu.features.settings.ui.SettingsAdapter +import org.sudachi.sudachi_emu.utils.ViewUtils.setVisible + +class RunnableViewHolder(val binding: ListItemSettingBinding, adapter: SettingsAdapter) : + SettingViewHolder(binding.root, adapter) { + private lateinit var setting: RunnableSetting + + override fun bind(item: SettingsItem) { + setting = item as RunnableSetting + binding.icon.setVisible(setting.iconId != 0) + if (setting.iconId != 0) { + binding.icon.setImageDrawable( + ResourcesCompat.getDrawable( + binding.icon.resources, + setting.iconId, + binding.icon.context.theme + ) + ) + } + + binding.textSettingName.text = setting.title + binding.textSettingDescription.setVisible(setting.description.isNotEmpty()) + binding.textSettingDescription.text = item.description + binding.textSettingValue.setVisible(false) + binding.buttonClear.setVisible(false) + + setStyle(setting.isEditable, binding) + } + + override fun onClick(clicked: View) { + if (setting.isRunnable) { + setting.runnable.invoke() + } + } + + override fun onLongClick(clicked: View): Boolean { + // no-op + return true + } +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/viewholder/SettingViewHolder.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/viewholder/SettingViewHolder.kt new file mode 100644 index 0000000..3aff869 --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/viewholder/SettingViewHolder.kt @@ -0,0 +1,54 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.features.settings.ui.viewholder + +import android.view.View +import androidx.recyclerview.widget.RecyclerView +import org.sudachi.sudachi_emu.databinding.ListItemSettingBinding +import org.sudachi.sudachi_emu.databinding.ListItemSettingSwitchBinding +import org.sudachi.sudachi_emu.features.settings.model.view.SettingsItem +import org.sudachi.sudachi_emu.features.settings.ui.SettingsAdapter + +abstract class SettingViewHolder(itemView: View, protected val adapter: SettingsAdapter) : + RecyclerView.ViewHolder(itemView), View.OnClickListener, View.OnLongClickListener { + + init { + itemView.setOnClickListener(this) + itemView.setOnLongClickListener(this) + } + + /** + * Called by the adapter to set this ViewHolder's child views to display the list item + * it must now represent. + * + * @param item The list item that should be represented by this ViewHolder. + */ + abstract fun bind(item: SettingsItem) + + /** + * Called when this ViewHolder's view is clicked on. Implementations should usually pass + * this event up to the adapter. + * + * @param clicked The view that was clicked on. + */ + abstract override fun onClick(clicked: View) + + abstract override fun onLongClick(clicked: View): Boolean + + fun setStyle(isEditable: Boolean, binding: ListItemSettingBinding) { + val opacity = if (isEditable) 1.0f else 0.5f + binding.textSettingName.alpha = opacity + binding.textSettingDescription.alpha = opacity + binding.textSettingValue.alpha = opacity + binding.buttonClear.isEnabled = isEditable + } + + fun setStyle(isEditable: Boolean, binding: ListItemSettingSwitchBinding) { + binding.switchWidget.isEnabled = isEditable + val opacity = if (isEditable) 1.0f else 0.5f + binding.textSettingName.alpha = opacity + binding.textSettingDescription.alpha = opacity + binding.buttonClear.isEnabled = isEditable + } +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/viewholder/SingleChoiceViewHolder.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/viewholder/SingleChoiceViewHolder.kt new file mode 100644 index 0000000..85a938d --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/viewholder/SingleChoiceViewHolder.kt @@ -0,0 +1,91 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.features.settings.ui.viewholder + +import android.view.View +import org.sudachi.sudachi_emu.databinding.ListItemSettingBinding +import org.sudachi.sudachi_emu.features.settings.model.view.IntSingleChoiceSetting +import org.sudachi.sudachi_emu.features.settings.model.view.SettingsItem +import org.sudachi.sudachi_emu.features.settings.model.view.SingleChoiceSetting +import org.sudachi.sudachi_emu.features.settings.model.view.StringSingleChoiceSetting +import org.sudachi.sudachi_emu.features.settings.ui.SettingsAdapter +import org.sudachi.sudachi_emu.utils.ViewUtils.setVisible + +class SingleChoiceViewHolder(val binding: ListItemSettingBinding, adapter: SettingsAdapter) : + SettingViewHolder(binding.root, adapter) { + private lateinit var setting: SettingsItem + + override fun bind(item: SettingsItem) { + setting = item + binding.textSettingName.text = setting.title + binding.textSettingDescription.setVisible(item.description.isNotEmpty()) + binding.textSettingDescription.text = item.description + + binding.textSettingValue.setVisible(true) + when (item) { + is SingleChoiceSetting -> { + val resMgr = binding.textSettingValue.context.resources + val values = resMgr.getIntArray(item.valuesId) + for (i in values.indices) { + if (values[i] == item.getSelectedValue()) { + binding.textSettingValue.text = resMgr.getStringArray(item.choicesId)[i] + break + } + } + } + + is StringSingleChoiceSetting -> { + binding.textSettingValue.text = item.getSelectedValue() + } + + is IntSingleChoiceSetting -> { + binding.textSettingValue.text = item.getChoiceAt(item.getSelectedValue()) + } + } + if (binding.textSettingValue.text.isEmpty()) { + binding.textSettingValue.setVisible(false) + } + + binding.buttonClear.setVisible(setting.clearable) + binding.buttonClear.setOnClickListener { + adapter.onClearClick(setting, bindingAdapterPosition) + } + + setStyle(setting.isEditable, binding) + } + + override fun onClick(clicked: View) { + if (!setting.isEditable) { + return + } + + when (setting) { + is SingleChoiceSetting -> adapter.onSingleChoiceClick( + setting as SingleChoiceSetting, + bindingAdapterPosition + ) + + is StringSingleChoiceSetting -> { + adapter.onStringSingleChoiceClick( + setting as StringSingleChoiceSetting, + bindingAdapterPosition + ) + } + + is IntSingleChoiceSetting -> { + adapter.onIntSingleChoiceClick( + setting as IntSingleChoiceSetting, + bindingAdapterPosition + ) + } + } + } + + override fun onLongClick(clicked: View): Boolean { + if (setting.isEditable) { + return adapter.onLongClick(setting, bindingAdapterPosition) + } + return false + } +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/viewholder/SliderViewHolder.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/viewholder/SliderViewHolder.kt new file mode 100644 index 0000000..c971bf2 --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/viewholder/SliderViewHolder.kt @@ -0,0 +1,50 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.features.settings.ui.viewholder + +import android.view.View +import org.sudachi.sudachi_emu.R +import org.sudachi.sudachi_emu.databinding.ListItemSettingBinding +import org.sudachi.sudachi_emu.features.settings.model.view.SettingsItem +import org.sudachi.sudachi_emu.features.settings.model.view.SliderSetting +import org.sudachi.sudachi_emu.features.settings.ui.SettingsAdapter +import org.sudachi.sudachi_emu.utils.ViewUtils.setVisible + +class SliderViewHolder(val binding: ListItemSettingBinding, adapter: SettingsAdapter) : + SettingViewHolder(binding.root, adapter) { + private lateinit var setting: SliderSetting + + override fun bind(item: SettingsItem) { + setting = item as SliderSetting + binding.textSettingName.text = setting.title + binding.textSettingDescription.setVisible(item.description.isNotEmpty()) + binding.textSettingDescription.text = setting.description + binding.textSettingValue.setVisible(true) + binding.textSettingValue.text = String.format( + binding.textSettingValue.context.getString(R.string.value_with_units), + setting.getSelectedValue(), + setting.units + ) + + binding.buttonClear.setVisible(setting.clearable) + binding.buttonClear.setOnClickListener { + adapter.onClearClick(setting, bindingAdapterPosition) + } + + setStyle(setting.isEditable, binding) + } + + override fun onClick(clicked: View) { + if (setting.isEditable) { + adapter.onSliderClick(setting, bindingAdapterPosition) + } + } + + override fun onLongClick(clicked: View): Boolean { + if (setting.isEditable) { + return adapter.onLongClick(setting, bindingAdapterPosition) + } + return false + } +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/viewholder/StringInputViewHolder.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/viewholder/StringInputViewHolder.kt new file mode 100644 index 0000000..48e132d --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/viewholder/StringInputViewHolder.kt @@ -0,0 +1,45 @@ +// SPDX-FileCopyrightText: 2024 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.features.settings.ui.viewholder + +import android.view.View +import org.sudachi.sudachi_emu.databinding.ListItemSettingBinding +import org.sudachi.sudachi_emu.features.settings.model.view.SettingsItem +import org.sudachi.sudachi_emu.features.settings.model.view.StringInputSetting +import org.sudachi.sudachi_emu.features.settings.ui.SettingsAdapter +import org.sudachi.sudachi_emu.utils.ViewUtils.setVisible + +class StringInputViewHolder(val binding: ListItemSettingBinding, adapter: SettingsAdapter) : + SettingViewHolder(binding.root, adapter) { + private lateinit var setting: StringInputSetting + + override fun bind(item: SettingsItem) { + setting = item as StringInputSetting + binding.textSettingName.text = setting.title + binding.textSettingDescription.setVisible(setting.description.isNotEmpty()) + binding.textSettingDescription.text = setting.description + binding.textSettingValue.setVisible(true) + binding.textSettingValue.text = setting.getSelectedValue() + + binding.buttonClear.setVisible(setting.clearable) + binding.buttonClear.setOnClickListener { + adapter.onClearClick(setting, bindingAdapterPosition) + } + + setStyle(setting.isEditable, binding) + } + + override fun onClick(clicked: View) { + if (setting.isEditable) { + adapter.onStringInputClick(setting, bindingAdapterPosition) + } + } + + override fun onLongClick(clicked: View): Boolean { + if (setting.isEditable) { + return adapter.onLongClick(setting, bindingAdapterPosition) + } + return false + } +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/viewholder/SubmenuViewHolder.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/viewholder/SubmenuViewHolder.kt new file mode 100644 index 0000000..4e60b9e --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/viewholder/SubmenuViewHolder.kt @@ -0,0 +1,46 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.features.settings.ui.viewholder + +import android.view.View +import androidx.core.content.res.ResourcesCompat +import org.sudachi.sudachi_emu.databinding.ListItemSettingBinding +import org.sudachi.sudachi_emu.features.settings.model.view.SettingsItem +import org.sudachi.sudachi_emu.features.settings.model.view.SubmenuSetting +import org.sudachi.sudachi_emu.features.settings.ui.SettingsAdapter +import org.sudachi.sudachi_emu.utils.ViewUtils.setVisible + +class SubmenuViewHolder(val binding: ListItemSettingBinding, adapter: SettingsAdapter) : + SettingViewHolder(binding.root, adapter) { + private lateinit var setting: SubmenuSetting + + override fun bind(item: SettingsItem) { + setting = item as SubmenuSetting + binding.icon.setVisible(setting.iconId != 0) + if (setting.iconId != 0) { + binding.icon.setImageDrawable( + ResourcesCompat.getDrawable( + binding.icon.resources, + setting.iconId, + binding.icon.context.theme + ) + ) + } + + binding.textSettingName.text = setting.title + binding.textSettingDescription.setVisible(setting.description.isNotEmpty()) + binding.textSettingDescription.text = setting.description + binding.textSettingValue.setVisible(false) + binding.buttonClear.setVisible(false) + } + + override fun onClick(clicked: View) { + adapter.onSubmenuClick(setting) + } + + override fun onLongClick(clicked: View): Boolean { + // no-op + return true + } +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/viewholder/SwitchSettingViewHolder.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/viewholder/SwitchSettingViewHolder.kt new file mode 100644 index 0000000..f2002ce --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/viewholder/SwitchSettingViewHolder.kt @@ -0,0 +1,51 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.features.settings.ui.viewholder + +import android.view.View +import android.widget.CompoundButton +import org.sudachi.sudachi_emu.databinding.ListItemSettingSwitchBinding +import org.sudachi.sudachi_emu.features.settings.model.view.SettingsItem +import org.sudachi.sudachi_emu.features.settings.model.view.SwitchSetting +import org.sudachi.sudachi_emu.features.settings.ui.SettingsAdapter +import org.sudachi.sudachi_emu.utils.ViewUtils.setVisible + +class SwitchSettingViewHolder(val binding: ListItemSettingSwitchBinding, adapter: SettingsAdapter) : + SettingViewHolder(binding.root, adapter) { + + private lateinit var setting: SwitchSetting + + override fun bind(item: SettingsItem) { + setting = item as SwitchSetting + binding.textSettingName.text = setting.title + binding.textSettingDescription.setVisible(setting.description.isNotEmpty()) + binding.textSettingDescription.text = setting.description + + binding.switchWidget.setOnCheckedChangeListener(null) + binding.switchWidget.isChecked = setting.getIsChecked(setting.needsRuntimeGlobal) + binding.switchWidget.setOnCheckedChangeListener { _: CompoundButton, _: Boolean -> + adapter.onBooleanClick(setting, binding.switchWidget.isChecked, bindingAdapterPosition) + } + + binding.buttonClear.setVisible(setting.clearable) + binding.buttonClear.setOnClickListener { + adapter.onClearClick(setting, bindingAdapterPosition) + } + + setStyle(setting.isEditable, binding) + } + + override fun onClick(clicked: View) { + if (setting.isEditable) { + binding.switchWidget.toggle() + } + } + + override fun onLongClick(clicked: View): Boolean { + if (setting.isEditable) { + return adapter.onLongClick(setting, bindingAdapterPosition) + } + return false + } +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/utils/SettingsFile.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/utils/SettingsFile.kt new file mode 100644 index 0000000..a9b9d84 --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/features/settings/utils/SettingsFile.kt @@ -0,0 +1,29 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.features.settings.utils + +import android.net.Uri +import org.sudachi.sudachi_emu.model.Game +import java.io.* +import org.sudachi.sudachi_emu.utils.DirectoryInitialization +import org.sudachi.sudachi_emu.utils.FileUtil +import org.sudachi.sudachi_emu.utils.NativeConfig + +/** + * Contains static methods for interacting with .ini files in which settings are stored. + */ +object SettingsFile { + const val FILE_NAME_CONFIG = "config.ini" + + fun getSettingsFile(fileName: String): File = + File(DirectoryInitialization.userDirectory + "/config/" + fileName) + + fun getCustomSettingsFile(game: Game): File = + File(DirectoryInitialization.userDirectory + "/config/custom/" + game.settingsName + ".ini") + + fun loadCustomConfig(game: Game) { + val fileName = FileUtil.getFilename(Uri.parse(game.path)) + NativeConfig.initializePerGameConfig(game.programId, fileName) + } +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/fragments/AboutFragment.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/fragments/AboutFragment.kt new file mode 100644 index 0000000..428a011 --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/fragments/AboutFragment.kt @@ -0,0 +1,124 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.fragments + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Toast +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.updatePadding +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.navigation.findNavController +import com.google.android.material.transition.MaterialSharedAxis +import org.sudachi.sudachi_emu.BuildConfig +import org.sudachi.sudachi_emu.R +import org.sudachi.sudachi_emu.databinding.FragmentAboutBinding +import org.sudachi.sudachi_emu.model.HomeViewModel +import org.sudachi.sudachi_emu.utils.ViewUtils.updateMargins + +class AboutFragment : Fragment() { + private var _binding: FragmentAboutBinding? = null + private val binding get() = _binding!! + + private val homeViewModel: HomeViewModel by activityViewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enterTransition = MaterialSharedAxis(MaterialSharedAxis.X, true) + returnTransition = MaterialSharedAxis(MaterialSharedAxis.X, false) + reenterTransition = MaterialSharedAxis(MaterialSharedAxis.X, false) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentAboutBinding.inflate(layoutInflater) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + homeViewModel.setNavigationVisibility(visible = false, animated = true) + homeViewModel.setStatusBarShadeVisibility(visible = false) + + binding.toolbarAbout.setNavigationOnClickListener { + binding.root.findNavController().popBackStack() + } + + binding.imageLogo.setOnLongClickListener { + Toast.makeText( + requireContext(), + R.string.gaia_is_not_real, + Toast.LENGTH_SHORT + ).show() + true + } + + binding.buttonContributors.setOnClickListener { + openLink( + getString(R.string.contributors_link) + ) + } + binding.buttonLicenses.setOnClickListener { + exitTransition = MaterialSharedAxis(MaterialSharedAxis.X, true) + binding.root.findNavController().navigate(R.id.action_aboutFragment_to_licensesFragment) + } + + binding.textVersionName.text = BuildConfig.VERSION_NAME + binding.buttonVersionName.setOnClickListener { + val clipBoard = + requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val clip = ClipData.newPlainText(getString(R.string.build), BuildConfig.GIT_HASH) + clipBoard.setPrimaryClip(clip) + + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + Toast.makeText( + requireContext(), + R.string.copied_to_clipboard, + Toast.LENGTH_SHORT + ).show() + } + } + + binding.buttonDiscord.setOnClickListener { openLink(getString(R.string.support_link)) } + binding.buttonWebsite.setOnClickListener { openLink(getString(R.string.website_link)) } + binding.buttonGithub.setOnClickListener { openLink(getString(R.string.github_link)) } + + setInsets() + } + + private fun openLink(link: String) { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(link)) + startActivity(intent) + } + + private fun setInsets() = + ViewCompat.setOnApplyWindowInsetsListener( + binding.root + ) { _: View, windowInsets: WindowInsetsCompat -> + val barInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) + val cutoutInsets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout()) + + val leftInsets = barInsets.left + cutoutInsets.left + val rightInsets = barInsets.right + cutoutInsets.right + + binding.toolbarAbout.updateMargins(left = leftInsets, right = rightInsets) + binding.scrollAbout.updateMargins(left = leftInsets, right = rightInsets) + + binding.contentAbout.updatePadding(bottom = barInsets.bottom) + + windowInsets + } +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/fragments/AddGameFolderDialogFragment.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/fragments/AddGameFolderDialogFragment.kt new file mode 100644 index 0000000..e39bdd5 --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/fragments/AddGameFolderDialogFragment.kt @@ -0,0 +1,56 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.fragments + +import android.app.Dialog +import android.content.DialogInterface +import android.net.Uri +import android.os.Bundle +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.activityViewModels +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import org.sudachi.sudachi_emu.R +import org.sudachi.sudachi_emu.databinding.DialogAddFolderBinding +import org.sudachi.sudachi_emu.model.GameDir +import org.sudachi.sudachi_emu.model.GamesViewModel +import org.sudachi.sudachi_emu.model.HomeViewModel + +class AddGameFolderDialogFragment : DialogFragment() { + private val homeViewModel: HomeViewModel by activityViewModels() + private val gamesViewModel: GamesViewModel by activityViewModels() + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val binding = DialogAddFolderBinding.inflate(layoutInflater) + val folderUriString = requireArguments().getString(FOLDER_URI_STRING) + if (folderUriString == null) { + dismiss() + } + binding.path.text = Uri.parse(folderUriString).path + + return MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.add_game_folder) + .setPositiveButton(android.R.string.ok) { _: DialogInterface, _: Int -> + val newGameDir = GameDir(folderUriString!!, binding.deepScanSwitch.isChecked) + homeViewModel.setGamesDirSelected(true) + gamesViewModel.addFolder(newGameDir) + } + .setNegativeButton(android.R.string.cancel, null) + .setView(binding.root) + .show() + } + + companion object { + const val TAG = "AddGameFolderDialogFragment" + + private const val FOLDER_URI_STRING = "FolderUriString" + + fun newInstance(folderUriString: String): AddGameFolderDialogFragment { + val args = Bundle() + args.putString(FOLDER_URI_STRING, folderUriString) + val fragment = AddGameFolderDialogFragment() + fragment.arguments = args + return fragment + } + } +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/fragments/AddonsFragment.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/fragments/AddonsFragment.kt new file mode 100644 index 0000000..07d5882 --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/fragments/AddonsFragment.kt @@ -0,0 +1,205 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.fragments + +import android.content.Intent +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.updatePadding +import androidx.documentfile.provider.DocumentFile +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.navigation.findNavController +import androidx.navigation.fragment.navArgs +import androidx.recyclerview.widget.LinearLayoutManager +import com.google.android.material.transition.MaterialSharedAxis +import kotlinx.coroutines.launch +import org.sudachi.sudachi_emu.R +import org.sudachi.sudachi_emu.adapters.AddonAdapter +import org.sudachi.sudachi_emu.databinding.FragmentAddonsBinding +import org.sudachi.sudachi_emu.model.AddonViewModel +import org.sudachi.sudachi_emu.model.HomeViewModel +import org.sudachi.sudachi_emu.utils.AddonUtil +import org.sudachi.sudachi_emu.utils.FileUtil.copyFilesTo +import org.sudachi.sudachi_emu.utils.ViewUtils.updateMargins +import org.sudachi.sudachi_emu.utils.collect +import java.io.File + +class AddonsFragment : Fragment() { + private var _binding: FragmentAddonsBinding? = null + private val binding get() = _binding!! + + private val homeViewModel: HomeViewModel by activityViewModels() + private val addonViewModel: AddonViewModel by activityViewModels() + + private val args by navArgs() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + addonViewModel.onOpenAddons(args.game) + enterTransition = MaterialSharedAxis(MaterialSharedAxis.X, true) + returnTransition = MaterialSharedAxis(MaterialSharedAxis.X, false) + reenterTransition = MaterialSharedAxis(MaterialSharedAxis.X, false) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentAddonsBinding.inflate(inflater) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + homeViewModel.setNavigationVisibility(visible = false, animated = false) + homeViewModel.setStatusBarShadeVisibility(false) + + binding.toolbarAddons.setNavigationOnClickListener { + binding.root.findNavController().popBackStack() + } + + binding.toolbarAddons.title = getString(R.string.addons_game, args.game.title) + + binding.listAddons.apply { + layoutManager = LinearLayoutManager(requireContext()) + adapter = AddonAdapter(addonViewModel) + } + + addonViewModel.addonList.collect(viewLifecycleOwner) { + (binding.listAddons.adapter as AddonAdapter).submitList(it) + } + addonViewModel.showModInstallPicker.collect( + viewLifecycleOwner, + resetState = { addonViewModel.showModInstallPicker(false) } + ) { if (it) installAddon.launch(Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).data) } + addonViewModel.showModNoticeDialog.collect( + viewLifecycleOwner, + resetState = { addonViewModel.showModNoticeDialog(false) } + ) { + if (it) { + MessageDialogFragment.newInstance( + requireActivity(), + titleId = R.string.addon_notice, + descriptionId = R.string.addon_notice_description, + dismissible = false, + positiveAction = { addonViewModel.showModInstallPicker(true) }, + negativeAction = {}, + negativeButtonTitleId = R.string.close + ).show(parentFragmentManager, MessageDialogFragment.TAG) + } + } + addonViewModel.addonToDelete.collect( + viewLifecycleOwner, + resetState = { addonViewModel.setAddonToDelete(null) } + ) { + if (it != null) { + MessageDialogFragment.newInstance( + requireActivity(), + titleId = R.string.confirm_uninstall, + descriptionId = R.string.confirm_uninstall_description, + positiveAction = { addonViewModel.onDeleteAddon(it) }, + negativeAction = {} + ).show(parentFragmentManager, MessageDialogFragment.TAG) + } + } + + binding.buttonInstall.setOnClickListener { + ContentTypeSelectionDialogFragment().show( + parentFragmentManager, + ContentTypeSelectionDialogFragment.TAG + ) + } + + setInsets() + } + + override fun onResume() { + super.onResume() + addonViewModel.refreshAddons() + } + + override fun onDestroy() { + super.onDestroy() + addonViewModel.onCloseAddons() + } + + val installAddon = + registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { result -> + if (result == null) { + return@registerForActivityResult + } + + val externalAddonDirectory = DocumentFile.fromTreeUri(requireContext(), result) + if (externalAddonDirectory == null) { + MessageDialogFragment.newInstance( + requireActivity(), + titleId = R.string.invalid_directory, + descriptionId = R.string.invalid_directory_description + ).show(parentFragmentManager, MessageDialogFragment.TAG) + return@registerForActivityResult + } + + val isValid = externalAddonDirectory.listFiles() + .any { AddonUtil.validAddonDirectories.contains(it.name?.lowercase()) } + val errorMessage = MessageDialogFragment.newInstance( + requireActivity(), + titleId = R.string.invalid_directory, + descriptionId = R.string.invalid_directory_description + ) + if (isValid) { + ProgressDialogFragment.newInstance( + requireActivity(), + R.string.installing_game_content, + false + ) { progressCallback, _ -> + val parentDirectoryName = externalAddonDirectory.name + val internalAddonDirectory = + File(args.game.addonDir + parentDirectoryName) + try { + externalAddonDirectory.copyFilesTo(internalAddonDirectory, progressCallback) + } catch (_: Exception) { + return@newInstance errorMessage + } + addonViewModel.refreshAddons() + return@newInstance getString(R.string.addon_installed_successfully) + }.show(parentFragmentManager, ProgressDialogFragment.TAG) + } else { + errorMessage.show(parentFragmentManager, MessageDialogFragment.TAG) + } + } + + private fun setInsets() = + ViewCompat.setOnApplyWindowInsetsListener( + binding.root + ) { _: View, windowInsets: WindowInsetsCompat -> + val barInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) + val cutoutInsets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout()) + + val leftInsets = barInsets.left + cutoutInsets.left + val rightInsets = barInsets.right + cutoutInsets.right + + binding.toolbarAddons.updateMargins(left = leftInsets, right = rightInsets) + binding.listAddons.updateMargins(left = leftInsets, right = rightInsets) + binding.listAddons.updatePadding( + bottom = barInsets.bottom + + resources.getDimensionPixelSize(R.dimen.spacing_bottom_list_fab) + ) + + val fabSpacing = resources.getDimensionPixelSize(R.dimen.spacing_fab) + binding.buttonInstall.updateMargins( + left = leftInsets + fabSpacing, + right = rightInsets + fabSpacing, + bottom = barInsets.bottom + fabSpacing + ) + + windowInsets + } +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/fragments/AppletLauncherFragment.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/fragments/AppletLauncherFragment.kt new file mode 100644 index 0000000..19c4500 --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/fragments/AppletLauncherFragment.kt @@ -0,0 +1,106 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.fragments + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.updatePadding +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.navigation.findNavController +import androidx.recyclerview.widget.GridLayoutManager +import com.google.android.material.transition.MaterialSharedAxis +import org.sudachi.sudachi_emu.R +import org.sudachi.sudachi_emu.adapters.AppletAdapter +import org.sudachi.sudachi_emu.databinding.FragmentAppletLauncherBinding +import org.sudachi.sudachi_emu.model.Applet +import org.sudachi.sudachi_emu.model.AppletInfo +import org.sudachi.sudachi_emu.model.HomeViewModel +import org.sudachi.sudachi_emu.utils.ViewUtils.updateMargins + +class AppletLauncherFragment : Fragment() { + private var _binding: FragmentAppletLauncherBinding? = null + private val binding get() = _binding!! + + private val homeViewModel: HomeViewModel by activityViewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enterTransition = MaterialSharedAxis(MaterialSharedAxis.X, true) + returnTransition = MaterialSharedAxis(MaterialSharedAxis.X, false) + reenterTransition = MaterialSharedAxis(MaterialSharedAxis.X, false) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentAppletLauncherBinding.inflate(inflater) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + homeViewModel.setNavigationVisibility(visible = false, animated = true) + homeViewModel.setStatusBarShadeVisibility(visible = false) + + binding.toolbarApplets.setNavigationOnClickListener { + binding.root.findNavController().popBackStack() + } + + val applets = listOf( + Applet( + R.string.album_applet, + R.string.album_applet_description, + R.drawable.ic_album, + AppletInfo.PhotoViewer + ), + Applet( + R.string.cabinet_applet, + R.string.cabinet_applet_description, + R.drawable.ic_nfc, + AppletInfo.Cabinet + ), + Applet( + R.string.mii_edit_applet, + R.string.mii_edit_applet_description, + R.drawable.ic_mii, + AppletInfo.MiiEdit + ) + ) + + binding.listApplets.apply { + layoutManager = GridLayoutManager( + requireContext(), + resources.getInteger(R.integer.grid_columns) + ) + adapter = AppletAdapter(requireActivity(), applets) + } + + setInsets() + } + + private fun setInsets() = + ViewCompat.setOnApplyWindowInsetsListener( + binding.root + ) { _: View, windowInsets: WindowInsetsCompat -> + val barInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) + val cutoutInsets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout()) + + val leftInsets = barInsets.left + cutoutInsets.left + val rightInsets = barInsets.right + cutoutInsets.right + + binding.toolbarApplets.updateMargins(left = leftInsets, right = rightInsets) + binding.listApplets.updateMargins(left = leftInsets, right = rightInsets) + + binding.listApplets.updatePadding(bottom = barInsets.bottom) + + windowInsets + } +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/fragments/CabinetLauncherDialogFragment.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/fragments/CabinetLauncherDialogFragment.kt new file mode 100644 index 0000000..056f31a --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/fragments/CabinetLauncherDialogFragment.kt @@ -0,0 +1,41 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.fragments + +import android.app.Dialog +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.DialogFragment +import androidx.recyclerview.widget.LinearLayoutManager +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import org.sudachi.sudachi_emu.R +import org.sudachi.sudachi_emu.adapters.CabinetLauncherDialogAdapter +import org.sudachi.sudachi_emu.databinding.DialogListBinding + +class CabinetLauncherDialogFragment : DialogFragment() { + private lateinit var binding: DialogListBinding + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + binding = DialogListBinding.inflate(layoutInflater) + binding.dialogList.apply { + layoutManager = LinearLayoutManager(requireContext()) + adapter = CabinetLauncherDialogAdapter(this@CabinetLauncherDialogFragment) + } + + return MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.cabinet_launcher) + .setView(binding.root) + .create() + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + return binding.root + } +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/fragments/ContentTypeSelectionDialogFragment.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/fragments/ContentTypeSelectionDialogFragment.kt new file mode 100644 index 0000000..384daf0 --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/fragments/ContentTypeSelectionDialogFragment.kt @@ -0,0 +1,68 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.fragments + +import android.app.Dialog +import android.content.DialogInterface +import android.os.Bundle +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.activityViewModels +import androidx.preference.PreferenceManager +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import org.sudachi.sudachi_emu.R +import org.sudachi.sudachi_emu.SudachiApplication +import org.sudachi.sudachi_emu.model.AddonViewModel +import org.sudachi.sudachi_emu.ui.main.MainActivity + +class ContentTypeSelectionDialogFragment : DialogFragment() { + private val addonViewModel: AddonViewModel by activityViewModels() + + private val preferences get() = + PreferenceManager.getDefaultSharedPreferences(SudachiApplication.appContext) + + private var selectedItem = 0 + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val launchOptions = + arrayOf(getString(R.string.updates_and_dlc), getString(R.string.mods_and_cheats)) + + if (savedInstanceState != null) { + selectedItem = savedInstanceState.getInt(SELECTED_ITEM) + } + + val mainActivity = requireActivity() as MainActivity + return MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.select_content_type) + .setPositiveButton(android.R.string.ok) { _: DialogInterface, _: Int -> + when (selectedItem) { + 0 -> mainActivity.installGameUpdate.launch(arrayOf("*/*")) + else -> { + if (!preferences.getBoolean(MOD_NOTICE_SEEN, false)) { + preferences.edit().putBoolean(MOD_NOTICE_SEEN, true).apply() + addonViewModel.showModNoticeDialog(true) + return@setPositiveButton + } + addonViewModel.showModInstallPicker(true) + } + } + } + .setSingleChoiceItems(launchOptions, 0) { _: DialogInterface, i: Int -> + selectedItem = i + } + .setNegativeButton(android.R.string.cancel, null) + .show() + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + outState.putInt(SELECTED_ITEM, selectedItem) + } + + companion object { + const val TAG = "ContentTypeSelectionDialogFragment" + + private const val SELECTED_ITEM = "SelectedItem" + private const val MOD_NOTICE_SEEN = "ModNoticeSeen" + } +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/fragments/CoreErrorDialogFragment.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/fragments/CoreErrorDialogFragment.kt new file mode 100644 index 0000000..c750e9e --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/fragments/CoreErrorDialogFragment.kt @@ -0,0 +1,47 @@ +// SPDX-FileCopyrightText: 2024 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.fragments + +import android.app.Dialog +import android.content.DialogInterface +import android.os.Bundle +import androidx.fragment.app.DialogFragment +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import org.sudachi.sudachi_emu.NativeLibrary +import org.sudachi.sudachi_emu.R + +class CoreErrorDialogFragment : DialogFragment() { + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog = + MaterialAlertDialogBuilder(requireActivity()) + .setTitle(requireArguments().getString(TITLE)) + .setMessage(requireArguments().getString(MESSAGE)) + .setPositiveButton(R.string.continue_button, null) + .setNegativeButton(R.string.abort_button) { _: DialogInterface?, _: Int -> + NativeLibrary.coreErrorAlertResult = false + synchronized(NativeLibrary.coreErrorAlertLock) { + NativeLibrary.coreErrorAlertLock.notify() + } + } + .create() + + override fun onDismiss(dialog: DialogInterface) { + super.onDismiss(dialog) + NativeLibrary.coreErrorAlertResult = true + synchronized(NativeLibrary.coreErrorAlertLock) { NativeLibrary.coreErrorAlertLock.notify() } + } + + companion object { + const val TITLE = "Title" + const val MESSAGE = "Message" + + fun newInstance(title: String, message: String): CoreErrorDialogFragment { + val frag = CoreErrorDialogFragment() + val args = Bundle() + args.putString(TITLE, title) + args.putString(MESSAGE, message) + frag.arguments = args + return frag + } + } +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/fragments/DriverManagerFragment.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/fragments/DriverManagerFragment.kt new file mode 100644 index 0000000..1ff81bc --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/fragments/DriverManagerFragment.kt @@ -0,0 +1,199 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.fragments + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.updatePadding +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.navigation.findNavController +import androidx.navigation.fragment.navArgs +import androidx.recyclerview.widget.GridLayoutManager +import com.google.android.material.transition.MaterialSharedAxis +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.sudachi.sudachi_emu.R +import org.sudachi.sudachi_emu.adapters.DriverAdapter +import org.sudachi.sudachi_emu.databinding.FragmentDriverManagerBinding +import org.sudachi.sudachi_emu.features.settings.model.StringSetting +import org.sudachi.sudachi_emu.model.Driver.Companion.toDriver +import org.sudachi.sudachi_emu.model.DriverViewModel +import org.sudachi.sudachi_emu.model.HomeViewModel +import org.sudachi.sudachi_emu.utils.FileUtil +import org.sudachi.sudachi_emu.utils.GpuDriverHelper +import org.sudachi.sudachi_emu.utils.NativeConfig +import org.sudachi.sudachi_emu.utils.ViewUtils.updateMargins +import org.sudachi.sudachi_emu.utils.collect +import java.io.File +import java.io.IOException + +class DriverManagerFragment : Fragment() { + private var _binding: FragmentDriverManagerBinding? = null + private val binding get() = _binding!! + + private val homeViewModel: HomeViewModel by activityViewModels() + private val driverViewModel: DriverViewModel by activityViewModels() + + private val args by navArgs() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enterTransition = MaterialSharedAxis(MaterialSharedAxis.X, true) + returnTransition = MaterialSharedAxis(MaterialSharedAxis.X, false) + reenterTransition = MaterialSharedAxis(MaterialSharedAxis.X, false) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentDriverManagerBinding.inflate(inflater) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + homeViewModel.setNavigationVisibility(visible = false, animated = true) + homeViewModel.setStatusBarShadeVisibility(visible = false) + + driverViewModel.onOpenDriverManager(args.game) + if (NativeConfig.isPerGameConfigLoaded()) { + binding.toolbarDrivers.inflateMenu(R.menu.menu_driver_manager) + driverViewModel.showClearButton(!StringSetting.DRIVER_PATH.global) + binding.toolbarDrivers.setOnMenuItemClickListener { + when (it.itemId) { + R.id.menu_driver_use_global -> { + StringSetting.DRIVER_PATH.global = true + driverViewModel.updateDriverList() + (binding.listDrivers.adapter as DriverAdapter) + .replaceList(driverViewModel.driverList.value) + driverViewModel.showClearButton(false) + true + } + + else -> false + } + } + + driverViewModel.showClearButton.collect(viewLifecycleOwner) { + binding.toolbarDrivers.menu.findItem(R.id.menu_driver_use_global).isVisible = it + } + } + + if (!driverViewModel.isInteractionAllowed.value) { + DriversLoadingDialogFragment().show( + childFragmentManager, + DriversLoadingDialogFragment.TAG + ) + } + + binding.toolbarDrivers.setNavigationOnClickListener { + binding.root.findNavController().popBackStack() + } + + binding.buttonInstall.setOnClickListener { + getDriver.launch(arrayOf("application/zip")) + } + + binding.listDrivers.apply { + layoutManager = GridLayoutManager( + requireContext(), + resources.getInteger(R.integer.grid_columns) + ) + adapter = DriverAdapter(driverViewModel) + } + + setInsets() + } + + override fun onDestroy() { + super.onDestroy() + driverViewModel.onCloseDriverManager(args.game) + } + + private fun setInsets() = + ViewCompat.setOnApplyWindowInsetsListener( + binding.root + ) { _: View, windowInsets: WindowInsetsCompat -> + val barInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) + val cutoutInsets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout()) + + val leftInsets = barInsets.left + cutoutInsets.left + val rightInsets = barInsets.right + cutoutInsets.right + + binding.toolbarDrivers.updateMargins(left = leftInsets, right = rightInsets) + binding.listDrivers.updateMargins(left = leftInsets, right = rightInsets) + + val fabSpacing = resources.getDimensionPixelSize(R.dimen.spacing_fab) + binding.buttonInstall.updateMargins( + left = leftInsets + fabSpacing, + right = rightInsets + fabSpacing, + bottom = barInsets.bottom + fabSpacing + ) + + binding.listDrivers.updatePadding( + bottom = barInsets.bottom + + resources.getDimensionPixelSize(R.dimen.spacing_bottom_list_fab) + ) + + windowInsets + } + + private val getDriver = + registerForActivityResult(ActivityResultContracts.OpenDocument()) { result -> + if (result == null) { + return@registerForActivityResult + } + + ProgressDialogFragment.newInstance( + requireActivity(), + R.string.installing_driver, + false + ) { _, _ -> + val driverPath = + "${GpuDriverHelper.driverStoragePath}${FileUtil.getFilename(result)}" + val driverFile = File(driverPath) + + // Ignore file exceptions when a user selects an invalid zip + try { + if (!GpuDriverHelper.copyDriverToInternalStorage(result)) { + throw IOException("Driver failed validation!") + } + } catch (_: IOException) { + if (driverFile.exists()) { + driverFile.delete() + } + return@newInstance getString(R.string.select_gpu_driver_error) + } + + val driverData = GpuDriverHelper.getMetadataFromZip(driverFile) + val driverInList = + driverViewModel.driverData.firstOrNull { it.second == driverData } + if (driverInList != null) { + return@newInstance getString(R.string.driver_already_installed) + } else { + driverViewModel.onDriverAdded(Pair(driverPath, driverData)) + withContext(Dispatchers.Main) { + if (_binding != null) { + val adapter = binding.listDrivers.adapter as DriverAdapter + adapter.addItem(driverData.toDriver()) + adapter.selectItem(adapter.currentList.indices.last) + driverViewModel.showClearButton(!StringSetting.DRIVER_PATH.global) + binding.listDrivers + .smoothScrollToPosition(adapter.currentList.indices.last) + } + } + } + return@newInstance Any() + }.show(childFragmentManager, ProgressDialogFragment.TAG) + } +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/fragments/DriversLoadingDialogFragment.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/fragments/DriversLoadingDialogFragment.kt new file mode 100644 index 0000000..bb34bd3 --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/fragments/DriversLoadingDialogFragment.kt @@ -0,0 +1,50 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.fragments + +import android.app.Dialog +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.activityViewModels +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import org.sudachi.sudachi_emu.R +import org.sudachi.sudachi_emu.databinding.DialogProgressBarBinding +import org.sudachi.sudachi_emu.model.DriverViewModel +import org.sudachi.sudachi_emu.utils.collect + +class DriversLoadingDialogFragment : DialogFragment() { + private val driverViewModel: DriverViewModel by activityViewModels() + + private lateinit var binding: DialogProgressBarBinding + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + binding = DialogProgressBarBinding.inflate(layoutInflater) + binding.progressBar.isIndeterminate = true + + isCancelable = false + + return MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.loading) + .setView(binding.root) + .create() + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View = binding.root + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + driverViewModel.isInteractionAllowed.collect(viewLifecycleOwner) { if (it) dismiss() } + } + + companion object { + const val TAG = "DriversLoadingDialogFragment" + } +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/fragments/EarlyAccessFragment.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/fragments/EarlyAccessFragment.kt new file mode 100644 index 0000000..0712521 --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/fragments/EarlyAccessFragment.kt @@ -0,0 +1,87 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.fragments + +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.updatePadding +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.navigation.fragment.findNavController +import com.google.android.material.transition.MaterialSharedAxis +import org.sudachi.sudachi_emu.R +import org.sudachi.sudachi_emu.databinding.FragmentEarlyAccessBinding +import org.sudachi.sudachi_emu.model.HomeViewModel +import org.sudachi.sudachi_emu.utils.ViewUtils.updateMargins + +class EarlyAccessFragment : Fragment() { + private var _binding: FragmentEarlyAccessBinding? = null + private val binding get() = _binding!! + + private val homeViewModel: HomeViewModel by activityViewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enterTransition = MaterialSharedAxis(MaterialSharedAxis.X, true) + returnTransition = MaterialSharedAxis(MaterialSharedAxis.X, false) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentEarlyAccessBinding.inflate(layoutInflater) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + homeViewModel.setNavigationVisibility(visible = false, animated = true) + homeViewModel.setStatusBarShadeVisibility(visible = false) + + binding.toolbarAbout.setNavigationOnClickListener { + parentFragmentManager.primaryNavigationFragment?.findNavController()?.popBackStack() + } + + binding.getEarlyAccessButton.setOnClickListener { + openLink( + getString(R.string.play_store_link) + ) + } + + setInsets() + } + + private fun openLink(link: String) { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(link)) + startActivity(intent) + } + + private fun setInsets() = + ViewCompat.setOnApplyWindowInsetsListener( + binding.root + ) { _: View, windowInsets: WindowInsetsCompat -> + val barInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) + val cutoutInsets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout()) + + val leftInsets = barInsets.left + cutoutInsets.left + val rightInsets = barInsets.right + cutoutInsets.right + + binding.appbarEa.updateMargins(left = leftInsets, right = rightInsets) + + binding.scrollEa.updatePadding( + left = leftInsets, + right = rightInsets, + bottom = barInsets.bottom + ) + + windowInsets + } +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/fragments/EmulationFragment.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/fragments/EmulationFragment.kt new file mode 100644 index 0000000..d5ca95d --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/fragments/EmulationFragment.kt @@ -0,0 +1,1048 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.fragments + +import android.annotation.SuppressLint +import android.app.AlertDialog +import android.content.Context +import android.content.DialogInterface +import android.content.pm.ActivityInfo +import android.content.res.Configuration +import android.net.Uri +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.os.PowerManager +import android.os.SystemClock +import android.util.Rational +import android.view.* +import android.widget.FrameLayout +import android.widget.TextView +import android.widget.Toast +import androidx.activity.OnBackPressedCallback +import androidx.appcompat.widget.PopupMenu +import androidx.core.content.res.ResourcesCompat +import androidx.core.graphics.Insets +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.updateLayoutParams +import androidx.core.view.updatePadding +import androidx.drawerlayout.widget.DrawerLayout +import androidx.drawerlayout.widget.DrawerLayout.DrawerListener +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.navigation.findNavController +import androidx.navigation.fragment.navArgs +import androidx.window.layout.FoldingFeature +import androidx.window.layout.WindowInfoTracker +import androidx.window.layout.WindowLayoutInfo +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.slider.Slider +import org.sudachi.sudachi_emu.HomeNavigationDirections +import org.sudachi.sudachi_emu.NativeLibrary +import org.sudachi.sudachi_emu.R +import org.sudachi.sudachi_emu.activities.EmulationActivity +import org.sudachi.sudachi_emu.databinding.DialogOverlayAdjustBinding +import org.sudachi.sudachi_emu.databinding.FragmentEmulationBinding +import org.sudachi.sudachi_emu.features.settings.model.BooleanSetting +import org.sudachi.sudachi_emu.features.settings.model.IntSetting +import org.sudachi.sudachi_emu.features.settings.model.Settings +import org.sudachi.sudachi_emu.features.settings.model.Settings.EmulationOrientation +import org.sudachi.sudachi_emu.features.settings.model.Settings.EmulationVerticalAlignment +import org.sudachi.sudachi_emu.features.settings.utils.SettingsFile +import org.sudachi.sudachi_emu.model.DriverViewModel +import org.sudachi.sudachi_emu.model.Game +import org.sudachi.sudachi_emu.model.EmulationViewModel +import org.sudachi.sudachi_emu.overlay.model.OverlayControl +import org.sudachi.sudachi_emu.overlay.model.OverlayLayout +import org.sudachi.sudachi_emu.utils.* +import org.sudachi.sudachi_emu.utils.ViewUtils.setVisible +import java.lang.NullPointerException + +class EmulationFragment : Fragment(), SurfaceHolder.Callback { + private lateinit var emulationState: EmulationState + private var emulationActivity: EmulationActivity? = null + private var perfStatsUpdater: (() -> Unit)? = null + private var thermalStatsUpdater: (() -> Unit)? = null + + private var _binding: FragmentEmulationBinding? = null + private val binding get() = _binding!! + + private val args by navArgs() + + private lateinit var game: Game + + private val emulationViewModel: EmulationViewModel by activityViewModels() + private val driverViewModel: DriverViewModel by activityViewModels() + + private var isInFoldableLayout = false + + private lateinit var powerManager: PowerManager + + override fun onAttach(context: Context) { + super.onAttach(context) + if (context is EmulationActivity) { + emulationActivity = context + NativeLibrary.setEmulationActivity(context) + } else { + throw IllegalStateException("EmulationFragment must have EmulationActivity parent") + } + } + + /** + * Initialize anything that doesn't depend on the layout / views in here. + */ + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + updateOrientation() + + powerManager = requireContext().getSystemService(Context.POWER_SERVICE) as PowerManager + + val intentUri: Uri? = requireActivity().intent.data + var intentGame: Game? = null + if (intentUri != null) { + intentGame = if (Game.extensions.contains(FileUtil.getExtension(intentUri))) { + GameHelper.getGame(requireActivity().intent.data!!, false) + } else { + null + } + } + + try { + game = if (args.game != null) { + args.game!! + } else { + intentGame!! + } + } catch (e: NullPointerException) { + Toast.makeText( + requireContext(), + R.string.no_game_present, + Toast.LENGTH_SHORT + ).show() + requireActivity().finish() + return + } + + // Always load custom settings when launching a game from an intent + if (args.custom || intentGame != null) { + SettingsFile.loadCustomConfig(game) + NativeConfig.unloadPerGameConfig() + } else { + NativeConfig.reloadGlobalConfig() + } + + // Install the selected driver asynchronously as the game starts + driverViewModel.onLaunchGame() + + // So this fragment doesn't restart on configuration changes; i.e. rotation. + retainInstance = true + emulationState = EmulationState(game.path) { + return@EmulationState driverViewModel.isInteractionAllowed.value + } + } + + /** + * Initialize the UI and start emulation in here. + */ + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentEmulationBinding.inflate(layoutInflater) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + if (requireActivity().isFinishing) { + return + } + + binding.surfaceEmulation.holder.addCallback(this) + binding.doneControlConfig.setOnClickListener { stopConfiguringControls() } + + binding.drawerLayout.addDrawerListener(object : DrawerListener { + override fun onDrawerSlide(drawerView: View, slideOffset: Float) { + binding.surfaceInputOverlay.dispatchTouchEvent( + MotionEvent.obtain( + SystemClock.uptimeMillis(), + SystemClock.uptimeMillis() + 100, + MotionEvent.ACTION_UP, + 0f, + 0f, + 0 + ) + ) + } + + override fun onDrawerOpened(drawerView: View) { + binding.drawerLayout.setDrawerLockMode(DrawerLayout.LOCK_MODE_UNLOCKED) + binding.inGameMenu.requestFocus() + emulationViewModel.setDrawerOpen(true) + } + + override fun onDrawerClosed(drawerView: View) { + binding.drawerLayout.setDrawerLockMode(IntSetting.LOCK_DRAWER.getInt()) + emulationViewModel.setDrawerOpen(false) + } + + override fun onDrawerStateChanged(newState: Int) { + // No op + } + }) + binding.drawerLayout.setDrawerLockMode(DrawerLayout.LOCK_MODE_LOCKED_CLOSED) + binding.inGameMenu.getHeaderView(0).findViewById(R.id.text_game_title).text = + game.title + + binding.inGameMenu.menu.findItem(R.id.menu_lock_drawer).apply { + val lockMode = IntSetting.LOCK_DRAWER.getInt() + val titleId = if (lockMode == DrawerLayout.LOCK_MODE_LOCKED_CLOSED) { + R.string.unlock_drawer + } else { + R.string.lock_drawer + } + val iconId = if (lockMode == DrawerLayout.LOCK_MODE_UNLOCKED) { + R.drawable.ic_unlock + } else { + R.drawable.ic_lock + } + + title = getString(titleId) + icon = ResourcesCompat.getDrawable( + resources, + iconId, + requireContext().theme + ) + } + + binding.inGameMenu.setNavigationItemSelectedListener { + when (it.itemId) { + R.id.menu_pause_emulation -> { + if (emulationState.isPaused) { + emulationState.run(false) + it.title = resources.getString(R.string.emulation_pause) + it.icon = ResourcesCompat.getDrawable( + resources, + R.drawable.ic_pause, + requireContext().theme + ) + } else { + emulationState.pause() + it.title = resources.getString(R.string.emulation_unpause) + it.icon = ResourcesCompat.getDrawable( + resources, + R.drawable.ic_play, + requireContext().theme + ) + } + binding.inGameMenu.requestFocus() + true + } + + R.id.menu_settings -> { + val action = HomeNavigationDirections.actionGlobalSettingsActivity( + null, + Settings.MenuTag.SECTION_ROOT + ) + binding.inGameMenu.requestFocus() + binding.root.findNavController().navigate(action) + true + } + + R.id.menu_settings_per_game -> { + val action = HomeNavigationDirections.actionGlobalSettingsActivity( + args.game, + Settings.MenuTag.SECTION_ROOT + ) + binding.inGameMenu.requestFocus() + binding.root.findNavController().navigate(action) + true + } + + R.id.menu_controls -> { + val action = HomeNavigationDirections.actionGlobalSettingsActivity( + null, + Settings.MenuTag.SECTION_INPUT + ) + binding.root.findNavController().navigate(action) + true + } + + R.id.menu_overlay_controls -> { + showOverlayOptions() + true + } + + R.id.menu_lock_drawer -> { + when (IntSetting.LOCK_DRAWER.getInt()) { + DrawerLayout.LOCK_MODE_UNLOCKED -> { + IntSetting.LOCK_DRAWER.setInt(DrawerLayout.LOCK_MODE_LOCKED_CLOSED) + it.title = resources.getString(R.string.unlock_drawer) + it.icon = ResourcesCompat.getDrawable( + resources, + R.drawable.ic_lock, + requireContext().theme + ) + } + + DrawerLayout.LOCK_MODE_LOCKED_CLOSED -> { + IntSetting.LOCK_DRAWER.setInt(DrawerLayout.LOCK_MODE_UNLOCKED) + it.title = resources.getString(R.string.lock_drawer) + it.icon = ResourcesCompat.getDrawable( + resources, + R.drawable.ic_unlock, + requireContext().theme + ) + } + } + binding.inGameMenu.requestFocus() + NativeConfig.saveGlobalConfig() + true + } + + R.id.menu_exit -> { + emulationState.stop() + NativeConfig.reloadGlobalConfig() + emulationViewModel.setIsEmulationStopping(true) + binding.drawerLayout.close() + binding.inGameMenu.requestFocus() + true + } + + else -> true + } + } + + setInsets() + + requireActivity().onBackPressedDispatcher.addCallback( + requireActivity(), + object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + if (!NativeLibrary.isRunning()) { + return + } + emulationViewModel.setDrawerOpen(!binding.drawerLayout.isOpen) + } + } + ) + + GameIconUtils.loadGameIcon(game, binding.loadingImage) + binding.loadingTitle.text = game.title + binding.loadingTitle.isSelected = true + binding.loadingText.isSelected = true + + WindowInfoTracker.getOrCreate(requireContext()) + .windowLayoutInfo(requireActivity()).collect(viewLifecycleOwner) { + updateFoldableLayout(requireActivity() as EmulationActivity, it) + } + emulationViewModel.shaderProgress.collect(viewLifecycleOwner) { + if (it > 0 && it != emulationViewModel.totalShaders.value) { + binding.loadingProgressIndicator.isIndeterminate = false + + if (it < binding.loadingProgressIndicator.max) { + binding.loadingProgressIndicator.progress = it + } + } + + if (it == emulationViewModel.totalShaders.value) { + binding.loadingText.setText(R.string.loading) + binding.loadingProgressIndicator.isIndeterminate = true + } + } + emulationViewModel.totalShaders.collect(viewLifecycleOwner) { + binding.loadingProgressIndicator.max = it + } + emulationViewModel.shaderMessage.collect(viewLifecycleOwner) { + if (it.isNotEmpty()) { + binding.loadingText.text = it + } + } + + emulationViewModel.emulationStarted.collect(viewLifecycleOwner) { + if (it) { + binding.drawerLayout.setDrawerLockMode(IntSetting.LOCK_DRAWER.getInt()) + ViewUtils.showView(binding.surfaceInputOverlay) + ViewUtils.hideView(binding.loadingIndicator) + + emulationState.updateSurface() + + // Setup overlays + updateShowFpsOverlay() + updateThermalOverlay() + } + } + emulationViewModel.isEmulationStopping.collect(viewLifecycleOwner) { + if (it) { + binding.loadingText.setText(R.string.shutting_down) + ViewUtils.showView(binding.loadingIndicator) + ViewUtils.hideView(binding.inputContainer) + ViewUtils.hideView(binding.showFpsText) + } + } + emulationViewModel.drawerOpen.collect(viewLifecycleOwner) { + if (it) { + binding.drawerLayout.open() + binding.inGameMenu.requestFocus() + } else { + binding.drawerLayout.close() + } + } + emulationViewModel.programChanged.collect(viewLifecycleOwner) { + if (it != 0) { + emulationViewModel.setEmulationStarted(false) + binding.drawerLayout.close() + binding.drawerLayout + .setDrawerLockMode(DrawerLayout.LOCK_MODE_LOCKED_CLOSED) + ViewUtils.hideView(binding.surfaceInputOverlay) + ViewUtils.showView(binding.loadingIndicator) + } + } + emulationViewModel.emulationStopped.collect(viewLifecycleOwner) { + if (it && emulationViewModel.programChanged.value != -1) { + if (perfStatsUpdater != null) { + perfStatsUpdateHandler.removeCallbacks(perfStatsUpdater!!) + } + emulationState.changeProgram(emulationViewModel.programChanged.value) + emulationViewModel.setProgramChanged(-1) + emulationViewModel.setEmulationStopped(false) + } + } + + driverViewModel.isInteractionAllowed.collect(viewLifecycleOwner) { + if (it) startEmulation() + } + } + + private fun startEmulation(programIndex: Int = 0) { + if (!NativeLibrary.isRunning() && !NativeLibrary.isPaused()) { + if (!DirectoryInitialization.areDirectoriesReady) { + DirectoryInitialization.start() + } + + updateScreenLayout() + + emulationState.run(emulationActivity!!.isActivityRecreated, programIndex) + } + } + + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + if (_binding == null) { + return + } + + updateScreenLayout() + val showInputOverlay = BooleanSetting.SHOW_INPUT_OVERLAY.getBoolean() + if (emulationActivity?.isInPictureInPictureMode == true) { + if (binding.drawerLayout.isOpen) { + binding.drawerLayout.close() + } + if (showInputOverlay) { + binding.surfaceInputOverlay.setVisible(visible = false, gone = false) + } + } else { + binding.surfaceInputOverlay.setVisible( + showInputOverlay && emulationViewModel.emulationStarted.value + ) + if (!isInFoldableLayout) { + if (newConfig.orientation == Configuration.ORIENTATION_PORTRAIT) { + binding.surfaceInputOverlay.layout = OverlayLayout.Portrait + } else { + binding.surfaceInputOverlay.layout = OverlayLayout.Landscape + } + } + } + } + + override fun onPause() { + if (emulationState.isRunning && emulationActivity?.isInPictureInPictureMode != true) { + emulationState.pause() + } + super.onPause() + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } + + override fun onDetach() { + NativeLibrary.clearEmulationActivity() + super.onDetach() + } + + private fun resetInputOverlay() { + IntSetting.OVERLAY_SCALE.reset() + IntSetting.OVERLAY_OPACITY.reset() + binding.surfaceInputOverlay.post { + binding.surfaceInputOverlay.resetLayoutVisibilityAndPlacement() + } + } + + private fun updateShowFpsOverlay() { + val showOverlay = BooleanSetting.SHOW_PERFORMANCE_OVERLAY.getBoolean() + binding.showFpsText.setVisible(showOverlay) + if (showOverlay) { + val SYSTEM_FPS = 0 + val FPS = 1 + val FRAMETIME = 2 + val SPEED = 3 + perfStatsUpdater = { + if (emulationViewModel.emulationStarted.value && + !emulationViewModel.isEmulationStopping.value + ) { + val perfStats = NativeLibrary.getPerfStats() + val cpuBackend = NativeLibrary.getCpuBackend() + val gpuDriver = NativeLibrary.getGpuDriver() + if (_binding != null) { + binding.showFpsText.text = + String.format("FPS: %.1f\n%s/%s", perfStats[FPS], cpuBackend, gpuDriver) + } + perfStatsUpdateHandler.postDelayed(perfStatsUpdater!!, 800) + } + } + perfStatsUpdateHandler.post(perfStatsUpdater!!) + } else { + if (perfStatsUpdater != null) { + perfStatsUpdateHandler.removeCallbacks(perfStatsUpdater!!) + } + } + } + + private fun updateThermalOverlay() { + val showOverlay = BooleanSetting.SHOW_THERMAL_OVERLAY.getBoolean() + binding.showThermalsText.setVisible(showOverlay) + if (showOverlay) { + thermalStatsUpdater = { + if (emulationViewModel.emulationStarted.value && + !emulationViewModel.isEmulationStopping.value + ) { + val thermalStatus = when (powerManager.currentThermalStatus) { + PowerManager.THERMAL_STATUS_LIGHT -> "😥" + PowerManager.THERMAL_STATUS_MODERATE -> "🥵" + PowerManager.THERMAL_STATUS_SEVERE -> "🔥" + PowerManager.THERMAL_STATUS_CRITICAL, + PowerManager.THERMAL_STATUS_EMERGENCY, + PowerManager.THERMAL_STATUS_SHUTDOWN -> "☢️" + + else -> "🙂" + } + if (_binding != null) { + binding.showThermalsText.text = thermalStatus + } + thermalStatsUpdateHandler.postDelayed(thermalStatsUpdater!!, 1000) + } + } + thermalStatsUpdateHandler.post(thermalStatsUpdater!!) + } else { + if (thermalStatsUpdater != null) { + thermalStatsUpdateHandler.removeCallbacks(thermalStatsUpdater!!) + } + } + } + + @SuppressLint("SourceLockedOrientationActivity") + private fun updateOrientation() { + emulationActivity?.let { + val orientationSetting = + EmulationOrientation.from(IntSetting.RENDERER_SCREEN_LAYOUT.getInt()) + it.requestedOrientation = when (orientationSetting) { + EmulationOrientation.Unspecified -> ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED + EmulationOrientation.SensorLandscape -> + ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE + + EmulationOrientation.Landscape -> ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE + EmulationOrientation.ReverseLandscape -> + ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE + + EmulationOrientation.SensorPortrait -> + ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT + + EmulationOrientation.Portrait -> ActivityInfo.SCREEN_ORIENTATION_PORTRAIT + EmulationOrientation.ReversePortrait -> + ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT + } + } + } + + private fun updateScreenLayout() { + val verticalAlignment = + EmulationVerticalAlignment.from(IntSetting.VERTICAL_ALIGNMENT.getInt()) + val aspectRatio = when (IntSetting.RENDERER_ASPECT_RATIO.getInt()) { + 0 -> Rational(16, 9) + 1 -> Rational(4, 3) + 2 -> Rational(21, 9) + 3 -> Rational(16, 10) + else -> null // Best fit + } + when (verticalAlignment) { + EmulationVerticalAlignment.Top -> { + binding.surfaceEmulation.setAspectRatio(aspectRatio) + val params = FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ) + params.gravity = Gravity.TOP or Gravity.CENTER_HORIZONTAL + binding.surfaceEmulation.layoutParams = params + } + + EmulationVerticalAlignment.Center -> { + binding.surfaceEmulation.setAspectRatio(null) + binding.surfaceEmulation.updateLayoutParams { + width = ViewGroup.LayoutParams.MATCH_PARENT + height = ViewGroup.LayoutParams.MATCH_PARENT + } + } + + EmulationVerticalAlignment.Bottom -> { + binding.surfaceEmulation.setAspectRatio(aspectRatio) + val params = + FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ) + params.gravity = Gravity.BOTTOM or Gravity.CENTER_HORIZONTAL + binding.surfaceEmulation.layoutParams = params + } + } + emulationState.updateSurface() + emulationActivity?.buildPictureInPictureParams() + updateOrientation() + } + + private fun updateFoldableLayout( + emulationActivity: EmulationActivity, + newLayoutInfo: WindowLayoutInfo + ) { + val isFolding = + (newLayoutInfo.displayFeatures.find { it is FoldingFeature } as? FoldingFeature)?.let { + if (it.isSeparating) { + emulationActivity.requestedOrientation = + ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED + if (it.orientation == FoldingFeature.Orientation.HORIZONTAL) { + // Restrict emulation and overlays to the top of the screen + binding.emulationContainer.layoutParams.height = it.bounds.top + // Restrict input and menu drawer to the bottom of the screen + binding.inputContainer.layoutParams.height = it.bounds.bottom + binding.inGameMenu.layoutParams.height = it.bounds.bottom + + isInFoldableLayout = true + binding.surfaceInputOverlay.layout = OverlayLayout.Foldable + } + } + it.isSeparating + } ?: false + if (!isFolding) { + binding.emulationContainer.layoutParams.height = ViewGroup.LayoutParams.MATCH_PARENT + binding.inputContainer.layoutParams.height = ViewGroup.LayoutParams.MATCH_PARENT + binding.inGameMenu.layoutParams.height = ViewGroup.LayoutParams.MATCH_PARENT + isInFoldableLayout = false + updateOrientation() + onConfigurationChanged(resources.configuration) + } + binding.emulationContainer.requestLayout() + binding.inputContainer.requestLayout() + binding.inGameMenu.requestLayout() + } + + override fun surfaceCreated(holder: SurfaceHolder) { + // We purposely don't do anything here. + // All work is done in surfaceChanged, which we are guaranteed to get even for surface creation. + } + + override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) { + Log.debug("[EmulationFragment] Surface changed. Resolution: " + width + "x" + height) + emulationState.newSurface(holder.surface) + } + + override fun surfaceDestroyed(holder: SurfaceHolder) { + emulationState.clearSurface() + } + + private fun showOverlayOptions() { + val anchor = binding.inGameMenu.findViewById(R.id.menu_overlay_controls) + val popup = PopupMenu(requireContext(), anchor) + + popup.menuInflater.inflate(R.menu.menu_overlay_options, popup.menu) + + popup.menu.apply { + findItem(R.id.menu_toggle_fps).isChecked = + BooleanSetting.SHOW_PERFORMANCE_OVERLAY.getBoolean() + findItem(R.id.thermal_indicator).isChecked = + BooleanSetting.SHOW_THERMAL_OVERLAY.getBoolean() + findItem(R.id.menu_rel_stick_center).isChecked = + BooleanSetting.JOYSTICK_REL_CENTER.getBoolean() + findItem(R.id.menu_dpad_slide).isChecked = BooleanSetting.DPAD_SLIDE.getBoolean() + findItem(R.id.menu_show_overlay).isChecked = + BooleanSetting.SHOW_INPUT_OVERLAY.getBoolean() + findItem(R.id.menu_haptics).isChecked = BooleanSetting.HAPTIC_FEEDBACK.getBoolean() + findItem(R.id.menu_touchscreen).isChecked = BooleanSetting.TOUCHSCREEN.getBoolean() + } + + popup.setOnDismissListener { NativeConfig.saveGlobalConfig() } + popup.setOnMenuItemClickListener { + when (it.itemId) { + R.id.menu_toggle_fps -> { + it.isChecked = !it.isChecked + BooleanSetting.SHOW_PERFORMANCE_OVERLAY.setBoolean(it.isChecked) + updateShowFpsOverlay() + true + } + + R.id.thermal_indicator -> { + it.isChecked = !it.isChecked + BooleanSetting.SHOW_THERMAL_OVERLAY.setBoolean(it.isChecked) + updateThermalOverlay() + true + } + + R.id.menu_edit_overlay -> { + binding.drawerLayout.close() + binding.surfaceInputOverlay.requestFocus() + startConfiguringControls() + true + } + + R.id.menu_adjust_overlay -> { + adjustOverlay() + true + } + + R.id.menu_toggle_controls -> { + val overlayControlData = NativeConfig.getOverlayControlData() + val optionsArray = BooleanArray(overlayControlData.size) + overlayControlData.forEachIndexed { i, _ -> + optionsArray[i] = overlayControlData.firstOrNull { data -> + OverlayControl.entries[i].id == data.id + }?.enabled == true + } + + val dialog = MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.emulation_toggle_controls) + .setMultiChoiceItems( + R.array.gamepadButtons, + optionsArray + ) { _, indexSelected, isChecked -> + overlayControlData.firstOrNull { data -> + OverlayControl.entries[indexSelected].id == data.id + }?.enabled = isChecked + } + .setPositiveButton(android.R.string.ok) { _, _ -> + NativeConfig.setOverlayControlData(overlayControlData) + NativeConfig.saveGlobalConfig() + binding.surfaceInputOverlay.refreshControls() + } + .setNegativeButton(android.R.string.cancel, null) + .setNeutralButton(R.string.emulation_toggle_all) { _, _ -> } + .show() + + // Override normal behaviour so the dialog doesn't close + dialog.getButton(AlertDialog.BUTTON_NEUTRAL) + .setOnClickListener { + val isChecked = !optionsArray[0] + overlayControlData.forEachIndexed { i, _ -> + optionsArray[i] = isChecked + dialog.listView.setItemChecked(i, isChecked) + overlayControlData[i].enabled = isChecked + } + } + true + } + + R.id.menu_show_overlay -> { + it.isChecked = !it.isChecked + BooleanSetting.SHOW_INPUT_OVERLAY.setBoolean(it.isChecked) + binding.surfaceInputOverlay.refreshControls() + true + } + + R.id.menu_rel_stick_center -> { + it.isChecked = !it.isChecked + BooleanSetting.JOYSTICK_REL_CENTER.setBoolean(it.isChecked) + true + } + + R.id.menu_dpad_slide -> { + it.isChecked = !it.isChecked + BooleanSetting.DPAD_SLIDE.setBoolean(it.isChecked) + true + } + + R.id.menu_haptics -> { + it.isChecked = !it.isChecked + BooleanSetting.HAPTIC_FEEDBACK.setBoolean(it.isChecked) + true + } + + R.id.menu_touchscreen -> { + it.isChecked = !it.isChecked + BooleanSetting.TOUCHSCREEN.setBoolean(it.isChecked) + true + } + + R.id.menu_reset_overlay -> { + binding.drawerLayout.close() + resetInputOverlay() + true + } + + else -> true + } + } + + popup.show() + } + + @SuppressLint("SourceLockedOrientationActivity") + private fun startConfiguringControls() { + // Lock the current orientation to prevent editing inconsistencies + if (IntSetting.RENDERER_SCREEN_LAYOUT.getInt() == EmulationOrientation.Unspecified.int) { + emulationActivity?.let { + it.requestedOrientation = + if (resources.configuration.orientation == Configuration.ORIENTATION_PORTRAIT) { + ActivityInfo.SCREEN_ORIENTATION_USER_PORTRAIT + } else { + ActivityInfo.SCREEN_ORIENTATION_USER_LANDSCAPE + } + } + } + binding.doneControlConfig.setVisible(true) + binding.surfaceInputOverlay.setIsInEditMode(true) + } + + private fun stopConfiguringControls() { + binding.doneControlConfig.setVisible(false) + binding.surfaceInputOverlay.setIsInEditMode(false) + // Unlock the orientation if it was locked for editing + if (IntSetting.RENDERER_SCREEN_LAYOUT.getInt() == EmulationOrientation.Unspecified.int) { + emulationActivity?.let { + it.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED + } + } + NativeConfig.saveGlobalConfig() + } + + @SuppressLint("SetTextI18n") + private fun adjustOverlay() { + val adjustBinding = DialogOverlayAdjustBinding.inflate(layoutInflater) + adjustBinding.apply { + inputScaleSlider.apply { + valueTo = 150F + value = IntSetting.OVERLAY_SCALE.getInt().toFloat() + addOnChangeListener( + Slider.OnChangeListener { _, value, _ -> + inputScaleValue.text = "${value.toInt()}%" + setControlScale(value.toInt()) + } + ) + } + inputOpacitySlider.apply { + valueTo = 100F + value = IntSetting.OVERLAY_OPACITY.getInt().toFloat() + addOnChangeListener( + Slider.OnChangeListener { _, value, _ -> + inputOpacityValue.text = "${value.toInt()}%" + setControlOpacity(value.toInt()) + } + ) + } + inputScaleValue.text = "${inputScaleSlider.value.toInt()}%" + inputOpacityValue.text = "${inputOpacitySlider.value.toInt()}%" + } + + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.emulation_control_adjust) + .setView(adjustBinding.root) + .setPositiveButton(android.R.string.ok) { _: DialogInterface?, _: Int -> + NativeConfig.saveGlobalConfig() + } + .setNeutralButton(R.string.slider_default) { _: DialogInterface?, _: Int -> + setControlScale(50) + setControlOpacity(100) + } + .show() + } + + private fun setControlScale(scale: Int) { + IntSetting.OVERLAY_SCALE.setInt(scale) + binding.surfaceInputOverlay.refreshControls() + } + + private fun setControlOpacity(opacity: Int) { + IntSetting.OVERLAY_OPACITY.setInt(opacity) + binding.surfaceInputOverlay.refreshControls() + } + + private fun setInsets() { + ViewCompat.setOnApplyWindowInsetsListener( + binding.inGameMenu + ) { v: View, windowInsets: WindowInsetsCompat -> + val cutInsets: Insets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout()) + var left = 0 + var right = 0 + if (ViewCompat.getLayoutDirection(v) == ViewCompat.LAYOUT_DIRECTION_LTR) { + left = cutInsets.left + } else { + right = cutInsets.right + } + + v.updatePadding(left = left, top = cutInsets.top, right = right) + windowInsets + } + } + + private class EmulationState( + private val gamePath: String, + private val emulationCanStart: () -> Boolean + ) { + private var state: State + private var surface: Surface? = null + lateinit var emulationThread: Thread + + init { + // Starting state is stopped. + state = State.STOPPED + } + + @get:Synchronized + val isStopped: Boolean + get() = state == State.STOPPED + + // Getters for the current state + @get:Synchronized + val isPaused: Boolean + get() = state == State.PAUSED + + @get:Synchronized + val isRunning: Boolean + get() = state == State.RUNNING + + @Synchronized + fun stop() { + if (state != State.STOPPED) { + Log.debug("[EmulationFragment] Stopping emulation.") + NativeLibrary.stopEmulation() + state = State.STOPPED + } else { + Log.warning("[EmulationFragment] Stop called while already stopped.") + } + } + + // State changing methods + @Synchronized + fun pause() { + if (state != State.PAUSED) { + Log.debug("[EmulationFragment] Pausing emulation.") + + NativeLibrary.pauseEmulation() + + state = State.PAUSED + } else { + Log.warning("[EmulationFragment] Pause called while already paused.") + } + } + + @Synchronized + fun run(isActivityRecreated: Boolean, programIndex: Int = 0) { + if (isActivityRecreated) { + if (NativeLibrary.isRunning()) { + state = State.PAUSED + } + } else { + Log.debug("[EmulationFragment] activity resumed or fresh start") + } + + // If the surface is set, run now. Otherwise, wait for it to get set. + if (surface != null) { + runWithValidSurface(programIndex) + } + } + + @Synchronized + fun changeProgram(programIndex: Int) { + emulationThread.join() + emulationThread = Thread({ + Log.debug("[EmulationFragment] Starting emulation thread.") + NativeLibrary.run(gamePath, programIndex, false) + }, "NativeEmulation") + emulationThread.start() + } + + // Surface callbacks + @Synchronized + fun newSurface(surface: Surface?) { + this.surface = surface + if (this.surface != null) { + runWithValidSurface() + } + } + + @Synchronized + fun updateSurface() { + if (surface != null) { + NativeLibrary.surfaceChanged(surface) + } + } + + @Synchronized + fun clearSurface() { + if (surface == null) { + Log.warning("[EmulationFragment] clearSurface called, but surface already null.") + } else { + surface = null + Log.debug("[EmulationFragment] Surface destroyed.") + when (state) { + State.RUNNING -> { + state = State.PAUSED + } + + State.PAUSED -> Log.warning( + "[EmulationFragment] Surface cleared while emulation paused." + ) + + else -> Log.warning( + "[EmulationFragment] Surface cleared while emulation stopped." + ) + } + } + } + + private fun runWithValidSurface(programIndex: Int = 0) { + NativeLibrary.surfaceChanged(surface) + if (!emulationCanStart.invoke()) { + return + } + + when (state) { + State.STOPPED -> { + emulationThread = Thread({ + Log.debug("[EmulationFragment] Starting emulation thread.") + NativeLibrary.run(gamePath, programIndex, true) + }, "NativeEmulation") + emulationThread.start() + } + + State.PAUSED -> { + Log.debug("[EmulationFragment] Resuming emulation.") + NativeLibrary.unpauseEmulation() + } + + else -> Log.debug("[EmulationFragment] Bug, run called while already running.") + } + state = State.RUNNING + } + + private enum class State { + STOPPED, RUNNING, PAUSED + } + } + + companion object { + private val perfStatsUpdateHandler = Handler(Looper.myLooper()!!) + private val thermalStatsUpdateHandler = Handler(Looper.myLooper()!!) + } +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/fragments/GameFolderPropertiesDialogFragment.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/fragments/GameFolderPropertiesDialogFragment.kt new file mode 100644 index 0000000..b75bd53 --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/fragments/GameFolderPropertiesDialogFragment.kt @@ -0,0 +1,78 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.fragments + +import android.app.Dialog +import android.content.DialogInterface +import android.os.Bundle +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.activityViewModels +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import org.sudachi.sudachi_emu.R +import org.sudachi.sudachi_emu.databinding.DialogFolderPropertiesBinding +import org.sudachi.sudachi_emu.model.GameDir +import org.sudachi.sudachi_emu.model.GamesViewModel +import org.sudachi.sudachi_emu.utils.NativeConfig +import org.sudachi.sudachi_emu.utils.SerializableHelper.parcelable + +class GameFolderPropertiesDialogFragment : DialogFragment() { + private val gamesViewModel: GamesViewModel by activityViewModels() + + private var deepScan = false + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val binding = DialogFolderPropertiesBinding.inflate(layoutInflater) + val gameDir = requireArguments().parcelable(GAME_DIR)!! + + // Restore checkbox state + binding.deepScanSwitch.isChecked = + savedInstanceState?.getBoolean(DEEP_SCAN) ?: gameDir.deepScan + + // Ensure that we can get the checkbox state even if the view is destroyed + deepScan = binding.deepScanSwitch.isChecked + binding.deepScanSwitch.setOnClickListener { + deepScan = binding.deepScanSwitch.isChecked + } + + return MaterialAlertDialogBuilder(requireContext()) + .setView(binding.root) + .setTitle(R.string.game_folder_properties) + .setPositiveButton(android.R.string.ok) { _: DialogInterface, _: Int -> + val folderIndex = gamesViewModel.folders.value.indexOf(gameDir) + if (folderIndex != -1) { + gamesViewModel.folders.value[folderIndex].deepScan = + binding.deepScanSwitch.isChecked + gamesViewModel.updateGameDirs() + } + } + .setNegativeButton(android.R.string.cancel, null) + .show() + } + + override fun onStop() { + super.onStop() + NativeConfig.saveGlobalConfig() + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + outState.putBoolean(DEEP_SCAN, deepScan) + } + + companion object { + const val TAG = "GameFolderPropertiesDialogFragment" + + private const val GAME_DIR = "GameDir" + + private const val DEEP_SCAN = "DeepScan" + + fun newInstance(gameDir: GameDir): GameFolderPropertiesDialogFragment { + val args = Bundle() + args.putParcelable(GAME_DIR, gameDir) + val fragment = GameFolderPropertiesDialogFragment() + fragment.arguments = args + return fragment + } + } +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/fragments/GameFoldersFragment.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/fragments/GameFoldersFragment.kt new file mode 100644 index 0000000..ee290c1 --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/fragments/GameFoldersFragment.kt @@ -0,0 +1,116 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.fragments + +import android.content.Intent +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.updatePadding +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.navigation.findNavController +import androidx.recyclerview.widget.GridLayoutManager +import com.google.android.material.transition.MaterialSharedAxis +import kotlinx.coroutines.launch +import org.sudachi.sudachi_emu.R +import org.sudachi.sudachi_emu.adapters.FolderAdapter +import org.sudachi.sudachi_emu.databinding.FragmentFoldersBinding +import org.sudachi.sudachi_emu.model.GamesViewModel +import org.sudachi.sudachi_emu.model.HomeViewModel +import org.sudachi.sudachi_emu.ui.main.MainActivity +import org.sudachi.sudachi_emu.utils.ViewUtils.updateMargins +import org.sudachi.sudachi_emu.utils.collect + +class GameFoldersFragment : Fragment() { + private var _binding: FragmentFoldersBinding? = null + private val binding get() = _binding!! + + private val homeViewModel: HomeViewModel by activityViewModels() + private val gamesViewModel: GamesViewModel by activityViewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enterTransition = MaterialSharedAxis(MaterialSharedAxis.X, true) + returnTransition = MaterialSharedAxis(MaterialSharedAxis.X, false) + reenterTransition = MaterialSharedAxis(MaterialSharedAxis.X, false) + + gamesViewModel.onOpenGameFoldersFragment() + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentFoldersBinding.inflate(inflater) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + homeViewModel.setNavigationVisibility(visible = false, animated = true) + homeViewModel.setStatusBarShadeVisibility(visible = false) + + binding.toolbarFolders.setNavigationOnClickListener { + binding.root.findNavController().popBackStack() + } + + binding.listFolders.apply { + layoutManager = GridLayoutManager( + requireContext(), + resources.getInteger(R.integer.grid_columns) + ) + adapter = FolderAdapter(requireActivity(), gamesViewModel) + } + + gamesViewModel.folders.collect(viewLifecycleOwner) { + (binding.listFolders.adapter as FolderAdapter).submitList(it) + } + + val mainActivity = requireActivity() as MainActivity + binding.buttonAdd.setOnClickListener { + mainActivity.getGamesDirectory.launch(Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).data) + } + + setInsets() + } + + override fun onStop() { + super.onStop() + gamesViewModel.onCloseGameFoldersFragment() + } + + private fun setInsets() = + ViewCompat.setOnApplyWindowInsetsListener( + binding.root + ) { _: View, windowInsets: WindowInsetsCompat -> + val barInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) + val cutoutInsets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout()) + + val leftInsets = barInsets.left + cutoutInsets.left + val rightInsets = barInsets.right + cutoutInsets.right + + binding.toolbarFolders.updateMargins(left = leftInsets, right = rightInsets) + + val fabSpacing = resources.getDimensionPixelSize(R.dimen.spacing_fab) + binding.buttonAdd.updateMargins( + left = leftInsets + fabSpacing, + right = rightInsets + fabSpacing, + bottom = barInsets.bottom + fabSpacing + ) + + binding.listFolders.updateMargins(left = leftInsets, right = rightInsets) + + binding.listFolders.updatePadding( + bottom = barInsets.bottom + + resources.getDimensionPixelSize(R.dimen.spacing_bottom_list_fab) + ) + + windowInsets + } +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/fragments/GameInfoFragment.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/fragments/GameInfoFragment.kt new file mode 100644 index 0000000..9c13afe --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/fragments/GameInfoFragment.kt @@ -0,0 +1,179 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.fragments + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Toast +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.updatePadding +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.navigation.findNavController +import androidx.navigation.fragment.navArgs +import com.google.android.material.transition.MaterialSharedAxis +import org.sudachi.sudachi_emu.NativeLibrary +import org.sudachi.sudachi_emu.R +import org.sudachi.sudachi_emu.databinding.FragmentGameInfoBinding +import org.sudachi.sudachi_emu.model.GameVerificationResult +import org.sudachi.sudachi_emu.model.HomeViewModel +import org.sudachi.sudachi_emu.utils.GameMetadata +import org.sudachi.sudachi_emu.utils.ViewUtils.setVisible +import org.sudachi.sudachi_emu.utils.ViewUtils.updateMargins + +class GameInfoFragment : Fragment() { + private var _binding: FragmentGameInfoBinding? = null + private val binding get() = _binding!! + + private val homeViewModel: HomeViewModel by activityViewModels() + + private val args by navArgs() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enterTransition = MaterialSharedAxis(MaterialSharedAxis.X, true) + returnTransition = MaterialSharedAxis(MaterialSharedAxis.X, false) + reenterTransition = MaterialSharedAxis(MaterialSharedAxis.X, false) + + // Check for an up-to-date version string + args.game.version = GameMetadata.getVersion(args.game.path, true) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentGameInfoBinding.inflate(inflater) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + homeViewModel.setNavigationVisibility(visible = false, animated = false) + homeViewModel.setStatusBarShadeVisibility(false) + + binding.apply { + toolbarInfo.title = args.game.title + toolbarInfo.setNavigationOnClickListener { + view.findNavController().popBackStack() + } + + val pathString = Uri.parse(args.game.path).path ?: "" + path.setHint(R.string.path) + pathField.setText(pathString) + pathField.setOnClickListener { copyToClipboard(getString(R.string.path), pathString) } + + programId.setHint(R.string.program_id) + programIdField.setText(args.game.programIdHex) + programIdField.setOnClickListener { + copyToClipboard(getString(R.string.program_id), args.game.programIdHex) + } + + if (args.game.developer.isNotEmpty()) { + developer.setHint(R.string.developer) + developerField.setText(args.game.developer) + developerField.setOnClickListener { + copyToClipboard(getString(R.string.developer), args.game.developer) + } + } else { + developer.setVisible(false) + } + + version.setHint(R.string.version) + versionField.setText(args.game.version) + versionField.setOnClickListener { + copyToClipboard(getString(R.string.version), args.game.version) + } + + buttonCopy.setOnClickListener { + val details = """ + ${args.game.title} + ${getString(R.string.path)} - $pathString + ${getString(R.string.program_id)} - ${args.game.programIdHex} + ${getString(R.string.developer)} - ${args.game.developer} + ${getString(R.string.version)} - ${args.game.version} + """.trimIndent() + copyToClipboard(args.game.title, details) + } + + buttonVerifyIntegrity.setOnClickListener { + ProgressDialogFragment.newInstance( + requireActivity(), + R.string.verifying, + true + ) { progressCallback, _ -> + val result = GameVerificationResult.from( + NativeLibrary.verifyGameContents( + args.game.path, + progressCallback + ) + ) + return@newInstance when (result) { + GameVerificationResult.Success -> + MessageDialogFragment.newInstance( + titleId = R.string.verify_success, + descriptionId = R.string.operation_completed_successfully + ) + + GameVerificationResult.Failed -> + MessageDialogFragment.newInstance( + titleId = R.string.verify_failure, + descriptionId = R.string.verify_failure_description + ) + + GameVerificationResult.NotImplemented -> + MessageDialogFragment.newInstance( + titleId = R.string.verify_no_result, + descriptionId = R.string.verify_no_result_description + ) + } + }.show(parentFragmentManager, ProgressDialogFragment.TAG) + } + } + + setInsets() + } + + private fun copyToClipboard(label: String, body: String) { + val clipBoard = + requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val clip = ClipData.newPlainText(label, body) + clipBoard.setPrimaryClip(clip) + + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + Toast.makeText( + requireContext(), + R.string.copied_to_clipboard, + Toast.LENGTH_SHORT + ).show() + } + } + + private fun setInsets() = + ViewCompat.setOnApplyWindowInsetsListener( + binding.root + ) { _: View, windowInsets: WindowInsetsCompat -> + val barInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) + val cutoutInsets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout()) + + val leftInsets = barInsets.left + cutoutInsets.left + val rightInsets = barInsets.right + cutoutInsets.right + + binding.toolbarInfo.updateMargins(left = leftInsets, right = rightInsets) + binding.scrollInfo.updateMargins(left = leftInsets, right = rightInsets) + + binding.contentInfo.updatePadding(bottom = barInsets.bottom) + + windowInsets + } +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/fragments/GamePropertiesFragment.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/fragments/GamePropertiesFragment.kt new file mode 100644 index 0000000..f6b0dd2 --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/fragments/GamePropertiesFragment.kt @@ -0,0 +1,424 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.fragments + +import android.content.pm.ShortcutInfo +import android.content.pm.ShortcutManager +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Toast +import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.updatePadding +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.lifecycle.lifecycleScope +import androidx.navigation.findNavController +import androidx.navigation.fragment.navArgs +import androidx.recyclerview.widget.GridLayoutManager +import com.google.android.material.transition.MaterialSharedAxis +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.sudachi.sudachi_emu.HomeNavigationDirections +import org.sudachi.sudachi_emu.R +import org.sudachi.sudachi_emu.SudachiApplication +import org.sudachi.sudachi_emu.adapters.GamePropertiesAdapter +import org.sudachi.sudachi_emu.databinding.FragmentGamePropertiesBinding +import org.sudachi.sudachi_emu.features.settings.model.Settings +import org.sudachi.sudachi_emu.model.DriverViewModel +import org.sudachi.sudachi_emu.model.GameProperty +import org.sudachi.sudachi_emu.model.GamesViewModel +import org.sudachi.sudachi_emu.model.HomeViewModel +import org.sudachi.sudachi_emu.model.InstallableProperty +import org.sudachi.sudachi_emu.model.SubmenuProperty +import org.sudachi.sudachi_emu.model.TaskState +import org.sudachi.sudachi_emu.utils.DirectoryInitialization +import org.sudachi.sudachi_emu.utils.FileUtil +import org.sudachi.sudachi_emu.utils.GameIconUtils +import org.sudachi.sudachi_emu.utils.GpuDriverHelper +import org.sudachi.sudachi_emu.utils.MemoryUtil +import org.sudachi.sudachi_emu.utils.ViewUtils.marquee +import org.sudachi.sudachi_emu.utils.ViewUtils.updateMargins +import org.sudachi.sudachi_emu.utils.collect +import java.io.BufferedOutputStream +import java.io.File + +class GamePropertiesFragment : Fragment() { + private var _binding: FragmentGamePropertiesBinding? = null + private val binding get() = _binding!! + + private val homeViewModel: HomeViewModel by activityViewModels() + private val gamesViewModel: GamesViewModel by activityViewModels() + private val driverViewModel: DriverViewModel by activityViewModels() + + private val args by navArgs() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enterTransition = MaterialSharedAxis(MaterialSharedAxis.Y, true) + returnTransition = MaterialSharedAxis(MaterialSharedAxis.Y, false) + reenterTransition = MaterialSharedAxis(MaterialSharedAxis.X, false) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentGamePropertiesBinding.inflate(layoutInflater) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + homeViewModel.setNavigationVisibility(visible = false, animated = true) + homeViewModel.setStatusBarShadeVisibility(true) + + binding.buttonBack.setOnClickListener { + view.findNavController().popBackStack() + } + + val shortcutManager = requireActivity().getSystemService(ShortcutManager::class.java) + binding.buttonShortcut.isEnabled = shortcutManager.isRequestPinShortcutSupported + binding.buttonShortcut.setOnClickListener { + viewLifecycleOwner.lifecycleScope.launch { + withContext(Dispatchers.IO) { + val shortcut = ShortcutInfo.Builder(requireContext(), args.game.title) + .setShortLabel(args.game.title) + .setIcon( + GameIconUtils.getShortcutIcon(requireActivity(), args.game) + .toIcon(requireContext()) + ) + .setIntent(args.game.launchIntent) + .build() + shortcutManager.requestPinShortcut(shortcut, null) + } + } + } + + GameIconUtils.loadGameIcon(args.game, binding.imageGameScreen) + binding.title.text = args.game.title + binding.title.marquee() + + binding.buttonStart.setOnClickListener { + LaunchGameDialogFragment.newInstance(args.game) + .show(childFragmentManager, LaunchGameDialogFragment.TAG) + } + + reloadList() + + homeViewModel.openImportSaves.collect( + viewLifecycleOwner, + resetState = { homeViewModel.setOpenImportSaves(false) } + ) { if (it) importSaves.launch(arrayOf("application/zip")) } + homeViewModel.reloadPropertiesList.collect( + viewLifecycleOwner, + resetState = { homeViewModel.reloadPropertiesList(false) } + ) { if (it) reloadList() } + + setInsets() + } + + override fun onDestroy() { + super.onDestroy() + gamesViewModel.reloadGames(true) + } + + private fun reloadList() { + _binding ?: return + + driverViewModel.updateDriverNameForGame(args.game) + val properties = mutableListOf().apply { + add( + SubmenuProperty( + R.string.info, + R.string.info_description, + R.drawable.ic_info_outline + ) { + val action = GamePropertiesFragmentDirections + .actionPerGamePropertiesFragmentToGameInfoFragment(args.game) + binding.root.findNavController().navigate(action) + } + ) + add( + SubmenuProperty( + R.string.preferences_settings, + R.string.per_game_settings_description, + R.drawable.ic_settings + ) { + val action = HomeNavigationDirections.actionGlobalSettingsActivity( + args.game, + Settings.MenuTag.SECTION_ROOT + ) + binding.root.findNavController().navigate(action) + } + ) + + if (GpuDriverHelper.supportsCustomDriverLoading()) { + add( + SubmenuProperty( + R.string.gpu_driver_manager, + R.string.install_gpu_driver_description, + R.drawable.ic_build, + detailsFlow = driverViewModel.selectedDriverTitle + ) { + val action = GamePropertiesFragmentDirections + .actionPerGamePropertiesFragmentToDriverManagerFragment(args.game) + binding.root.findNavController().navigate(action) + } + ) + } + + if (!args.game.isHomebrew) { + add( + SubmenuProperty( + R.string.add_ons, + R.string.add_ons_description, + R.drawable.ic_edit + ) { + val action = GamePropertiesFragmentDirections + .actionPerGamePropertiesFragmentToAddonsFragment(args.game) + binding.root.findNavController().navigate(action) + } + ) + add( + InstallableProperty( + R.string.save_data, + R.string.save_data_description, + R.drawable.ic_save, + { + MessageDialogFragment.newInstance( + requireActivity(), + titleId = R.string.import_save_warning, + descriptionId = R.string.import_save_warning_description, + positiveAction = { homeViewModel.setOpenImportSaves(true) } + ).show(parentFragmentManager, MessageDialogFragment.TAG) + }, + if (File(args.game.saveDir).exists()) { + { exportSaves.launch(args.game.saveZipName) } + } else { + null + } + ) + ) + + val saveDirFile = File(args.game.saveDir) + if (saveDirFile.exists()) { + add( + SubmenuProperty( + R.string.delete_save_data, + R.string.delete_save_data_description, + R.drawable.ic_delete, + action = { + MessageDialogFragment.newInstance( + requireActivity(), + titleId = R.string.delete_save_data, + descriptionId = R.string.delete_save_data_warning_description, + positiveButtonTitleId = android.R.string.cancel, + negativeButtonTitleId = android.R.string.ok, + negativeAction = { + File(args.game.saveDir).deleteRecursively() + Toast.makeText( + SudachiApplication.appContext, + R.string.save_data_deleted_successfully, + Toast.LENGTH_SHORT + ).show() + homeViewModel.reloadPropertiesList(true) + } + ).show(parentFragmentManager, MessageDialogFragment.TAG) + } + ) + ) + } + + val shaderCacheDir = File( + DirectoryInitialization.userDirectory + + "/shader/" + args.game.settingsName.lowercase() + ) + if (shaderCacheDir.exists()) { + add( + SubmenuProperty( + R.string.clear_shader_cache, + R.string.clear_shader_cache_description, + R.drawable.ic_delete, + { + if (shaderCacheDir.exists()) { + val bytes = shaderCacheDir.walkTopDown().filter { it.isFile } + .map { it.length() }.sum() + MemoryUtil.bytesToSizeUnit(bytes.toFloat()) + } else { + MemoryUtil.bytesToSizeUnit(0f) + } + } + ) { + MessageDialogFragment.newInstance( + requireActivity(), + titleId = R.string.clear_shader_cache, + descriptionId = R.string.clear_shader_cache_warning_description, + positiveAction = { + shaderCacheDir.deleteRecursively() + Toast.makeText( + SudachiApplication.appContext, + R.string.cleared_shaders_successfully, + Toast.LENGTH_SHORT + ).show() + homeViewModel.reloadPropertiesList(true) + } + ).show(parentFragmentManager, MessageDialogFragment.TAG) + } + ) + } + } + } + binding.listProperties.apply { + layoutManager = + GridLayoutManager(requireContext(), resources.getInteger(R.integer.grid_columns)) + adapter = GamePropertiesAdapter(viewLifecycleOwner, properties) + } + } + + override fun onResume() { + super.onResume() + driverViewModel.updateDriverNameForGame(args.game) + } + + private fun setInsets() = + ViewCompat.setOnApplyWindowInsetsListener( + binding.root + ) { _: View, windowInsets: WindowInsetsCompat -> + val barInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) + val cutoutInsets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout()) + + val leftInsets = barInsets.left + cutoutInsets.left + val rightInsets = barInsets.right + cutoutInsets.right + + val smallLayout = resources.getBoolean(R.bool.small_layout) + if (smallLayout) { + binding.listAll.updateMargins(left = leftInsets, right = rightInsets) + } else { + if (ViewCompat.getLayoutDirection(binding.root) == + ViewCompat.LAYOUT_DIRECTION_LTR + ) { + binding.listAll.updateMargins(right = rightInsets) + binding.iconLayout!!.updateMargins(top = barInsets.top, left = leftInsets) + } else { + binding.listAll.updateMargins(left = leftInsets) + binding.iconLayout!!.updateMargins(top = barInsets.top, right = rightInsets) + } + } + + val fabSpacing = resources.getDimensionPixelSize(R.dimen.spacing_fab) + binding.buttonStart.updateMargins( + left = leftInsets + fabSpacing, + right = rightInsets + fabSpacing, + bottom = barInsets.bottom + fabSpacing + ) + + binding.layoutAll.updatePadding( + top = barInsets.top, + bottom = barInsets.bottom + + resources.getDimensionPixelSize(R.dimen.spacing_bottom_list_fab) + ) + + windowInsets + } + + private val importSaves = + registerForActivityResult(ActivityResultContracts.OpenDocument()) { result -> + if (result == null) { + return@registerForActivityResult + } + + val savesFolder = File(args.game.saveDir) + val cacheSaveDir = File("${requireContext().cacheDir.path}/saves/") + cacheSaveDir.mkdir() + + ProgressDialogFragment.newInstance( + requireActivity(), + R.string.save_files_importing, + false + ) { _, _ -> + try { + FileUtil.unzipToInternalStorage(result.toString(), cacheSaveDir) + val files = cacheSaveDir.listFiles() + var savesFolderFile: File? = null + if (files != null) { + val savesFolderName = args.game.programIdHex + for (file in files) { + if (file.isDirectory && file.name == savesFolderName) { + savesFolderFile = file + break + } + } + } + + if (savesFolderFile != null) { + savesFolder.deleteRecursively() + savesFolder.mkdir() + savesFolderFile.copyRecursively(savesFolder) + savesFolderFile.deleteRecursively() + } + + withContext(Dispatchers.Main) { + if (savesFolderFile == null) { + MessageDialogFragment.newInstance( + requireActivity(), + titleId = R.string.save_file_invalid_zip_structure, + descriptionId = R.string.save_file_invalid_zip_structure_description + ).show(parentFragmentManager, MessageDialogFragment.TAG) + return@withContext + } + Toast.makeText( + SudachiApplication.appContext, + getString(R.string.save_file_imported_success), + Toast.LENGTH_LONG + ).show() + homeViewModel.reloadPropertiesList(true) + } + + cacheSaveDir.deleteRecursively() + } catch (e: Exception) { + Toast.makeText( + SudachiApplication.appContext, + getString(R.string.fatal_error), + Toast.LENGTH_LONG + ).show() + } + }.show(parentFragmentManager, ProgressDialogFragment.TAG) + } + + /** + * Exports the save file located in the given folder path by creating a zip file and opening a + * file picker to save. + */ + private val exportSaves = registerForActivityResult( + ActivityResultContracts.CreateDocument("application/zip") + ) { result -> + if (result == null) { + return@registerForActivityResult + } + + ProgressDialogFragment.newInstance( + requireActivity(), + R.string.save_files_exporting, + false + ) { _, _ -> + val saveLocation = args.game.saveDir + val zipResult = FileUtil.zipFromInternalStorage( + File(saveLocation), + saveLocation.replaceAfterLast("/", ""), + BufferedOutputStream(requireContext().contentResolver.openOutputStream(result)), + compression = false + ) + return@newInstance when (zipResult) { + TaskState.Completed -> getString(R.string.export_success) + TaskState.Cancelled, TaskState.Failed -> getString(R.string.export_failed) + } + }.show(parentFragmentManager, ProgressDialogFragment.TAG) + } +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/fragments/HomeSettingsFragment.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/fragments/HomeSettingsFragment.kt new file mode 100644 index 0000000..a6acd08 --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/fragments/HomeSettingsFragment.kt @@ -0,0 +1,437 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.fragments + +import android.Manifest +import android.content.ActivityNotFoundException +import android.content.Intent +import android.content.pm.PackageManager +import android.os.Bundle +import android.provider.DocumentsContract +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Toast +import androidx.appcompat.app.AppCompatActivity +import androidx.core.app.ActivityCompat +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.updatePadding +import androidx.documentfile.provider.DocumentFile +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.navigation.findNavController +import androidx.navigation.fragment.findNavController +import androidx.recyclerview.widget.GridLayoutManager +import com.google.android.material.transition.MaterialSharedAxis +import org.sudachi.sudachi_emu.BuildConfig +import org.sudachi.sudachi_emu.HomeNavigationDirections +import org.sudachi.sudachi_emu.NativeLibrary +import org.sudachi.sudachi_emu.R +import org.sudachi.sudachi_emu.SudachiApplication +import org.sudachi.sudachi_emu.adapters.HomeSettingAdapter +import org.sudachi.sudachi_emu.databinding.FragmentHomeSettingsBinding +import org.sudachi.sudachi_emu.features.DocumentProvider +import org.sudachi.sudachi_emu.features.settings.model.Settings +import org.sudachi.sudachi_emu.model.DriverViewModel +import org.sudachi.sudachi_emu.model.HomeSetting +import org.sudachi.sudachi_emu.model.HomeViewModel +import org.sudachi.sudachi_emu.ui.main.MainActivity +import org.sudachi.sudachi_emu.utils.FileUtil +import org.sudachi.sudachi_emu.utils.GpuDriverHelper +import org.sudachi.sudachi_emu.utils.Log +import org.sudachi.sudachi_emu.utils.ViewUtils.updateMargins + +class HomeSettingsFragment : Fragment() { + private var _binding: FragmentHomeSettingsBinding? = null + private val binding get() = _binding!! + + private lateinit var mainActivity: MainActivity + + private val homeViewModel: HomeViewModel by activityViewModels() + private val driverViewModel: DriverViewModel by activityViewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + reenterTransition = MaterialSharedAxis(MaterialSharedAxis.X, false) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentHomeSettingsBinding.inflate(layoutInflater) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + homeViewModel.setNavigationVisibility(visible = true, animated = true) + homeViewModel.setStatusBarShadeVisibility(visible = true) + mainActivity = requireActivity() as MainActivity + + val optionsList: MutableList = mutableListOf().apply { + add( + HomeSetting( + R.string.advanced_settings, + R.string.settings_description, + R.drawable.ic_settings, + { + val action = HomeNavigationDirections.actionGlobalSettingsActivity( + null, + Settings.MenuTag.SECTION_ROOT + ) + binding.root.findNavController().navigate(action) + } + ) + ) + add( + HomeSetting( + R.string.preferences_controls, + R.string.preferences_controls_description, + R.drawable.ic_controller, + { + val action = HomeNavigationDirections.actionGlobalSettingsActivity( + null, + Settings.MenuTag.SECTION_INPUT + ) + binding.root.findNavController().navigate(action) + } + ) + ) + add( + HomeSetting( + R.string.gpu_driver_manager, + R.string.install_gpu_driver_description, + R.drawable.ic_build, + { + val action = HomeSettingsFragmentDirections + .actionHomeSettingsFragmentToDriverManagerFragment(null) + binding.root.findNavController().navigate(action) + }, + { GpuDriverHelper.supportsCustomDriverLoading() }, + R.string.custom_driver_not_supported, + R.string.custom_driver_not_supported_description, + driverViewModel.selectedDriverTitle + ) + ) + add( + HomeSetting( + R.string.applets, + R.string.applets_description, + R.drawable.ic_applet, + { + binding.root.findNavController() + .navigate(R.id.action_homeSettingsFragment_to_appletLauncherFragment) + }, + { NativeLibrary.isFirmwareAvailable() }, + R.string.applets_error_firmware, + R.string.applets_error_description + ) + ) + add( + HomeSetting( + R.string.manage_sudachi_data, + R.string.manage_sudachi_data_description, + R.drawable.ic_install, + { + binding.root.findNavController() + .navigate(R.id.action_homeSettingsFragment_to_installableFragment) + } + ) + ) + add( + HomeSetting( + R.string.manage_game_folders, + R.string.select_games_folder_description, + R.drawable.ic_add, + { + binding.root.findNavController() + .navigate(R.id.action_homeSettingsFragment_to_gameFoldersFragment) + } + ) + ) + add( + HomeSetting( + R.string.verify_installed_content, + R.string.verify_installed_content_description, + R.drawable.ic_check_circle, + { + ProgressDialogFragment.newInstance( + requireActivity(), + titleId = R.string.verifying, + cancellable = true + ) { progressCallback, _ -> + val result = NativeLibrary.verifyInstalledContents(progressCallback) + return@newInstance if (progressCallback.invoke(100, 100)) { + // Invoke the progress callback to check if the process was cancelled + MessageDialogFragment.newInstance( + titleId = R.string.verify_no_result, + descriptionId = R.string.verify_no_result_description + ) + } else if (result.isEmpty()) { + MessageDialogFragment.newInstance( + titleId = R.string.verify_success, + descriptionId = R.string.operation_completed_successfully + ) + } else { + val failedNames = result.joinToString("\n") + val errorMessage = SudachiApplication.appContext.getString( + R.string.verification_failed_for, + failedNames + ) + MessageDialogFragment.newInstance( + titleId = R.string.verify_failure, + descriptionString = errorMessage + ) + } + }.show(parentFragmentManager, ProgressDialogFragment.TAG) + } + ) + ) + add( + HomeSetting( + R.string.share_log, + R.string.share_log_description, + R.drawable.ic_log, + { shareLog() } + ) + ) + add( + HomeSetting( + R.string.open_user_folder, + R.string.open_user_folder_description, + R.drawable.ic_folder_open, + { openFileManager() } + ) + ) + add( + HomeSetting( + R.string.preferences_theme, + R.string.theme_and_color_description, + R.drawable.ic_palette, + { + val action = HomeNavigationDirections.actionGlobalSettingsActivity( + null, + Settings.MenuTag.SECTION_THEME + ) + binding.root.findNavController().navigate(action) + } + ) + ) + add( + HomeSetting( + R.string.about, + R.string.about_description, + R.drawable.ic_info_outline, + { + exitTransition = MaterialSharedAxis(MaterialSharedAxis.X, true) + parentFragmentManager.primaryNavigationFragment?.findNavController() + ?.navigate(R.id.action_homeSettingsFragment_to_aboutFragment) + } + ) + ) + } + + if (!BuildConfig.PREMIUM) { + optionsList.add( + 0, + HomeSetting( + R.string.get_early_access, + R.string.get_early_access_description, + R.drawable.ic_diamond, + { + exitTransition = MaterialSharedAxis(MaterialSharedAxis.X, true) + parentFragmentManager.primaryNavigationFragment?.findNavController() + ?.navigate(R.id.action_homeSettingsFragment_to_earlyAccessFragment) + } + ) + ) + } + + binding.homeSettingsList.apply { + layoutManager = + GridLayoutManager(requireContext(), resources.getInteger(R.integer.grid_columns)) + adapter = HomeSettingAdapter( + requireActivity() as AppCompatActivity, + viewLifecycleOwner, + optionsList + ) + } + + setInsets() + } + + override fun onStart() { + super.onStart() + exitTransition = null + } + + override fun onResume() { + super.onResume() + driverViewModel.updateDriverNameForGame(null) + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } + + private fun openFileManager() { + // First, try to open the user data folder directly + try { + startActivity(getFileManagerIntentOnDocumentProvider(Intent.ACTION_VIEW)) + return + } catch (_: ActivityNotFoundException) { + } + + try { + startActivity(getFileManagerIntentOnDocumentProvider("android.provider.action.BROWSE")) + return + } catch (_: ActivityNotFoundException) { + } + + // Just try to open the file manager, try the package name used on "normal" phones + try { + startActivity(getFileManagerIntent("com.google.android.documentsui")) + showNoLinkNotification() + return + } catch (_: ActivityNotFoundException) { + } + + try { + // Next, try the AOSP package name + startActivity(getFileManagerIntent("com.android.documentsui")) + showNoLinkNotification() + return + } catch (_: ActivityNotFoundException) { + } + + Toast.makeText( + requireContext(), + resources.getString(R.string.no_file_manager), + Toast.LENGTH_LONG + ).show() + } + + private fun getFileManagerIntent(packageName: String): Intent { + // Fragile, but some phones don't expose the system file manager in any better way + val intent = Intent(Intent.ACTION_MAIN) + intent.setClassName(packageName, "com.android.documentsui.files.FilesActivity") + intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK + return intent + } + + private fun getFileManagerIntentOnDocumentProvider(action: String): Intent { + val authority = "${requireContext().packageName}.user" + val intent = Intent(action) + intent.addCategory(Intent.CATEGORY_DEFAULT) + intent.data = DocumentsContract.buildRootUri(authority, DocumentProvider.ROOT_ID) + intent.addFlags( + Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION or + Intent.FLAG_GRANT_PREFIX_URI_PERMISSION or + Intent.FLAG_GRANT_WRITE_URI_PERMISSION + ) + return intent + } + + private fun showNoLinkNotification() { + val builder = NotificationCompat.Builder( + requireContext(), + getString(R.string.notice_notification_channel_id) + ) + .setSmallIcon(R.drawable.ic_stat_notification_logo) + .setContentTitle(getString(R.string.notification_no_directory_link)) + .setContentText(getString(R.string.notification_no_directory_link_description)) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setAutoCancel(true) + // TODO: Make the click action for this notification lead to a help article + + with(NotificationManagerCompat.from(requireContext())) { + if (ActivityCompat.checkSelfPermission( + requireContext(), + Manifest.permission.POST_NOTIFICATIONS + ) != PackageManager.PERMISSION_GRANTED + ) { + Toast.makeText( + requireContext(), + resources.getString(R.string.notification_permission_not_granted), + Toast.LENGTH_LONG + ).show() + return + } + notify(0, builder.build()) + } + } + + // Share the current log if we just returned from a game but share the old log + // if we just started the app and the old log exists. + private fun shareLog() { + val currentLog = DocumentFile.fromSingleUri( + mainActivity, + DocumentsContract.buildDocumentUri( + DocumentProvider.AUTHORITY, + "${DocumentProvider.ROOT_ID}/log/sudachi_log.txt" + ) + )!! + val oldLog = DocumentFile.fromSingleUri( + mainActivity, + DocumentsContract.buildDocumentUri( + DocumentProvider.AUTHORITY, + "${DocumentProvider.ROOT_ID}/log/sudachi_log.txt.old.txt" + ) + )!! + + val intent = Intent(Intent.ACTION_SEND) + .setDataAndType(currentLog.uri, FileUtil.TEXT_PLAIN) + .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + if (!Log.gameLaunched && oldLog.exists()) { + intent.putExtra(Intent.EXTRA_STREAM, oldLog.uri) + startActivity(Intent.createChooser(intent, getText(R.string.share_log))) + } else if (currentLog.exists()) { + intent.putExtra(Intent.EXTRA_STREAM, currentLog.uri) + startActivity(Intent.createChooser(intent, getText(R.string.share_log))) + } else { + Toast.makeText( + requireContext(), + getText(R.string.share_log_missing), + Toast.LENGTH_SHORT + ).show() + } + } + + private fun setInsets() = + ViewCompat.setOnApplyWindowInsetsListener( + binding.root + ) { view: View, windowInsets: WindowInsetsCompat -> + val barInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) + val cutoutInsets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout()) + val spacingNavigation = resources.getDimensionPixelSize(R.dimen.spacing_navigation) + val spacingNavigationRail = + resources.getDimensionPixelSize(R.dimen.spacing_navigation_rail) + + val leftInsets = barInsets.left + cutoutInsets.left + val rightInsets = barInsets.right + cutoutInsets.right + + binding.scrollViewSettings.updatePadding( + top = barInsets.top, + bottom = barInsets.bottom + ) + + binding.scrollViewSettings.updateMargins(left = leftInsets, right = rightInsets) + + binding.linearLayoutSettings.updatePadding(bottom = spacingNavigation) + + if (ViewCompat.getLayoutDirection(view) == ViewCompat.LAYOUT_DIRECTION_LTR) { + binding.linearLayoutSettings.updatePadding(left = spacingNavigationRail) + } else { + binding.linearLayoutSettings.updatePadding(right = spacingNavigationRail) + } + + windowInsets + } +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/fragments/InstallableFragment.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/fragments/InstallableFragment.kt new file mode 100644 index 0000000..18f116e --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/fragments/InstallableFragment.kt @@ -0,0 +1,323 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.fragments + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Toast +import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.updatePadding +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.navigation.findNavController +import androidx.recyclerview.widget.GridLayoutManager +import com.google.android.material.transition.MaterialSharedAxis +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.sudachi.sudachi_emu.NativeLibrary +import org.sudachi.sudachi_emu.R +import org.sudachi.sudachi_emu.SudachiApplication +import org.sudachi.sudachi_emu.adapters.InstallableAdapter +import org.sudachi.sudachi_emu.databinding.FragmentInstallablesBinding +import org.sudachi.sudachi_emu.model.HomeViewModel +import org.sudachi.sudachi_emu.model.Installable +import org.sudachi.sudachi_emu.model.TaskState +import org.sudachi.sudachi_emu.ui.main.MainActivity +import org.sudachi.sudachi_emu.utils.DirectoryInitialization +import org.sudachi.sudachi_emu.utils.FileUtil +import org.sudachi.sudachi_emu.utils.ViewUtils.updateMargins +import org.sudachi.sudachi_emu.utils.collect +import java.io.BufferedOutputStream +import java.io.File +import java.math.BigInteger +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter + +class InstallableFragment : Fragment() { + private var _binding: FragmentInstallablesBinding? = null + private val binding get() = _binding!! + + private val homeViewModel: HomeViewModel by activityViewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enterTransition = MaterialSharedAxis(MaterialSharedAxis.X, true) + returnTransition = MaterialSharedAxis(MaterialSharedAxis.X, false) + reenterTransition = MaterialSharedAxis(MaterialSharedAxis.X, false) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentInstallablesBinding.inflate(layoutInflater) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + val mainActivity = requireActivity() as MainActivity + + homeViewModel.setNavigationVisibility(visible = false, animated = true) + homeViewModel.setStatusBarShadeVisibility(visible = false) + + binding.toolbarInstallables.setNavigationOnClickListener { + binding.root.findNavController().popBackStack() + } + + homeViewModel.openImportSaves.collect(viewLifecycleOwner) { + if (it) { + importSaves.launch(arrayOf("application/zip")) + homeViewModel.setOpenImportSaves(false) + } + } + + val installables = listOf( + Installable( + R.string.user_data, + R.string.user_data_description, + install = { mainActivity.importUserData.launch(arrayOf("application/zip")) }, + export = { mainActivity.exportUserData.launch("export.zip") } + ), + Installable( + R.string.manage_save_data, + R.string.manage_save_data_description, + install = { + MessageDialogFragment.newInstance( + requireActivity(), + titleId = R.string.import_save_warning, + descriptionId = R.string.import_save_warning_description, + positiveAction = { homeViewModel.setOpenImportSaves(true) } + ).show(parentFragmentManager, MessageDialogFragment.TAG) + }, + export = { + val oldSaveDataFolder = File( + "${DirectoryInitialization.userDirectory}/nand" + + NativeLibrary.getDefaultProfileSaveDataRoot(false) + ) + val futureSaveDataFolder = File( + "${DirectoryInitialization.userDirectory}/nand" + + NativeLibrary.getDefaultProfileSaveDataRoot(true) + ) + if (!oldSaveDataFolder.exists() && !futureSaveDataFolder.exists()) { + Toast.makeText( + SudachiApplication.appContext, + R.string.no_save_data_found, + Toast.LENGTH_SHORT + ).show() + return@Installable + } else { + exportSaves.launch( + "${getString(R.string.save_data)} " + + LocalDateTime.now().format( + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm") + ) + ) + } + } + ), + Installable( + R.string.install_game_content, + R.string.install_game_content_description, + install = { mainActivity.installGameUpdate.launch(arrayOf("*/*")) } + ), + Installable( + R.string.install_firmware, + R.string.install_firmware_description, + install = { mainActivity.getFirmware.launch(arrayOf("application/zip")) } + ), + Installable( + R.string.install_prod_keys, + R.string.install_prod_keys_description, + install = { mainActivity.getProdKey.launch(arrayOf("*/*")) } + ), + Installable( + R.string.install_amiibo_keys, + R.string.install_amiibo_keys_description, + install = { mainActivity.getAmiiboKey.launch(arrayOf("*/*")) } + ) + ) + + binding.listInstallables.apply { + layoutManager = GridLayoutManager( + requireContext(), + resources.getInteger(R.integer.grid_columns) + ) + adapter = InstallableAdapter(installables) + } + + setInsets() + } + + private fun setInsets() = + ViewCompat.setOnApplyWindowInsetsListener( + binding.root + ) { _: View, windowInsets: WindowInsetsCompat -> + val barInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) + val cutoutInsets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout()) + + val leftInsets = barInsets.left + cutoutInsets.left + val rightInsets = barInsets.right + cutoutInsets.right + + binding.toolbarInstallables.updateMargins(left = leftInsets, right = rightInsets) + binding.listInstallables.updateMargins(left = leftInsets, right = rightInsets) + + binding.listInstallables.updatePadding(bottom = barInsets.bottom) + + windowInsets + } + + private val importSaves = + registerForActivityResult(ActivityResultContracts.OpenDocument()) { result -> + if (result == null) { + return@registerForActivityResult + } + + val cacheSaveDir = File("${requireContext().cacheDir.path}/saves/") + cacheSaveDir.mkdir() + + ProgressDialogFragment.newInstance( + requireActivity(), + R.string.save_files_importing, + false + ) { progressCallback, _ -> + try { + FileUtil.unzipToInternalStorage( + result.toString(), + cacheSaveDir, + progressCallback + ) + val files = cacheSaveDir.listFiles() + var successfulImports = 0 + var failedImports = 0 + if (files != null) { + for (file in files) { + if (file.isDirectory) { + val baseSaveDir = + NativeLibrary.getSavePath(BigInteger(file.name, 16).toString()) + if (baseSaveDir.isEmpty()) { + failedImports++ + continue + } + + val internalSaveFolder = File( + "${DirectoryInitialization.userDirectory}/nand$baseSaveDir" + ) + internalSaveFolder.deleteRecursively() + internalSaveFolder.mkdir() + file.copyRecursively(target = internalSaveFolder, overwrite = true) + successfulImports++ + } + } + } + + withContext(Dispatchers.Main) { + if (successfulImports == 0) { + MessageDialogFragment.newInstance( + requireActivity(), + titleId = R.string.save_file_invalid_zip_structure, + descriptionId = R.string.save_file_invalid_zip_structure_description + ).show(parentFragmentManager, MessageDialogFragment.TAG) + return@withContext + } + val successString = if (failedImports > 0) { + """ + ${ + requireContext().resources.getQuantityString( + R.plurals.saves_import_success, + successfulImports, + successfulImports + ) + } + ${ + requireContext().resources.getQuantityString( + R.plurals.saves_import_failed, + failedImports, + failedImports + ) + } + """ + } else { + requireContext().resources.getQuantityString( + R.plurals.saves_import_success, + successfulImports, + successfulImports + ) + } + MessageDialogFragment.newInstance( + requireActivity(), + titleId = R.string.import_complete, + descriptionString = successString + ).show(parentFragmentManager, MessageDialogFragment.TAG) + } + + cacheSaveDir.deleteRecursively() + } catch (e: Exception) { + Toast.makeText( + SudachiApplication.appContext, + getString(R.string.fatal_error), + Toast.LENGTH_LONG + ).show() + } + }.show(parentFragmentManager, ProgressDialogFragment.TAG) + } + + private val exportSaves = registerForActivityResult( + ActivityResultContracts.CreateDocument("application/zip") + ) { result -> + if (result == null) { + return@registerForActivityResult + } + + ProgressDialogFragment.newInstance( + requireActivity(), + R.string.save_files_exporting, + false + ) { _, _ -> + val cacheSaveDir = File("${requireContext().cacheDir.path}/saves/") + cacheSaveDir.mkdir() + + val oldSaveDataFolder = File( + "${DirectoryInitialization.userDirectory}/nand" + + NativeLibrary.getDefaultProfileSaveDataRoot(false) + ) + if (oldSaveDataFolder.exists()) { + oldSaveDataFolder.copyRecursively(cacheSaveDir) + } + + val futureSaveDataFolder = File( + "${DirectoryInitialization.userDirectory}/nand" + + NativeLibrary.getDefaultProfileSaveDataRoot(true) + ) + if (futureSaveDataFolder.exists()) { + futureSaveDataFolder.copyRecursively(cacheSaveDir) + } + + val saveFilesTotal = cacheSaveDir.listFiles()?.size ?: 0 + if (saveFilesTotal == 0) { + cacheSaveDir.deleteRecursively() + return@newInstance getString(R.string.no_save_data_found) + } + + val zipResult = FileUtil.zipFromInternalStorage( + cacheSaveDir, + cacheSaveDir.path, + BufferedOutputStream(requireContext().contentResolver.openOutputStream(result)) + ) + cacheSaveDir.deleteRecursively() + + return@newInstance when (zipResult) { + TaskState.Completed -> getString(R.string.export_success) + TaskState.Cancelled, TaskState.Failed -> getString(R.string.export_failed) + } + }.show(parentFragmentManager, ProgressDialogFragment.TAG) + } +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/fragments/LaunchGameDialogFragment.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/fragments/LaunchGameDialogFragment.kt new file mode 100644 index 0000000..a807bb0 --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/fragments/LaunchGameDialogFragment.kt @@ -0,0 +1,61 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.fragments + +import android.app.Dialog +import android.content.DialogInterface +import android.os.Bundle +import androidx.fragment.app.DialogFragment +import androidx.navigation.fragment.findNavController +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import org.sudachi.sudachi_emu.HomeNavigationDirections +import org.sudachi.sudachi_emu.R +import org.sudachi.sudachi_emu.model.Game +import org.sudachi.sudachi_emu.utils.SerializableHelper.parcelable + +class LaunchGameDialogFragment : DialogFragment() { + private var selectedItem = 1 + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val game = requireArguments().parcelable(GAME) + val launchOptions = arrayOf(getString(R.string.global), getString(R.string.custom)) + + if (savedInstanceState != null) { + selectedItem = savedInstanceState.getInt(SELECTED_ITEM) + } + + return MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.launch_options) + .setPositiveButton(android.R.string.ok) { _: DialogInterface, _: Int -> + val action = HomeNavigationDirections + .actionGlobalEmulationActivity(game, selectedItem != 0) + requireParentFragment().findNavController().navigate(action) + } + .setSingleChoiceItems(launchOptions, 1) { _: DialogInterface, i: Int -> + selectedItem = i + } + .setNegativeButton(android.R.string.cancel, null) + .show() + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + outState.putInt(SELECTED_ITEM, selectedItem) + } + + companion object { + const val TAG = "LaunchGameDialogFragment" + + const val GAME = "Game" + const val SELECTED_ITEM = "SelectedItem" + + fun newInstance(game: Game): LaunchGameDialogFragment { + val args = Bundle() + args.putParcelable(GAME, game) + val fragment = LaunchGameDialogFragment() + fragment.arguments = args + return fragment + } + } +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/fragments/LicenseBottomSheetDialogFragment.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/fragments/LicenseBottomSheetDialogFragment.kt new file mode 100644 index 0000000..a525bd3 --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/fragments/LicenseBottomSheetDialogFragment.kt @@ -0,0 +1,59 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.fragments + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import org.sudachi.sudachi_emu.databinding.DialogLicenseBinding +import org.sudachi.sudachi_emu.model.License +import org.sudachi.sudachi_emu.utils.SerializableHelper.parcelable + +class LicenseBottomSheetDialogFragment : BottomSheetDialogFragment() { + private var _binding: DialogLicenseBinding? = null + private val binding get() = _binding!! + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = DialogLicenseBinding.inflate(layoutInflater) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + BottomSheetBehavior.from(view.parent as View).state = + BottomSheetBehavior.STATE_HALF_EXPANDED + + val license = requireArguments().parcelable(LICENSE)!! + + binding.apply { + textTitle.setText(license.titleId) + textLink.setText(license.linkId) + textCopyright.setText(license.copyrightId) + textLicense.setText(license.licenseId) + } + } + + companion object { + const val TAG = "LicenseBottomSheetDialogFragment" + + const val LICENSE = "License" + + fun newInstance( + license: License + ): LicenseBottomSheetDialogFragment { + val dialog = LicenseBottomSheetDialogFragment() + val bundle = Bundle() + bundle.putParcelable(LICENSE, license) + dialog.arguments = bundle + return dialog + } + } +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/fragments/LicensesFragment.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/fragments/LicensesFragment.kt new file mode 100644 index 0000000..76dec47 --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/fragments/LicensesFragment.kt @@ -0,0 +1,132 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.fragments + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.appcompat.app.AppCompatActivity +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.updatePadding +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.navigation.findNavController +import androidx.recyclerview.widget.LinearLayoutManager +import com.google.android.material.transition.MaterialSharedAxis +import org.sudachi.sudachi_emu.R +import org.sudachi.sudachi_emu.adapters.LicenseAdapter +import org.sudachi.sudachi_emu.databinding.FragmentLicensesBinding +import org.sudachi.sudachi_emu.model.HomeViewModel +import org.sudachi.sudachi_emu.model.License +import org.sudachi.sudachi_emu.utils.ViewUtils.updateMargins + +class LicensesFragment : Fragment() { + private var _binding: FragmentLicensesBinding? = null + private val binding get() = _binding!! + + private val homeViewModel: HomeViewModel by activityViewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enterTransition = MaterialSharedAxis(MaterialSharedAxis.X, true) + returnTransition = MaterialSharedAxis(MaterialSharedAxis.X, false) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentLicensesBinding.inflate(layoutInflater) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + homeViewModel.setNavigationVisibility(visible = false, animated = true) + homeViewModel.setStatusBarShadeVisibility(visible = false) + + binding.toolbarLicenses.setNavigationOnClickListener { + binding.root.findNavController().popBackStack() + } + + val licenses = listOf( + License( + R.string.license_fidelityfx_fsr, + R.string.license_fidelityfx_fsr_description, + R.string.license_fidelityfx_fsr_link, + R.string.license_fidelityfx_fsr_copyright, + R.string.license_fidelityfx_fsr_text + ), + License( + R.string.license_cubeb, + R.string.license_cubeb_description, + R.string.license_cubeb_link, + R.string.license_cubeb_copyright, + R.string.license_cubeb_text + ), + License( + R.string.license_dynarmic, + R.string.license_dynarmic_description, + R.string.license_dynarmic_link, + R.string.license_dynarmic_copyright, + R.string.license_dynarmic_text + ), + License( + R.string.license_ffmpeg, + R.string.license_ffmpeg_description, + R.string.license_ffmpeg_link, + R.string.license_ffmpeg_copyright, + R.string.license_ffmpeg_text + ), + License( + R.string.license_opus, + R.string.license_opus_description, + R.string.license_opus_link, + R.string.license_opus_copyright, + R.string.license_opus_text + ), + License( + R.string.license_sirit, + R.string.license_sirit_description, + R.string.license_sirit_link, + R.string.license_sirit_copyright, + R.string.license_sirit_text + ), + License( + R.string.license_adreno_tools, + R.string.license_adreno_tools_description, + R.string.license_adreno_tools_link, + R.string.license_adreno_tools_copyright, + R.string.license_adreno_tools_text + ) + ) + + binding.listLicenses.apply { + layoutManager = LinearLayoutManager(requireContext()) + adapter = LicenseAdapter(requireActivity() as AppCompatActivity, licenses) + } + + setInsets() + } + + private fun setInsets() = + ViewCompat.setOnApplyWindowInsetsListener( + binding.root + ) { _: View, windowInsets: WindowInsetsCompat -> + val barInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) + val cutoutInsets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout()) + + val leftInsets = barInsets.left + cutoutInsets.left + val rightInsets = barInsets.right + cutoutInsets.right + + binding.appbarLicenses.updateMargins(left = leftInsets, right = rightInsets) + binding.listLicenses.updateMargins(left = leftInsets, right = rightInsets) + + binding.listLicenses.updatePadding(bottom = barInsets.bottom) + + windowInsets + } +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/fragments/MessageDialogFragment.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/fragments/MessageDialogFragment.kt new file mode 100644 index 0000000..37f272b --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/fragments/MessageDialogFragment.kt @@ -0,0 +1,195 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.fragments + +import android.app.Dialog +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.text.Html +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.FragmentActivity +import androidx.fragment.app.activityViewModels +import androidx.lifecycle.ViewModelProvider +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import org.sudachi.sudachi_emu.R +import org.sudachi.sudachi_emu.model.MessageDialogViewModel +import org.sudachi.sudachi_emu.utils.Log + +class MessageDialogFragment : DialogFragment() { + private val messageDialogViewModel: MessageDialogViewModel by activityViewModels() + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val titleId = requireArguments().getInt(TITLE_ID) + val title = if (titleId != 0) { + getString(titleId) + } else { + requireArguments().getString(TITLE_STRING)!! + } + + val descriptionId = requireArguments().getInt(DESCRIPTION_ID) + val description = if (descriptionId != 0) { + getString(descriptionId) + } else { + requireArguments().getString(DESCRIPTION_STRING)!! + } + + val positiveButtonId = requireArguments().getInt(POSITIVE_BUTTON_TITLE_ID) + val positiveButtonString = requireArguments().getString(POSITIVE_BUTTON_TITLE_STRING)!! + val positiveButton = if (positiveButtonId != 0) { + getString(positiveButtonId) + } else if (positiveButtonString.isNotEmpty()) { + positiveButtonString + } else if (messageDialogViewModel.positiveAction != null) { + getString(android.R.string.ok) + } else { + getString(R.string.close) + } + + val negativeButtonId = requireArguments().getInt(NEGATIVE_BUTTON_TITLE_ID) + val negativeButtonString = requireArguments().getString(NEGATIVE_BUTTON_TITLE_STRING)!! + val negativeButton = if (negativeButtonId != 0) { + getString(negativeButtonId) + } else if (negativeButtonString.isNotEmpty()) { + negativeButtonString + } else { + getString(android.R.string.cancel) + } + + val helpLinkId = requireArguments().getInt(HELP_LINK) + val dismissible = requireArguments().getBoolean(DISMISSIBLE) + val clearPositiveAction = requireArguments().getBoolean(CLEAR_ACTIONS) + val showNegativeButton = requireArguments().getBoolean(SHOW_NEGATIVE_BUTTON) + + val builder = MaterialAlertDialogBuilder(requireContext()) + + if (clearPositiveAction) { + messageDialogViewModel.positiveAction = null + } + + builder.setPositiveButton(positiveButton) { _, _ -> + messageDialogViewModel.positiveAction?.invoke() + } + if (messageDialogViewModel.negativeAction != null || showNegativeButton) { + builder.setNegativeButton(negativeButton) { _, _ -> + messageDialogViewModel.negativeAction?.invoke() + } + } + + if (title.isNotEmpty()) builder.setTitle(title) + if (description.isNotEmpty()) { + builder.setMessage(Html.fromHtml(description, Html.FROM_HTML_MODE_LEGACY)) + } + + if (helpLinkId != 0) { + builder.setNeutralButton(R.string.learn_more) { _, _ -> + openLink(getString(helpLinkId)) + } + } + + isCancelable = dismissible + + return builder.show() + } + + private fun openLink(link: String) { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(link)) + startActivity(intent) + } + + companion object { + const val TAG = "MessageDialogFragment" + + private const val TITLE_ID = "Title" + private const val TITLE_STRING = "TitleString" + private const val DESCRIPTION_ID = "DescriptionId" + private const val DESCRIPTION_STRING = "DescriptionString" + private const val HELP_LINK = "Link" + private const val DISMISSIBLE = "Dismissible" + private const val CLEAR_ACTIONS = "ClearActions" + private const val POSITIVE_BUTTON_TITLE_ID = "PositiveButtonTitleId" + private const val POSITIVE_BUTTON_TITLE_STRING = "PositiveButtonTitleString" + private const val SHOW_NEGATIVE_BUTTON = "ShowNegativeButton" + private const val NEGATIVE_BUTTON_TITLE_ID = "NegativeButtonTitleId" + private const val NEGATIVE_BUTTON_TITLE_STRING = "NegativeButtonTitleString" + + /** + * Creates a new [MessageDialogFragment] instance. + * @param activity Activity that will hold a [MessageDialogViewModel] instance if using + * [positiveAction] or [negativeAction]. + * @param titleId String resource ID that will be used for the title. [titleString] used if 0. + * @param titleString String that will be used for the title. No title is set if empty. + * @param descriptionId String resource ID that will be used for the description. + * [descriptionString] used if 0. + * @param descriptionString String that will be used for the description. + * No description is set if empty. + * @param helpLinkId String resource ID that contains a help link. Will be added as a neutral + * button with the title R.string.help. + * @param dismissible Whether the dialog is dismissible or not. Typically used to ensure that + * the user clicks on one of the dialog buttons before closing. + * @param positiveButtonTitleId String resource ID that will be used for the positive button. + * [positiveButtonTitleString] used if 0. + * @param positiveButtonTitleString String that will be used for the positive button. + * android.R.string.close used if empty. android.R.string.ok will be used if [positiveAction] + * is not null. + * @param positiveAction Lambda to run when the positive button is clicked. + * @param showNegativeButton Normally the negative button isn't shown if there is no + * [negativeAction] set. This can override that behavior to always show a button. + * @param negativeButtonTitleId String resource ID that will be used for the negative button. + * [negativeButtonTitleString] used if 0. + * @param negativeButtonTitleString String that will be used for the negative button. + * android.R.string.cancel used if empty. + * @param negativeAction Lambda to run when the negative button is clicked + */ + fun newInstance( + activity: FragmentActivity? = null, + titleId: Int = 0, + titleString: String = "", + descriptionId: Int = 0, + descriptionString: String = "", + helpLinkId: Int = 0, + dismissible: Boolean = true, + positiveButtonTitleId: Int = 0, + positiveButtonTitleString: String = "", + positiveAction: (() -> Unit)? = null, + showNegativeButton: Boolean = false, + negativeButtonTitleId: Int = 0, + negativeButtonTitleString: String = "", + negativeAction: (() -> Unit)? = null + ): MessageDialogFragment { + var clearActions = false + if (activity != null) { + ViewModelProvider(activity)[MessageDialogViewModel::class.java].apply { + clear() + this.positiveAction = positiveAction + this.negativeAction = negativeAction + } + } else { + clearActions = true + } + + if (activity == null && (positiveAction == null || negativeAction == null)) { + Log.warning("[$TAG] Tried to set action with no activity!") + } + + val dialog = MessageDialogFragment() + val bundle = Bundle().apply { + putInt(TITLE_ID, titleId) + putString(TITLE_STRING, titleString) + putInt(DESCRIPTION_ID, descriptionId) + putString(DESCRIPTION_STRING, descriptionString) + putInt(HELP_LINK, helpLinkId) + putBoolean(DISMISSIBLE, dismissible) + putBoolean(CLEAR_ACTIONS, clearActions) + putInt(POSITIVE_BUTTON_TITLE_ID, positiveButtonTitleId) + putString(POSITIVE_BUTTON_TITLE_STRING, positiveButtonTitleString) + putBoolean(SHOW_NEGATIVE_BUTTON, showNegativeButton) + putInt(NEGATIVE_BUTTON_TITLE_ID, negativeButtonTitleId) + putString(NEGATIVE_BUTTON_TITLE_STRING, negativeButtonTitleString) + } + dialog.arguments = bundle + return dialog + } + } +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/fragments/PermissionDeniedDialogFragment.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/fragments/PermissionDeniedDialogFragment.kt new file mode 100644 index 0000000..33ccdfb --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/fragments/PermissionDeniedDialogFragment.kt @@ -0,0 +1,38 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.fragments + +import android.app.Dialog +import android.content.DialogInterface +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.provider.Settings +import androidx.fragment.app.DialogFragment +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import org.sudachi.sudachi_emu.R + +class PermissionDeniedDialogFragment : DialogFragment() { + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + return MaterialAlertDialogBuilder(requireContext()) + .setPositiveButton(R.string.home_settings) { _: DialogInterface?, _: Int -> + openSettings() + } + .setNegativeButton(android.R.string.cancel, null) + .setTitle(R.string.permission_denied) + .setMessage(R.string.permission_denied_description) + .show() + } + + private fun openSettings() { + val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) + val uri = Uri.fromParts("package", requireActivity().packageName, null) + intent.data = uri + startActivity(intent) + } + + companion object { + const val TAG = "PermissionDeniedDialogFragment" + } +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/fragments/ProgressDialogFragment.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/fragments/ProgressDialogFragment.kt new file mode 100644 index 0000000..03eaa8d --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/fragments/ProgressDialogFragment.kt @@ -0,0 +1,148 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.fragments + +import android.app.Dialog +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Toast +import androidx.appcompat.app.AlertDialog +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.FragmentActivity +import androidx.fragment.app.activityViewModels +import androidx.lifecycle.ViewModelProvider +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import org.sudachi.sudachi_emu.R +import org.sudachi.sudachi_emu.databinding.DialogProgressBarBinding +import org.sudachi.sudachi_emu.model.TaskViewModel +import org.sudachi.sudachi_emu.utils.ViewUtils.setVisible +import org.sudachi.sudachi_emu.utils.collect + +class ProgressDialogFragment : DialogFragment() { + private val taskViewModel: TaskViewModel by activityViewModels() + + private lateinit var binding: DialogProgressBarBinding + + private val PROGRESS_BAR_RESOLUTION = 1000 + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val titleId = requireArguments().getInt(TITLE) + val cancellable = requireArguments().getBoolean(CANCELLABLE) + + binding = DialogProgressBarBinding.inflate(layoutInflater) + binding.progressBar.isIndeterminate = true + val dialog = MaterialAlertDialogBuilder(requireContext()) + .setTitle(titleId) + .setView(binding.root) + + if (cancellable) { + dialog.setNegativeButton(android.R.string.cancel, null) + } + + val alertDialog = dialog.create() + alertDialog.setCanceledOnTouchOutside(false) + + if (!taskViewModel.isRunning.value) { + taskViewModel.runTask() + } + return alertDialog + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + binding.message.isSelected = true + taskViewModel.isComplete.collect(viewLifecycleOwner) { + if (it) { + dismiss() + when (val result = taskViewModel.result.value) { + is String -> Toast.makeText( + requireContext(), + result, + Toast.LENGTH_LONG + ).show() + + is MessageDialogFragment -> result.show( + requireActivity().supportFragmentManager, + MessageDialogFragment.TAG + ) + + else -> { + // Do nothing + } + } + taskViewModel.clear() + } + } + taskViewModel.cancelled.collect(viewLifecycleOwner) { + if (it) { + dialog?.setTitle(R.string.cancelling) + } + } + taskViewModel.progress.collect(viewLifecycleOwner) { + if (it != 0.0) { + binding.progressBar.apply { + isIndeterminate = false + progress = ( + (it / taskViewModel.maxProgress.value) * + PROGRESS_BAR_RESOLUTION + ).toInt() + min = 0 + max = PROGRESS_BAR_RESOLUTION + } + } + } + taskViewModel.message.collect(viewLifecycleOwner) { + binding.message.setVisible(it.isNotEmpty()) + binding.message.text = it + } + } + + // By default, the ProgressDialog will immediately dismiss itself upon a button being pressed. + // Setting the OnClickListener again after the dialog is shown overrides this behavior. + override fun onResume() { + super.onResume() + val alertDialog = dialog as AlertDialog + val negativeButton = alertDialog.getButton(Dialog.BUTTON_NEGATIVE) + negativeButton.setOnClickListener { + alertDialog.setTitle(getString(R.string.cancelling)) + binding.progressBar.isIndeterminate = true + taskViewModel.setCancelled(true) + } + } + + companion object { + const val TAG = "IndeterminateProgressDialogFragment" + + private const val TITLE = "Title" + private const val CANCELLABLE = "Cancellable" + + fun newInstance( + activity: FragmentActivity, + titleId: Int, + cancellable: Boolean = false, + task: suspend ( + progressCallback: (max: Long, progress: Long) -> Boolean, + messageCallback: (message: String) -> Unit + ) -> Any + ): ProgressDialogFragment { + val dialog = ProgressDialogFragment() + val args = Bundle() + ViewModelProvider(activity)[TaskViewModel::class.java].task = task + args.putInt(TITLE, titleId) + args.putBoolean(CANCELLABLE, cancellable) + dialog.arguments = args + return dialog + } + } +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/fragments/ResetSettingsDialogFragment.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/fragments/ResetSettingsDialogFragment.kt new file mode 100644 index 0000000..8a82475 --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/fragments/ResetSettingsDialogFragment.kt @@ -0,0 +1,30 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.fragments + +import android.app.Dialog +import android.os.Bundle +import androidx.fragment.app.DialogFragment +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import org.sudachi.sudachi_emu.R +import org.sudachi.sudachi_emu.features.settings.ui.SettingsActivity + +class ResetSettingsDialogFragment : DialogFragment() { + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val settingsActivity = requireActivity() as SettingsActivity + + return MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.reset_all_settings) + .setMessage(R.string.reset_all_settings_description) + .setPositiveButton(android.R.string.ok) { _, _ -> + settingsActivity.onSettingsReset() + } + .setNegativeButton(android.R.string.cancel, null) + .show() + } + + companion object { + const val TAG = "ResetSettingsDialogFragment" + } +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/fragments/SearchFragment.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/fragments/SearchFragment.kt new file mode 100644 index 0000000..803e00c --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/fragments/SearchFragment.kt @@ -0,0 +1,218 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.fragments + +import android.content.Context +import android.content.SharedPreferences +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.inputmethod.InputMethodManager +import androidx.appcompat.app.AppCompatActivity +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.updatePadding +import androidx.core.widget.doOnTextChanged +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.preference.PreferenceManager +import info.debatty.java.stringsimilarity.Jaccard +import info.debatty.java.stringsimilarity.JaroWinkler +import java.util.Locale +import org.sudachi.sudachi_emu.R +import org.sudachi.sudachi_emu.SudachiApplication +import org.sudachi.sudachi_emu.adapters.GameAdapter +import org.sudachi.sudachi_emu.databinding.FragmentSearchBinding +import org.sudachi.sudachi_emu.layout.AutofitGridLayoutManager +import org.sudachi.sudachi_emu.model.Game +import org.sudachi.sudachi_emu.model.GamesViewModel +import org.sudachi.sudachi_emu.model.HomeViewModel +import org.sudachi.sudachi_emu.utils.ViewUtils.setVisible +import org.sudachi.sudachi_emu.utils.collect + +class SearchFragment : Fragment() { + private var _binding: FragmentSearchBinding? = null + private val binding get() = _binding!! + + private val gamesViewModel: GamesViewModel by activityViewModels() + private val homeViewModel: HomeViewModel by activityViewModels() + + private lateinit var preferences: SharedPreferences + + companion object { + private const val SEARCH_TEXT = "SearchText" + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentSearchBinding.inflate(layoutInflater) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + homeViewModel.setNavigationVisibility(visible = true, animated = true) + homeViewModel.setStatusBarShadeVisibility(true) + preferences = PreferenceManager.getDefaultSharedPreferences(SudachiApplication.appContext) + + if (savedInstanceState != null) { + binding.searchText.setText(savedInstanceState.getString(SEARCH_TEXT)) + } + + binding.gridGamesSearch.apply { + layoutManager = AutofitGridLayoutManager( + requireContext(), + requireContext().resources.getDimensionPixelSize(R.dimen.card_width) + ) + adapter = GameAdapter(requireActivity() as AppCompatActivity) + } + + binding.chipGroup.setOnCheckedStateChangeListener { _, _ -> filterAndSearch() } + + binding.searchText.doOnTextChanged { text: CharSequence?, _: Int, _: Int, _: Int -> + binding.clearButton.setVisible(text.toString().isNotEmpty()) + filterAndSearch() + } + + gamesViewModel.searchFocused.collect( + viewLifecycleOwner, + resetState = { gamesViewModel.setSearchFocused(false) } + ) { if (it) focusSearch() } + gamesViewModel.games.collect(viewLifecycleOwner) { filterAndSearch() } + gamesViewModel.searchedGames.collect(viewLifecycleOwner) { + (binding.gridGamesSearch.adapter as GameAdapter).submitList(it) + binding.noResultsView.setVisible(it.isNotEmpty()) + } + + binding.clearButton.setOnClickListener { binding.searchText.setText("") } + + binding.searchBackground.setOnClickListener { focusSearch() } + + setInsets() + filterAndSearch() + } + + private inner class ScoredGame(val score: Double, val item: Game) + + private fun filterAndSearch() { + val baseList = gamesViewModel.games.value + val filteredList: List = when (binding.chipGroup.checkedChipId) { + R.id.chip_recently_played -> { + baseList.filter { + val lastPlayedTime = preferences.getLong(it.keyLastPlayedTime, 0L) + lastPlayedTime > (System.currentTimeMillis() - 24 * 60 * 60 * 1000) + }.sortedByDescending { preferences.getLong(it.keyLastPlayedTime, 0L) } + } + + R.id.chip_recently_added -> { + baseList.filter { + val addedTime = preferences.getLong(it.keyAddedToLibraryTime, 0L) + addedTime > (System.currentTimeMillis() - 24 * 60 * 60 * 1000) + }.sortedByDescending { preferences.getLong(it.keyAddedToLibraryTime, 0L) } + } + + R.id.chip_homebrew -> baseList.filter { it.isHomebrew } + + R.id.chip_retail -> baseList.filter { !it.isHomebrew } + + else -> baseList + } + + if (binding.searchText.text.toString().isEmpty() && + binding.chipGroup.checkedChipId != View.NO_ID + ) { + gamesViewModel.setSearchedGames(filteredList) + return + } + + val searchTerm = binding.searchText.text.toString().lowercase(Locale.getDefault()) + val searchAlgorithm = if (searchTerm.length > 1) Jaccard(2) else JaroWinkler() + val sortedList: List = filteredList.mapNotNull { game -> + val title = game.title.lowercase(Locale.getDefault()) + val score = searchAlgorithm.similarity(searchTerm, title) + if (score > 0.03) { + ScoredGame(score, game) + } else { + null + } + }.sortedByDescending { it.score }.map { it.item } + gamesViewModel.setSearchedGames(sortedList) + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + if (_binding != null) { + outState.putString(SEARCH_TEXT, binding.searchText.text.toString()) + } + } + + private fun focusSearch() { + if (_binding != null) { + binding.searchText.requestFocus() + val imm = requireActivity() + .getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager? + imm?.showSoftInput(binding.searchText, InputMethodManager.SHOW_IMPLICIT) + } + } + + private fun setInsets() = + ViewCompat.setOnApplyWindowInsetsListener( + binding.root + ) { view: View, windowInsets: WindowInsetsCompat -> + val barInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) + val cutoutInsets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout()) + val extraListSpacing = resources.getDimensionPixelSize(R.dimen.spacing_med) + val spacingNavigation = resources.getDimensionPixelSize(R.dimen.spacing_navigation) + val spacingNavigationRail = + resources.getDimensionPixelSize(R.dimen.spacing_navigation_rail) + val chipSpacing = resources.getDimensionPixelSize(R.dimen.spacing_chip) + + binding.constraintSearch.updatePadding( + left = barInsets.left + cutoutInsets.left, + top = barInsets.top, + right = barInsets.right + cutoutInsets.right + ) + + binding.gridGamesSearch.updatePadding( + top = extraListSpacing, + bottom = barInsets.bottom + spacingNavigation + extraListSpacing + ) + binding.noResultsView.updatePadding(bottom = spacingNavigation + barInsets.bottom) + + val mlpDivider = binding.divider.layoutParams as ViewGroup.MarginLayoutParams + if (ViewCompat.getLayoutDirection(view) == ViewCompat.LAYOUT_DIRECTION_LTR) { + binding.frameSearch.updatePadding(left = spacingNavigationRail) + binding.gridGamesSearch.updatePadding(left = spacingNavigationRail) + binding.noResultsView.updatePadding(left = spacingNavigationRail) + binding.chipGroup.updatePadding( + left = chipSpacing + spacingNavigationRail, + right = chipSpacing + ) + mlpDivider.leftMargin = chipSpacing + spacingNavigationRail + mlpDivider.rightMargin = chipSpacing + } else { + binding.frameSearch.updatePadding(right = spacingNavigationRail) + binding.gridGamesSearch.updatePadding(right = spacingNavigationRail) + binding.noResultsView.updatePadding(right = spacingNavigationRail) + binding.chipGroup.updatePadding( + left = chipSpacing, + right = chipSpacing + spacingNavigationRail + ) + mlpDivider.leftMargin = chipSpacing + mlpDivider.rightMargin = chipSpacing + spacingNavigationRail + } + binding.divider.layoutParams = mlpDivider + + windowInsets + } +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/fragments/SetupFragment.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/fragments/SetupFragment.kt new file mode 100644 index 0000000..a5d504b --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/fragments/SetupFragment.kt @@ -0,0 +1,396 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.fragments + +import android.Manifest +import android.content.Intent +import android.os.Build +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.activity.OnBackPressedCallback +import androidx.activity.result.contract.ActivityResultContracts +import androidx.annotation.RequiresApi +import androidx.appcompat.app.AppCompatActivity +import androidx.core.app.NotificationManagerCompat +import androidx.core.content.ContextCompat +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.isVisible +import androidx.core.view.updatePadding +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.navigation.findNavController +import androidx.preference.PreferenceManager +import androidx.viewpager2.widget.ViewPager2.OnPageChangeCallback +import com.google.android.material.transition.MaterialFadeThrough +import kotlinx.coroutines.launch +import org.sudachi.sudachi_emu.NativeLibrary +import java.io.File +import org.sudachi.sudachi_emu.R +import org.sudachi.sudachi_emu.SudachiApplication +import org.sudachi.sudachi_emu.adapters.SetupAdapter +import org.sudachi.sudachi_emu.databinding.FragmentSetupBinding +import org.sudachi.sudachi_emu.features.settings.model.Settings +import org.sudachi.sudachi_emu.model.HomeViewModel +import org.sudachi.sudachi_emu.model.SetupCallback +import org.sudachi.sudachi_emu.model.SetupPage +import org.sudachi.sudachi_emu.model.StepState +import org.sudachi.sudachi_emu.ui.main.MainActivity +import org.sudachi.sudachi_emu.utils.DirectoryInitialization +import org.sudachi.sudachi_emu.utils.NativeConfig +import org.sudachi.sudachi_emu.utils.ViewUtils +import org.sudachi.sudachi_emu.utils.ViewUtils.setVisible +import org.sudachi.sudachi_emu.utils.collect + +class SetupFragment : Fragment() { + private var _binding: FragmentSetupBinding? = null + private val binding get() = _binding!! + + private val homeViewModel: HomeViewModel by activityViewModels() + + private lateinit var mainActivity: MainActivity + + private lateinit var hasBeenWarned: BooleanArray + + companion object { + const val KEY_NEXT_VISIBILITY = "NextButtonVisibility" + const val KEY_BACK_VISIBILITY = "BackButtonVisibility" + const val KEY_HAS_BEEN_WARNED = "HasBeenWarned" + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + exitTransition = MaterialFadeThrough() + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentSetupBinding.inflate(layoutInflater) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + mainActivity = requireActivity() as MainActivity + + homeViewModel.setNavigationVisibility(visible = false, animated = false) + + requireActivity().onBackPressedDispatcher.addCallback( + viewLifecycleOwner, + object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + if (binding.viewPager2.currentItem > 0) { + pageBackward() + } else { + requireActivity().finish() + } + } + } + ) + + requireActivity().window.navigationBarColor = + ContextCompat.getColor(requireContext(), android.R.color.transparent) + + val pages = mutableListOf() + pages.apply { + add( + SetupPage( + R.drawable.ic_sudachi_title, + R.string.welcome, + R.string.welcome_description, + 0, + true, + R.string.get_started, + { pageForward() }, + false + ) + ) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + add( + SetupPage( + R.drawable.ic_notification, + R.string.notifications, + R.string.notifications_description, + 0, + false, + R.string.give_permission, + { + notificationCallback = it + permissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) + }, + true, + R.string.notification_warning, + R.string.notification_warning_description, + 0, + { + if (NotificationManagerCompat.from(requireContext()) + .areNotificationsEnabled() + ) { + StepState.COMPLETE + } else { + StepState.INCOMPLETE + } + } + ) + ) + } + + add( + SetupPage( + R.drawable.ic_key, + R.string.keys, + R.string.keys_description, + R.drawable.ic_add, + true, + R.string.select_keys, + { + keyCallback = it + getProdKey.launch(arrayOf("*/*")) + }, + true, + R.string.install_prod_keys_warning, + R.string.install_prod_keys_warning_description, + R.string.install_prod_keys_warning_help, + { + val file = File(DirectoryInitialization.userDirectory + "/keys/prod.keys") + if (file.exists() && NativeLibrary.areKeysPresent()) { + StepState.COMPLETE + } else { + StepState.INCOMPLETE + } + } + ) + ) + add( + SetupPage( + R.drawable.ic_controller, + R.string.games, + R.string.games_description, + R.drawable.ic_add, + true, + R.string.add_games, + { + gamesDirCallback = it + getGamesDirectory.launch(Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).data) + }, + true, + R.string.add_games_warning, + R.string.add_games_warning_description, + R.string.add_games_warning_help, + { + if (NativeConfig.getGameDirs().isNotEmpty()) { + StepState.COMPLETE + } else { + StepState.INCOMPLETE + } + } + ) + ) + add( + SetupPage( + R.drawable.ic_check, + R.string.done, + R.string.done_description, + R.drawable.ic_arrow_forward, + false, + R.string.text_continue, + { finishSetup() }, + false + ) + ) + } + + homeViewModel.shouldPageForward.collect( + viewLifecycleOwner, + resetState = { homeViewModel.setShouldPageForward(false) } + ) { if (it) pageForward() } + homeViewModel.gamesDirSelected.collect( + viewLifecycleOwner, + resetState = { homeViewModel.setGamesDirSelected(false) } + ) { if (it) gamesDirCallback.onStepCompleted() } + + binding.viewPager2.apply { + adapter = SetupAdapter(requireActivity() as AppCompatActivity, pages) + offscreenPageLimit = 2 + isUserInputEnabled = false + } + + binding.viewPager2.registerOnPageChangeCallback(object : OnPageChangeCallback() { + var previousPosition: Int = 0 + + override fun onPageSelected(position: Int) { + super.onPageSelected(position) + + if (position == 1 && previousPosition == 0) { + ViewUtils.showView(binding.buttonNext) + ViewUtils.showView(binding.buttonBack) + } else if (position == 0 && previousPosition == 1) { + ViewUtils.hideView(binding.buttonBack) + ViewUtils.hideView(binding.buttonNext) + } else if (position == pages.size - 1 && previousPosition == pages.size - 2) { + ViewUtils.hideView(binding.buttonNext) + } else if (position == pages.size - 2 && previousPosition == pages.size - 1) { + ViewUtils.showView(binding.buttonNext) + } + + previousPosition = position + } + }) + + binding.buttonNext.setOnClickListener { + val index = binding.viewPager2.currentItem + val currentPage = pages[index] + + // Checks if the user has completed the task on the current page + if (currentPage.hasWarning) { + val stepState = currentPage.stepCompleted.invoke() + if (stepState != StepState.INCOMPLETE) { + pageForward() + return@setOnClickListener + } + + if (!hasBeenWarned[index]) { + SetupWarningDialogFragment.newInstance( + currentPage.warningTitleId, + currentPage.warningDescriptionId, + currentPage.warningHelpLinkId, + index + ).show(childFragmentManager, SetupWarningDialogFragment.TAG) + return@setOnClickListener + } + } + pageForward() + } + binding.buttonBack.setOnClickListener { pageBackward() } + + if (savedInstanceState != null) { + val nextIsVisible = savedInstanceState.getBoolean(KEY_NEXT_VISIBILITY) + val backIsVisible = savedInstanceState.getBoolean(KEY_BACK_VISIBILITY) + hasBeenWarned = savedInstanceState.getBooleanArray(KEY_HAS_BEEN_WARNED)!! + + binding.buttonNext.setVisible(nextIsVisible) + binding.buttonBack.setVisible(backIsVisible) + } else { + hasBeenWarned = BooleanArray(pages.size) + } + + setInsets() + } + + override fun onStop() { + super.onStop() + NativeConfig.saveGlobalConfig() + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + if (_binding != null) { + outState.putBoolean(KEY_NEXT_VISIBILITY, binding.buttonNext.isVisible) + outState.putBoolean(KEY_BACK_VISIBILITY, binding.buttonBack.isVisible) + } + outState.putBooleanArray(KEY_HAS_BEEN_WARNED, hasBeenWarned) + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } + + private lateinit var notificationCallback: SetupCallback + + @RequiresApi(Build.VERSION_CODES.TIRAMISU) + private val permissionLauncher = + registerForActivityResult(ActivityResultContracts.RequestPermission()) { + if (it) { + notificationCallback.onStepCompleted() + } + + if (!it && + !shouldShowRequestPermissionRationale(Manifest.permission.POST_NOTIFICATIONS) + ) { + PermissionDeniedDialogFragment().show( + childFragmentManager, + PermissionDeniedDialogFragment.TAG + ) + } + } + + private lateinit var keyCallback: SetupCallback + + val getProdKey = + registerForActivityResult(ActivityResultContracts.OpenDocument()) { result -> + if (result != null) { + mainActivity.processKey(result) + if (NativeLibrary.areKeysPresent()) { + keyCallback.onStepCompleted() + } + } + } + + private lateinit var gamesDirCallback: SetupCallback + + val getGamesDirectory = + registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { result -> + if (result != null) { + mainActivity.processGamesDir(result) + } + } + + private fun finishSetup() { + PreferenceManager.getDefaultSharedPreferences(SudachiApplication.appContext).edit() + .putBoolean(Settings.PREF_FIRST_APP_LAUNCH, false) + .apply() + mainActivity.finishSetup(binding.root.findNavController()) + } + + fun pageForward() { + if (_binding != null) { + binding.viewPager2.currentItem += 1 + } + } + + fun pageBackward() { + if (_binding != null) { + binding.viewPager2.currentItem -= 1 + } + } + + fun setPageWarned(page: Int) { + hasBeenWarned[page] = true + } + + private fun setInsets() = + ViewCompat.setOnApplyWindowInsetsListener( + binding.root + ) { _: View, windowInsets: WindowInsetsCompat -> + val barInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) + val cutoutInsets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout()) + + val leftPadding = barInsets.left + cutoutInsets.left + val topPadding = barInsets.top + cutoutInsets.top + val rightPadding = barInsets.right + cutoutInsets.right + val bottomPadding = barInsets.bottom + cutoutInsets.bottom + + if (resources.getBoolean(R.bool.small_layout)) { + binding.viewPager2 + .updatePadding(left = leftPadding, top = topPadding, right = rightPadding) + binding.constraintButtons + .updatePadding(left = leftPadding, right = rightPadding, bottom = bottomPadding) + } else { + binding.viewPager2.updatePadding(top = topPadding, bottom = bottomPadding) + binding.constraintButtons + .updatePadding( + left = leftPadding, + right = rightPadding, + bottom = bottomPadding + ) + } + windowInsets + } +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/fragments/SetupWarningDialogFragment.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/fragments/SetupWarningDialogFragment.kt new file mode 100644 index 0000000..f964594 --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/fragments/SetupWarningDialogFragment.kt @@ -0,0 +1,86 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.fragments + +import android.app.Dialog +import android.content.DialogInterface +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import androidx.fragment.app.DialogFragment +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import org.sudachi.sudachi_emu.R + +class SetupWarningDialogFragment : DialogFragment() { + private var titleId: Int = 0 + private var descriptionId: Int = 0 + private var helpLinkId: Int = 0 + private var page: Int = 0 + + private lateinit var setupFragment: SetupFragment + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + titleId = requireArguments().getInt(TITLE) + descriptionId = requireArguments().getInt(DESCRIPTION) + helpLinkId = requireArguments().getInt(HELP_LINK) + page = requireArguments().getInt(PAGE) + + setupFragment = requireParentFragment() as SetupFragment + } + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val builder = MaterialAlertDialogBuilder(requireContext()) + .setPositiveButton(R.string.warning_skip) { _: DialogInterface?, _: Int -> + setupFragment.pageForward() + setupFragment.setPageWarned(page) + } + .setNegativeButton(R.string.warning_cancel, null) + + if (titleId != 0) { + builder.setTitle(titleId) + } else { + builder.setTitle("") + } + if (descriptionId != 0) { + builder.setMessage(descriptionId) + } + if (helpLinkId != 0) { + builder.setNeutralButton(R.string.warning_help) { _: DialogInterface?, _: Int -> + val helpLink = resources.getString(R.string.install_prod_keys_warning_help) + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(helpLink)) + startActivity(intent) + } + } + + return builder.show() + } + + companion object { + const val TAG = "SetupWarningDialogFragment" + + private const val TITLE = "Title" + private const val DESCRIPTION = "Description" + private const val HELP_LINK = "HelpLink" + private const val PAGE = "Page" + + fun newInstance( + titleId: Int, + descriptionId: Int, + helpLinkId: Int, + page: Int + ): SetupWarningDialogFragment { + val dialog = SetupWarningDialogFragment() + val bundle = Bundle() + bundle.apply { + putInt(TITLE, titleId) + putInt(DESCRIPTION, descriptionId) + putInt(HELP_LINK, helpLinkId) + putInt(PAGE, page) + } + dialog.arguments = bundle + return dialog + } + } +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/layout/AutofitGridLayoutManager.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/layout/AutofitGridLayoutManager.kt new file mode 100644 index 0000000..aedda0e --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/layout/AutofitGridLayoutManager.kt @@ -0,0 +1,63 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.layout + +import android.content.Context +import androidx.recyclerview.widget.GridLayoutManager +import androidx.recyclerview.widget.RecyclerView +import androidx.recyclerview.widget.RecyclerView.Recycler +import org.sudachi.sudachi_emu.R + +/** + * Cut down version of the solution provided here + * https://stackoverflow.com/questions/26666143/recyclerview-gridlayoutmanager-how-to-auto-detect-span-count + */ +class AutofitGridLayoutManager( + context: Context, + columnWidth: Int +) : GridLayoutManager(context, 1) { + private var columnWidth = 0 + private var isColumnWidthChanged = true + private var lastWidth = 0 + private var lastHeight = 0 + + init { + setColumnWidth(checkedColumnWidth(context, columnWidth)) + } + + private fun checkedColumnWidth(context: Context, columnWidth: Int): Int { + var newColumnWidth = columnWidth + if (newColumnWidth <= 0) { + newColumnWidth = context.resources.getDimensionPixelSize(R.dimen.spacing_xtralarge) + } + return newColumnWidth + } + + private fun setColumnWidth(newColumnWidth: Int) { + if (newColumnWidth > 0 && newColumnWidth != columnWidth) { + columnWidth = newColumnWidth + isColumnWidthChanged = true + } + } + + override fun onLayoutChildren(recycler: Recycler, state: RecyclerView.State) { + val width = width + val height = height + if (columnWidth > 0 && width > 0 && height > 0 && + (isColumnWidthChanged || lastWidth != width || lastHeight != height) + ) { + val totalSpace: Int = if (orientation == VERTICAL) { + width - paddingRight - paddingLeft + } else { + height - paddingTop - paddingBottom + } + val spanCount = 1.coerceAtLeast(totalSpace / columnWidth) + setSpanCount(spanCount) + isColumnWidthChanged = false + } + lastWidth = width + lastHeight = height + super.onLayoutChildren(recycler, state) + } +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/model/Addon.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/model/Addon.kt new file mode 100644 index 0000000..bedbc37 --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/model/Addon.kt @@ -0,0 +1,10 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.model + +data class Addon( + var enabled: Boolean, + val title: String, + val version: String +) diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/model/AddonViewModel.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/model/AddonViewModel.kt new file mode 100644 index 0000000..b83ad2f --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/model/AddonViewModel.kt @@ -0,0 +1,97 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.model + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.sudachi.sudachi_emu.NativeLibrary +import org.sudachi.sudachi_emu.utils.NativeConfig +import java.util.concurrent.atomic.AtomicBoolean + +class AddonViewModel : ViewModel() { + private val _patchList = MutableStateFlow(mutableListOf()) + val addonList get() = _patchList.asStateFlow() + + private val _showModInstallPicker = MutableStateFlow(false) + val showModInstallPicker get() = _showModInstallPicker.asStateFlow() + + private val _showModNoticeDialog = MutableStateFlow(false) + val showModNoticeDialog get() = _showModNoticeDialog.asStateFlow() + + private val _addonToDelete = MutableStateFlow(null) + val addonToDelete = _addonToDelete.asStateFlow() + + var game: Game? = null + + private val isRefreshing = AtomicBoolean(false) + + fun onOpenAddons(game: Game) { + this.game = game + refreshAddons() + } + + fun refreshAddons() { + if (isRefreshing.get() || game == null) { + return + } + isRefreshing.set(true) + viewModelScope.launch { + withContext(Dispatchers.IO) { + val patchList = ( + NativeLibrary.getPatchesForFile(game!!.path, game!!.programId) + ?: emptyArray() + ).toMutableList() + patchList.sortBy { it.name } + _patchList.value = patchList + isRefreshing.set(false) + } + } + } + + fun setAddonToDelete(patch: Patch?) { + _addonToDelete.value = patch + } + + fun onDeleteAddon(patch: Patch) { + when (PatchType.from(patch.type)) { + PatchType.Update -> NativeLibrary.removeUpdate(patch.programId) + PatchType.DLC -> NativeLibrary.removeDLC(patch.programId) + PatchType.Mod -> NativeLibrary.removeMod(patch.programId, patch.name) + } + refreshAddons() + } + + fun onCloseAddons() { + if (_patchList.value.isEmpty()) { + return + } + + NativeConfig.setDisabledAddons( + game!!.programId, + _patchList.value.mapNotNull { + if (it.enabled) { + null + } else { + it.name + } + }.toTypedArray() + ) + NativeConfig.saveGlobalConfig() + _patchList.value.clear() + game = null + } + + fun showModInstallPicker(install: Boolean) { + _showModInstallPicker.value = install + } + + fun showModNoticeDialog(show: Boolean) { + _showModNoticeDialog.value = show + } +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/model/Applet.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/model/Applet.kt new file mode 100644 index 0000000..a7c9aef --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/model/Applet.kt @@ -0,0 +1,55 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.model + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import org.sudachi.sudachi_emu.R + +data class Applet( + @StringRes val titleId: Int, + @StringRes val descriptionId: Int, + @DrawableRes val iconId: Int, + val appletInfo: AppletInfo, + val cabinetMode: CabinetMode = CabinetMode.None +) + +// Combination of Common::AM::Applets::AppletId enum and the entry id +enum class AppletInfo(val appletId: Int, val entryId: Long = 0) { + None(0x00), + Application(0x01), + OverlayDisplay(0x02), + QLaunch(0x03), + Starter(0x04), + Auth(0x0A), + Cabinet(0x0B, 0x0100000000001002), + Controller(0x0C), + DataErase(0x0D), + Error(0x0E), + NetConnect(0x0F), + ProfileSelect(0x10), + SoftwareKeyboard(0x11), + MiiEdit(0x12, 0x0100000000001009), + Web(0x13), + Shop(0x14), + PhotoViewer(0x015, 0x010000000000100D), + Settings(0x16), + OfflineWeb(0x17), + LoginShare(0x18), + WebAuth(0x19), + MyPage(0x1A) +} + +// Matches enum in Service::NFP::CabinetMode with extra metadata +enum class CabinetMode( + val id: Int, + @StringRes val titleId: Int = 0, + @DrawableRes val iconId: Int = 0 +) { + None(-1), + StartNicknameAndOwnerSettings(0, R.string.cabinet_nickname_and_owner, R.drawable.ic_edit), + StartGameDataEraser(1, R.string.cabinet_game_data_eraser, R.drawable.ic_refresh), + StartRestorer(2, R.string.cabinet_restorer, R.drawable.ic_restore), + StartFormatter(3, R.string.cabinet_formatter, R.drawable.ic_clear) +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/model/Driver.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/model/Driver.kt new file mode 100644 index 0000000..756fa25 --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/model/Driver.kt @@ -0,0 +1,27 @@ +// SPDX-FileCopyrightText: 2024 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.model + +import org.sudachi.sudachi_emu.utils.GpuDriverMetadata + +data class Driver( + override var selected: Boolean, + val title: String, + val version: String = "", + val description: String = "" +) : SelectableItem { + override fun onSelectionStateChanged(selected: Boolean) { + this.selected = selected + } + + companion object { + fun GpuDriverMetadata.toDriver(selected: Boolean = false): Driver = + Driver( + selected, + this.name ?: "", + this.version ?: "", + this.description ?: "" + ) + } +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/model/DriverViewModel.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/model/DriverViewModel.kt new file mode 100644 index 0000000..de2604a --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/model/DriverViewModel.kt @@ -0,0 +1,196 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.model + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.sudachi.sudachi_emu.R +import org.sudachi.sudachi_emu.SudachiApplication +import org.sudachi.sudachi_emu.features.settings.model.StringSetting +import org.sudachi.sudachi_emu.features.settings.utils.SettingsFile +import org.sudachi.sudachi_emu.model.Driver.Companion.toDriver +import org.sudachi.sudachi_emu.utils.GpuDriverHelper +import org.sudachi.sudachi_emu.utils.GpuDriverMetadata +import org.sudachi.sudachi_emu.utils.NativeConfig +import java.io.File + +class DriverViewModel : ViewModel() { + private val _areDriversLoading = MutableStateFlow(false) + private val _isDriverReady = MutableStateFlow(true) + private val _isDeletingDrivers = MutableStateFlow(false) + + val isInteractionAllowed: StateFlow = + combine( + _areDriversLoading, + _isDriverReady, + _isDeletingDrivers + ) { loading, ready, deleting -> + !loading && ready && !deleting + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), initialValue = false) + + var driverData = GpuDriverHelper.getDrivers() + + private val _driverList = MutableStateFlow(emptyList()) + val driverList: StateFlow> get() = _driverList + + // Used for showing which driver is currently installed within the driver manager card + private val _selectedDriverTitle = MutableStateFlow("") + val selectedDriverTitle: StateFlow get() = _selectedDriverTitle + + private val _showClearButton = MutableStateFlow(false) + val showClearButton = _showClearButton.asStateFlow() + + private val driversToDelete = mutableListOf() + + init { + updateDriverList() + updateDriverNameForGame(null) + } + + fun reloadDriverData() { + _areDriversLoading.value = true + driverData = GpuDriverHelper.getDrivers() + updateDriverList() + _areDriversLoading.value = false + } + + fun updateDriverList() { + val selectedDriver = GpuDriverHelper.customDriverSettingData + val systemDriverData = GpuDriverHelper.getSystemDriverInfo() + val newDriverList = mutableListOf( + Driver( + selectedDriver == GpuDriverMetadata(), + SudachiApplication.appContext.getString(R.string.system_gpu_driver), + systemDriverData?.get(0) ?: "", + systemDriverData?.get(1) ?: "" + ) + ) + driverData.forEach { + newDriverList.add(it.second.toDriver(it.second == selectedDriver)) + } + _driverList.value = newDriverList + } + + fun onOpenDriverManager(game: Game?) { + if (game != null) { + SettingsFile.loadCustomConfig(game) + } + updateDriverList() + } + + fun showClearButton(value: Boolean) { + _showClearButton.value = value + } + + fun onDriverSelected(position: Int) { + if (position == 0) { + StringSetting.DRIVER_PATH.setString("") + } else { + StringSetting.DRIVER_PATH.setString(driverData[position - 1].first) + } + } + + fun onDriverRemoved(removedPosition: Int, selectedPosition: Int) { + driversToDelete.add(driverData[removedPosition - 1].first) + driverData.removeAt(removedPosition - 1) + onDriverSelected(selectedPosition) + } + + fun onDriverAdded(driver: Pair) { + if (driversToDelete.contains(driver.first)) { + driversToDelete.remove(driver.first) + } + driverData.add(driver) + onDriverSelected(driverData.size) + } + + fun onCloseDriverManager(game: Game?) { + _isDeletingDrivers.value = true + updateDriverNameForGame(game) + if (game == null) { + NativeConfig.saveGlobalConfig() + } else { + NativeConfig.savePerGameConfig() + NativeConfig.unloadPerGameConfig() + NativeConfig.reloadGlobalConfig() + } + + viewModelScope.launch { + withContext(Dispatchers.IO) { + driversToDelete.forEach { + val driver = File(it) + if (driver.exists()) { + driver.delete() + } + } + driversToDelete.clear() + _isDeletingDrivers.value = false + } + } + } + + // It is the Emulation Fragment's responsibility to load per-game settings so that this function + // knows what driver to load. + fun onLaunchGame() { + _isDriverReady.value = false + + val selectedDriverFile = File(StringSetting.DRIVER_PATH.getString()) + val selectedDriverMetadata = GpuDriverHelper.customDriverSettingData + if (GpuDriverHelper.installedCustomDriverData == selectedDriverMetadata) { + setDriverReady() + return + } + + viewModelScope.launch { + withContext(Dispatchers.IO) { + if (selectedDriverMetadata.name == null) { + GpuDriverHelper.installDefaultDriver() + setDriverReady() + return@withContext + } + + if (selectedDriverFile.exists()) { + GpuDriverHelper.installCustomDriver(selectedDriverFile) + } else { + GpuDriverHelper.installDefaultDriver() + } + setDriverReady() + } + } + } + + fun updateDriverNameForGame(game: Game?) { + if (!GpuDriverHelper.supportsCustomDriverLoading()) { + return + } + + if (game == null || NativeConfig.isPerGameConfigLoaded()) { + updateName() + } else { + SettingsFile.loadCustomConfig(game) + updateName() + NativeConfig.unloadPerGameConfig() + NativeConfig.reloadGlobalConfig() + } + } + + private fun updateName() { + _selectedDriverTitle.value = GpuDriverHelper.customDriverSettingData.name + ?: SudachiApplication.appContext.getString(R.string.system_gpu_driver) + } + + private fun setDriverReady() { + _isDriverReady.value = true + updateName() + } +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/model/EmulationViewModel.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/model/EmulationViewModel.kt new file mode 100644 index 0000000..5595c32 --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/model/EmulationViewModel.kt @@ -0,0 +1,76 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +package org.sudachi.sudachi_emu.model + +import androidx.lifecycle.ViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +class EmulationViewModel : ViewModel() { + val emulationStarted: StateFlow get() = _emulationStarted + private val _emulationStarted = MutableStateFlow(false) + + val isEmulationStopping: StateFlow get() = _isEmulationStopping + private val _isEmulationStopping = MutableStateFlow(false) + + private val _emulationStopped = MutableStateFlow(false) + val emulationStopped = _emulationStopped.asStateFlow() + + private val _programChanged = MutableStateFlow(-1) + val programChanged = _programChanged.asStateFlow() + + val shaderProgress: StateFlow get() = _shaderProgress + private val _shaderProgress = MutableStateFlow(0) + + val totalShaders: StateFlow get() = _totalShaders + private val _totalShaders = MutableStateFlow(0) + + val shaderMessage: StateFlow get() = _shaderMessage + private val _shaderMessage = MutableStateFlow("") + + private val _drawerOpen = MutableStateFlow(false) + val drawerOpen = _drawerOpen.asStateFlow() + + fun setEmulationStarted(started: Boolean) { + _emulationStarted.value = started + } + + fun setIsEmulationStopping(value: Boolean) { + _isEmulationStopping.value = value + } + + fun setEmulationStopped(value: Boolean) { + if (value) { + _emulationStarted.value = false + } + _emulationStopped.value = value + } + + fun setProgramChanged(programIndex: Int) { + _programChanged.value = programIndex + } + + fun setShaderProgress(progress: Int) { + _shaderProgress.value = progress + } + + fun setTotalShaders(max: Int) { + _totalShaders.value = max + } + + fun setShaderMessage(msg: String) { + _shaderMessage.value = msg + } + + fun updateProgress(msg: String, progress: Int, max: Int) { + setShaderMessage(msg) + setShaderProgress(progress) + setTotalShaders(max) + } + + fun setDrawerOpen(value: Boolean) { + _drawerOpen.value = value + } +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/model/Game.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/model/Game.kt new file mode 100644 index 0000000..3d7fd2f --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/model/Game.kt @@ -0,0 +1,103 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.model + +import android.content.Intent +import android.net.Uri +import android.os.Parcelable +import java.util.HashSet +import kotlinx.parcelize.Parcelize +import kotlinx.serialization.Serializable +import org.sudachi.sudachi_emu.NativeLibrary +import org.sudachi.sudachi_emu.R +import org.sudachi.sudachi_emu.SudachiApplication +import org.sudachi.sudachi_emu.activities.EmulationActivity +import org.sudachi.sudachi_emu.utils.DirectoryInitialization +import org.sudachi.sudachi_emu.utils.FileUtil +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter + +@Parcelize +@Serializable +class Game( + val title: String = "", + val path: String, + val programId: String = "", + val developer: String = "", + var version: String = "", + val isHomebrew: Boolean = false +) : Parcelable { + val keyAddedToLibraryTime get() = "${path}_AddedToLibraryTime" + val keyLastPlayedTime get() = "${path}_LastPlayed" + + val settingsName: String + get() { + val programIdLong = programId.toLong() + return if (programIdLong == 0L) { + FileUtil.getFilename(Uri.parse(path)) + } else { + "0" + programIdLong.toString(16).uppercase() + } + } + + val programIdHex: String + get() { + val programIdLong = programId.toLong() + return if (programIdLong == 0L) { + "0" + } else { + "0" + programIdLong.toString(16).uppercase() + } + } + + val saveZipName: String + get() = "$title ${SudachiApplication.appContext.getString(R.string.save_data).lowercase()} - ${ + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")) + }.zip" + + val saveDir: String + get() = DirectoryInitialization.userDirectory + "/nand" + + NativeLibrary.getSavePath(programId) + + val addonDir: String + get() = DirectoryInitialization.userDirectory + "/load/" + programIdHex + "/" + + val launchIntent: Intent + get() = Intent(SudachiApplication.appContext, EmulationActivity::class.java).apply { + action = Intent.ACTION_VIEW + data = Uri.parse(path) + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as Game + + if (title != other.title) return false + if (path != other.path) return false + if (programId != other.programId) return false + if (developer != other.developer) return false + if (version != other.version) return false + if (isHomebrew != other.isHomebrew) return false + + return true + } + + override fun hashCode(): Int { + var result = title.hashCode() + result = 31 * result + path.hashCode() + result = 31 * result + programId.hashCode() + result = 31 * result + developer.hashCode() + result = 31 * result + version.hashCode() + result = 31 * result + isHomebrew.hashCode() + return result + } + + companion object { + val extensions: Set = HashSet( + listOf("xci", "nsp", "nca", "nro") + ) + } +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/model/GameDir.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/model/GameDir.kt new file mode 100644 index 0000000..6095848 --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/model/GameDir.kt @@ -0,0 +1,13 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.model + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +data class GameDir( + val uriString: String, + var deepScan: Boolean +) : Parcelable diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/model/GameProperties.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/model/GameProperties.kt new file mode 100644 index 0000000..a218631 --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/model/GameProperties.kt @@ -0,0 +1,36 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.model + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import kotlinx.coroutines.flow.StateFlow + +interface GameProperty { + @get:StringRes + val titleId: Int + + @get:StringRes + val descriptionId: Int + + @get:DrawableRes + val iconId: Int +} + +data class SubmenuProperty( + override val titleId: Int, + override val descriptionId: Int, + override val iconId: Int, + val details: (() -> String)? = null, + val detailsFlow: StateFlow? = null, + val action: () -> Unit +) : GameProperty + +data class InstallableProperty( + override val titleId: Int, + override val descriptionId: Int, + override val iconId: Int, + val install: (() -> Unit)? = null, + val export: (() -> Unit)? = null +) : GameProperty diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/model/GameVerificationResult.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/model/GameVerificationResult.kt new file mode 100644 index 0000000..aff32ff --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/model/GameVerificationResult.kt @@ -0,0 +1,15 @@ +// SPDX-FileCopyrightText: 2024 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.model + +enum class GameVerificationResult(val int: Int) { + Success(0), + Failed(1), + NotImplemented(2); + + companion object { + fun from(int: Int): GameVerificationResult = + entries.firstOrNull { it.int == int } ?: Success + } +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/model/GamesViewModel.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/model/GamesViewModel.kt new file mode 100644 index 0000000..eb3c532 --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/model/GamesViewModel.kt @@ -0,0 +1,186 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.model + +import android.net.Uri +import androidx.documentfile.provider.DocumentFile +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.preference.PreferenceManager +import java.util.Locale +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.json.Json +import org.sudachi.sudachi_emu.NativeLibrary +import org.sudachi.sudachi_emu.SudachiApplication +import org.sudachi.sudachi_emu.utils.GameHelper +import org.sudachi.sudachi_emu.utils.NativeConfig +import java.util.concurrent.atomic.AtomicBoolean + +class GamesViewModel : ViewModel() { + val games: StateFlow> get() = _games + private val _games = MutableStateFlow(emptyList()) + + val searchedGames: StateFlow> get() = _searchedGames + private val _searchedGames = MutableStateFlow(emptyList()) + + val isReloading: StateFlow get() = _isReloading + private val _isReloading = MutableStateFlow(false) + + private val reloading = AtomicBoolean(false) + + val shouldSwapData: StateFlow get() = _shouldSwapData + private val _shouldSwapData = MutableStateFlow(false) + + val shouldScrollToTop: StateFlow get() = _shouldScrollToTop + private val _shouldScrollToTop = MutableStateFlow(false) + + val searchFocused: StateFlow get() = _searchFocused + private val _searchFocused = MutableStateFlow(false) + + private val _folders = MutableStateFlow(mutableListOf()) + val folders = _folders.asStateFlow() + + init { + // Ensure keys are loaded so that ROM metadata can be decrypted. + NativeLibrary.reloadKeys() + + getGameDirs() + reloadGames(directoriesChanged = false, firstStartup = true) + } + + fun setGames(games: List) { + val sortedList = games.sortedWith( + compareBy( + { it.title.lowercase(Locale.getDefault()) }, + { it.path } + ) + ) + + _games.value = sortedList + } + + fun setSearchedGames(games: List) { + _searchedGames.value = games + } + + fun setShouldSwapData(shouldSwap: Boolean) { + _shouldSwapData.value = shouldSwap + } + + fun setShouldScrollToTop(shouldScroll: Boolean) { + _shouldScrollToTop.value = shouldScroll + } + + fun setSearchFocused(searchFocused: Boolean) { + _searchFocused.value = searchFocused + } + + fun reloadGames(directoriesChanged: Boolean, firstStartup: Boolean = false) { + if (reloading.get()) { + return + } + reloading.set(true) + _isReloading.value = true + + viewModelScope.launch { + withContext(Dispatchers.IO) { + if (firstStartup) { + // Retrieve list of cached games + val storedGames = + PreferenceManager.getDefaultSharedPreferences(SudachiApplication.appContext) + .getStringSet(GameHelper.KEY_GAMES, emptySet()) + if (storedGames!!.isNotEmpty()) { + val deserializedGames = mutableSetOf() + storedGames.forEach { + val game: Game + try { + game = Json.decodeFromString(it) + } catch (e: Exception) { + // We don't care about any errors related to parsing the game cache + return@forEach + } + + val gameExists = + DocumentFile.fromSingleUri( + SudachiApplication.appContext, + Uri.parse(game.path) + )?.exists() + if (gameExists == true) { + deserializedGames.add(game) + } + } + setGames(deserializedGames.toList()) + } + } + + setGames(GameHelper.getGames()) + reloading.set(false) + _isReloading.value = false + + if (directoriesChanged) { + setShouldSwapData(true) + } + } + } + } + + fun addFolder(gameDir: GameDir) = + viewModelScope.launch { + withContext(Dispatchers.IO) { + NativeConfig.addGameDir(gameDir) + getGameDirs(true) + } + } + + fun removeFolder(gameDir: GameDir) = + viewModelScope.launch { + withContext(Dispatchers.IO) { + val gameDirs = _folders.value.toMutableList() + val removedDirIndex = gameDirs.indexOf(gameDir) + if (removedDirIndex != -1) { + gameDirs.removeAt(removedDirIndex) + NativeConfig.setGameDirs(gameDirs.toTypedArray()) + getGameDirs() + } + } + } + + fun updateGameDirs() = + viewModelScope.launch { + withContext(Dispatchers.IO) { + NativeConfig.setGameDirs(_folders.value.toTypedArray()) + getGameDirs() + } + } + + fun onOpenGameFoldersFragment() = + viewModelScope.launch { + withContext(Dispatchers.IO) { + getGameDirs() + } + } + + fun onCloseGameFoldersFragment() { + NativeConfig.saveGlobalConfig() + viewModelScope.launch { + withContext(Dispatchers.IO) { + getGameDirs(true) + } + } + } + + private fun getGameDirs(reloadList: Boolean = false) { + val gameDirs = NativeConfig.getGameDirs() + _folders.value = gameDirs.toMutableList() + if (reloadList) { + reloadGames(true) + } + } +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/model/HomeSetting.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/model/HomeSetting.kt new file mode 100644 index 0000000..dc9aa5e --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/model/HomeSetting.kt @@ -0,0 +1,18 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.model + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +data class HomeSetting( + val titleId: Int, + val descriptionId: Int, + val iconId: Int, + val onClick: () -> Unit, + val isEnabled: () -> Boolean = { true }, + val disabledTitleId: Int = 0, + val disabledMessageId: Int = 0, + val details: StateFlow = MutableStateFlow("") +) diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/model/HomeViewModel.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/model/HomeViewModel.kt new file mode 100644 index 0000000..d003e36 --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/model/HomeViewModel.kt @@ -0,0 +1,76 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +package org.sudachi.sudachi_emu.model + +import android.net.Uri +import androidx.lifecycle.ViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +class HomeViewModel : ViewModel() { + val navigationVisible: StateFlow> get() = _navigationVisible + private val _navigationVisible = MutableStateFlow(Pair(false, false)) + + val statusBarShadeVisible: StateFlow get() = _statusBarShadeVisible + private val _statusBarShadeVisible = MutableStateFlow(true) + + val shouldPageForward: StateFlow get() = _shouldPageForward + private val _shouldPageForward = MutableStateFlow(false) + + private val _gamesDirSelected = MutableStateFlow(false) + val gamesDirSelected get() = _gamesDirSelected.asStateFlow() + + private val _openImportSaves = MutableStateFlow(false) + val openImportSaves get() = _openImportSaves.asStateFlow() + + private val _contentToInstall = MutableStateFlow?>(null) + val contentToInstall get() = _contentToInstall.asStateFlow() + + private val _reloadPropertiesList = MutableStateFlow(false) + val reloadPropertiesList get() = _reloadPropertiesList.asStateFlow() + + private val _checkKeys = MutableStateFlow(false) + val checkKeys = _checkKeys.asStateFlow() + + var navigatedToSetup = false + + fun setNavigationVisibility(visible: Boolean, animated: Boolean) { + if (navigationVisible.value.first == visible) { + return + } + _navigationVisible.value = Pair(visible, animated) + } + + fun setStatusBarShadeVisibility(visible: Boolean) { + if (statusBarShadeVisible.value == visible) { + return + } + _statusBarShadeVisible.value = visible + } + + fun setShouldPageForward(pageForward: Boolean) { + _shouldPageForward.value = pageForward + } + + fun setGamesDirSelected(selected: Boolean) { + _gamesDirSelected.value = selected + } + + fun setOpenImportSaves(import: Boolean) { + _openImportSaves.value = import + } + + fun setContentToInstall(documents: List?) { + _contentToInstall.value = documents + } + + fun reloadPropertiesList(reload: Boolean) { + _reloadPropertiesList.value = reload + } + + fun setCheckKeys(value: Boolean) { + _checkKeys.value = value + } +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/model/InstallResult.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/model/InstallResult.kt new file mode 100644 index 0000000..c736305 --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/model/InstallResult.kt @@ -0,0 +1,15 @@ +// SPDX-FileCopyrightText: 2024 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.model + +enum class InstallResult(val int: Int) { + Success(0), + Overwrite(1), + Failure(2), + BaseInstallAttempted(3); + + companion object { + fun from(int: Int): InstallResult = entries.firstOrNull { it.int == int } ?: Success + } +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/model/Installable.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/model/Installable.kt new file mode 100644 index 0000000..669f4c8 --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/model/Installable.kt @@ -0,0 +1,13 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.model + +import androidx.annotation.StringRes + +data class Installable( + @StringRes val titleId: Int, + @StringRes val descriptionId: Int, + val install: (() -> Unit)? = null, + val export: (() -> Unit)? = null +) diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/model/License.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/model/License.kt new file mode 100644 index 0000000..24f2800 --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/model/License.kt @@ -0,0 +1,16 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.model + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +data class License( + val titleId: Int, + val descriptionId: Int, + val linkId: Int, + val copyrightId: Int, + val licenseId: Int +) : Parcelable diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/model/MessageDialogViewModel.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/model/MessageDialogViewModel.kt new file mode 100644 index 0000000..2452d1f --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/model/MessageDialogViewModel.kt @@ -0,0 +1,16 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.model + +import androidx.lifecycle.ViewModel + +class MessageDialogViewModel : ViewModel() { + var positiveAction: (() -> Unit)? = null + var negativeAction: (() -> Unit)? = null + + fun clear() { + positiveAction = null + negativeAction = null + } +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/model/MinimalDocumentFile.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/model/MinimalDocumentFile.kt new file mode 100644 index 0000000..521f99e --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/model/MinimalDocumentFile.kt @@ -0,0 +1,11 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.model + +import android.net.Uri +import android.provider.DocumentsContract + +class MinimalDocumentFile(val filename: String, mimeType: String, val uri: Uri) { + val isDirectory: Boolean = mimeType == DocumentsContract.Document.MIME_TYPE_DIR +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/model/Patch.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/model/Patch.kt new file mode 100644 index 0000000..b556efb --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/model/Patch.kt @@ -0,0 +1,16 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.model + +import androidx.annotation.Keep + +@Keep +data class Patch( + var enabled: Boolean, + val name: String, + val version: String, + val type: Int, + val programId: String, + val titleId: String +) diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/model/PatchType.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/model/PatchType.kt new file mode 100644 index 0000000..305d75d --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/model/PatchType.kt @@ -0,0 +1,14 @@ +// SPDX-FileCopyrightText: 2024 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.model + +enum class PatchType(val int: Int) { + Update(0), + DLC(1), + Mod(2); + + companion object { + fun from(int: Int): PatchType = entries.firstOrNull { it.int == int } ?: Update + } +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/model/SelectableItem.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/model/SelectableItem.kt new file mode 100644 index 0000000..c24819a --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/model/SelectableItem.kt @@ -0,0 +1,9 @@ +// SPDX-FileCopyrightText: 2024 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.model + +interface SelectableItem { + var selected: Boolean + fun onSelectionStateChanged(selected: Boolean) +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/model/SettingsViewModel.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/model/SettingsViewModel.kt new file mode 100644 index 0000000..b4ee78e --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/model/SettingsViewModel.kt @@ -0,0 +1,71 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.model + +import androidx.lifecycle.ViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import org.sudachi.sudachi_emu.R +import org.sudachi.sudachi_emu.SudachiApplication +import org.sudachi.sudachi_emu.features.settings.model.view.SettingsItem + +class SettingsViewModel : ViewModel() { + var game: Game? = null + + var clickedItem: SettingsItem? = null + + val shouldRecreate: StateFlow get() = _shouldRecreate + private val _shouldRecreate = MutableStateFlow(false) + + val shouldNavigateBack: StateFlow get() = _shouldNavigateBack + private val _shouldNavigateBack = MutableStateFlow(false) + + val shouldShowResetSettingsDialog: StateFlow get() = _shouldShowResetSettingsDialog + private val _shouldShowResetSettingsDialog = MutableStateFlow(false) + + val shouldReloadSettingsList: StateFlow get() = _shouldReloadSettingsList + private val _shouldReloadSettingsList = MutableStateFlow(false) + + val sliderProgress: StateFlow get() = _sliderProgress + private val _sliderProgress = MutableStateFlow(-1) + + val sliderTextValue: StateFlow get() = _sliderTextValue + private val _sliderTextValue = MutableStateFlow("") + + val adapterItemChanged: StateFlow get() = _adapterItemChanged + private val _adapterItemChanged = MutableStateFlow(-1) + + fun setShouldRecreate(value: Boolean) { + _shouldRecreate.value = value + } + + fun setShouldNavigateBack(value: Boolean) { + _shouldNavigateBack.value = value + } + + fun setShouldShowResetSettingsDialog(value: Boolean) { + _shouldShowResetSettingsDialog.value = value + } + + fun setShouldReloadSettingsList(value: Boolean) { + _shouldReloadSettingsList.value = value + } + + fun setSliderTextValue(value: Float, units: String) { + _sliderProgress.value = value.toInt() + _sliderTextValue.value = String.format( + SudachiApplication.appContext.getString(R.string.value_with_units), + value.toInt().toString(), + units + ) + } + + fun setSliderProgress(value: Float) { + _sliderProgress.value = value.toInt() + } + + fun setAdapterItemChanged(value: Int) { + _adapterItemChanged.value = value + } +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/model/SetupPage.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/model/SetupPage.kt new file mode 100644 index 0000000..3f6ad5c --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/model/SetupPage.kt @@ -0,0 +1,29 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.model + +data class SetupPage( + val iconId: Int, + val titleId: Int, + val descriptionId: Int, + val buttonIconId: Int, + val leftAlignedIcon: Boolean, + val buttonTextId: Int, + val buttonAction: (callback: SetupCallback) -> Unit, + val hasWarning: Boolean, + val warningTitleId: Int = 0, + val warningDescriptionId: Int = 0, + val warningHelpLinkId: Int = 0, + val stepCompleted: () -> StepState = { StepState.UNDEFINED } +) + +interface SetupCallback { + fun onStepCompleted() +} + +enum class StepState { + COMPLETE, + INCOMPLETE, + UNDEFINED +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/model/TaskViewModel.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/model/TaskViewModel.kt new file mode 100644 index 0000000..f90e678 --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/model/TaskViewModel.kt @@ -0,0 +1,83 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.model + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +class TaskViewModel : ViewModel() { + val result: StateFlow get() = _result + private val _result = MutableStateFlow(Any()) + + val isComplete: StateFlow get() = _isComplete + private val _isComplete = MutableStateFlow(false) + + val isRunning: StateFlow get() = _isRunning + private val _isRunning = MutableStateFlow(false) + + val cancelled: StateFlow get() = _cancelled + private val _cancelled = MutableStateFlow(false) + + private val _progress = MutableStateFlow(0.0) + val progress = _progress.asStateFlow() + + private val _maxProgress = MutableStateFlow(0.0) + val maxProgress = _maxProgress.asStateFlow() + + private val _message = MutableStateFlow("") + val message = _message.asStateFlow() + + lateinit var task: suspend ( + progressCallback: (max: Long, progress: Long) -> Boolean, + messageCallback: (message: String) -> Unit + ) -> Any + + fun clear() { + _result.value = Any() + _isComplete.value = false + _isRunning.value = false + _cancelled.value = false + _progress.value = 0.0 + _maxProgress.value = 0.0 + _message.value = "" + } + + fun setCancelled(value: Boolean) { + _cancelled.value = value + } + + fun runTask() { + if (isRunning.value) { + return + } + _isRunning.value = true + + viewModelScope.launch(Dispatchers.IO) { + val res = task( + { max, progress -> + _maxProgress.value = max.toDouble() + _progress.value = progress.toDouble() + return@task cancelled.value + }, + { message -> + _message.value = message + } + ) + _result.value = res + _isComplete.value = true + _isRunning.value = false + } + } +} + +enum class TaskState { + Completed, + Failed, + Cancelled +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/overlay/InputOverlay.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/overlay/InputOverlay.kt new file mode 100644 index 0000000..850cbf9 --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/overlay/InputOverlay.kt @@ -0,0 +1,1049 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.overlay + +import android.app.Activity +import android.content.Context +import android.content.SharedPreferences +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Point +import android.graphics.Rect +import android.graphics.drawable.Drawable +import android.graphics.drawable.VectorDrawable +import android.os.Build +import android.util.AttributeSet +import android.view.HapticFeedbackConstants +import android.view.MotionEvent +import android.view.SurfaceView +import android.view.View +import android.view.View.OnTouchListener +import android.view.WindowInsets +import androidx.core.content.ContextCompat +import androidx.window.layout.WindowMetricsCalculator +import kotlin.math.max +import kotlin.math.min +import org.sudachi.sudachi_emu.features.input.NativeInput +import org.sudachi.sudachi_emu.R +import org.sudachi.sudachi_emu.features.input.model.NativeAnalog +import org.sudachi.sudachi_emu.features.input.model.NativeButton +import org.sudachi.sudachi_emu.features.input.model.NpadStyleIndex +import org.sudachi.sudachi_emu.features.settings.model.BooleanSetting +import org.sudachi.sudachi_emu.features.settings.model.IntSetting +import org.sudachi.sudachi_emu.overlay.model.OverlayControl +import org.sudachi.sudachi_emu.overlay.model.OverlayControlData +import org.sudachi.sudachi_emu.overlay.model.OverlayLayout +import org.sudachi.sudachi_emu.utils.NativeConfig + +/** + * Draws the interactive input overlay on top of the + * [SurfaceView] that is rendering emulation. + */ +class InputOverlay(context: Context, attrs: AttributeSet?) : + SurfaceView(context, attrs), + OnTouchListener { + private val overlayButtons: MutableSet = HashSet() + private val overlayDpads: MutableSet = HashSet() + private val overlayJoysticks: MutableSet = HashSet() + + private var inEditMode = false + private var buttonBeingConfigured: InputOverlayDrawableButton? = null + private var dpadBeingConfigured: InputOverlayDrawableDpad? = null + private var joystickBeingConfigured: InputOverlayDrawableJoystick? = null + + private lateinit var windowInsets: WindowInsets + + var layout = OverlayLayout.Landscape + + override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) { + super.onLayout(changed, left, top, right, bottom) + + windowInsets = rootWindowInsets + + val overlayControlData = NativeConfig.getOverlayControlData() + if (overlayControlData.isEmpty()) { + populateDefaultConfig() + } else { + checkForNewControls(overlayControlData) + } + + // Load the controls. + refreshControls() + + // Set the on touch listener. + setOnTouchListener(this) + + // Force draw + setWillNotDraw(false) + + // Request focus for the overlay so it has priority on presses. + requestFocus() + } + + override fun draw(canvas: Canvas) { + super.draw(canvas) + for (button in overlayButtons) { + button.draw(canvas) + } + for (dpad in overlayDpads) { + dpad.draw(canvas) + } + for (joystick in overlayJoysticks) { + joystick.draw(canvas) + } + } + + override fun onTouch(v: View, event: MotionEvent): Boolean { + if (inEditMode) { + return onTouchWhileEditing(event) + } + + var shouldUpdateView = false + val playerIndex = when (NativeInput.getStyleIndex(0)) { + NpadStyleIndex.Handheld -> 8 + else -> 0 + } + + for (button in overlayButtons) { + if (!button.updateStatus(event)) { + continue + } + NativeInput.onOverlayButtonEvent( + playerIndex, + button.button, + button.status + ) + playHaptics(event) + shouldUpdateView = true + } + + for (dpad in overlayDpads) { + if (!dpad.updateStatus(event, BooleanSetting.DPAD_SLIDE.getBoolean())) { + continue + } + NativeInput.onOverlayButtonEvent( + playerIndex, + dpad.up, + dpad.upStatus + ) + NativeInput.onOverlayButtonEvent( + playerIndex, + dpad.down, + dpad.downStatus + ) + NativeInput.onOverlayButtonEvent( + playerIndex, + dpad.left, + dpad.leftStatus + ) + NativeInput.onOverlayButtonEvent( + playerIndex, + dpad.right, + dpad.rightStatus + ) + playHaptics(event) + shouldUpdateView = true + } + + for (joystick in overlayJoysticks) { + if (!joystick.updateStatus(event)) { + continue + } + NativeInput.onOverlayJoystickEvent( + playerIndex, + joystick.joystick, + joystick.xAxis, + joystick.realYAxis + ) + NativeInput.onOverlayButtonEvent( + playerIndex, + joystick.button, + joystick.buttonStatus + ) + playHaptics(event) + shouldUpdateView = true + } + + if (shouldUpdateView) { + invalidate() + } + + if (!BooleanSetting.TOUCHSCREEN.getBoolean()) { + return true + } + + val pointerIndex = event.actionIndex + val xPosition = event.getX(pointerIndex).toInt() + val yPosition = event.getY(pointerIndex).toInt() + val pointerId = event.getPointerId(pointerIndex) + val motionEvent = event.action and MotionEvent.ACTION_MASK + val isActionDown = + motionEvent == MotionEvent.ACTION_DOWN || motionEvent == MotionEvent.ACTION_POINTER_DOWN + val isActionMove = motionEvent == MotionEvent.ACTION_MOVE + val isActionUp = + motionEvent == MotionEvent.ACTION_UP || motionEvent == MotionEvent.ACTION_POINTER_UP + + if (isActionDown && !isTouchInputConsumed(pointerId)) { + NativeInput.onTouchPressed(pointerId, xPosition.toFloat(), yPosition.toFloat()) + } + + if (isActionMove) { + for (i in 0 until event.pointerCount) { + val fingerId = event.getPointerId(i) + if (isTouchInputConsumed(fingerId)) { + continue + } + NativeInput.onTouchMoved(fingerId, event.getX(i), event.getY(i)) + } + } + + if (isActionUp && !isTouchInputConsumed(pointerId)) { + NativeInput.onTouchReleased(pointerId) + } + + return true + } + + private fun playHaptics(event: MotionEvent) { + if (BooleanSetting.HAPTIC_FEEDBACK.getBoolean()) { + when (event.actionMasked) { + MotionEvent.ACTION_DOWN, + MotionEvent.ACTION_POINTER_DOWN -> + performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY) + + MotionEvent.ACTION_UP, + MotionEvent.ACTION_POINTER_UP -> + performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY_RELEASE) + } + } + } + + private fun isTouchInputConsumed(track_id: Int): Boolean { + for (button in overlayButtons) { + if (button.trackId == track_id) { + return true + } + } + for (dpad in overlayDpads) { + if (dpad.trackId == track_id) { + return true + } + } + for (joystick in overlayJoysticks) { + if (joystick.trackId == track_id) { + return true + } + } + return false + } + + private fun onTouchWhileEditing(event: MotionEvent): Boolean { + val pointerIndex = event.actionIndex + val fingerPositionX = event.getX(pointerIndex).toInt() + val fingerPositionY = event.getY(pointerIndex).toInt() + + for (button in overlayButtons) { + // Determine the button state to apply based on the MotionEvent action flag. + when (event.action and MotionEvent.ACTION_MASK) { + MotionEvent.ACTION_DOWN, + MotionEvent.ACTION_POINTER_DOWN -> + // If no button is being moved now, remember the currently touched button to move. + if (buttonBeingConfigured == null && + button.bounds.contains(fingerPositionX, fingerPositionY) + ) { + buttonBeingConfigured = button + buttonBeingConfigured!!.onConfigureTouch(event) + } + + MotionEvent.ACTION_MOVE -> if (buttonBeingConfigured != null) { + buttonBeingConfigured!!.onConfigureTouch(event) + invalidate() + return true + } + + MotionEvent.ACTION_UP, + MotionEvent.ACTION_POINTER_UP -> if (buttonBeingConfigured === button) { + // Persist button position by saving new place. + saveControlPosition( + buttonBeingConfigured!!.overlayControlData.id, + buttonBeingConfigured!!.bounds.centerX(), + buttonBeingConfigured!!.bounds.centerY(), + layout + ) + buttonBeingConfigured = null + } + } + } + + for (dpad in overlayDpads) { + // Determine the button state to apply based on the MotionEvent action flag. + when (event.action and MotionEvent.ACTION_MASK) { + MotionEvent.ACTION_DOWN, + MotionEvent.ACTION_POINTER_DOWN -> + // If no button is being moved now, remember the currently touched button to move. + if (buttonBeingConfigured == null && + dpad.bounds.contains(fingerPositionX, fingerPositionY) + ) { + dpadBeingConfigured = dpad + dpadBeingConfigured!!.onConfigureTouch(event) + } + + MotionEvent.ACTION_MOVE -> if (dpadBeingConfigured != null) { + dpadBeingConfigured!!.onConfigureTouch(event) + invalidate() + return true + } + + MotionEvent.ACTION_UP, + MotionEvent.ACTION_POINTER_UP -> if (dpadBeingConfigured === dpad) { + // Persist button position by saving new place. + saveControlPosition( + OverlayControl.COMBINED_DPAD.id, + dpadBeingConfigured!!.bounds.centerX(), + dpadBeingConfigured!!.bounds.centerY(), + layout + ) + dpadBeingConfigured = null + } + } + } + + for (joystick in overlayJoysticks) { + when (event.action) { + MotionEvent.ACTION_DOWN, + MotionEvent.ACTION_POINTER_DOWN -> if (joystickBeingConfigured == null && + joystick.bounds.contains(fingerPositionX, fingerPositionY) + ) { + joystickBeingConfigured = joystick + joystickBeingConfigured!!.onConfigureTouch(event) + } + + MotionEvent.ACTION_MOVE -> if (joystickBeingConfigured != null) { + joystickBeingConfigured!!.onConfigureTouch(event) + invalidate() + } + + MotionEvent.ACTION_UP, + MotionEvent.ACTION_POINTER_UP -> if (joystickBeingConfigured != null) { + saveControlPosition( + joystickBeingConfigured!!.prefId, + joystickBeingConfigured!!.bounds.centerX(), + joystickBeingConfigured!!.bounds.centerY(), + layout + ) + joystickBeingConfigured = null + } + } + } + + return true + } + + private fun addOverlayControls(layout: OverlayLayout) { + val windowSize = getSafeScreenSize(context, Pair(measuredWidth, measuredHeight)) + val overlayControlData = NativeConfig.getOverlayControlData() + for (data in overlayControlData) { + if (!data.enabled) { + continue + } + + val position = data.positionFromLayout(layout) + when (data.id) { + OverlayControl.BUTTON_A.id -> { + overlayButtons.add( + initializeOverlayButton( + context, + windowSize, + R.drawable.facebutton_a, + R.drawable.facebutton_a_depressed, + NativeButton.A, + data, + position + ) + ) + } + + OverlayControl.BUTTON_B.id -> { + overlayButtons.add( + initializeOverlayButton( + context, + windowSize, + R.drawable.facebutton_b, + R.drawable.facebutton_b_depressed, + NativeButton.B, + data, + position + ) + ) + } + + OverlayControl.BUTTON_X.id -> { + overlayButtons.add( + initializeOverlayButton( + context, + windowSize, + R.drawable.facebutton_x, + R.drawable.facebutton_x_depressed, + NativeButton.X, + data, + position + ) + ) + } + + OverlayControl.BUTTON_Y.id -> { + overlayButtons.add( + initializeOverlayButton( + context, + windowSize, + R.drawable.facebutton_y, + R.drawable.facebutton_y_depressed, + NativeButton.Y, + data, + position + ) + ) + } + + OverlayControl.BUTTON_PLUS.id -> { + overlayButtons.add( + initializeOverlayButton( + context, + windowSize, + R.drawable.facebutton_plus, + R.drawable.facebutton_plus_depressed, + NativeButton.Plus, + data, + position + ) + ) + } + + OverlayControl.BUTTON_MINUS.id -> { + overlayButtons.add( + initializeOverlayButton( + context, + windowSize, + R.drawable.facebutton_minus, + R.drawable.facebutton_minus_depressed, + NativeButton.Minus, + data, + position + ) + ) + } + + OverlayControl.BUTTON_HOME.id -> { + overlayButtons.add( + initializeOverlayButton( + context, + windowSize, + R.drawable.facebutton_home, + R.drawable.facebutton_home_depressed, + NativeButton.Home, + data, + position + ) + ) + } + + OverlayControl.BUTTON_CAPTURE.id -> { + overlayButtons.add( + initializeOverlayButton( + context, + windowSize, + R.drawable.facebutton_screenshot, + R.drawable.facebutton_screenshot_depressed, + NativeButton.Capture, + data, + position + ) + ) + } + + OverlayControl.BUTTON_L.id -> { + overlayButtons.add( + initializeOverlayButton( + context, + windowSize, + R.drawable.l_shoulder, + R.drawable.l_shoulder_depressed, + NativeButton.L, + data, + position + ) + ) + } + + OverlayControl.BUTTON_R.id -> { + overlayButtons.add( + initializeOverlayButton( + context, + windowSize, + R.drawable.r_shoulder, + R.drawable.r_shoulder_depressed, + NativeButton.R, + data, + position + ) + ) + } + + OverlayControl.BUTTON_ZL.id -> { + overlayButtons.add( + initializeOverlayButton( + context, + windowSize, + R.drawable.zl_trigger, + R.drawable.zl_trigger_depressed, + NativeButton.ZL, + data, + position + ) + ) + } + + OverlayControl.BUTTON_ZR.id -> { + overlayButtons.add( + initializeOverlayButton( + context, + windowSize, + R.drawable.zr_trigger, + R.drawable.zr_trigger_depressed, + NativeButton.ZR, + data, + position + ) + ) + } + + OverlayControl.BUTTON_STICK_L.id -> { + overlayButtons.add( + initializeOverlayButton( + context, + windowSize, + R.drawable.button_l3, + R.drawable.button_l3_depressed, + NativeButton.LStick, + data, + position + ) + ) + } + + OverlayControl.BUTTON_STICK_R.id -> { + overlayButtons.add( + initializeOverlayButton( + context, + windowSize, + R.drawable.button_r3, + R.drawable.button_r3_depressed, + NativeButton.RStick, + data, + position + ) + ) + } + + OverlayControl.STICK_L.id -> { + overlayJoysticks.add( + initializeOverlayJoystick( + context, + windowSize, + R.drawable.joystick_range, + R.drawable.joystick, + R.drawable.joystick_depressed, + NativeAnalog.LStick, + NativeButton.LStick, + data, + position + ) + ) + } + + OverlayControl.STICK_R.id -> { + overlayJoysticks.add( + initializeOverlayJoystick( + context, + windowSize, + R.drawable.joystick_range, + R.drawable.joystick, + R.drawable.joystick_depressed, + NativeAnalog.RStick, + NativeButton.RStick, + data, + position + ) + ) + } + + OverlayControl.COMBINED_DPAD.id -> { + overlayDpads.add( + initializeOverlayDpad( + context, + windowSize, + R.drawable.dpad_standard, + R.drawable.dpad_standard_cardinal_depressed, + R.drawable.dpad_standard_diagonal_depressed, + position + ) + ) + } + } + } + } + + fun refreshControls() { + // Remove all the overlay buttons from the HashSet. + overlayButtons.clear() + overlayDpads.clear() + overlayJoysticks.clear() + + // Add all the enabled overlay items back to the HashSet. + if (BooleanSetting.SHOW_INPUT_OVERLAY.getBoolean()) { + addOverlayControls(layout) + } + invalidate() + } + + private fun saveControlPosition(id: String, x: Int, y: Int, layout: OverlayLayout) { + val windowSize = getSafeScreenSize(context, Pair(measuredWidth, measuredHeight)) + val min = windowSize.first + val max = windowSize.second + val overlayControlData = NativeConfig.getOverlayControlData() + val data = overlayControlData.firstOrNull { it.id == id } + val newPosition = Pair((x - min.x).toDouble() / max.x, (y - min.y).toDouble() / max.y) + when (layout) { + OverlayLayout.Landscape -> data?.landscapePosition = newPosition + OverlayLayout.Portrait -> data?.portraitPosition = newPosition + OverlayLayout.Foldable -> data?.foldablePosition = newPosition + } + NativeConfig.setOverlayControlData(overlayControlData) + } + + fun setIsInEditMode(editMode: Boolean) { + inEditMode = editMode + } + + /** + * Applies and saves all default values for the overlay + */ + private fun populateDefaultConfig() { + val newConfig = OverlayControl.entries.map { it.toOverlayControlData() } + NativeConfig.setOverlayControlData(newConfig.toTypedArray()) + NativeConfig.saveGlobalConfig() + } + + /** + * Checks if any new controls were added to OverlayControl that do not exist within deserialized + * config and adds / saves them if necessary + * + * @param overlayControlData Overlay control data from [NativeConfig.getOverlayControlData] + */ + private fun checkForNewControls(overlayControlData: Array) { + val missingControls = mutableListOf() + OverlayControl.entries.forEach { defaultControl -> + val controlData = overlayControlData.firstOrNull { it.id == defaultControl.id } + if (controlData == null) { + missingControls.add(defaultControl.toOverlayControlData()) + } + } + + if (missingControls.isNotEmpty()) { + NativeConfig.setOverlayControlData( + arrayOf(*overlayControlData, *(missingControls.toTypedArray())) + ) + NativeConfig.saveGlobalConfig() + } + } + + fun resetLayoutVisibilityAndPlacement() { + defaultOverlayPositionByLayout(layout) + + val overlayControlData = NativeConfig.getOverlayControlData() + overlayControlData.forEach { + it.enabled = OverlayControl.from(it.id)?.defaultVisibility == true + } + NativeConfig.setOverlayControlData(overlayControlData) + + refreshControls() + } + + private fun defaultOverlayPositionByLayout(layout: OverlayLayout) { + val overlayControlData = NativeConfig.getOverlayControlData() + for (data in overlayControlData) { + val defaultControlData = OverlayControl.from(data.id) ?: continue + val position = defaultControlData.getDefaultPositionForLayout(layout) + when (layout) { + OverlayLayout.Landscape -> data.landscapePosition = position + OverlayLayout.Portrait -> data.portraitPosition = position + OverlayLayout.Foldable -> data.foldablePosition = position + } + } + NativeConfig.setOverlayControlData(overlayControlData) + } + + override fun isInEditMode(): Boolean { + return inEditMode + } + + companion object { + // Increase this number every time there is a breaking change to every overlay layout + const val OVERLAY_VERSION = 1 + + // Increase the corresponding layout version number whenever that layout has a breaking change + private const val LANDSCAPE_OVERLAY_VERSION = 1 + private const val PORTRAIT_OVERLAY_VERSION = 1 + private const val FOLDABLE_OVERLAY_VERSION = 1 + val overlayLayoutVersions = listOf( + LANDSCAPE_OVERLAY_VERSION, + PORTRAIT_OVERLAY_VERSION, + FOLDABLE_OVERLAY_VERSION + ) + + /** + * Resizes a [Bitmap] by a given scale factor + * + * @param context Context for getting the vector drawable + * @param drawableId The ID of the drawable to scale. + * @param scale The scale factor for the bitmap. + * @return The scaled [Bitmap] + */ + private fun getBitmap(context: Context, drawableId: Int, scale: Float): Bitmap { + val vectorDrawable = ContextCompat.getDrawable(context, drawableId) as VectorDrawable + + val bitmap = Bitmap.createBitmap( + (vectorDrawable.intrinsicWidth * scale).toInt(), + (vectorDrawable.intrinsicHeight * scale).toInt(), + Bitmap.Config.ARGB_8888 + ) + + val dm = context.resources.displayMetrics + val minScreenDimension = min(dm.widthPixels, dm.heightPixels) + + val maxBitmapDimension = max(bitmap.width, bitmap.height) + val bitmapScale = scale * minScreenDimension / maxBitmapDimension + + val scaledBitmap = Bitmap.createScaledBitmap( + bitmap, + (bitmap.width * bitmapScale).toInt(), + (bitmap.height * bitmapScale).toInt(), + true + ) + + val canvas = Canvas(scaledBitmap) + vectorDrawable.setBounds(0, 0, canvas.width, canvas.height) + vectorDrawable.draw(canvas) + return scaledBitmap + } + + /** + * Gets the safe screen size for drawing the overlay + * + * @param context Context for getting the window metrics + * @return A pair of points, the first being the top left corner of the safe area, + * the second being the bottom right corner of the safe area + */ + private fun getSafeScreenSize( + context: Context, + screenSize: Pair + ): Pair { + // Get screen size + val windowMetrics = WindowMetricsCalculator.getOrCreate() + .computeCurrentWindowMetrics(context as Activity) + var maxX = screenSize.first.toFloat() + var maxY = screenSize.second.toFloat() + var minX = 0 + var minY = 0 + + // If we have API access, calculate the safe area to draw the overlay + var cutoutLeft = 0 + var cutoutBottom = 0 + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + val insets = context.windowManager.currentWindowMetrics.windowInsets.displayCutout + if (insets != null) { + if (insets.boundingRectTop.bottom != 0 && + insets.boundingRectTop.bottom > maxY / 2 + ) { + maxY = insets.boundingRectTop.bottom.toFloat() + } + if (insets.boundingRectRight.left != 0 && + insets.boundingRectRight.left > maxX / 2 + ) { + maxX = insets.boundingRectRight.left.toFloat() + } + + minX = insets.boundingRectLeft.right - insets.boundingRectLeft.left + minY = insets.boundingRectBottom.top - insets.boundingRectBottom.bottom + + cutoutLeft = insets.boundingRectRight.right - insets.boundingRectRight.left + cutoutBottom = insets.boundingRectTop.top - insets.boundingRectTop.bottom + } + } + + // This makes sure that if we have an inset on one side of the screen, we mirror it on + // the other side. Since removing space from one of the max values messes with the scale, + // we also have to account for it using our min values. + if (maxX.toInt() != windowMetrics.bounds.width()) minX += cutoutLeft + if (maxY.toInt() != windowMetrics.bounds.height()) minY += cutoutBottom + if (minX > 0 && maxX.toInt() == windowMetrics.bounds.width()) { + maxX -= (minX * 2) + } else if (minX > 0) { + maxX -= minX + } + if (minY > 0 && maxY.toInt() == windowMetrics.bounds.height()) { + maxY -= (minY * 2) + } else if (minY > 0) { + maxY -= minY + } + + return Pair(Point(minX, minY), Point(maxX.toInt(), maxY.toInt())) + } + + /** + * Initializes an InputOverlayDrawableButton, given by resId, with all of the + * parameters set for it to be properly shown on the InputOverlay. + * + * + * This works due to the way the X and Y coordinates are stored within + * the [SharedPreferences]. + * + * + * In the input overlay configuration menu, + * once a touch event begins and then ends (ie. Organizing the buttons to one's own liking for the overlay). + * the X and Y coordinates of the button at the END of its touch event + * (when you remove your finger/stylus from the touchscreen) are then stored in a native . + * + * Technically no modifications should need to be performed on the returned + * InputOverlayDrawableButton. Simply add it to the HashSet of overlay items and wait + * for Android to call the onDraw method. + * + * @param context The current [Context]. + * @param windowSize The size of the window to draw the overlay on. + * @param defaultResId The resource ID of the [Drawable] to get the [Bitmap] of (Default State). + * @param pressedResId The resource ID of the [Drawable] to get the [Bitmap] of (Pressed State). + * @param buttonId Identifier for determining what type of button the initialized InputOverlayDrawableButton represents. + * @param overlayControlData Identifier for determining where a button appears on screen. + * @param position The position on screen as represented by an x and y value between 0 and 1. + * @return An [InputOverlayDrawableButton] with the correct drawing bounds set. + */ + private fun initializeOverlayButton( + context: Context, + windowSize: Pair, + defaultResId: Int, + pressedResId: Int, + button: NativeButton, + overlayControlData: OverlayControlData, + position: Pair + ): InputOverlayDrawableButton { + // Resources handle for fetching the initial Drawable resource. + val res = context.resources + + // Decide scale based on button preference ID and user preference + var scale: Float = when (overlayControlData.id) { + OverlayControl.BUTTON_HOME.id, + OverlayControl.BUTTON_CAPTURE.id, + OverlayControl.BUTTON_PLUS.id, + OverlayControl.BUTTON_MINUS.id -> 0.07f + + OverlayControl.BUTTON_L.id, + OverlayControl.BUTTON_R.id, + OverlayControl.BUTTON_ZL.id, + OverlayControl.BUTTON_ZR.id -> 0.26f + + OverlayControl.BUTTON_STICK_L.id, + OverlayControl.BUTTON_STICK_R.id -> 0.155f + + else -> 0.11f + } + scale *= (IntSetting.OVERLAY_SCALE.getInt() + 50).toFloat() + scale /= 100f + + // Initialize the InputOverlayDrawableButton. + val defaultStateBitmap = getBitmap(context, defaultResId, scale) + val pressedStateBitmap = getBitmap(context, pressedResId, scale) + val overlayDrawable = InputOverlayDrawableButton( + res, + defaultStateBitmap, + pressedStateBitmap, + button, + overlayControlData + ) + + // Get the minimum and maximum coordinates of the screen where the button can be placed. + val min = windowSize.first + val max = windowSize.second + + // The X and Y coordinates of the InputOverlayDrawableButton on the InputOverlay. + // These were set in the input overlay configuration menu. + val drawableX = (position.first * max.x + min.x).toInt() + val drawableY = (position.second * max.y + min.y).toInt() + val width = overlayDrawable.width + val height = overlayDrawable.height + + // Now set the bounds for the InputOverlayDrawableButton. + // This will dictate where on the screen (and the what the size) the InputOverlayDrawableButton will be. + overlayDrawable.setBounds( + drawableX - (width / 2), + drawableY - (height / 2), + drawableX + (width / 2), + drawableY + (height / 2) + ) + + // Need to set the image's position + overlayDrawable.setPosition( + drawableX - (width / 2), + drawableY - (height / 2) + ) + overlayDrawable.setOpacity(IntSetting.OVERLAY_OPACITY.getInt() * 255 / 100) + return overlayDrawable + } + + /** + * Initializes an [InputOverlayDrawableDpad] + * + * @param context The current [Context]. + * @param windowSize The size of the window to draw the overlay on. + * @param defaultResId The [Bitmap] resource ID of the default state. + * @param pressedOneDirectionResId The [Bitmap] resource ID of the pressed state in one direction. + * @param pressedTwoDirectionsResId The [Bitmap] resource ID of the pressed state in two directions. + * @param position The position on screen as represented by an x and y value between 0 and 1. + * @return The initialized [InputOverlayDrawableDpad] + */ + private fun initializeOverlayDpad( + context: Context, + windowSize: Pair, + defaultResId: Int, + pressedOneDirectionResId: Int, + pressedTwoDirectionsResId: Int, + position: Pair + ): InputOverlayDrawableDpad { + // Resources handle for fetching the initial Drawable resource. + val res = context.resources + + // Decide scale based on button ID and user preference + var scale = 0.25f + scale *= (IntSetting.OVERLAY_SCALE.getInt() + 50).toFloat() + scale /= 100f + + // Initialize the InputOverlayDrawableDpad. + val defaultStateBitmap = + getBitmap(context, defaultResId, scale) + val pressedOneDirectionStateBitmap = getBitmap(context, pressedOneDirectionResId, scale) + val pressedTwoDirectionsStateBitmap = + getBitmap(context, pressedTwoDirectionsResId, scale) + + val overlayDrawable = InputOverlayDrawableDpad( + res, + defaultStateBitmap, + pressedOneDirectionStateBitmap, + pressedTwoDirectionsStateBitmap + ) + + // Get the minimum and maximum coordinates of the screen where the button can be placed. + val min = windowSize.first + val max = windowSize.second + + // The X and Y coordinates of the InputOverlayDrawableDpad on the InputOverlay. + // These were set in the input overlay configuration menu. + val drawableX = (position.first * max.x + min.x).toInt() + val drawableY = (position.second * max.y + min.y).toInt() + val width = overlayDrawable.width + val height = overlayDrawable.height + + // Now set the bounds for the InputOverlayDrawableDpad. + // This will dictate where on the screen (and the what the size) the InputOverlayDrawableDpad will be. + overlayDrawable.setBounds( + drawableX - (width / 2), + drawableY - (height / 2), + drawableX + (width / 2), + drawableY + (height / 2) + ) + + // Need to set the image's position + overlayDrawable.setPosition(drawableX - (width / 2), drawableY - (height / 2)) + overlayDrawable.setOpacity(IntSetting.OVERLAY_OPACITY.getInt() * 255 / 100) + return overlayDrawable + } + + /** + * Initializes an [InputOverlayDrawableJoystick] + * + * @param context The current [Context] + * @param windowSize The size of the window to draw the overlay on. + * @param resOuter Resource ID for the outer image of the joystick (the static image that shows the circular bounds). + * @param defaultResInner Resource ID for the default inner image of the joystick (the one you actually move around). + * @param pressedResInner Resource ID for the pressed inner image of the joystick. + * @param joystick Identifier for which joystick this is. + * @param buttonId Identifier for which joystick button this is. + * @param overlayControlData Identifier for determining where a button appears on screen. + * @param position The position on screen as represented by an x and y value between 0 and 1. + * @return The initialized [InputOverlayDrawableJoystick]. + */ + private fun initializeOverlayJoystick( + context: Context, + windowSize: Pair, + resOuter: Int, + defaultResInner: Int, + pressedResInner: Int, + joystick: NativeAnalog, + button: NativeButton, + overlayControlData: OverlayControlData, + position: Pair + ): InputOverlayDrawableJoystick { + // Resources handle for fetching the initial Drawable resource. + val res = context.resources + + // Decide scale based on user preference + var scale = 0.3f + scale *= (IntSetting.OVERLAY_SCALE.getInt() + 50).toFloat() + scale /= 100f + + // Initialize the InputOverlayDrawableJoystick. + val bitmapOuter = getBitmap(context, resOuter, scale) + val bitmapInnerDefault = getBitmap(context, defaultResInner, 1.0f) + val bitmapInnerPressed = getBitmap(context, pressedResInner, 1.0f) + + // Get the minimum and maximum coordinates of the screen where the button can be placed. + val min = windowSize.first + val max = windowSize.second + + // The X and Y coordinates of the InputOverlayDrawableButton on the InputOverlay. + // These were set in the input overlay configuration menu. + val drawableX = (position.first * max.x + min.x).toInt() + val drawableY = (position.second * max.y + min.y).toInt() + val outerScale = 1.66f + + // Now set the bounds for the InputOverlayDrawableJoystick. + // This will dictate where on the screen (and the what the size) the InputOverlayDrawableJoystick will be. + val outerSize = bitmapOuter.width + val outerRect = Rect( + drawableX - (outerSize / 2), + drawableY - (outerSize / 2), + drawableX + (outerSize / 2), + drawableY + (outerSize / 2) + ) + val innerRect = + Rect(0, 0, (outerSize / outerScale).toInt(), (outerSize / outerScale).toInt()) + + // Send the drawableId to the joystick so it can be referenced when saving control position. + val overlayDrawable = InputOverlayDrawableJoystick( + res, + bitmapOuter, + bitmapInnerDefault, + bitmapInnerPressed, + outerRect, + innerRect, + joystick, + button, + overlayControlData.id + ) + + // Need to set the image's position + overlayDrawable.setPosition(drawableX, drawableY) + overlayDrawable.setOpacity(IntSetting.OVERLAY_OPACITY.getInt() * 255 / 100) + return overlayDrawable + } + } +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/overlay/InputOverlayDrawableButton.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/overlay/InputOverlayDrawableButton.kt new file mode 100644 index 0000000..4c5bc1a --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/overlay/InputOverlayDrawableButton.kt @@ -0,0 +1,151 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.overlay + +import android.content.res.Resources +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Rect +import android.graphics.drawable.BitmapDrawable +import android.view.MotionEvent +import org.sudachi.sudachi_emu.features.input.NativeInput.ButtonState +import org.sudachi.sudachi_emu.features.input.model.NativeButton +import org.sudachi.sudachi_emu.overlay.model.OverlayControlData + +/** + * Custom [BitmapDrawable] that is capable + * of storing it's own ID. + * + * @param res [Resources] instance. + * @param defaultStateBitmap [Bitmap] to use with the default state Drawable. + * @param pressedStateBitmap [Bitmap] to use with the pressed state Drawable. + * @param button [NativeButton] for this type of button. + */ +class InputOverlayDrawableButton( + res: Resources, + defaultStateBitmap: Bitmap, + pressedStateBitmap: Bitmap, + val button: NativeButton, + val overlayControlData: OverlayControlData +) { + // The ID value what motion event is tracking + var trackId: Int + + // The drawable position on the screen + private var buttonPositionX = 0 + private var buttonPositionY = 0 + + val width: Int + val height: Int + + private val defaultStateBitmap: BitmapDrawable + private val pressedStateBitmap: BitmapDrawable + private var pressedState = false + + private var previousTouchX = 0 + private var previousTouchY = 0 + var controlPositionX = 0 + var controlPositionY = 0 + + init { + this.defaultStateBitmap = BitmapDrawable(res, defaultStateBitmap) + this.pressedStateBitmap = BitmapDrawable(res, pressedStateBitmap) + trackId = -1 + width = this.defaultStateBitmap.intrinsicWidth + height = this.defaultStateBitmap.intrinsicHeight + } + + /** + * Updates button status based on the motion event. + * + * @return true if value was changed + */ + fun updateStatus(event: MotionEvent): Boolean { + val pointerIndex = event.actionIndex + val xPosition = event.getX(pointerIndex).toInt() + val yPosition = event.getY(pointerIndex).toInt() + val pointerId = event.getPointerId(pointerIndex) + val motionEvent = event.action and MotionEvent.ACTION_MASK + val isActionDown = + motionEvent == MotionEvent.ACTION_DOWN || motionEvent == MotionEvent.ACTION_POINTER_DOWN + val isActionUp = + motionEvent == MotionEvent.ACTION_UP || motionEvent == MotionEvent.ACTION_POINTER_UP + + if (isActionDown) { + if (!bounds.contains(xPosition, yPosition)) { + return false + } + pressedState = true + trackId = pointerId + return true + } + + if (isActionUp) { + if (trackId != pointerId) { + return false + } + pressedState = false + trackId = -1 + return true + } + + return false + } + + fun setPosition(x: Int, y: Int) { + buttonPositionX = x + buttonPositionY = y + } + + fun draw(canvas: Canvas?) { + currentStateBitmapDrawable.draw(canvas!!) + } + + private val currentStateBitmapDrawable: BitmapDrawable + get() = if (pressedState) pressedStateBitmap else defaultStateBitmap + + fun onConfigureTouch(event: MotionEvent): Boolean { + val pointerIndex = event.actionIndex + val fingerPositionX = event.getX(pointerIndex).toInt() + val fingerPositionY = event.getY(pointerIndex).toInt() + + when (event.action) { + MotionEvent.ACTION_DOWN -> { + previousTouchX = fingerPositionX + previousTouchY = fingerPositionY + controlPositionX = fingerPositionX - (width / 2) + controlPositionY = fingerPositionY - (height / 2) + } + + MotionEvent.ACTION_MOVE -> { + controlPositionX += fingerPositionX - previousTouchX + controlPositionY += fingerPositionY - previousTouchY + setBounds( + controlPositionX, + controlPositionY, + width + controlPositionX, + height + controlPositionY + ) + previousTouchX = fingerPositionX + previousTouchY = fingerPositionY + } + } + return true + } + + fun setBounds(left: Int, top: Int, right: Int, bottom: Int) { + defaultStateBitmap.setBounds(left, top, right, bottom) + pressedStateBitmap.setBounds(left, top, right, bottom) + } + + fun setOpacity(value: Int) { + defaultStateBitmap.alpha = value + pressedStateBitmap.alpha = value + } + + val status: Int + get() = if (pressedState) ButtonState.PRESSED else ButtonState.RELEASED + val bounds: Rect + get() = defaultStateBitmap.bounds +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/overlay/InputOverlayDrawableDpad.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/overlay/InputOverlayDrawableDpad.kt new file mode 100644 index 0000000..82ba281 --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/overlay/InputOverlayDrawableDpad.kt @@ -0,0 +1,266 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.overlay + +import android.content.res.Resources +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Rect +import android.graphics.drawable.BitmapDrawable +import android.view.MotionEvent +import org.sudachi.sudachi_emu.features.input.NativeInput.ButtonState +import org.sudachi.sudachi_emu.features.input.model.NativeButton + +/** + * Custom [BitmapDrawable] that is capable + * of storing it's own ID. + * + * @param res [Resources] instance. + * @param defaultStateBitmap [Bitmap] of the default state. + * @param pressedOneDirectionStateBitmap [Bitmap] of the pressed state in one direction. + * @param pressedTwoDirectionsStateBitmap [Bitmap] of the pressed state in two direction. + */ +class InputOverlayDrawableDpad( + res: Resources, + defaultStateBitmap: Bitmap, + pressedOneDirectionStateBitmap: Bitmap, + pressedTwoDirectionsStateBitmap: Bitmap +) { + /** + * Gets one of the InputOverlayDrawableDpad's button IDs. + * + * @return the requested InputOverlayDrawableDpad's button ID. + */ + // The ID identifying what type of button this Drawable represents. + val up = NativeButton.DUp + val down = NativeButton.DDown + val left = NativeButton.DLeft + val right = NativeButton.DRight + var trackId: Int + + val width: Int + val height: Int + + private val defaultStateBitmap: BitmapDrawable + private val pressedOneDirectionStateBitmap: BitmapDrawable + private val pressedTwoDirectionsStateBitmap: BitmapDrawable + + private var previousTouchX = 0 + private var previousTouchY = 0 + private var controlPositionX = 0 + private var controlPositionY = 0 + + private var upButtonState = false + private var downButtonState = false + private var leftButtonState = false + private var rightButtonState = false + + init { + this.defaultStateBitmap = BitmapDrawable(res, defaultStateBitmap) + this.pressedOneDirectionStateBitmap = BitmapDrawable(res, pressedOneDirectionStateBitmap) + this.pressedTwoDirectionsStateBitmap = BitmapDrawable(res, pressedTwoDirectionsStateBitmap) + width = this.defaultStateBitmap.intrinsicWidth + height = this.defaultStateBitmap.intrinsicHeight + trackId = -1 + } + + fun updateStatus(event: MotionEvent, dpad_slide: Boolean): Boolean { + val pointerIndex = event.actionIndex + val xPosition = event.getX(pointerIndex).toInt() + val yPosition = event.getY(pointerIndex).toInt() + val pointerId = event.getPointerId(pointerIndex) + val motionEvent = event.action and MotionEvent.ACTION_MASK + val isActionDown = + motionEvent == MotionEvent.ACTION_DOWN || motionEvent == MotionEvent.ACTION_POINTER_DOWN + val isActionUp = + motionEvent == MotionEvent.ACTION_UP || motionEvent == MotionEvent.ACTION_POINTER_UP + if (isActionDown) { + if (!bounds.contains(xPosition, yPosition)) { + return false + } + trackId = pointerId + } + if (isActionUp) { + if (trackId != pointerId) { + return false + } + trackId = -1 + upButtonState = false + downButtonState = false + leftButtonState = false + rightButtonState = false + return true + } + if (trackId == -1) { + return false + } + if (!dpad_slide && !isActionDown) { + return false + } + for (i in 0 until event.pointerCount) { + if (trackId != event.getPointerId(i)) { + continue + } + + var touchX = event.getX(i) + var touchY = event.getY(i) + var maxY = bounds.bottom.toFloat() + var maxX = bounds.right.toFloat() + touchX -= bounds.centerX().toFloat() + maxX -= bounds.centerX().toFloat() + touchY -= bounds.centerY().toFloat() + maxY -= bounds.centerY().toFloat() + val axisX = touchX / maxX + val axisY = touchY / maxY + val oldUpState = upButtonState + val oldDownState = downButtonState + val oldLeftState = leftButtonState + val oldRightState = rightButtonState + + upButtonState = axisY < -VIRT_AXIS_DEADZONE + downButtonState = axisY > VIRT_AXIS_DEADZONE + leftButtonState = axisX < -VIRT_AXIS_DEADZONE + rightButtonState = axisX > VIRT_AXIS_DEADZONE + return oldUpState != upButtonState || + oldDownState != downButtonState || + oldLeftState != leftButtonState || + oldRightState != rightButtonState + } + return false + } + + fun draw(canvas: Canvas) { + val px = controlPositionX + width / 2 + val py = controlPositionY + height / 2 + + // Pressed up + if (upButtonState && !leftButtonState && !rightButtonState) { + pressedOneDirectionStateBitmap.draw(canvas) + return + } + + // Pressed down + if (downButtonState && !leftButtonState && !rightButtonState) { + canvas.save() + canvas.rotate(180f, px.toFloat(), py.toFloat()) + pressedOneDirectionStateBitmap.draw(canvas) + canvas.restore() + return + } + + // Pressed left + if (leftButtonState && !upButtonState && !downButtonState) { + canvas.save() + canvas.rotate(270f, px.toFloat(), py.toFloat()) + pressedOneDirectionStateBitmap.draw(canvas) + canvas.restore() + return + } + + // Pressed right + if (rightButtonState && !upButtonState && !downButtonState) { + canvas.save() + canvas.rotate(90f, px.toFloat(), py.toFloat()) + pressedOneDirectionStateBitmap.draw(canvas) + canvas.restore() + return + } + + // Pressed up left + if (upButtonState && leftButtonState && !rightButtonState) { + pressedTwoDirectionsStateBitmap.draw(canvas) + return + } + + // Pressed up right + if (upButtonState && !leftButtonState && rightButtonState) { + canvas.save() + canvas.rotate(90f, px.toFloat(), py.toFloat()) + pressedTwoDirectionsStateBitmap.draw(canvas) + canvas.restore() + return + } + + // Pressed down right + if (downButtonState && !leftButtonState && rightButtonState) { + canvas.save() + canvas.rotate(180f, px.toFloat(), py.toFloat()) + pressedTwoDirectionsStateBitmap.draw(canvas) + canvas.restore() + return + } + + // Pressed down left + if (downButtonState && leftButtonState && !rightButtonState) { + canvas.save() + canvas.rotate(270f, px.toFloat(), py.toFloat()) + pressedTwoDirectionsStateBitmap.draw(canvas) + canvas.restore() + return + } + + // Not pressed + defaultStateBitmap.draw(canvas) + } + + val upStatus: Int + get() = if (upButtonState) ButtonState.PRESSED else ButtonState.RELEASED + val downStatus: Int + get() = if (downButtonState) ButtonState.PRESSED else ButtonState.RELEASED + val leftStatus: Int + get() = if (leftButtonState) ButtonState.PRESSED else ButtonState.RELEASED + val rightStatus: Int + get() = if (rightButtonState) ButtonState.PRESSED else ButtonState.RELEASED + + fun onConfigureTouch(event: MotionEvent): Boolean { + val pointerIndex = event.actionIndex + val fingerPositionX = event.getX(pointerIndex).toInt() + val fingerPositionY = event.getY(pointerIndex).toInt() + + when (event.action) { + MotionEvent.ACTION_DOWN -> { + previousTouchX = fingerPositionX + previousTouchY = fingerPositionY + } + + MotionEvent.ACTION_MOVE -> { + controlPositionX += fingerPositionX - previousTouchX + controlPositionY += fingerPositionY - previousTouchY + setBounds( + controlPositionX, + controlPositionY, + width + controlPositionX, + height + controlPositionY + ) + previousTouchX = fingerPositionX + previousTouchY = fingerPositionY + } + } + return true + } + + fun setPosition(x: Int, y: Int) { + controlPositionX = x + controlPositionY = y + } + + fun setBounds(left: Int, top: Int, right: Int, bottom: Int) { + defaultStateBitmap.setBounds(left, top, right, bottom) + pressedOneDirectionStateBitmap.setBounds(left, top, right, bottom) + pressedTwoDirectionsStateBitmap.setBounds(left, top, right, bottom) + } + + fun setOpacity(value: Int) { + defaultStateBitmap.alpha = value + pressedOneDirectionStateBitmap.alpha = value + pressedTwoDirectionsStateBitmap.alpha = value + } + + val bounds: Rect + get() = defaultStateBitmap.bounds + + companion object { + const val VIRT_AXIS_DEADZONE = 0.5f + } +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/overlay/InputOverlayDrawableJoystick.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/overlay/InputOverlayDrawableJoystick.kt new file mode 100644 index 0000000..1b7ba40 --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/overlay/InputOverlayDrawableJoystick.kt @@ -0,0 +1,292 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.overlay + +import android.content.res.Resources +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Rect +import android.graphics.drawable.BitmapDrawable +import android.view.MotionEvent +import kotlin.math.atan2 +import kotlin.math.cos +import kotlin.math.sin +import kotlin.math.sqrt +import org.sudachi.sudachi_emu.features.input.NativeInput.ButtonState +import org.sudachi.sudachi_emu.features.input.model.NativeAnalog +import org.sudachi.sudachi_emu.features.input.model.NativeButton +import org.sudachi.sudachi_emu.features.settings.model.BooleanSetting + +/** + * Custom [BitmapDrawable] that is capable + * of storing it's own ID. + * + * @param res [Resources] instance. + * @param bitmapOuter [Bitmap] which represents the outer non-movable part of the joystick. + * @param bitmapInnerDefault [Bitmap] which represents the default inner movable part of the joystick. + * @param bitmapInnerPressed [Bitmap] which represents the pressed inner movable part of the joystick. + * @param rectOuter [Rect] which represents the outer joystick bounds. + * @param rectInner [Rect] which represents the inner joystick bounds. + * @param joystick The [NativeAnalog] this Drawable represents. + * @param button The [NativeButton] this Drawable represents. + */ +class InputOverlayDrawableJoystick( + res: Resources, + bitmapOuter: Bitmap, + bitmapInnerDefault: Bitmap, + bitmapInnerPressed: Bitmap, + rectOuter: Rect, + rectInner: Rect, + val joystick: NativeAnalog, + val button: NativeButton, + val prefId: String +) { + // The ID value what motion event is tracking + var trackId = -1 + + var xAxis = 0f + private var yAxis = 0f + + val width: Int + val height: Int + + private var opacity: Int = 0 + + private var virtBounds: Rect + private var origBounds: Rect + + private val outerBitmap: BitmapDrawable + private val defaultStateInnerBitmap: BitmapDrawable + private val pressedStateInnerBitmap: BitmapDrawable + + private var previousTouchX = 0 + private var previousTouchY = 0 + var controlPositionX = 0 + var controlPositionY = 0 + + private val boundsBoxBitmap: BitmapDrawable + + private var pressedState = false + + // TODO: Add button support + val buttonStatus: Int + get() = ButtonState.RELEASED + var bounds: Rect + get() = outerBitmap.bounds + set(bounds) { + outerBitmap.bounds = bounds + } + + // Nintendo joysticks have y axis inverted + val realYAxis: Float + get() = -yAxis + + private val currentStateBitmapDrawable: BitmapDrawable + get() = if (pressedState) pressedStateInnerBitmap else defaultStateInnerBitmap + + init { + outerBitmap = BitmapDrawable(res, bitmapOuter) + defaultStateInnerBitmap = BitmapDrawable(res, bitmapInnerDefault) + pressedStateInnerBitmap = BitmapDrawable(res, bitmapInnerPressed) + boundsBoxBitmap = BitmapDrawable(res, bitmapOuter) + width = bitmapOuter.width + height = bitmapOuter.height + bounds = rectOuter + defaultStateInnerBitmap.bounds = rectInner + pressedStateInnerBitmap.bounds = rectInner + virtBounds = bounds + origBounds = outerBitmap.copyBounds() + boundsBoxBitmap.alpha = 0 + boundsBoxBitmap.bounds = virtBounds + setInnerBounds() + } + + fun draw(canvas: Canvas?) { + outerBitmap.draw(canvas!!) + currentStateBitmapDrawable.draw(canvas) + boundsBoxBitmap.draw(canvas) + } + + fun updateStatus(event: MotionEvent): Boolean { + val pointerIndex = event.actionIndex + val xPosition = event.getX(pointerIndex).toInt() + val yPosition = event.getY(pointerIndex).toInt() + val pointerId = event.getPointerId(pointerIndex) + val motionEvent = event.action and MotionEvent.ACTION_MASK + val isActionDown = + motionEvent == MotionEvent.ACTION_DOWN || motionEvent == MotionEvent.ACTION_POINTER_DOWN + val isActionUp = + motionEvent == MotionEvent.ACTION_UP || motionEvent == MotionEvent.ACTION_POINTER_UP + + if (isActionDown) { + if (!bounds.contains(xPosition, yPosition)) { + return false + } + pressedState = true + outerBitmap.alpha = 0 + boundsBoxBitmap.alpha = opacity + if (BooleanSetting.JOYSTICK_REL_CENTER.getBoolean()) { + virtBounds.offset( + xPosition - virtBounds.centerX(), + yPosition - virtBounds.centerY() + ) + } + boundsBoxBitmap.bounds = virtBounds + trackId = pointerId + } + + if (isActionUp) { + if (trackId != pointerId) { + return false + } + pressedState = false + xAxis = 0.0f + yAxis = 0.0f + outerBitmap.alpha = opacity + boundsBoxBitmap.alpha = 0 + virtBounds = Rect( + origBounds.left, + origBounds.top, + origBounds.right, + origBounds.bottom + ) + bounds = Rect( + origBounds.left, + origBounds.top, + origBounds.right, + origBounds.bottom + ) + setInnerBounds() + trackId = -1 + return true + } + + if (trackId == -1) return false + + for (i in 0 until event.pointerCount) { + if (trackId != event.getPointerId(i)) { + continue + } + var touchX = event.getX(i) + var touchY = event.getY(i) + var maxY = virtBounds.bottom.toFloat() + var maxX = virtBounds.right.toFloat() + touchX -= virtBounds.centerX().toFloat() + maxX -= virtBounds.centerX().toFloat() + touchY -= virtBounds.centerY().toFloat() + maxY -= virtBounds.centerY().toFloat() + val axisX = touchX / maxX + val axisY = touchY / maxY + val oldXAxis = xAxis + val oldYAxis = yAxis + + // Clamp the circle pad input to a circle + val angle = atan2(axisY.toDouble(), axisX.toDouble()).toFloat() + var radius = sqrt((axisX * axisX + axisY * axisY).toDouble()).toFloat() + if (radius > 1.0f) { + radius = 1.0f + } + xAxis = cos(angle.toDouble()).toFloat() * radius + yAxis = sin(angle.toDouble()).toFloat() * radius + setInnerBounds() + return oldXAxis != xAxis && oldYAxis != yAxis + } + return false + } + + fun onConfigureTouch(event: MotionEvent): Boolean { + val pointerIndex = event.actionIndex + val fingerPositionX = event.getX(pointerIndex).toInt() + val fingerPositionY = event.getY(pointerIndex).toInt() + + when (event.action) { + MotionEvent.ACTION_DOWN -> { + previousTouchX = fingerPositionX + previousTouchY = fingerPositionY + controlPositionX = fingerPositionX - (width / 2) + controlPositionY = fingerPositionY - (height / 2) + } + + MotionEvent.ACTION_MOVE -> { + controlPositionX += fingerPositionX - previousTouchX + controlPositionY += fingerPositionY - previousTouchY + bounds = Rect( + controlPositionX, + controlPositionY, + outerBitmap.intrinsicWidth + controlPositionX, + outerBitmap.intrinsicHeight + controlPositionY + ) + virtBounds = Rect( + controlPositionX, + controlPositionY, + outerBitmap.intrinsicWidth + controlPositionX, + outerBitmap.intrinsicHeight + controlPositionY + ) + setInnerBounds() + bounds = Rect( + Rect( + controlPositionX, + controlPositionY, + outerBitmap.intrinsicWidth + controlPositionX, + outerBitmap.intrinsicHeight + controlPositionY + ) + ) + previousTouchX = fingerPositionX + previousTouchY = fingerPositionY + } + } + origBounds = outerBitmap.copyBounds() + return true + } + + private fun setInnerBounds() { + var x = virtBounds.centerX() + (xAxis * (virtBounds.width() / 2)).toInt() + var y = virtBounds.centerY() + (yAxis * (virtBounds.height() / 2)).toInt() + if (x > virtBounds.centerX() + virtBounds.width() / 2) { + x = + virtBounds.centerX() + virtBounds.width() / 2 + } + if (x < virtBounds.centerX() - virtBounds.width() / 2) { + x = + virtBounds.centerX() - virtBounds.width() / 2 + } + if (y > virtBounds.centerY() + virtBounds.height() / 2) { + y = + virtBounds.centerY() + virtBounds.height() / 2 + } + if (y < virtBounds.centerY() - virtBounds.height() / 2) { + y = + virtBounds.centerY() - virtBounds.height() / 2 + } + val width = pressedStateInnerBitmap.bounds.width() / 2 + val height = pressedStateInnerBitmap.bounds.height() / 2 + defaultStateInnerBitmap.setBounds( + x - width, + y - height, + x + width, + y + height + ) + pressedStateInnerBitmap.bounds = defaultStateInnerBitmap.bounds + } + + fun setPosition(x: Int, y: Int) { + controlPositionX = x + controlPositionY = y + } + + fun setOpacity(value: Int) { + opacity = value + + defaultStateInnerBitmap.alpha = value + pressedStateInnerBitmap.alpha = value + + if (trackId == -1) { + outerBitmap.alpha = value + boundsBoxBitmap.alpha = 0 + } else { + outerBitmap.alpha = 0 + boundsBoxBitmap.alpha = value + } + } +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/overlay/model/OverlayControl.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/overlay/model/OverlayControl.kt new file mode 100644 index 0000000..d0d1630 --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/overlay/model/OverlayControl.kt @@ -0,0 +1,188 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.overlay.model + +import androidx.annotation.IntegerRes +import org.sudachi.sudachi_emu.R +import org.sudachi.sudachi_emu.SudachiApplication + +enum class OverlayControl( + val id: String, + val defaultVisibility: Boolean, + @IntegerRes val defaultLandscapePositionResources: Pair, + @IntegerRes val defaultPortraitPositionResources: Pair, + @IntegerRes val defaultFoldablePositionResources: Pair +) { + BUTTON_A( + "button_a", + true, + Pair(R.integer.BUTTON_A_X, R.integer.BUTTON_A_Y), + Pair(R.integer.BUTTON_A_X_PORTRAIT, R.integer.BUTTON_A_Y_PORTRAIT), + Pair(R.integer.BUTTON_A_X_FOLDABLE, R.integer.BUTTON_A_Y_FOLDABLE) + ), + BUTTON_B( + "button_b", + true, + Pair(R.integer.BUTTON_B_X, R.integer.BUTTON_B_Y), + Pair(R.integer.BUTTON_B_X_PORTRAIT, R.integer.BUTTON_B_Y_PORTRAIT), + Pair(R.integer.BUTTON_B_X_FOLDABLE, R.integer.BUTTON_B_Y_FOLDABLE) + ), + BUTTON_X( + "button_x", + true, + Pair(R.integer.BUTTON_X_X, R.integer.BUTTON_X_Y), + Pair(R.integer.BUTTON_X_X_PORTRAIT, R.integer.BUTTON_X_Y_PORTRAIT), + Pair(R.integer.BUTTON_X_X_FOLDABLE, R.integer.BUTTON_X_Y_FOLDABLE) + ), + BUTTON_Y( + "button_y", + true, + Pair(R.integer.BUTTON_Y_X, R.integer.BUTTON_Y_Y), + Pair(R.integer.BUTTON_Y_X_PORTRAIT, R.integer.BUTTON_Y_Y_PORTRAIT), + Pair(R.integer.BUTTON_Y_X_FOLDABLE, R.integer.BUTTON_Y_Y_FOLDABLE) + ), + BUTTON_PLUS( + "button_plus", + true, + Pair(R.integer.BUTTON_PLUS_X, R.integer.BUTTON_PLUS_Y), + Pair(R.integer.BUTTON_PLUS_X_PORTRAIT, R.integer.BUTTON_PLUS_Y_PORTRAIT), + Pair(R.integer.BUTTON_PLUS_X_FOLDABLE, R.integer.BUTTON_PLUS_Y_FOLDABLE) + ), + BUTTON_MINUS( + "button_minus", + true, + Pair(R.integer.BUTTON_MINUS_X, R.integer.BUTTON_MINUS_Y), + Pair(R.integer.BUTTON_MINUS_X_PORTRAIT, R.integer.BUTTON_MINUS_Y_PORTRAIT), + Pair(R.integer.BUTTON_MINUS_X_FOLDABLE, R.integer.BUTTON_MINUS_Y_FOLDABLE) + ), + BUTTON_HOME( + "button_home", + false, + Pair(R.integer.BUTTON_HOME_X, R.integer.BUTTON_HOME_Y), + Pair(R.integer.BUTTON_HOME_X_PORTRAIT, R.integer.BUTTON_HOME_Y_PORTRAIT), + Pair(R.integer.BUTTON_HOME_X_FOLDABLE, R.integer.BUTTON_HOME_Y_FOLDABLE) + ), + BUTTON_CAPTURE( + "button_capture", + false, + Pair(R.integer.BUTTON_CAPTURE_X, R.integer.BUTTON_CAPTURE_Y), + Pair(R.integer.BUTTON_CAPTURE_X_PORTRAIT, R.integer.BUTTON_CAPTURE_Y_PORTRAIT), + Pair(R.integer.BUTTON_CAPTURE_X_FOLDABLE, R.integer.BUTTON_CAPTURE_Y_FOLDABLE) + ), + BUTTON_L( + "button_l", + true, + Pair(R.integer.BUTTON_L_X, R.integer.BUTTON_L_Y), + Pair(R.integer.BUTTON_L_X_PORTRAIT, R.integer.BUTTON_L_Y_PORTRAIT), + Pair(R.integer.BUTTON_L_X_FOLDABLE, R.integer.BUTTON_L_Y_FOLDABLE) + ), + BUTTON_R( + "button_r", + true, + Pair(R.integer.BUTTON_R_X, R.integer.BUTTON_R_Y), + Pair(R.integer.BUTTON_R_X_PORTRAIT, R.integer.BUTTON_R_Y_PORTRAIT), + Pair(R.integer.BUTTON_R_X_FOLDABLE, R.integer.BUTTON_R_Y_FOLDABLE) + ), + BUTTON_ZL( + "button_zl", + true, + Pair(R.integer.BUTTON_ZL_X, R.integer.BUTTON_ZL_Y), + Pair(R.integer.BUTTON_ZL_X_PORTRAIT, R.integer.BUTTON_ZL_Y_PORTRAIT), + Pair(R.integer.BUTTON_ZL_X_FOLDABLE, R.integer.BUTTON_ZL_Y_FOLDABLE) + ), + BUTTON_ZR( + "button_zr", + true, + Pair(R.integer.BUTTON_ZR_X, R.integer.BUTTON_ZR_Y), + Pair(R.integer.BUTTON_ZR_X_PORTRAIT, R.integer.BUTTON_ZR_Y_PORTRAIT), + Pair(R.integer.BUTTON_ZR_X_FOLDABLE, R.integer.BUTTON_ZR_Y_FOLDABLE) + ), + BUTTON_STICK_L( + "button_stick_l", + true, + Pair(R.integer.BUTTON_STICK_L_X, R.integer.BUTTON_STICK_L_Y), + Pair(R.integer.BUTTON_STICK_L_X_PORTRAIT, R.integer.BUTTON_STICK_L_Y_PORTRAIT), + Pair(R.integer.BUTTON_STICK_L_X_FOLDABLE, R.integer.BUTTON_STICK_L_Y_FOLDABLE) + ), + BUTTON_STICK_R( + "button_stick_r", + true, + Pair(R.integer.BUTTON_STICK_R_X, R.integer.BUTTON_STICK_R_Y), + Pair(R.integer.BUTTON_STICK_R_X_PORTRAIT, R.integer.BUTTON_STICK_R_Y_PORTRAIT), + Pair(R.integer.BUTTON_STICK_R_X_FOLDABLE, R.integer.BUTTON_STICK_R_Y_FOLDABLE) + ), + STICK_L( + "stick_l", + true, + Pair(R.integer.STICK_L_X, R.integer.STICK_L_Y), + Pair(R.integer.STICK_L_X_PORTRAIT, R.integer.STICK_L_Y_PORTRAIT), + Pair(R.integer.STICK_L_X_FOLDABLE, R.integer.STICK_L_Y_FOLDABLE) + ), + STICK_R( + "stick_r", + true, + Pair(R.integer.STICK_R_X, R.integer.STICK_R_Y), + Pair(R.integer.STICK_R_X_PORTRAIT, R.integer.STICK_R_Y_PORTRAIT), + Pair(R.integer.STICK_R_X_FOLDABLE, R.integer.STICK_R_Y_FOLDABLE) + ), + COMBINED_DPAD( + "combined_dpad", + true, + Pair(R.integer.COMBINED_DPAD_X, R.integer.COMBINED_DPAD_Y), + Pair(R.integer.COMBINED_DPAD_X_PORTRAIT, R.integer.COMBINED_DPAD_Y_PORTRAIT), + Pair(R.integer.COMBINED_DPAD_X_FOLDABLE, R.integer.COMBINED_DPAD_Y_FOLDABLE) + ); + + fun getDefaultPositionForLayout(layout: OverlayLayout): Pair { + val rawResourcePair: Pair + SudachiApplication.appContext.resources.apply { + rawResourcePair = when (layout) { + OverlayLayout.Landscape -> { + Pair( + getInteger(this@OverlayControl.defaultLandscapePositionResources.first), + getInteger(this@OverlayControl.defaultLandscapePositionResources.second) + ) + } + + OverlayLayout.Portrait -> { + Pair( + getInteger(this@OverlayControl.defaultPortraitPositionResources.first), + getInteger(this@OverlayControl.defaultPortraitPositionResources.second) + ) + } + + OverlayLayout.Foldable -> { + Pair( + getInteger(this@OverlayControl.defaultFoldablePositionResources.first), + getInteger(this@OverlayControl.defaultFoldablePositionResources.second) + ) + } + } + } + + return Pair( + rawResourcePair.first.toDouble() / 1000, + rawResourcePair.second.toDouble() / 1000 + ) + } + + fun toOverlayControlData(): OverlayControlData = + OverlayControlData( + id, + defaultVisibility, + getDefaultPositionForLayout(OverlayLayout.Landscape), + getDefaultPositionForLayout(OverlayLayout.Portrait), + getDefaultPositionForLayout(OverlayLayout.Foldable) + ) + + companion object { + val map: HashMap by lazy { + val hashMap = hashMapOf() + entries.forEach { hashMap[it.id] = it } + hashMap + } + + fun from(id: String): OverlayControl? = map[id] + } +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/overlay/model/OverlayControlData.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/overlay/model/OverlayControlData.kt new file mode 100644 index 0000000..901187c --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/overlay/model/OverlayControlData.kt @@ -0,0 +1,19 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.overlay.model + +data class OverlayControlData( + val id: String, + var enabled: Boolean, + var landscapePosition: Pair, + var portraitPosition: Pair, + var foldablePosition: Pair +) { + fun positionFromLayout(layout: OverlayLayout): Pair = + when (layout) { + OverlayLayout.Landscape -> landscapePosition + OverlayLayout.Portrait -> portraitPosition + OverlayLayout.Foldable -> foldablePosition + } +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/overlay/model/OverlayControlDefault.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/overlay/model/OverlayControlDefault.kt new file mode 100644 index 0000000..ec9bd67 --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/overlay/model/OverlayControlDefault.kt @@ -0,0 +1,13 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.overlay.model + +import androidx.annotation.IntegerRes + +data class OverlayControlDefault( + val buttonId: String, + @IntegerRes val landscapePositionResource: Pair, + @IntegerRes val portraitPositionResource: Pair, + @IntegerRes val foldablePositionResource: Pair +) diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/overlay/model/OverlayLayout.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/overlay/model/OverlayLayout.kt new file mode 100644 index 0000000..fe3d2d0 --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/overlay/model/OverlayLayout.kt @@ -0,0 +1,10 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.overlay.model + +enum class OverlayLayout(val id: String) { + Landscape("Landscape"), + Portrait("Portrait"), + Foldable("Foldable") +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/ui/GamesFragment.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/ui/GamesFragment.kt new file mode 100644 index 0000000..e9eddee --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/ui/GamesFragment.kt @@ -0,0 +1,160 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.ui + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.appcompat.app.AppCompatActivity +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.updatePadding +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import com.google.android.material.color.MaterialColors +import org.sudachi.sudachi_emu.R +import org.sudachi.sudachi_emu.adapters.GameAdapter +import org.sudachi.sudachi_emu.databinding.FragmentGamesBinding +import org.sudachi.sudachi_emu.layout.AutofitGridLayoutManager +import org.sudachi.sudachi_emu.model.GamesViewModel +import org.sudachi.sudachi_emu.model.HomeViewModel +import org.sudachi.sudachi_emu.utils.ViewUtils.setVisible +import org.sudachi.sudachi_emu.utils.ViewUtils.updateMargins +import org.sudachi.sudachi_emu.utils.collect + +class GamesFragment : Fragment() { + private var _binding: FragmentGamesBinding? = null + private val binding get() = _binding!! + + private val gamesViewModel: GamesViewModel by activityViewModels() + private val homeViewModel: HomeViewModel by activityViewModels() + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentGamesBinding.inflate(inflater) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + homeViewModel.setNavigationVisibility(visible = true, animated = true) + homeViewModel.setStatusBarShadeVisibility(true) + + binding.gridGames.apply { + layoutManager = AutofitGridLayoutManager( + requireContext(), + requireContext().resources.getDimensionPixelSize(R.dimen.card_width) + ) + adapter = GameAdapter(requireActivity() as AppCompatActivity) + } + + binding.swipeRefresh.apply { + // Add swipe down to refresh gesture + setOnRefreshListener { + gamesViewModel.reloadGames(false) + } + + // Set theme color to the refresh animation's background + setProgressBackgroundColorSchemeColor( + MaterialColors.getColor( + binding.swipeRefresh, + com.google.android.material.R.attr.colorPrimary + ) + ) + setColorSchemeColors( + MaterialColors.getColor( + binding.swipeRefresh, + com.google.android.material.R.attr.colorOnPrimary + ) + ) + + // Make sure the loading indicator appears even if the layout is told to refresh before being fully drawn + post { + if (_binding == null) { + return@post + } + binding.swipeRefresh.isRefreshing = gamesViewModel.isReloading.value + } + } + + gamesViewModel.isReloading.collect(viewLifecycleOwner) { + binding.swipeRefresh.isRefreshing = it + binding.noticeText.setVisible( + visible = gamesViewModel.games.value.isEmpty() && !it, + gone = false + ) + } + gamesViewModel.games.collect(viewLifecycleOwner) { + (binding.gridGames.adapter as GameAdapter).submitList(it) + } + gamesViewModel.shouldSwapData.collect( + viewLifecycleOwner, + resetState = { gamesViewModel.setShouldSwapData(false) } + ) { + if (it) { + (binding.gridGames.adapter as GameAdapter).submitList(gamesViewModel.games.value) + } + } + gamesViewModel.shouldScrollToTop.collect( + viewLifecycleOwner, + resetState = { gamesViewModel.setShouldScrollToTop(false) } + ) { if (it) scrollToTop() } + + setInsets() + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } + + private fun scrollToTop() { + if (_binding != null) { + binding.gridGames.smoothScrollToPosition(0) + } + } + + private fun setInsets() = + ViewCompat.setOnApplyWindowInsetsListener( + binding.root + ) { view: View, windowInsets: WindowInsetsCompat -> + val barInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) + val cutoutInsets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout()) + val extraListSpacing = resources.getDimensionPixelSize(R.dimen.spacing_large) + val spacingNavigation = resources.getDimensionPixelSize(R.dimen.spacing_navigation) + val spacingNavigationRail = + resources.getDimensionPixelSize(R.dimen.spacing_navigation_rail) + + binding.gridGames.updatePadding( + top = barInsets.top + extraListSpacing, + bottom = barInsets.bottom + spacingNavigation + extraListSpacing + ) + + binding.swipeRefresh.setProgressViewEndTarget( + false, + barInsets.top + resources.getDimensionPixelSize(R.dimen.spacing_refresh_end) + ) + + val leftInsets = barInsets.left + cutoutInsets.left + val rightInsets = barInsets.right + cutoutInsets.right + val left: Int + val right: Int + if (ViewCompat.getLayoutDirection(view) == ViewCompat.LAYOUT_DIRECTION_LTR) { + left = leftInsets + spacingNavigationRail + right = rightInsets + } else { + left = leftInsets + right = rightInsets + spacingNavigationRail + } + binding.swipeRefresh.updateMargins(left = left, right = right) + + binding.noticeText.updatePadding(bottom = spacingNavigation) + + windowInsets + } +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/ui/main/MainActivity.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/ui/main/MainActivity.kt new file mode 100644 index 0000000..1a84992 --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/ui/main/MainActivity.kt @@ -0,0 +1,692 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.ui.main + +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.view.View +import android.view.ViewGroup.MarginLayoutParams +import android.view.WindowManager +import android.view.animation.PathInterpolator +import android.widget.Toast +import androidx.activity.result.contract.ActivityResultContracts +import androidx.activity.viewModels +import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.ContextCompat +import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen +import androidx.core.view.ViewCompat +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import androidx.navigation.NavController +import androidx.navigation.fragment.NavHostFragment +import androidx.navigation.ui.setupWithNavController +import androidx.preference.PreferenceManager +import com.google.android.material.color.MaterialColors +import com.google.android.material.navigation.NavigationBarView +import java.io.File +import java.io.FilenameFilter +import org.sudachi.sudachi_emu.HomeNavigationDirections +import org.sudachi.sudachi_emu.NativeLibrary +import org.sudachi.sudachi_emu.R +import org.sudachi.sudachi_emu.databinding.ActivityMainBinding +import org.sudachi.sudachi_emu.features.settings.model.Settings +import org.sudachi.sudachi_emu.fragments.AddGameFolderDialogFragment +import org.sudachi.sudachi_emu.fragments.ProgressDialogFragment +import org.sudachi.sudachi_emu.fragments.MessageDialogFragment +import org.sudachi.sudachi_emu.model.AddonViewModel +import org.sudachi.sudachi_emu.model.DriverViewModel +import org.sudachi.sudachi_emu.model.GamesViewModel +import org.sudachi.sudachi_emu.model.HomeViewModel +import org.sudachi.sudachi_emu.model.InstallResult +import org.sudachi.sudachi_emu.model.TaskState +import org.sudachi.sudachi_emu.model.TaskViewModel +import org.sudachi.sudachi_emu.utils.* +import org.sudachi.sudachi_emu.utils.ViewUtils.setVisible +import java.io.BufferedInputStream +import java.io.BufferedOutputStream +import java.util.zip.ZipEntry +import java.util.zip.ZipInputStream + +class MainActivity : AppCompatActivity(), ThemeProvider { + private lateinit var binding: ActivityMainBinding + + private val homeViewModel: HomeViewModel by viewModels() + private val gamesViewModel: GamesViewModel by viewModels() + private val taskViewModel: TaskViewModel by viewModels() + private val addonViewModel: AddonViewModel by viewModels() + private val driverViewModel: DriverViewModel by viewModels() + + override var themeId: Int = 0 + + private val CHECKED_DECRYPTION = "CheckedDecryption" + private var checkedDecryption = false + + override fun onCreate(savedInstanceState: Bundle?) { + val splashScreen = installSplashScreen() + splashScreen.setKeepOnScreenCondition { !DirectoryInitialization.areDirectoriesReady } + + ThemeHelper.setTheme(this) + + super.onCreate(savedInstanceState) + + binding = ActivityMainBinding.inflate(layoutInflater) + setContentView(binding.root) + + if (savedInstanceState != null) { + checkedDecryption = savedInstanceState.getBoolean(CHECKED_DECRYPTION) + } + if (!checkedDecryption) { + val firstTimeSetup = PreferenceManager.getDefaultSharedPreferences(applicationContext) + .getBoolean(Settings.PREF_FIRST_APP_LAUNCH, true) + if (!firstTimeSetup) { + checkKeys() + } + checkedDecryption = true + } + + WindowCompat.setDecorFitsSystemWindows(window, false) + window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING) + + window.statusBarColor = + ContextCompat.getColor(applicationContext, android.R.color.transparent) + window.navigationBarColor = + ContextCompat.getColor(applicationContext, android.R.color.transparent) + + binding.statusBarShade.setBackgroundColor( + ThemeHelper.getColorWithOpacity( + MaterialColors.getColor( + binding.root, + com.google.android.material.R.attr.colorSurface + ), + ThemeHelper.SYSTEM_BAR_ALPHA + ) + ) + if (InsetsHelper.getSystemGestureType(applicationContext) != + InsetsHelper.GESTURE_NAVIGATION + ) { + binding.navigationBarShade.setBackgroundColor( + ThemeHelper.getColorWithOpacity( + MaterialColors.getColor( + binding.root, + com.google.android.material.R.attr.colorSurface + ), + ThemeHelper.SYSTEM_BAR_ALPHA + ) + ) + } + + val navHostFragment = + supportFragmentManager.findFragmentById(R.id.fragment_container) as NavHostFragment + setUpNavigation(navHostFragment.navController) + (binding.navigationView as NavigationBarView).setOnItemReselectedListener { + when (it.itemId) { + R.id.gamesFragment -> gamesViewModel.setShouldScrollToTop(true) + R.id.searchFragment -> gamesViewModel.setSearchFocused(true) + R.id.homeSettingsFragment -> { + val action = HomeNavigationDirections.actionGlobalSettingsActivity( + null, + Settings.MenuTag.SECTION_ROOT + ) + navHostFragment.navController.navigate(action) + } + } + } + + // Prevents navigation from being drawn for a short time on recreation if set to hidden + if (!homeViewModel.navigationVisible.value.first) { + binding.navigationView.setVisible(visible = false, gone = false) + binding.statusBarShade.setVisible(visible = false, gone = false) + } + + homeViewModel.navigationVisible.collect(this) { showNavigation(it.first, it.second) } + homeViewModel.statusBarShadeVisible.collect(this) { showStatusBarShade(it) } + homeViewModel.contentToInstall.collect( + this, + resetState = { homeViewModel.setContentToInstall(null) } + ) { + if (it != null) { + installContent(it) + } + } + homeViewModel.checkKeys.collect(this, resetState = { homeViewModel.setCheckKeys(false) }) { + if (it) checkKeys() + } + + setInsets() + } + + private fun checkKeys() { + if (!NativeLibrary.areKeysPresent()) { + MessageDialogFragment.newInstance( + titleId = R.string.keys_missing, + descriptionId = R.string.keys_missing_description, + helpLinkId = R.string.keys_missing_help + ).show(supportFragmentManager, MessageDialogFragment.TAG) + } + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + outState.putBoolean(CHECKED_DECRYPTION, checkedDecryption) + } + + fun finishSetup(navController: NavController) { + navController.navigate(R.id.action_firstTimeSetupFragment_to_gamesFragment) + (binding.navigationView as NavigationBarView).setupWithNavController(navController) + showNavigation(visible = true, animated = true) + } + + private fun setUpNavigation(navController: NavController) { + val firstTimeSetup = PreferenceManager.getDefaultSharedPreferences(applicationContext) + .getBoolean(Settings.PREF_FIRST_APP_LAUNCH, true) + + if (firstTimeSetup && !homeViewModel.navigatedToSetup) { + navController.navigate(R.id.firstTimeSetupFragment) + homeViewModel.navigatedToSetup = true + } else { + (binding.navigationView as NavigationBarView).setupWithNavController(navController) + } + } + + private fun showNavigation(visible: Boolean, animated: Boolean) { + if (!animated) { + binding.navigationView.setVisible(visible) + return + } + + val smallLayout = resources.getBoolean(R.bool.small_layout) + binding.navigationView.animate().apply { + if (visible) { + binding.navigationView.setVisible(true) + duration = 300 + interpolator = PathInterpolator(0.05f, 0.7f, 0.1f, 1f) + + if (smallLayout) { + binding.navigationView.translationY = + binding.navigationView.height.toFloat() * 2 + translationY(0f) + } else { + if (ViewCompat.getLayoutDirection(binding.navigationView) == + ViewCompat.LAYOUT_DIRECTION_LTR + ) { + binding.navigationView.translationX = + binding.navigationView.width.toFloat() * -2 + translationX(0f) + } else { + binding.navigationView.translationX = + binding.navigationView.width.toFloat() * 2 + translationX(0f) + } + } + } else { + duration = 300 + interpolator = PathInterpolator(0.3f, 0f, 0.8f, 0.15f) + + if (smallLayout) { + translationY(binding.navigationView.height.toFloat() * 2) + } else { + if (ViewCompat.getLayoutDirection(binding.navigationView) == + ViewCompat.LAYOUT_DIRECTION_LTR + ) { + translationX(binding.navigationView.width.toFloat() * -2) + } else { + translationX(binding.navigationView.width.toFloat() * 2) + } + } + } + }.withEndAction { + if (!visible) { + binding.navigationView.setVisible(visible = false, gone = false) + } + }.start() + } + + private fun showStatusBarShade(visible: Boolean) { + binding.statusBarShade.animate().apply { + if (visible) { + binding.statusBarShade.setVisible(true) + binding.statusBarShade.translationY = binding.statusBarShade.height.toFloat() * -2 + duration = 300 + translationY(0f) + interpolator = PathInterpolator(0.05f, 0.7f, 0.1f, 1f) + } else { + duration = 300 + translationY(binding.navigationView.height.toFloat() * -2) + interpolator = PathInterpolator(0.3f, 0f, 0.8f, 0.15f) + } + }.withEndAction { + if (!visible) { + binding.statusBarShade.setVisible(visible = false, gone = false) + } + }.start() + } + + override fun onResume() { + ThemeHelper.setCorrectTheme(this) + super.onResume() + } + + private fun setInsets() = + ViewCompat.setOnApplyWindowInsetsListener( + binding.root + ) { _: View, windowInsets: WindowInsetsCompat -> + val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) + val mlpStatusShade = binding.statusBarShade.layoutParams as MarginLayoutParams + mlpStatusShade.height = insets.top + binding.statusBarShade.layoutParams = mlpStatusShade + + // The only situation where we care to have a nav bar shade is when it's at the bottom + // of the screen where scrolling list elements can go behind it. + val mlpNavShade = binding.navigationBarShade.layoutParams as MarginLayoutParams + mlpNavShade.height = insets.bottom + binding.navigationBarShade.layoutParams = mlpNavShade + + windowInsets + } + + override fun setTheme(resId: Int) { + super.setTheme(resId) + themeId = resId + } + + val getGamesDirectory = + registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { result -> + if (result != null) { + processGamesDir(result) + } + } + + fun processGamesDir(result: Uri) { + contentResolver.takePersistableUriPermission( + result, + Intent.FLAG_GRANT_READ_URI_PERMISSION + ) + + val uriString = result.toString() + val folder = gamesViewModel.folders.value.firstOrNull { it.uriString == uriString } + if (folder != null) { + Toast.makeText( + applicationContext, + R.string.folder_already_added, + Toast.LENGTH_SHORT + ).show() + return + } + + AddGameFolderDialogFragment.newInstance(uriString) + .show(supportFragmentManager, AddGameFolderDialogFragment.TAG) + } + + val getProdKey = + registerForActivityResult(ActivityResultContracts.OpenDocument()) { result -> + if (result != null) { + processKey(result) + } + } + + fun processKey(result: Uri): Boolean { + if (FileUtil.getExtension(result) != "keys") { + MessageDialogFragment.newInstance( + this, + titleId = R.string.reading_keys_failure, + descriptionId = R.string.install_prod_keys_failure_extension_description + ).show(supportFragmentManager, MessageDialogFragment.TAG) + return false + } + + contentResolver.takePersistableUriPermission( + result, + Intent.FLAG_GRANT_READ_URI_PERMISSION + ) + + val dstPath = DirectoryInitialization.userDirectory + "/keys/" + if (FileUtil.copyUriToInternalStorage( + result, + dstPath, + "prod.keys" + ) != null + ) { + if (NativeLibrary.reloadKeys()) { + Toast.makeText( + applicationContext, + R.string.install_keys_success, + Toast.LENGTH_SHORT + ).show() + homeViewModel.setCheckKeys(true) + gamesViewModel.reloadGames(true) + return true + } else { + MessageDialogFragment.newInstance( + this, + titleId = R.string.invalid_keys_error, + descriptionId = R.string.install_keys_failure_description, + helpLinkId = R.string.dumping_keys_quickstart_link + ).show(supportFragmentManager, MessageDialogFragment.TAG) + return false + } + } + return false + } + + val getFirmware = + registerForActivityResult(ActivityResultContracts.OpenDocument()) { result -> + if (result == null) { + return@registerForActivityResult + } + + val filterNCA = FilenameFilter { _, dirName -> dirName.endsWith(".nca") } + + val firmwarePath = + File(DirectoryInitialization.userDirectory + "/nand/system/Contents/registered/") + val cacheFirmwareDir = File("${cacheDir.path}/registered/") + + ProgressDialogFragment.newInstance( + this, + R.string.firmware_installing + ) { progressCallback, _ -> + var messageToShow: Any + try { + FileUtil.unzipToInternalStorage( + result.toString(), + cacheFirmwareDir, + progressCallback + ) + val unfilteredNumOfFiles = cacheFirmwareDir.list()?.size ?: -1 + val filteredNumOfFiles = cacheFirmwareDir.list(filterNCA)?.size ?: -2 + messageToShow = if (unfilteredNumOfFiles != filteredNumOfFiles) { + MessageDialogFragment.newInstance( + this, + titleId = R.string.firmware_installed_failure, + descriptionId = R.string.firmware_installed_failure_description + ) + } else { + firmwarePath.deleteRecursively() + cacheFirmwareDir.copyRecursively(firmwarePath, true) + NativeLibrary.initializeSystem(true) + homeViewModel.setCheckKeys(true) + getString(R.string.save_file_imported_success) + } + } catch (e: Exception) { + Log.error("[MainActivity] Firmware install failed - ${e.message}") + messageToShow = getString(R.string.fatal_error) + } finally { + cacheFirmwareDir.deleteRecursively() + } + messageToShow + }.show(supportFragmentManager, ProgressDialogFragment.TAG) + } + + val getAmiiboKey = + registerForActivityResult(ActivityResultContracts.OpenDocument()) { result -> + if (result == null) { + return@registerForActivityResult + } + + if (FileUtil.getExtension(result) != "bin") { + MessageDialogFragment.newInstance( + this, + titleId = R.string.reading_keys_failure, + descriptionId = R.string.install_amiibo_keys_failure_extension_description + ).show(supportFragmentManager, MessageDialogFragment.TAG) + return@registerForActivityResult + } + + contentResolver.takePersistableUriPermission( + result, + Intent.FLAG_GRANT_READ_URI_PERMISSION + ) + + val dstPath = DirectoryInitialization.userDirectory + "/keys/" + if (FileUtil.copyUriToInternalStorage( + result, + dstPath, + "key_retail.bin" + ) != null + ) { + if (NativeLibrary.reloadKeys()) { + Toast.makeText( + applicationContext, + R.string.install_keys_success, + Toast.LENGTH_SHORT + ).show() + } else { + MessageDialogFragment.newInstance( + this, + titleId = R.string.invalid_keys_error, + descriptionId = R.string.install_keys_failure_description, + helpLinkId = R.string.dumping_keys_quickstart_link + ).show(supportFragmentManager, MessageDialogFragment.TAG) + } + } + } + + val installGameUpdate = registerForActivityResult( + ActivityResultContracts.OpenMultipleDocuments() + ) { documents: List -> + if (documents.isEmpty()) { + return@registerForActivityResult + } + + if (addonViewModel.game == null) { + installContent(documents) + return@registerForActivityResult + } + + ProgressDialogFragment.newInstance( + this@MainActivity, + R.string.verifying_content, + false + ) { _, _ -> + var updatesMatchProgram = true + for (document in documents) { + val valid = NativeLibrary.doesUpdateMatchProgram( + addonViewModel.game!!.programId, + document.toString() + ) + if (!valid) { + updatesMatchProgram = false + break + } + } + + if (updatesMatchProgram) { + homeViewModel.setContentToInstall(documents) + } else { + MessageDialogFragment.newInstance( + this@MainActivity, + titleId = R.string.content_install_notice, + descriptionId = R.string.content_install_notice_description, + positiveAction = { homeViewModel.setContentToInstall(documents) }, + negativeAction = {} + ) + } + }.show(supportFragmentManager, ProgressDialogFragment.TAG) + } + + private fun installContent(documents: List) { + ProgressDialogFragment.newInstance( + this@MainActivity, + R.string.installing_game_content + ) { progressCallback, messageCallback -> + var installSuccess = 0 + var installOverwrite = 0 + var errorBaseGame = 0 + var error = 0 + documents.forEach { + messageCallback.invoke(FileUtil.getFilename(it)) + when ( + InstallResult.from( + NativeLibrary.installFileToNand( + it.toString(), + progressCallback + ) + ) + ) { + InstallResult.Success -> { + installSuccess += 1 + } + + InstallResult.Overwrite -> { + installOverwrite += 1 + } + + InstallResult.BaseInstallAttempted -> { + errorBaseGame += 1 + } + + InstallResult.Failure -> { + error += 1 + } + } + } + + addonViewModel.refreshAddons() + + val separator = System.getProperty("line.separator") ?: "\n" + val installResult = StringBuilder() + if (installSuccess > 0) { + installResult.append( + getString( + R.string.install_game_content_success_install, + installSuccess + ) + ) + installResult.append(separator) + } + if (installOverwrite > 0) { + installResult.append( + getString( + R.string.install_game_content_success_overwrite, + installOverwrite + ) + ) + installResult.append(separator) + } + val errorTotal: Int = errorBaseGame + error + if (errorTotal > 0) { + installResult.append(separator) + installResult.append( + getString( + R.string.install_game_content_failed_count, + errorTotal + ) + ) + installResult.append(separator) + if (errorBaseGame > 0) { + installResult.append(separator) + installResult.append( + getString(R.string.install_game_content_failure_base) + ) + installResult.append(separator) + } + if (error > 0) { + installResult.append( + getString(R.string.install_game_content_failure_description) + ) + installResult.append(separator) + } + return@newInstance MessageDialogFragment.newInstance( + this, + titleId = R.string.install_game_content_failure, + descriptionString = installResult.toString().trim(), + helpLinkId = R.string.install_game_content_help_link + ) + } else { + return@newInstance MessageDialogFragment.newInstance( + this, + titleId = R.string.install_game_content_success, + descriptionString = installResult.toString().trim() + ) + } + }.show(supportFragmentManager, ProgressDialogFragment.TAG) + } + + val exportUserData = registerForActivityResult( + ActivityResultContracts.CreateDocument("application/zip") + ) { result -> + if (result == null) { + return@registerForActivityResult + } + + ProgressDialogFragment.newInstance( + this, + R.string.exporting_user_data, + true + ) { progressCallback, _ -> + val zipResult = FileUtil.zipFromInternalStorage( + File(DirectoryInitialization.userDirectory!!), + DirectoryInitialization.userDirectory!!, + BufferedOutputStream(contentResolver.openOutputStream(result)), + progressCallback, + compression = false + ) + return@newInstance when (zipResult) { + TaskState.Completed -> getString(R.string.user_data_export_success) + TaskState.Failed -> R.string.export_failed + TaskState.Cancelled -> R.string.user_data_export_cancelled + } + }.show(supportFragmentManager, ProgressDialogFragment.TAG) + } + + val importUserData = + registerForActivityResult(ActivityResultContracts.OpenDocument()) { result -> + if (result == null) { + return@registerForActivityResult + } + + ProgressDialogFragment.newInstance( + this, + R.string.importing_user_data + ) { progressCallback, _ -> + val checkStream = + ZipInputStream(BufferedInputStream(contentResolver.openInputStream(result))) + var isSudachiBackup = false + checkStream.use { stream -> + var ze: ZipEntry? = null + while (stream.nextEntry?.also { ze = it } != null) { + val itemName = ze!!.name.trim() + if (itemName == "/config/config.ini" || itemName == "config/config.ini") { + isSudachiBackup = true + return@use + } + } + } + if (!isSudachiBackup) { + return@newInstance MessageDialogFragment.newInstance( + this, + titleId = R.string.invalid_sudachi_backup, + descriptionId = R.string.user_data_import_failed_description + ) + } + + // Clear existing user data + NativeConfig.unloadGlobalConfig() + File(DirectoryInitialization.userDirectory!!).deleteRecursively() + + // Copy archive to internal storage + try { + FileUtil.unzipToInternalStorage( + result.toString(), + File(DirectoryInitialization.userDirectory!!), + progressCallback + ) + } catch (e: Exception) { + return@newInstance MessageDialogFragment.newInstance( + this, + titleId = R.string.import_failed, + descriptionId = R.string.user_data_import_failed_description + ) + } + + // Reinitialize relevant data + NativeLibrary.initializeSystem(true) + NativeConfig.initializeGlobalConfig() + gamesViewModel.reloadGames(false) + driverViewModel.reloadDriverData() + + return@newInstance getString(R.string.user_data_import_success) + }.show(supportFragmentManager, ProgressDialogFragment.TAG) + } +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/ui/main/ThemeProvider.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/ui/main/ThemeProvider.kt new file mode 100644 index 0000000..baf1994 --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/ui/main/ThemeProvider.kt @@ -0,0 +1,11 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.ui.main + +interface ThemeProvider { + /** + * Provides theme ID by overriding an activity's 'setTheme' method and returning that result + */ + var themeId: Int +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/utils/AddonUtil.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/utils/AddonUtil.kt new file mode 100644 index 0000000..5aacbdb --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/utils/AddonUtil.kt @@ -0,0 +1,8 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.utils + +object AddonUtil { + val validAddonDirectories = listOf("cheats", "exefs", "romfs") +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/utils/DirectoryInitialization.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/utils/DirectoryInitialization.kt new file mode 100644 index 0000000..6a5fcd6 --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/utils/DirectoryInitialization.kt @@ -0,0 +1,213 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.utils + +import androidx.preference.PreferenceManager +import java.io.IOException +import org.sudachi.sudachi_emu.NativeLibrary +import org.sudachi.sudachi_emu.SudachiApplication +import org.sudachi.sudachi_emu.features.settings.model.BooleanSetting +import org.sudachi.sudachi_emu.features.settings.model.IntSetting +import org.sudachi.sudachi_emu.features.settings.model.Settings +import org.sudachi.sudachi_emu.overlay.model.OverlayControlData +import org.sudachi.sudachi_emu.overlay.model.OverlayControl +import org.sudachi.sudachi_emu.overlay.model.OverlayLayout +import org.sudachi.sudachi_emu.utils.PreferenceUtil.migratePreference + +object DirectoryInitialization { + private var userPath: String? = null + + var areDirectoriesReady: Boolean = false + + fun start() { + if (!areDirectoriesReady) { + initializeInternalStorage() + NativeLibrary.initializeSystem(false) + NativeConfig.initializeGlobalConfig() + migrateSettings() + areDirectoriesReady = true + } + } + + val userDirectory: String? + get() { + check(areDirectoriesReady) { "Directory initialization is not ready!" } + return userPath + } + + private fun initializeInternalStorage() { + try { + userPath = SudachiApplication.appContext.getExternalFilesDir(null)!!.canonicalPath + NativeLibrary.setAppDirectory(userPath!!) + } catch (e: IOException) { + e.printStackTrace() + } + } + + private fun migrateSettings() { + val preferences = PreferenceManager.getDefaultSharedPreferences(SudachiApplication.appContext) + var saveConfig = false + val theme = preferences.migratePreference(Settings.PREF_THEME) + if (theme != null) { + IntSetting.THEME.setInt(theme) + saveConfig = true + } + + val themeMode = preferences.migratePreference(Settings.PREF_THEME_MODE) + if (themeMode != null) { + IntSetting.THEME_MODE.setInt(themeMode) + saveConfig = true + } + + val blackBackgrounds = + preferences.migratePreference(Settings.PREF_BLACK_BACKGROUNDS) + if (blackBackgrounds != null) { + BooleanSetting.BLACK_BACKGROUNDS.setBoolean(blackBackgrounds) + saveConfig = true + } + + val joystickRelCenter = + preferences.migratePreference(Settings.PREF_MENU_SETTINGS_JOYSTICK_REL_CENTER) + if (joystickRelCenter != null) { + BooleanSetting.JOYSTICK_REL_CENTER.setBoolean(joystickRelCenter) + saveConfig = true + } + + val dpadSlide = + preferences.migratePreference(Settings.PREF_MENU_SETTINGS_DPAD_SLIDE) + if (dpadSlide != null) { + BooleanSetting.DPAD_SLIDE.setBoolean(dpadSlide) + saveConfig = true + } + + val hapticFeedback = + preferences.migratePreference(Settings.PREF_MENU_SETTINGS_HAPTICS) + if (hapticFeedback != null) { + BooleanSetting.HAPTIC_FEEDBACK.setBoolean(hapticFeedback) + saveConfig = true + } + + val showPerformanceOverlay = + preferences.migratePreference(Settings.PREF_MENU_SETTINGS_SHOW_FPS) + if (showPerformanceOverlay != null) { + BooleanSetting.SHOW_PERFORMANCE_OVERLAY.setBoolean(showPerformanceOverlay) + saveConfig = true + } + + val showInputOverlay = + preferences.migratePreference(Settings.PREF_MENU_SETTINGS_SHOW_OVERLAY) + if (showInputOverlay != null) { + BooleanSetting.SHOW_INPUT_OVERLAY.setBoolean(showInputOverlay) + saveConfig = true + } + + val overlayOpacity = preferences.migratePreference(Settings.PREF_CONTROL_OPACITY) + if (overlayOpacity != null) { + IntSetting.OVERLAY_OPACITY.setInt(overlayOpacity) + saveConfig = true + } + + val overlayScale = preferences.migratePreference(Settings.PREF_CONTROL_SCALE) + if (overlayScale != null) { + IntSetting.OVERLAY_SCALE.setInt(overlayScale) + saveConfig = true + } + + var setOverlayData = false + val overlayControlData = NativeConfig.getOverlayControlData() + if (overlayControlData.isEmpty()) { + val overlayControlDataMap = + NativeConfig.getOverlayControlData().associateBy { it.id }.toMutableMap() + for (button in Settings.overlayPreferences) { + val buttonId = convertButtonId(button) + var buttonEnabled = preferences.migratePreference(button) + if (buttonEnabled == null) { + buttonEnabled = OverlayControl.map[buttonId]?.defaultVisibility == true + } + + var landscapeXPosition = preferences.migratePreference( + "$button-X${Settings.PREF_LANDSCAPE_SUFFIX}" + )?.toDouble() + var landscapeYPosition = preferences.migratePreference( + "$button-Y${Settings.PREF_LANDSCAPE_SUFFIX}" + )?.toDouble() + if (landscapeXPosition == null || landscapeYPosition == null) { + val landscapePosition = OverlayControl.map[buttonId] + ?.getDefaultPositionForLayout(OverlayLayout.Landscape) ?: Pair(0.0, 0.0) + landscapeXPosition = landscapePosition.first + landscapeYPosition = landscapePosition.second + } + + var portraitXPosition = preferences.migratePreference( + "$button-X${Settings.PREF_PORTRAIT_SUFFIX}" + )?.toDouble() + var portraitYPosition = preferences.migratePreference( + "$button-Y${Settings.PREF_PORTRAIT_SUFFIX}" + )?.toDouble() + if (portraitXPosition == null || portraitYPosition == null) { + val portraitPosition = OverlayControl.map[buttonId] + ?.getDefaultPositionForLayout(OverlayLayout.Portrait) ?: Pair(0.0, 0.0) + portraitXPosition = portraitPosition.first + portraitYPosition = portraitPosition.second + } + + var foldableXPosition = preferences.migratePreference( + "$button-X${Settings.PREF_FOLDABLE_SUFFIX}" + )?.toDouble() + var foldableYPosition = preferences.migratePreference( + "$button-Y${Settings.PREF_FOLDABLE_SUFFIX}" + )?.toDouble() + if (foldableXPosition == null || foldableYPosition == null) { + val foldablePosition = OverlayControl.map[buttonId] + ?.getDefaultPositionForLayout(OverlayLayout.Foldable) ?: Pair(0.0, 0.0) + foldableXPosition = foldablePosition.first + foldableYPosition = foldablePosition.second + } + + val controlData = OverlayControlData( + buttonId, + buttonEnabled, + Pair(landscapeXPosition, landscapeYPosition), + Pair(portraitXPosition, portraitYPosition), + Pair(foldableXPosition, foldableYPosition) + ) + overlayControlDataMap[buttonId] = controlData + setOverlayData = true + } + + if (setOverlayData) { + NativeConfig.setOverlayControlData( + overlayControlDataMap.map { it.value }.toTypedArray() + ) + saveConfig = true + } + } + + if (saveConfig) { + NativeConfig.saveGlobalConfig() + } + } + + private fun convertButtonId(buttonId: String): String = + when (buttonId) { + Settings.PREF_BUTTON_A -> OverlayControl.BUTTON_A.id + Settings.PREF_BUTTON_B -> OverlayControl.BUTTON_B.id + Settings.PREF_BUTTON_X -> OverlayControl.BUTTON_X.id + Settings.PREF_BUTTON_Y -> OverlayControl.BUTTON_Y.id + Settings.PREF_BUTTON_L -> OverlayControl.BUTTON_L.id + Settings.PREF_BUTTON_R -> OverlayControl.BUTTON_R.id + Settings.PREF_BUTTON_ZL -> OverlayControl.BUTTON_ZL.id + Settings.PREF_BUTTON_ZR -> OverlayControl.BUTTON_ZR.id + Settings.PREF_BUTTON_PLUS -> OverlayControl.BUTTON_PLUS.id + Settings.PREF_BUTTON_MINUS -> OverlayControl.BUTTON_MINUS.id + Settings.PREF_BUTTON_DPAD -> OverlayControl.COMBINED_DPAD.id + Settings.PREF_STICK_L -> OverlayControl.STICK_L.id + Settings.PREF_STICK_R -> OverlayControl.STICK_R.id + Settings.PREF_BUTTON_HOME -> OverlayControl.BUTTON_HOME.id + Settings.PREF_BUTTON_SCREENSHOT -> OverlayControl.BUTTON_CAPTURE.id + Settings.PREF_BUTTON_STICK_L -> OverlayControl.BUTTON_STICK_L.id + Settings.PREF_BUTTON_STICK_R -> OverlayControl.BUTTON_STICK_R.id + else -> "" + } +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/utils/DocumentsTree.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/utils/DocumentsTree.kt new file mode 100644 index 0000000..b9ce009 --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/utils/DocumentsTree.kt @@ -0,0 +1,137 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.utils + +import android.net.Uri +import androidx.documentfile.provider.DocumentFile +import java.io.File +import java.util.* +import org.sudachi.sudachi_emu.model.MinimalDocumentFile + +class DocumentsTree { + private var root: DocumentsNode? = null + + fun setRoot(rootUri: Uri?) { + root = null + root = DocumentsNode() + root!!.uri = rootUri + root!!.isDirectory = true + } + + fun openContentUri(filepath: String, openMode: String?): Int { + val node = resolvePath(filepath) ?: return -1 + return FileUtil.openContentUri(node.uri.toString(), openMode) + } + + fun getFileSize(filepath: String): Long { + val node = resolvePath(filepath) + return if (node == null || node.isDirectory) { + 0 + } else { + FileUtil.getFileSize(node.uri.toString()) + } + } + + fun exists(filepath: String): Boolean { + return resolvePath(filepath) != null + } + + fun isDirectory(filepath: String): Boolean { + val node = resolvePath(filepath) + return node != null && node.isDirectory + } + + fun getParentDirectory(filepath: String): String { + val node = resolvePath(filepath)!! + val parentNode = node.parent + if (parentNode != null && parentNode.isDirectory) { + return parentNode.uri!!.toString() + } + return node.uri!!.toString() + } + + fun getFilename(filepath: String): String { + val node = resolvePath(filepath) + if (node != null) { + return node.name!! + } + return filepath + } + + private fun resolvePath(filepath: String): DocumentsNode? { + val tokens = StringTokenizer(filepath, File.separator, false) + var iterator = root + while (tokens.hasMoreTokens()) { + val token = tokens.nextToken() + if (token.isEmpty()) continue + iterator = find(iterator, token) + if (iterator == null) return null + } + return iterator + } + + private fun find(parent: DocumentsNode?, filename: String): DocumentsNode? { + if (parent!!.isDirectory && !parent.loaded) { + structTree(parent) + } + return parent.children[filename] + } + + /** + * Construct current level directory tree + * @param parent parent node of this level + */ + private fun structTree(parent: DocumentsNode) { + val documents = FileUtil.listFiles(parent.uri!!) + for (document in documents) { + val node = DocumentsNode(document) + node.parent = parent + parent.children[node.name] = node + } + parent.loaded = true + } + + private class DocumentsNode { + var parent: DocumentsNode? = null + val children: MutableMap = HashMap() + var name: String? = null + var uri: Uri? = null + var loaded = false + var isDirectory = false + + constructor() + constructor(document: MinimalDocumentFile) { + name = document.filename + uri = document.uri + isDirectory = document.isDirectory + loaded = !isDirectory + } + + private constructor(document: DocumentFile, isCreateDir: Boolean) { + name = document.name + uri = document.uri + isDirectory = isCreateDir + loaded = true + } + + private fun rename(name: String) { + if (parent == null) { + return + } + parent!!.children.remove(this.name) + this.name = name + parent!!.children[name] = this + } + } + + companion object { + fun isNativePath(path: String): Boolean { + return if (path.isNotEmpty()) { + path[0] == '/' + } else { + false + } + } + } +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/utils/FileUtil.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/utils/FileUtil.kt new file mode 100644 index 0000000..0c56d8d --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/utils/FileUtil.kt @@ -0,0 +1,503 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.utils + +import android.database.Cursor +import android.net.Uri +import android.provider.DocumentsContract +import androidx.documentfile.provider.DocumentFile +import java.io.BufferedInputStream +import java.io.File +import java.io.IOException +import java.io.InputStream +import java.net.URLDecoder +import java.util.zip.ZipEntry +import java.util.zip.ZipInputStream +import org.sudachi.sudachi_emu.SudachiApplication +import org.sudachi.sudachi_emu.model.MinimalDocumentFile +import org.sudachi.sudachi_emu.model.TaskState +import java.io.BufferedOutputStream +import java.io.OutputStream +import java.lang.NullPointerException +import java.nio.charset.StandardCharsets +import java.util.zip.Deflater +import java.util.zip.ZipOutputStream +import kotlin.IllegalStateException + +object FileUtil { + const val PATH_TREE = "tree" + const val DECODE_METHOD = "UTF-8" + const val APPLICATION_OCTET_STREAM = "application/octet-stream" + const val TEXT_PLAIN = "text/plain" + + private val context get() = SudachiApplication.appContext + + /** + * Create a file from directory with filename. + * @param context Application context + * @param directory parent path for file. + * @param filename file display name. + * @return boolean + */ + fun createFile(directory: String?, filename: String): DocumentFile? { + var decodedFilename = filename + try { + val directoryUri = Uri.parse(directory) + val parent = DocumentFile.fromTreeUri(context, directoryUri) ?: return null + decodedFilename = URLDecoder.decode(decodedFilename, DECODE_METHOD) + var mimeType = APPLICATION_OCTET_STREAM + if (decodedFilename.endsWith(".txt")) { + mimeType = TEXT_PLAIN + } + val exists = parent.findFile(decodedFilename) + return exists ?: parent.createFile(mimeType, decodedFilename) + } catch (e: Exception) { + Log.error("[FileUtil]: Cannot create file, error: " + e.message) + } + return null + } + + /** + * Create a directory from directory with filename. + * @param directory parent path for directory. + * @param directoryName directory display name. + * @return boolean + */ + fun createDir(directory: String?, directoryName: String?): DocumentFile? { + var decodedDirectoryName = directoryName + try { + val directoryUri = Uri.parse(directory) + val parent = DocumentFile.fromTreeUri(context, directoryUri) ?: return null + decodedDirectoryName = URLDecoder.decode(decodedDirectoryName, DECODE_METHOD) + val isExist = parent.findFile(decodedDirectoryName) + return isExist ?: parent.createDirectory(decodedDirectoryName) + } catch (e: Exception) { + Log.error("[FileUtil]: Cannot create file, error: " + e.message) + } + return null + } + + /** + * Open content uri and return file descriptor to JNI. + * @param path Native content uri path + * @param openMode will be one of "r", "r", "rw", "wa", "rwa" + * @return file descriptor + */ + @JvmStatic + fun openContentUri(path: String, openMode: String?): Int { + try { + val uri = Uri.parse(path) + val parcelFileDescriptor = context.contentResolver.openFileDescriptor(uri, openMode!!) + if (parcelFileDescriptor == null) { + Log.error("[FileUtil]: Cannot get the file descriptor from uri: $path") + return -1 + } + val fileDescriptor = parcelFileDescriptor.detachFd() + parcelFileDescriptor.close() + return fileDescriptor + } catch (e: Exception) { + Log.error("[FileUtil]: Cannot open content uri, error: " + e.message) + } + return -1 + } + + /** + * Reference: https://stackoverflow.com/questions/42186820/documentfile-is-very-slow + * This function will be faster than DocumentFile.listFiles + * @param uri Directory uri. + * @return CheapDocument lists. + */ + fun listFiles(uri: Uri): Array { + val resolver = context.contentResolver + val columns = arrayOf( + DocumentsContract.Document.COLUMN_DOCUMENT_ID, + DocumentsContract.Document.COLUMN_DISPLAY_NAME, + DocumentsContract.Document.COLUMN_MIME_TYPE + ) + var c: Cursor? = null + val results: MutableList = ArrayList() + try { + val docId: String = if (isRootTreeUri(uri)) { + DocumentsContract.getTreeDocumentId(uri) + } else { + DocumentsContract.getDocumentId(uri) + } + val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(uri, docId) + c = resolver.query(childrenUri, columns, null, null, null) + while (c!!.moveToNext()) { + val documentId = c.getString(0) + val documentName = c.getString(1) + val documentMimeType = c.getString(2) + val documentUri = DocumentsContract.buildDocumentUriUsingTree(uri, documentId) + val document = MinimalDocumentFile(documentName, documentMimeType, documentUri) + results.add(document) + } + } catch (e: Exception) { + Log.error("[FileUtil]: Cannot list file error: " + e.message) + } finally { + closeQuietly(c) + } + return results.toTypedArray() + } + + /** + * Check whether given path exists. + * @param path Native content uri path + * @return bool + */ + fun exists(path: String?, suppressLog: Boolean = false): Boolean { + var c: Cursor? = null + try { + val mUri = Uri.parse(path) + val columns = arrayOf(DocumentsContract.Document.COLUMN_DOCUMENT_ID) + c = context.contentResolver.query(mUri, columns, null, null, null) + return c!!.count > 0 + } catch (e: Exception) { + if (!suppressLog) { + Log.info("[FileUtil] Cannot find file from given path, error: " + e.message) + } + } finally { + closeQuietly(c) + } + return false + } + + /** + * Check whether given path is a directory + * @param path content uri path + * @return bool + */ + fun isDirectory(path: String): Boolean { + val resolver = context.contentResolver + val columns = arrayOf( + DocumentsContract.Document.COLUMN_MIME_TYPE + ) + var isDirectory = false + var c: Cursor? = null + try { + val mUri = Uri.parse(path) + c = resolver.query(mUri, columns, null, null, null) + c!!.moveToNext() + val mimeType = c.getString(0) + isDirectory = mimeType == DocumentsContract.Document.MIME_TYPE_DIR + } catch (e: Exception) { + Log.error("[FileUtil]: Cannot list files, error: " + e.message) + } finally { + closeQuietly(c) + } + return isDirectory + } + + /** + * Get file display name from given path + * @param uri content uri + * @return String display name + */ + fun getFilename(uri: Uri): String { + val resolver = SudachiApplication.appContext.contentResolver + val columns = arrayOf( + DocumentsContract.Document.COLUMN_DISPLAY_NAME + ) + var filename = "" + var c: Cursor? = null + try { + c = resolver.query(uri, columns, null, null, null) + c!!.moveToNext() + filename = c.getString(0) + } catch (e: Exception) { + Log.error("[FileUtil]: Cannot get file size, error: " + e.message) + } finally { + closeQuietly(c) + } + return filename + } + + fun getFilesName(path: String): Array { + val uri = Uri.parse(path) + val files: MutableList = ArrayList() + for (file in listFiles(uri)) { + files.add(file.filename) + } + return files.toTypedArray() + } + + /** + * Get file size from given path. + * @param path content uri path + * @return long file size + */ + @JvmStatic + fun getFileSize(path: String): Long { + val resolver = context.contentResolver + val columns = arrayOf( + DocumentsContract.Document.COLUMN_SIZE + ) + var size: Long = 0 + var c: Cursor? = null + try { + val mUri = Uri.parse(path) + c = resolver.query(mUri, columns, null, null, null) + c!!.moveToNext() + size = c.getLong(0) + } catch (e: Exception) { + Log.error("[FileUtil]: Cannot get file size, error: " + e.message) + } finally { + closeQuietly(c) + } + return size + } + + /** + * Creates an input stream with a given [Uri] and copies its data to the given path. This will + * overwrite any pre-existing files. + * + * @param sourceUri The [Uri] to copy data from + * @param destinationParentPath Destination directory + * @param destinationFilename Optionally renames the file once copied + */ + fun copyUriToInternalStorage( + sourceUri: Uri, + destinationParentPath: String, + destinationFilename: String = "" + ): File? = + try { + val fileName = + if (destinationFilename == "") getFilename(sourceUri) else "/$destinationFilename" + val inputStream = context.contentResolver.openInputStream(sourceUri)!! + + val destinationFile = File("$destinationParentPath$fileName") + if (destinationFile.exists()) { + destinationFile.delete() + } + + destinationFile.outputStream().use { fos -> + inputStream.use { it.copyTo(fos) } + } + destinationFile + } catch (e: IOException) { + null + } catch (e: NullPointerException) { + null + } + + /** + * Extracts the given zip file into the given directory. + * @param path String representation of a [Uri] or a typical path delimited by '/' + * @param destDir Location to unzip the contents of [path] into + * @param progressCallback Lambda that is called with the total number of files and the current + * progress through the process. Stops execution as soon as possible if this returns true. + */ + @Throws(SecurityException::class) + fun unzipToInternalStorage( + path: String, + destDir: File, + progressCallback: (max: Long, progress: Long) -> Boolean = { _, _ -> false } + ) { + var totalEntries = 0L + ZipInputStream(getInputStream(path)).use { zis -> + var tempEntry = zis.nextEntry + while (tempEntry != null) { + tempEntry = zis.nextEntry + totalEntries++ + } + } + + var progress = 0L + ZipInputStream(getInputStream(path)).use { zis -> + var entry: ZipEntry? = zis.nextEntry + while (entry != null) { + if (progressCallback.invoke(totalEntries, progress)) { + return@use + } + + val newFile = File(destDir, entry.name) + val destinationDirectory = if (entry.isDirectory) newFile else newFile.parentFile + + if (!newFile.canonicalPath.startsWith(destDir.canonicalPath + File.separator)) { + throw SecurityException("Zip file attempted path traversal! ${entry.name}") + } + + if (!destinationDirectory.isDirectory && !destinationDirectory.mkdirs()) { + throw IOException("Failed to create directory $destinationDirectory") + } + + if (!entry.isDirectory) { + newFile.outputStream().use { fos -> zis.copyTo(fos) } + } + entry = zis.nextEntry + progress++ + } + } + } + + /** + * Creates a zip file from a directory within internal storage + * @param inputFile File representation of the item that will be zipped + * @param rootDir Directory containing the inputFile + * @param outputStream Stream where the zip file will be output + * @param progressCallback Lambda that is called with the total number of files and the current + * progress through the process. Stops execution as soon as possible if this returns true. + * @param compression Disables compression if true + */ + fun zipFromInternalStorage( + inputFile: File, + rootDir: String, + outputStream: BufferedOutputStream, + progressCallback: (max: Long, progress: Long) -> Boolean = { _, _ -> false }, + compression: Boolean = true + ): TaskState { + try { + ZipOutputStream(outputStream).use { zos -> + if (!compression) { + zos.setMethod(ZipOutputStream.DEFLATED) + zos.setLevel(Deflater.NO_COMPRESSION) + } + + var count = 0L + val totalFiles = inputFile.walkTopDown().count().toLong() + inputFile.walkTopDown().forEach { file -> + if (progressCallback.invoke(totalFiles, count)) { + return TaskState.Cancelled + } + + if (!file.isDirectory) { + val entryName = + file.absolutePath.removePrefix(rootDir).removePrefix("/") + val entry = ZipEntry(entryName) + zos.putNextEntry(entry) + if (file.isFile) { + file.inputStream().use { fis -> fis.copyTo(zos) } + } + count++ + } + } + } + } catch (e: Exception) { + Log.error("[FileUtil] Failed creating zip file - ${e.message}") + return TaskState.Failed + } + return TaskState.Completed + } + + /** + * Helper function that copies the contents of a DocumentFile folder into a [File] + * @param file [File] representation of the folder to copy into + * @param progressCallback Lambda that is called with the total number of files and the current + * progress through the process. Stops execution as soon as possible if this returns true. + * @throws IllegalStateException Fails when trying to copy a folder into a file and vice versa + */ + fun DocumentFile.copyFilesTo( + file: File, + progressCallback: (max: Long, progress: Long) -> Boolean = { _, _ -> false } + ) { + file.mkdirs() + if (!this.isDirectory || !file.isDirectory) { + throw IllegalStateException( + "[FileUtil] Tried to copy a folder into a file or vice versa" + ) + } + + var count = 0L + val totalFiles = this.listFiles().size.toLong() + this.listFiles().forEach { + if (progressCallback.invoke(totalFiles, count)) { + return + } + + val newFile = File(file, it.name!!) + if (it.isDirectory) { + newFile.mkdirs() + DocumentFile.fromTreeUri(SudachiApplication.appContext, it.uri)?.copyFilesTo(newFile) + } else { + val inputStream = + SudachiApplication.appContext.contentResolver.openInputStream(it.uri) + BufferedInputStream(inputStream).use { bos -> + if (!newFile.exists()) { + newFile.createNewFile() + } + newFile.outputStream().use { os -> bos.copyTo(os) } + } + } + count++ + } + } + + fun isRootTreeUri(uri: Uri): Boolean { + val paths = uri.pathSegments + return paths.size == 2 && PATH_TREE == paths[0] + } + + fun closeQuietly(closeable: AutoCloseable?) { + if (closeable != null) { + try { + closeable.close() + } catch (rethrown: RuntimeException) { + throw rethrown + } catch (ignored: Exception) { + } + } + } + + fun getExtension(uri: Uri): String { + val fileName = getFilename(uri) + return fileName.substring(fileName.lastIndexOf(".") + 1) + .lowercase() + } + + fun isTreeUriValid(uri: Uri): Boolean { + val resolver = context.contentResolver + val columns = arrayOf( + DocumentsContract.Document.COLUMN_DOCUMENT_ID, + DocumentsContract.Document.COLUMN_DISPLAY_NAME, + DocumentsContract.Document.COLUMN_MIME_TYPE + ) + return try { + val docId: String = if (isRootTreeUri(uri)) { + DocumentsContract.getTreeDocumentId(uri) + } else { + DocumentsContract.getDocumentId(uri) + } + val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(uri, docId) + resolver.query(childrenUri, columns, null, null, null) + true + } catch (_: Exception) { + false + } + } + + fun getInputStream(path: String) = if (path.contains("content://")) { + Uri.parse(path).inputStream() + } else { + File(path).inputStream() + } + + fun getOutputStream(path: String) = if (path.contains("content://")) { + Uri.parse(path).outputStream() + } else { + File(path).outputStream() + } + + @Throws(IOException::class) + fun getStringFromFile(file: File): String = + String(file.readBytes(), StandardCharsets.UTF_8) + + @Throws(IOException::class) + fun getStringFromInputStream(stream: InputStream): String = + String(stream.readBytes(), StandardCharsets.UTF_8) + + fun DocumentFile.inputStream(): InputStream = + SudachiApplication.appContext.contentResolver.openInputStream(uri)!! + + fun DocumentFile.outputStream(): OutputStream = + SudachiApplication.appContext.contentResolver.openOutputStream(uri)!! + + fun Uri.inputStream(): InputStream = + SudachiApplication.appContext.contentResolver.openInputStream(this)!! + + fun Uri.outputStream(): OutputStream = + SudachiApplication.appContext.contentResolver.openOutputStream(this)!! + + fun Uri.asDocumentFile(): DocumentFile? = + DocumentFile.fromSingleUri(SudachiApplication.appContext, this) +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/utils/GameHelper.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/utils/GameHelper.kt new file mode 100644 index 0000000..9dce357 --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/utils/GameHelper.kt @@ -0,0 +1,152 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.utils + +import android.content.SharedPreferences +import android.net.Uri +import androidx.preference.PreferenceManager +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.sudachi.sudachi_emu.NativeLibrary +import org.sudachi.sudachi_emu.SudachiApplication +import org.sudachi.sudachi_emu.model.Game +import org.sudachi.sudachi_emu.model.GameDir +import org.sudachi.sudachi_emu.model.MinimalDocumentFile + +object GameHelper { + private const val KEY_OLD_GAME_PATH = "game_path" + const val KEY_GAMES = "Games" + + private lateinit var preferences: SharedPreferences + + fun getGames(): List { + val games = mutableListOf() + val context = SudachiApplication.appContext + preferences = PreferenceManager.getDefaultSharedPreferences(context) + + val gameDirs = mutableListOf() + val oldGamesDir = preferences.getString(KEY_OLD_GAME_PATH, "") ?: "" + if (oldGamesDir.isNotEmpty()) { + gameDirs.add(GameDir(oldGamesDir, true)) + preferences.edit().remove(KEY_OLD_GAME_PATH).apply() + } + gameDirs.addAll(NativeConfig.getGameDirs()) + + // Ensure keys are loaded so that ROM metadata can be decrypted. + NativeLibrary.reloadKeys() + + // Reset metadata so we don't use stale information + GameMetadata.resetMetadata() + + // Remove previous filesystem provider information so we can get up to date version info + NativeLibrary.clearFilesystemProvider() + + val badDirs = mutableListOf() + gameDirs.forEachIndexed { index: Int, gameDir: GameDir -> + val gameDirUri = Uri.parse(gameDir.uriString) + val isValid = FileUtil.isTreeUriValid(gameDirUri) + if (isValid) { + addGamesRecursive( + games, + FileUtil.listFiles(gameDirUri), + if (gameDir.deepScan) 3 else 1 + ) + } else { + badDirs.add(index) + } + } + + // Remove all game dirs with insufficient permissions from config + if (badDirs.isNotEmpty()) { + var offset = 0 + badDirs.forEach { + gameDirs.removeAt(it - offset) + offset++ + } + } + NativeConfig.setGameDirs(gameDirs.toTypedArray()) + + // Cache list of games found on disk + val serializedGames = mutableSetOf() + games.forEach { + serializedGames.add(Json.encodeToString(it)) + } + preferences.edit() + .remove(KEY_GAMES) + .putStringSet(KEY_GAMES, serializedGames) + .apply() + + return games.toList() + } + + private fun addGamesRecursive( + games: MutableList, + files: Array, + depth: Int + ) { + if (depth <= 0) { + return + } + + files.forEach { + if (it.isDirectory) { + addGamesRecursive( + games, + FileUtil.listFiles(it.uri), + depth - 1 + ) + } else { + if (Game.extensions.contains(FileUtil.getExtension(it.uri))) { + val game = getGame(it.uri, true) + if (game != null) { + games.add(game) + } + } + } + } + } + + fun getGame(uri: Uri, addedToLibrary: Boolean): Game? { + val filePath = uri.toString() + if (!GameMetadata.getIsValid(filePath)) { + return null + } + + // Needed to update installed content information + NativeLibrary.addFileToFilesystemProvider(filePath) + + var name = GameMetadata.getTitle(filePath) + + // If the game's title field is empty, use the filename. + if (name.isEmpty()) { + name = FileUtil.getFilename(uri) + } + var programId = GameMetadata.getProgramId(filePath) + + // If the game's ID field is empty, use the filename without extension. + if (programId.isEmpty()) { + programId = name.substring(0, name.lastIndexOf(".")) + } + + val newGame = Game( + name, + filePath, + programId, + GameMetadata.getDeveloper(filePath), + GameMetadata.getVersion(filePath, false), + GameMetadata.getIsHomebrew(filePath) + ) + + if (addedToLibrary) { + val addedTime = preferences.getLong(newGame.keyAddedToLibraryTime, 0L) + if (addedTime == 0L) { + preferences.edit() + .putLong(newGame.keyAddedToLibraryTime, System.currentTimeMillis()) + .apply() + } + } + + return newGame + } +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/utils/GameIconUtils.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/utils/GameIconUtils.kt new file mode 100644 index 0000000..ef42f54 --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/utils/GameIconUtils.kt @@ -0,0 +1,109 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.utils + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.drawable.LayerDrawable +import android.widget.ImageView +import androidx.core.content.res.ResourcesCompat +import androidx.core.graphics.drawable.IconCompat +import androidx.core.graphics.drawable.toBitmap +import androidx.core.graphics.drawable.toDrawable +import androidx.lifecycle.LifecycleOwner +import coil.ImageLoader +import coil.decode.DataSource +import coil.fetch.DrawableResult +import coil.fetch.FetchResult +import coil.fetch.Fetcher +import coil.key.Keyer +import coil.memory.MemoryCache +import coil.request.ImageRequest +import coil.request.Options +import org.sudachi.sudachi_emu.R +import org.sudachi.sudachi_emu.SudachiApplication +import org.sudachi.sudachi_emu.model.Game + +class GameIconFetcher( + private val game: Game, + private val options: Options +) : Fetcher { + override suspend fun fetch(): FetchResult { + return DrawableResult( + drawable = decodeGameIcon(game.path)!!.toDrawable(options.context.resources), + isSampled = false, + dataSource = DataSource.DISK + ) + } + + private fun decodeGameIcon(uri: String): Bitmap? { + val data = GameMetadata.getIcon(uri) + return BitmapFactory.decodeByteArray( + data, + 0, + data.size, + BitmapFactory.Options() + ) + } + + class Factory : Fetcher.Factory { + override fun create(data: Game, options: Options, imageLoader: ImageLoader): Fetcher = + GameIconFetcher(data, options) + } +} + +class GameIconKeyer : Keyer { + override fun key(data: Game, options: Options): String = data.path +} + +object GameIconUtils { + private val imageLoader = ImageLoader.Builder(SudachiApplication.appContext) + .components { + add(GameIconKeyer()) + add(GameIconFetcher.Factory()) + } + .memoryCache { + MemoryCache.Builder(SudachiApplication.appContext) + .maxSizePercent(0.25) + .build() + } + .build() + + fun loadGameIcon(game: Game, imageView: ImageView) { + val request = ImageRequest.Builder(SudachiApplication.appContext) + .data(game) + .target(imageView) + .error(R.drawable.default_icon) + .build() + imageLoader.enqueue(request) + } + + suspend fun getGameIcon(lifecycleOwner: LifecycleOwner, game: Game): Bitmap { + val request = ImageRequest.Builder(SudachiApplication.appContext) + .data(game) + .lifecycle(lifecycleOwner) + .error(R.drawable.default_icon) + .build() + return imageLoader.execute(request) + .drawable!!.toBitmap(config = Bitmap.Config.ARGB_8888) + } + + suspend fun getShortcutIcon(lifecycleOwner: LifecycleOwner, game: Game): IconCompat { + val layerDrawable = ResourcesCompat.getDrawable( + SudachiApplication.appContext.resources, + R.drawable.shortcut, + null + ) as LayerDrawable + layerDrawable.setDrawableByLayerId( + R.id.shortcut_foreground, + getGameIcon(lifecycleOwner, game).toDrawable(SudachiApplication.appContext.resources) + ) + val inset = SudachiApplication.appContext.resources + .getDimensionPixelSize(R.dimen.icon_inset) + layerDrawable.setLayerInset(1, inset, inset, inset, inset) + return IconCompat.createWithAdaptiveBitmap( + layerDrawable.toBitmap(config = Bitmap.Config.ARGB_8888) + ) + } +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/utils/GameMetadata.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/utils/GameMetadata.kt new file mode 100644 index 0000000..3aaee61 --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/utils/GameMetadata.kt @@ -0,0 +1,22 @@ +// SPDX-FileCopyrightText: Copyright 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.utils + +object GameMetadata { + external fun getIsValid(path: String): Boolean + + external fun getTitle(path: String): String + + external fun getProgramId(path: String): String + + external fun getDeveloper(path: String): String + + external fun getVersion(path: String, reload: Boolean): String + + external fun getIcon(path: String): ByteArray + + external fun getIsHomebrew(path: String): Boolean + + external fun resetMetadata() +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/utils/GpuDriverHelper.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/utils/GpuDriverHelper.kt new file mode 100644 index 0000000..94ef649 --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/utils/GpuDriverHelper.kt @@ -0,0 +1,229 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.utils + +import android.graphics.SurfaceTexture +import android.net.Uri +import android.os.Build +import android.view.Surface +import java.io.File +import java.io.IOException +import org.sudachi.sudachi_emu.NativeLibrary +import org.sudachi.sudachi_emu.SudachiApplication +import org.sudachi.sudachi_emu.features.settings.model.StringSetting +import java.io.FileNotFoundException +import java.util.zip.ZipException +import java.util.zip.ZipFile + +object GpuDriverHelper { + private const val META_JSON_FILENAME = "meta.json" + private var fileRedirectionPath: String? = null + var driverInstallationPath: String? = null + private var hookLibPath: String? = null + + val driverStoragePath get() = DirectoryInitialization.userDirectory!! + "/gpu_drivers/" + + fun initializeDriverParameters() { + try { + // Initialize the file redirection directory. + fileRedirectionPath = SudachiApplication.appContext + .getExternalFilesDir(null)!!.canonicalPath + "/gpu/vk_file_redirect/" + + // Initialize the driver installation directory. + driverInstallationPath = SudachiApplication.appContext + .filesDir.canonicalPath + "/gpu_driver/" + } catch (e: IOException) { + throw RuntimeException(e) + } + + // Initialize directories. + initializeDirectories() + + // Initialize hook libraries directory. + hookLibPath = SudachiApplication.appContext.applicationInfo.nativeLibraryDir + "/" + + // Initialize GPU driver. + NativeLibrary.initializeGpuDriver( + hookLibPath, + driverInstallationPath, + installedCustomDriverData.libraryName, + fileRedirectionPath + ) + } + + fun getDrivers(): MutableList> { + val driverZips = File(driverStoragePath).listFiles() + val drivers: MutableList> = + driverZips + ?.mapNotNull { + val metadata = getMetadataFromZip(it) + metadata.name?.let { _ -> Pair(it.path, metadata) } + } + ?.sortedByDescending { it: Pair -> it.second.name } + ?.distinct() + ?.toMutableList() ?: mutableListOf() + return drivers + } + + fun installDefaultDriver() { + // Removing the installed driver will result in the backend using the default system driver. + File(driverInstallationPath!!).deleteRecursively() + initializeDriverParameters() + } + + fun copyDriverToInternalStorage(driverUri: Uri): Boolean { + // Ensure we have directories. + initializeDirectories() + + // Copy the zip file URI to user data + val copiedFile = + FileUtil.copyUriToInternalStorage(driverUri, driverStoragePath) ?: return false + + // Validate driver + val metadata = getMetadataFromZip(copiedFile) + if (metadata.name == null) { + copiedFile.delete() + return false + } + + if (metadata.minApi > Build.VERSION.SDK_INT) { + copiedFile.delete() + return false + } + return true + } + + /** + * Copies driver zip into user data directory so that it can be exported along with + * other user data and also unzipped into the installation directory + */ + fun installCustomDriver(driverUri: Uri): Boolean { + // Revert to system default in the event the specified driver is bad. + installDefaultDriver() + + // Ensure we have directories. + initializeDirectories() + + // Copy the zip file URI to user data + val copiedFile = + FileUtil.copyUriToInternalStorage(driverUri, driverStoragePath) ?: return false + + // Validate driver + val metadata = getMetadataFromZip(copiedFile) + if (metadata.name == null) { + copiedFile.delete() + return false + } + + if (metadata.minApi > Build.VERSION.SDK_INT) { + copiedFile.delete() + return false + } + + // Unzip the driver. + try { + FileUtil.unzipToInternalStorage( + copiedFile.path, + File(driverInstallationPath!!) + ) + } catch (e: SecurityException) { + return false + } + + // Initialize the driver parameters. + initializeDriverParameters() + + return true + } + + /** + * Unzips driver into installation directory + */ + fun installCustomDriver(driver: File): Boolean { + // Revert to system default in the event the specified driver is bad. + installDefaultDriver() + + // Ensure we have directories. + initializeDirectories() + + // Validate driver + val metadata = getMetadataFromZip(driver) + if (metadata.name == null) { + driver.delete() + return false + } + + // Unzip the driver to the private installation directory + try { + FileUtil.unzipToInternalStorage( + driver.path, + File(driverInstallationPath!!) + ) + } catch (e: SecurityException) { + return false + } + + // Initialize the driver parameters. + initializeDriverParameters() + + return true + } + + /** + * Takes in a zip file and reads the meta.json file for presentation to the UI + * + * @param driver Zip containing driver and meta.json file + * @return A non-null [GpuDriverMetadata] instance that may have null members + */ + fun getMetadataFromZip(driver: File): GpuDriverMetadata { + try { + ZipFile(driver).use { zf -> + val entries = zf.entries() + while (entries.hasMoreElements()) { + val entry = entries.nextElement() + if (!entry.isDirectory && entry.name.lowercase().contains(".json")) { + zf.getInputStream(entry).use { + return GpuDriverMetadata(it, entry.size) + } + } + } + } + } catch (_: ZipException) { + } catch (_: FileNotFoundException) { + } + return GpuDriverMetadata() + } + + external fun supportsCustomDriverLoading(): Boolean + + external fun getSystemDriverInfo( + surface: Surface = Surface(SurfaceTexture(true)), + hookLibPath: String = GpuDriverHelper.hookLibPath!! + ): Array? + + // Parse the custom driver metadata to retrieve the name. + val installedCustomDriverData: GpuDriverMetadata + get() = GpuDriverMetadata(File(driverInstallationPath + META_JSON_FILENAME)) + + val customDriverSettingData: GpuDriverMetadata + get() = getMetadataFromZip(File(StringSetting.DRIVER_PATH.getString())) + + fun initializeDirectories() { + // Ensure the file redirection directory exists. + val fileRedirectionDir = File(fileRedirectionPath!!) + if (!fileRedirectionDir.exists()) { + fileRedirectionDir.mkdirs() + } + // Ensure the driver installation directory exists. + val driverInstallationDir = File(driverInstallationPath!!) + if (!driverInstallationDir.exists()) { + driverInstallationDir.mkdirs() + } + // Ensure the driver storage directory exists + val driverStorageDirectory = File(driverStoragePath) + if (!driverStorageDirectory.exists()) { + driverStorageDirectory.mkdirs() + } + } +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/utils/GpuDriverMetadata.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/utils/GpuDriverMetadata.kt new file mode 100644 index 0000000..3e00e8a --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/utils/GpuDriverMetadata.kt @@ -0,0 +1,119 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.utils + +import java.io.IOException +import org.json.JSONException +import org.json.JSONObject +import java.io.File +import java.io.InputStream + +class GpuDriverMetadata { + /** + * Tries to get driver metadata information from a meta.json [File] + * + * @param metadataFile meta.json file provided with a GPU driver + */ + constructor(metadataFile: File) { + if (metadataFile.length() > MAX_META_SIZE_BYTES) { + return + } + + try { + val json = JSONObject(FileUtil.getStringFromFile(metadataFile)) + name = json.getString("name") + description = json.getString("description") + author = json.getString("author") + vendor = json.getString("vendor") + version = json.getString("driverVersion") + minApi = json.getInt("minApi") + libraryName = json.getString("libraryName") + } catch (e: JSONException) { + // JSON is malformed, ignore and treat as unsupported metadata. + } catch (e: IOException) { + // File is inaccessible, ignore and treat as unsupported metadata. + } + } + + /** + * Tries to get driver metadata information from an input stream that's intended to be + * from a zip file + * + * @param metadataStream ZipEntry input stream + * @param size Size of the file in bytes + */ + constructor(metadataStream: InputStream, size: Long) { + if (size > MAX_META_SIZE_BYTES) { + return + } + + try { + val json = JSONObject(FileUtil.getStringFromInputStream(metadataStream)) + name = json.getString("name") + description = json.getString("description") + author = json.getString("author") + vendor = json.getString("vendor") + version = json.getString("driverVersion") + minApi = json.getInt("minApi") + libraryName = json.getString("libraryName") + } catch (e: JSONException) { + // JSON is malformed, ignore and treat as unsupported metadata. + } catch (e: IOException) { + // File is inaccessible, ignore and treat as unsupported metadata. + } + } + + /** + * Creates an empty metadata instance + */ + constructor() + + override fun equals(other: Any?): Boolean { + if (other !is GpuDriverMetadata) { + return false + } + + return other.name == name && + other.description == description && + other.author == author && + other.vendor == vendor && + other.version == version && + other.minApi == minApi && + other.libraryName == libraryName + } + + override fun hashCode(): Int { + var result = name?.hashCode() ?: 0 + result = 31 * result + (description?.hashCode() ?: 0) + result = 31 * result + (author?.hashCode() ?: 0) + result = 31 * result + (vendor?.hashCode() ?: 0) + result = 31 * result + (version?.hashCode() ?: 0) + result = 31 * result + minApi + result = 31 * result + (libraryName?.hashCode() ?: 0) + return result + } + + override fun toString(): String = + """ + Name - $name + Description - $description + Author - $author + Vendor - $vendor + Version - $version + Min API - $minApi + Library Name - $libraryName + """.trimMargin().trimIndent() + + var name: String? = null + var description: String? = null + var author: String? = null + var vendor: String? = null + var version: String? = null + var minApi = 0 + var libraryName: String? = null + + companion object { + private const val MAX_META_SIZE_BYTES = 500000 + } +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/utils/InputHandler.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/utils/InputHandler.kt new file mode 100644 index 0000000..3aa83be --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/utils/InputHandler.kt @@ -0,0 +1,94 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +package org.sudachi.sudachi_emu.utils + +import android.view.InputDevice +import android.view.KeyEvent +import android.view.MotionEvent +import org.sudachi.sudachi_emu.features.input.NativeInput +import org.sudachi.sudachi_emu.features.input.SudachiInputOverlayDevice +import org.sudachi.sudachi_emu.features.input.SudachiPhysicalDevice + +object InputHandler { + var androidControllers = mapOf() + var registeredControllers = mutableListOf() + + fun dispatchKeyEvent(event: KeyEvent): Boolean { + val action = when (event.action) { + KeyEvent.ACTION_DOWN -> NativeInput.ButtonState.PRESSED + KeyEvent.ACTION_UP -> NativeInput.ButtonState.RELEASED + else -> return false + } + + var controllerData = androidControllers[event.device.controllerNumber] + if (controllerData == null) { + updateControllerData() + controllerData = androidControllers[event.device.controllerNumber] ?: return false + } + + NativeInput.onGamePadButtonEvent( + controllerData.getGUID(), + controllerData.getPort(), + event.keyCode, + action + ) + return true + } + + fun dispatchGenericMotionEvent(event: MotionEvent): Boolean { + val controllerData = + androidControllers[event.device.controllerNumber] ?: return false + event.device.motionRanges.forEach { + NativeInput.onGamePadAxisEvent( + controllerData.getGUID(), + controllerData.getPort(), + it.axis, + event.getAxisValue(it.axis) + ) + } + return true + } + + fun getDevices(): Map { + val gameControllerDeviceIds = mutableMapOf() + val deviceIds = InputDevice.getDeviceIds() + var port = 0 + val inputSettings = NativeConfig.getInputSettings(true) + deviceIds.forEach { deviceId -> + InputDevice.getDevice(deviceId)?.apply { + // Verify that the device has gamepad buttons, control sticks, or both. + if (sources and InputDevice.SOURCE_GAMEPAD == InputDevice.SOURCE_GAMEPAD || + sources and InputDevice.SOURCE_JOYSTICK == InputDevice.SOURCE_JOYSTICK + ) { + if (!gameControllerDeviceIds.contains(controllerNumber)) { + gameControllerDeviceIds[controllerNumber] = SudachiPhysicalDevice( + this, + port, + inputSettings[port].useSystemVibrator + ) + } + port++ + } + } + } + return gameControllerDeviceIds + } + + fun updateControllerData() { + androidControllers = getDevices() + androidControllers.forEach { + NativeInput.registerController(it.value) + } + + // Register the input overlay on a dedicated port for all player 1 vibrations + NativeInput.registerController(SudachiInputOverlayDevice(androidControllers.isEmpty(), 100)) + registeredControllers.clear() + NativeInput.getInputDevices().forEach { + registeredControllers.add(ParamPackage(it)) + } + registeredControllers.sortBy { it.get("port", 0) } + } + + fun InputDevice.getGUID(): String = String.format("%016x%016x", productId, vendorId) +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/utils/InsetsHelper.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/utils/InsetsHelper.kt new file mode 100644 index 0000000..01d0a93 --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/utils/InsetsHelper.kt @@ -0,0 +1,25 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +package org.sudachi.sudachi_emu.utils + +import android.annotation.SuppressLint +import android.content.Context + +object InsetsHelper { + const val THREE_BUTTON_NAVIGATION = 0 + const val TWO_BUTTON_NAVIGATION = 1 + const val GESTURE_NAVIGATION = 2 + + @SuppressLint("DiscouragedApi") + fun getSystemGestureType(context: Context): Int { + val resources = context.resources + val resourceId = + resources.getIdentifier("config_navBarInteractionMode", "integer", "android") + return if (resourceId != 0) { + resources.getInteger(resourceId) + } else { + 0 + } + } +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/utils/LifecycleUtils.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/utils/LifecycleUtils.kt new file mode 100644 index 0000000..2158a49 --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/utils/LifecycleUtils.kt @@ -0,0 +1,38 @@ +// SPDX-FileCopyrightText: 2024 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.utils + +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch + +/** + * Collects this [Flow] with a given [LifecycleOwner]. + * @param scope [LifecycleOwner] that this [Flow] will be collected with. + * @param repeatState When to repeat collection on this [Flow]. + * @param resetState Optional lambda to reset state of an underlying [MutableStateFlow] after + * [stateCollector] has been run. + * @param stateCollector Lambda that receives new state. + */ +inline fun Flow.collect( + scope: LifecycleOwner, + repeatState: Lifecycle.State = Lifecycle.State.CREATED, + crossinline resetState: () -> Unit = {}, + crossinline stateCollector: (state: T) -> Unit +) { + scope.apply { + lifecycleScope.launch { + repeatOnLifecycle(repeatState) { + this@collect.collect { + stateCollector(it) + resetState() + } + } + } + } +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/utils/Log.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/utils/Log.kt new file mode 100644 index 0000000..2995648 --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/utils/Log.kt @@ -0,0 +1,31 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.utils + +import android.os.Build + +object Log { + // Tracks whether we should share the old log or the current log + var gameLaunched = false + + external fun debug(message: String) + + external fun warning(message: String) + + external fun info(message: String) + + external fun error(message: String) + + external fun critical(message: String) + + fun logDeviceInfo() { + info("Device Manufacturer - ${Build.MANUFACTURER}") + info("Device Model - ${Build.MODEL}") + if (Build.VERSION.SDK_INT > Build.VERSION_CODES.R) { + info("SoC Manufacturer - ${Build.SOC_MANUFACTURER}") + info("SoC Model - ${Build.SOC_MODEL}") + } + info("Total System Memory - ${MemoryUtil.getDeviceRAM()}") + } +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/utils/MemoryUtil.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/utils/MemoryUtil.kt new file mode 100644 index 0000000..884ab83 --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/utils/MemoryUtil.kt @@ -0,0 +1,111 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.utils + +import android.app.ActivityManager +import android.content.Context +import android.os.Build +import org.sudachi.sudachi_emu.R +import org.sudachi.sudachi_emu.SudachiApplication +import java.util.Locale +import kotlin.math.ceil + +object MemoryUtil { + private val context get() = SudachiApplication.appContext + + private val Float.hundredths: String + get() = String.format(Locale.ROOT, "%.2f", this) + + // Required total system memory + const val REQUIRED_MEMORY = 8 + + const val Kb: Float = 1024F + const val Mb = Kb * 1024 + const val Gb = Mb * 1024 + const val Tb = Gb * 1024 + const val Pb = Tb * 1024 + const val Eb = Pb * 1024 + + fun bytesToSizeUnit(size: Float, roundUp: Boolean = false): String = + when { + size < Kb -> { + context.getString( + R.string.memory_formatted, + size.hundredths, + context.getString(R.string.memory_byte_shorthand) + ) + } + size < Mb -> { + context.getString( + R.string.memory_formatted, + if (roundUp) ceil(size / Kb) else (size / Kb).hundredths, + context.getString(R.string.memory_kilobyte) + ) + } + size < Gb -> { + context.getString( + R.string.memory_formatted, + if (roundUp) ceil(size / Mb) else (size / Mb).hundredths, + context.getString(R.string.memory_megabyte) + ) + } + size < Tb -> { + context.getString( + R.string.memory_formatted, + if (roundUp) ceil(size / Gb) else (size / Gb).hundredths, + context.getString(R.string.memory_gigabyte) + ) + } + size < Pb -> { + context.getString( + R.string.memory_formatted, + if (roundUp) ceil(size / Tb) else (size / Tb).hundredths, + context.getString(R.string.memory_terabyte) + ) + } + size < Eb -> { + context.getString( + R.string.memory_formatted, + if (roundUp) ceil(size / Pb) else (size / Pb).hundredths, + context.getString(R.string.memory_petabyte) + ) + } + else -> { + context.getString( + R.string.memory_formatted, + if (roundUp) ceil(size / Eb) else (size / Eb).hundredths, + context.getString(R.string.memory_exabyte) + ) + } + } + + val totalMemory: Float + get() { + val memInfo = ActivityManager.MemoryInfo() + with(context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager) { + getMemoryInfo(memInfo) + } + + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + memInfo.advertisedMem.toFloat() + } else { + memInfo.totalMem.toFloat() + } + } + + fun isLessThan(minimum: Int, size: Float): Boolean = + when (size) { + Kb -> totalMemory < Mb && totalMemory < minimum + Mb -> totalMemory < Gb && (totalMemory / Mb) < minimum + Gb -> totalMemory < Tb && (totalMemory / Gb) < minimum + Tb -> totalMemory < Pb && (totalMemory / Tb) < minimum + Pb -> totalMemory < Eb && (totalMemory / Pb) < minimum + Eb -> totalMemory / Eb < minimum + else -> totalMemory < Kb && totalMemory < minimum + } + + // Devices are unlikely to have 0.5GB increments of memory so we'll just round up to account for + // the potential error created by memInfo.totalMem + fun getDeviceRAM(): String = bytesToSizeUnit(totalMemory, true) +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/utils/NativeConfig.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/utils/NativeConfig.kt new file mode 100644 index 0000000..a8d3694 --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/utils/NativeConfig.kt @@ -0,0 +1,186 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.utils + +import org.sudachi.sudachi_emu.model.GameDir +import org.sudachi.sudachi_emu.overlay.model.OverlayControlData + +import org.sudachi.sudachi_emu.features.input.model.PlayerInput + +object NativeConfig { + /** + * Loads global config. + */ + @Synchronized + external fun initializeGlobalConfig() + + /** + * Destroys the stored global config object. This does not save the existing config. + */ + @Synchronized + external fun unloadGlobalConfig() + + /** + * Reads values in the global config file and saves them. + */ + @Synchronized + external fun reloadGlobalConfig() + + /** + * Saves global settings values in memory to disk. + */ + @Synchronized + external fun saveGlobalConfig() + + /** + * Creates per-game config for the specified parameters. Must be unloaded once per-game config + * is closed with [unloadPerGameConfig]. All switchable values that [NativeConfig] gets/sets + * will follow the per-game config until the global config is reloaded. + * + * @param programId String representation of the u64 programId + * @param fileName Filename of the game, including its extension + */ + @Synchronized + external fun initializePerGameConfig(programId: String, fileName: String) + + @Synchronized + external fun isPerGameConfigLoaded(): Boolean + + /** + * Saves per-game settings values in memory to disk. + */ + @Synchronized + external fun savePerGameConfig() + + /** + * Destroys the stored per-game config object. This does not save the config. + */ + @Synchronized + external fun unloadPerGameConfig() + + @Synchronized + external fun getBoolean(key: String, needsGlobal: Boolean): Boolean + + @Synchronized + external fun setBoolean(key: String, value: Boolean) + + @Synchronized + external fun getByte(key: String, needsGlobal: Boolean): Byte + + @Synchronized + external fun setByte(key: String, value: Byte) + + @Synchronized + external fun getShort(key: String, needsGlobal: Boolean): Short + + @Synchronized + external fun setShort(key: String, value: Short) + + @Synchronized + external fun getInt(key: String, needsGlobal: Boolean): Int + + @Synchronized + external fun setInt(key: String, value: Int) + + @Synchronized + external fun getFloat(key: String, needsGlobal: Boolean): Float + + @Synchronized + external fun setFloat(key: String, value: Float) + + @Synchronized + external fun getLong(key: String, needsGlobal: Boolean): Long + + @Synchronized + external fun setLong(key: String, value: Long) + + @Synchronized + external fun getString(key: String, needsGlobal: Boolean): String + + @Synchronized + external fun setString(key: String, value: String) + + external fun getIsRuntimeModifiable(key: String): Boolean + + external fun getPairedSettingKey(key: String): String + + external fun getIsSwitchable(key: String): Boolean + + @Synchronized + external fun usingGlobal(key: String): Boolean + + @Synchronized + external fun setGlobal(key: String, global: Boolean) + + external fun getIsSaveable(key: String): Boolean + + external fun getDefaultToString(key: String): String + + /** + * Gets every [GameDir] in AndroidSettings::values.game_dirs + */ + @Synchronized + external fun getGameDirs(): Array + + /** + * Clears the AndroidSettings::values.game_dirs array and replaces them with the provided array + */ + @Synchronized + external fun setGameDirs(dirs: Array) + + /** + * Adds a single [GameDir] to the AndroidSettings::values.game_dirs array + */ + @Synchronized + external fun addGameDir(dir: GameDir) + + /** + * Gets an array of the addons that are disabled for a given game + * + * @param programId String representation of a game's program ID + * @return An array of disabled addons + */ + @Synchronized + external fun getDisabledAddons(programId: String): Array + + /** + * Clears the disabled addons array corresponding to [programId] and replaces them + * with [disabledAddons] + * + * @param programId String representation of a game's program ID + * @param disabledAddons Replacement array of disabled addons + */ + @Synchronized + external fun setDisabledAddons(programId: String, disabledAddons: Array) + + /** + * Gets an array of [OverlayControlData] from settings + * + * @return An array of [OverlayControlData] + */ + @Synchronized + external fun getOverlayControlData(): Array + + /** + * Clears the AndroidSettings::values.overlay_control_data array and replaces its values + * with [overlayControlData] + * + * @param overlayControlData Replacement array of [OverlayControlData] + */ + @Synchronized + external fun setOverlayControlData(overlayControlData: Array) + + @Synchronized + external fun getInputSettings(global: Boolean): Array + + @Synchronized + external fun setInputSettings(value: Array, global: Boolean) + + /** + * Saves control values for a specific player + * Must be used when per game config is loaded + */ + @Synchronized + external fun saveControlPlayerValues() +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/utils/NfcReader.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/utils/NfcReader.kt new file mode 100644 index 0000000..b855c46 --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/utils/NfcReader.kt @@ -0,0 +1,171 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +package org.sudachi.sudachi_emu.utils + +import android.app.Activity +import android.app.PendingIntent +import android.content.Intent +import android.content.IntentFilter +import android.nfc.NfcAdapter +import android.nfc.Tag +import android.nfc.tech.NfcA +import android.os.Build +import android.os.Handler +import android.os.Looper +import java.io.IOException +import org.sudachi.sudachi_emu.features.input.NativeInput + +class NfcReader(private val activity: Activity) { + private var nfcAdapter: NfcAdapter? = null + private var pendingIntent: PendingIntent? = null + + fun initialize() { + nfcAdapter = NfcAdapter.getDefaultAdapter(activity) ?: return + + pendingIntent = PendingIntent.getActivity( + activity, + 0, + Intent(activity, activity.javaClass), + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE + } else { + PendingIntent.FLAG_UPDATE_CURRENT + } + ) + + val tagDetected = IntentFilter(NfcAdapter.ACTION_TAG_DISCOVERED) + tagDetected.addCategory(Intent.CATEGORY_DEFAULT) + } + + fun startScanning() { + nfcAdapter?.enableForegroundDispatch(activity, pendingIntent, null, null) + } + + fun stopScanning() { + nfcAdapter?.disableForegroundDispatch(activity) + } + + fun onNewIntent(intent: Intent) { + val action = intent.action + if (NfcAdapter.ACTION_TAG_DISCOVERED != action && + NfcAdapter.ACTION_TECH_DISCOVERED != action && + NfcAdapter.ACTION_NDEF_DISCOVERED != action + ) { + return + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + val tag = + intent.getParcelableExtra(NfcAdapter.EXTRA_TAG, Tag::class.java) ?: return + readTagData(tag) + return + } + + val tag = + intent.getParcelableExtra(NfcAdapter.EXTRA_TAG) ?: return + readTagData(tag) + } + + private fun readTagData(tag: Tag) { + if (!tag.techList.contains("android.nfc.tech.NfcA")) { + return + } + + val amiibo = NfcA.get(tag) ?: return + amiibo.connect() + + val tagData = ntag215ReadAll(amiibo) ?: return + NativeInput.onReadNfcTag(tagData) + + nfcAdapter?.ignore( + tag, + 1000, + { NativeInput.onRemoveNfcTag() }, + Handler(Looper.getMainLooper()) + ) + } + + private fun ntag215ReadAll(amiibo: NfcA): ByteArray? { + val bufferSize = amiibo.maxTransceiveLength + val tagSize = 0x21C + val pageSize = 4 + val lastPage = tagSize / pageSize - 1 + val tagData = ByteArray(tagSize) + + // We need to read the ntag in steps otherwise we overflow the buffer + for (i in 0..tagSize step bufferSize - 1) { + val dataStart = i / pageSize + var dataEnd = (i + bufferSize) / pageSize + + if (dataEnd > lastPage) { + dataEnd = lastPage + } + + try { + val data = ntag215FastRead(amiibo, dataStart, dataEnd - 1) + System.arraycopy(data, 0, tagData, i, (dataEnd - dataStart) * pageSize) + } catch (e: IOException) { + return null + } + } + return tagData + } + + private fun ntag215Read(amiibo: NfcA, page: Int): ByteArray? { + return amiibo.transceive( + byteArrayOf( + 0x30.toByte(), + (page and 0xFF).toByte() + ) + ) + } + + private fun ntag215FastRead(amiibo: NfcA, start: Int, end: Int): ByteArray? { + return amiibo.transceive( + byteArrayOf( + 0x3A.toByte(), + (start and 0xFF).toByte(), + (end and 0xFF).toByte() + ) + ) + } + + private fun ntag215PWrite( + amiibo: NfcA, + page: Int, + data1: Int, + data2: Int, + data3: Int, + data4: Int + ): ByteArray? { + return amiibo.transceive( + byteArrayOf( + 0xA2.toByte(), + (page and 0xFF).toByte(), + (data1 and 0xFF).toByte(), + (data2 and 0xFF).toByte(), + (data3 and 0xFF).toByte(), + (data4 and 0xFF).toByte() + ) + ) + } + + private fun ntag215PwdAuth( + amiibo: NfcA, + data1: Int, + data2: Int, + data3: Int, + data4: Int + ): ByteArray? { + return amiibo.transceive( + byteArrayOf( + 0x1B.toByte(), + (data1 and 0xFF).toByte(), + (data2 and 0xFF).toByte(), + (data3 and 0xFF).toByte(), + (data4 and 0xFF).toByte() + ) + ) + } +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/utils/ParamPackage.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/utils/ParamPackage.kt new file mode 100644 index 0000000..8d9ccc4 --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/utils/ParamPackage.kt @@ -0,0 +1,141 @@ +// SPDX-FileCopyrightText: 2024 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.utils + +// Kotlin version of src/common/param_package.h +class ParamPackage(serialized: String = "") { + private val KEY_VALUE_SEPARATOR = ":" + private val PARAM_SEPARATOR = "," + + private val ESCAPE_CHARACTER = "$" + private val KEY_VALUE_SEPARATOR_ESCAPE = "$0" + private val PARAM_SEPARATOR_ESCAPE = "$1" + private val ESCAPE_CHARACTER_ESCAPE = "$2" + + private val EMPTY_PLACEHOLDER = "[empty]" + + val data = mutableMapOf() + + init { + val pairs = serialized.split(PARAM_SEPARATOR) + for (pair in pairs) { + val keyValue = pair.split(KEY_VALUE_SEPARATOR).toMutableList() + if (keyValue.size != 2) { + Log.error("[ParamPackage] Invalid key pair $keyValue") + continue + } + + keyValue.forEachIndexed { i: Int, _: String -> + keyValue[i] = keyValue[i].replace(KEY_VALUE_SEPARATOR_ESCAPE, KEY_VALUE_SEPARATOR) + keyValue[i] = keyValue[i].replace(PARAM_SEPARATOR_ESCAPE, PARAM_SEPARATOR) + keyValue[i] = keyValue[i].replace(ESCAPE_CHARACTER_ESCAPE, ESCAPE_CHARACTER) + } + + set(keyValue[0], keyValue[1]) + } + } + + constructor(params: List>) : this() { + params.forEach { + data[it.first] = it.second + } + } + + fun serialize(): String { + if (data.isEmpty()) { + return EMPTY_PLACEHOLDER + } + + val result = StringBuilder() + data.forEach { + val keyValue = mutableListOf(it.key, it.value) + keyValue.forEachIndexed { i, _ -> + keyValue[i] = keyValue[i].replace(ESCAPE_CHARACTER, ESCAPE_CHARACTER_ESCAPE) + keyValue[i] = keyValue[i].replace(PARAM_SEPARATOR, PARAM_SEPARATOR_ESCAPE) + keyValue[i] = keyValue[i].replace(KEY_VALUE_SEPARATOR, KEY_VALUE_SEPARATOR_ESCAPE) + } + result.append("${keyValue[0]}$KEY_VALUE_SEPARATOR${keyValue[1]}$PARAM_SEPARATOR") + } + return result.removeSuffix(PARAM_SEPARATOR).toString() + } + + fun get(key: String, defaultValue: String): String = + if (has(key)) { + data[key]!! + } else { + Log.debug("[ParamPackage] key $key not found") + defaultValue + } + + fun get(key: String, defaultValue: Int): Int = + if (has(key)) { + try { + data[key]!!.toInt() + } catch (e: NumberFormatException) { + Log.debug("[ParamPackage] failed to convert ${data[key]!!} to int") + defaultValue + } + } else { + Log.debug("[ParamPackage] key $key not found") + defaultValue + } + + private fun Int.toBoolean(): Boolean = + if (this == 1) { + true + } else if (this == 0) { + false + } else { + throw Exception("Tried to convert a value to a boolean that was not 0 or 1!") + } + + fun get(key: String, defaultValue: Boolean): Boolean = + if (has(key)) { + try { + get(key, if (defaultValue) 1 else 0).toBoolean() + } catch (e: Exception) { + Log.debug("[ParamPackage] failed to convert ${data[key]!!} to boolean") + defaultValue + } + } else { + Log.debug("[ParamPackage] key $key not found") + defaultValue + } + + fun get(key: String, defaultValue: Float): Float = + if (has(key)) { + try { + data[key]!!.toFloat() + } catch (e: NumberFormatException) { + Log.debug("[ParamPackage] failed to convert ${data[key]!!} to float") + defaultValue + } + } else { + Log.debug("[ParamPackage] key $key not found") + defaultValue + } + + fun set(key: String, value: String) { + data[key] = value + } + + fun set(key: String, value: Int) { + data[key] = value.toString() + } + + fun Boolean.toInt(): Int = if (this) 1 else 0 + fun set(key: String, value: Boolean) { + data[key] = value.toInt().toString() + } + + fun set(key: String, value: Float) { + data[key] = value.toString() + } + + fun has(key: String): Boolean = data.containsKey(key) + + fun erase(key: String) = data.remove(key) + + fun clear() = data.clear() +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/utils/PreferenceUtil.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/utils/PreferenceUtil.kt new file mode 100644 index 0000000..a3daa27 --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/utils/PreferenceUtil.kt @@ -0,0 +1,37 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.utils + +import android.content.SharedPreferences + +object PreferenceUtil { + /** + * Retrieves a shared preference value and then deletes the value in storage. + * @param key Associated key for the value in this preferences instance + * @return Typed value associated with [key]. Null if no such key exists. + */ + inline fun SharedPreferences.migratePreference(key: String): T? { + if (!this.contains(key)) { + return null + } + + val value: Any = when (T::class) { + String::class -> this.getString(key, "")!! + + Boolean::class -> this.getBoolean(key, false) + + Int::class -> this.getInt(key, 0) + + Float::class -> this.getFloat(key, 0f) + + Long::class -> this.getLong(key, 0) + + else -> throw IllegalStateException("Tried to migrate preference with invalid type!") + } + deletePreference(key) + return value as T + } + + fun SharedPreferences.deletePreference(key: String) = this.edit().remove(key).apply() +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/utils/SerializableHelper.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/utils/SerializableHelper.kt new file mode 100644 index 0000000..a8182ab --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/utils/SerializableHelper.kt @@ -0,0 +1,44 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +package org.sudachi.sudachi_emu.utils + +import android.content.Intent +import android.os.Build +import android.os.Bundle +import android.os.Parcelable +import java.io.Serializable + +object SerializableHelper { + inline fun Bundle.serializable(key: String): T? { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + getSerializable(key, T::class.java) + } else { + getSerializable(key) as? T + } + } + + inline fun Intent.serializable(key: String): T? { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + getSerializableExtra(key, T::class.java) + } else { + getSerializableExtra(key) as? T + } + } + + inline fun Bundle.parcelable(key: String): T? { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + getParcelable(key, T::class.java) + } else { + getParcelable(key) as? T + } + } + + inline fun Intent.parcelable(key: String): T? { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + getParcelableExtra(key, T::class.java) + } else { + getParcelableExtra(key) as? T + } + } +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/utils/ThemeHelper.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/utils/ThemeHelper.kt new file mode 100644 index 0000000..0d44249 --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/utils/ThemeHelper.kt @@ -0,0 +1,105 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.utils + +import android.content.res.Configuration +import android.graphics.Color +import android.os.Build +import androidx.annotation.ColorInt +import androidx.appcompat.app.AppCompatActivity +import androidx.appcompat.app.AppCompatDelegate +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsControllerCompat +import kotlin.math.roundToInt +import org.sudachi.sudachi_emu.R +import org.sudachi.sudachi_emu.features.settings.model.BooleanSetting +import org.sudachi.sudachi_emu.features.settings.model.IntSetting +import org.sudachi.sudachi_emu.ui.main.ThemeProvider + +object ThemeHelper { + const val SYSTEM_BAR_ALPHA = 0.9f + + fun setTheme(activity: AppCompatActivity) { + setThemeMode(activity) + when (Theme.from(IntSetting.THEME.getInt())) { + Theme.Default -> activity.setTheme(R.style.Theme_Sudachi_Main) + Theme.MaterialYou -> { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + activity.setTheme(R.style.Theme_Sudachi_Main_MaterialYou) + } else { + activity.setTheme(R.style.Theme_Sudachi_Main) + } + } + } + + // Using a specific night mode check because this could apply incorrectly when using the + // light app mode, dark system mode, and black backgrounds. Launching the settings activity + // will then show light mode colors/navigation bars but with black backgrounds. + if (BooleanSetting.BLACK_BACKGROUNDS.getBoolean() && isNightMode(activity)) { + activity.setTheme(R.style.ThemeOverlay_Sudachi_Dark) + } + } + + @ColorInt + fun getColorWithOpacity(@ColorInt color: Int, alphaFactor: Float): Int { + return Color.argb( + (alphaFactor * Color.alpha(color)).roundToInt(), + Color.red(color), + Color.green(color), + Color.blue(color) + ) + } + + fun setCorrectTheme(activity: AppCompatActivity) { + val currentTheme = (activity as ThemeProvider).themeId + setTheme(activity) + if (currentTheme != (activity as ThemeProvider).themeId) { + activity.recreate() + } + } + + fun setThemeMode(activity: AppCompatActivity) { + val themeMode = IntSetting.THEME_MODE.getInt() + activity.delegate.localNightMode = themeMode + val windowController = WindowCompat.getInsetsController( + activity.window, + activity.window.decorView + ) + when (themeMode) { + AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM -> when (isNightMode(activity)) { + false -> setLightModeSystemBars(windowController) + true -> setDarkModeSystemBars(windowController) + } + AppCompatDelegate.MODE_NIGHT_NO -> setLightModeSystemBars(windowController) + AppCompatDelegate.MODE_NIGHT_YES -> setDarkModeSystemBars(windowController) + } + } + + private fun isNightMode(activity: AppCompatActivity): Boolean { + return when (activity.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) { + Configuration.UI_MODE_NIGHT_NO -> false + Configuration.UI_MODE_NIGHT_YES -> true + else -> false + } + } + + private fun setLightModeSystemBars(windowController: WindowInsetsControllerCompat) { + windowController.isAppearanceLightStatusBars = true + windowController.isAppearanceLightNavigationBars = true + } + + private fun setDarkModeSystemBars(windowController: WindowInsetsControllerCompat) { + windowController.isAppearanceLightStatusBars = false + windowController.isAppearanceLightNavigationBars = false + } +} + +enum class Theme(val int: Int) { + Default(0), + MaterialYou(1); + + companion object { + fun from(int: Int): Theme = entries.firstOrNull { it.int == int } ?: Default + } +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/utils/ViewUtils.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/utils/ViewUtils.kt new file mode 100644 index 0000000..4509cdc --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/utils/ViewUtils.kt @@ -0,0 +1,93 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.utils + +import android.text.TextUtils +import android.view.View +import android.view.ViewGroup +import android.widget.TextView + +object ViewUtils { + fun showView(view: View, length: Long = 300) { + view.apply { + alpha = 0f + visibility = View.VISIBLE + isClickable = true + }.animate().apply { + duration = length + alpha(1f) + }.start() + } + + fun hideView(view: View, length: Long = 300) { + if (view.visibility == View.INVISIBLE) { + return + } + + view.apply { + alpha = 1f + isClickable = false + }.animate().apply { + duration = length + alpha(0f) + }.withEndAction { + view.visibility = View.INVISIBLE + }.start() + } + + fun View.updateMargins( + left: Int = -1, + top: Int = -1, + right: Int = -1, + bottom: Int = -1 + ) { + val layoutParams = this.layoutParams as ViewGroup.MarginLayoutParams + layoutParams.apply { + if (left != -1) { + leftMargin = left + } + if (top != -1) { + topMargin = top + } + if (right != -1) { + rightMargin = right + } + if (bottom != -1) { + bottomMargin = bottom + } + } + this.layoutParams = layoutParams + } + + /** + * Shows or hides a view. + * @param visible Whether a view will be made View.VISIBLE or View.INVISIBLE/GONE. + * @param gone Optional parameter for hiding a view. Uses View.GONE if true and View.INVISIBLE otherwise. + */ + fun View.setVisible(visible: Boolean, gone: Boolean = true) { + visibility = if (visible) { + View.VISIBLE + } else { + if (gone) { + View.GONE + } else { + View.INVISIBLE + } + } + } + + /** + * Starts a marquee on some text. + * @param delay Optional parameter for changing the start delay. 3 seconds of delay by default. + */ + fun TextView.marquee(delay: Long = 3000) { + ellipsize = null + marqueeRepeatLimit = -1 + isSingleLine = true + postDelayed({ + ellipsize = TextUtils.TruncateAt.MARQUEE + isSelected = true + }, delay) + } +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/viewholder/AbstractViewHolder.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/viewholder/AbstractViewHolder.kt new file mode 100644 index 0000000..b30b68b --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/viewholder/AbstractViewHolder.kt @@ -0,0 +1,18 @@ +// SPDX-FileCopyrightText: 2024 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.viewholder + +import androidx.recyclerview.widget.RecyclerView +import androidx.viewbinding.ViewBinding +import org.sudachi.sudachi_emu.adapters.AbstractDiffAdapter +import org.sudachi.sudachi_emu.adapters.AbstractListAdapter + +/** + * [RecyclerView.ViewHolder] meant to work together with a [AbstractDiffAdapter] or a + * [AbstractListAdapter] so we can run [bind] on each list item without needing a manual hookup. + */ +abstract class AbstractViewHolder(binding: ViewBinding) : + RecyclerView.ViewHolder(binding.root) { + abstract fun bind(model: Model) +} diff --git a/src/android/app/src/main/java/org/sudachi/sudachi_emu/views/FixedRatioSurfaceView.kt b/src/android/app/src/main/java/org/sudachi/sudachi_emu/views/FixedRatioSurfaceView.kt new file mode 100644 index 0000000..1a2874c --- /dev/null +++ b/src/android/app/src/main/java/org/sudachi/sudachi_emu/views/FixedRatioSurfaceView.kt @@ -0,0 +1,64 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.views + +import android.content.Context +import android.util.AttributeSet +import android.util.Rational +import android.view.SurfaceView + +class FixedRatioSurfaceView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : SurfaceView(context, attrs, defStyleAttr) { + private var aspectRatio: Float = 0f // (width / height), 0f is a special value for stretch + + /** + * Sets the desired aspect ratio for this view + * @param ratio the ratio to force the view to, or null to stretch to fit + */ + fun setAspectRatio(ratio: Rational?) { + aspectRatio = ratio?.toFloat() ?: 0f + requestLayout() + } + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + val displayWidth: Float = MeasureSpec.getSize(widthMeasureSpec).toFloat() + val displayHeight: Float = MeasureSpec.getSize(heightMeasureSpec).toFloat() + if (aspectRatio != 0f) { + val displayAspect = displayWidth / displayHeight + if (displayAspect < aspectRatio) { + // Max out width + val halfHeight = displayHeight / 2 + val surfaceHeight = displayWidth / aspectRatio + val newTop: Float = halfHeight - (surfaceHeight / 2) + val newBottom: Float = halfHeight + (surfaceHeight / 2) + super.onMeasure( + widthMeasureSpec, + MeasureSpec.makeMeasureSpec( + newBottom.toInt() - newTop.toInt(), + MeasureSpec.EXACTLY + ) + ) + return + } else { + // Max out height + val halfWidth = displayWidth / 2 + val surfaceWidth = displayHeight * aspectRatio + val newLeft: Float = halfWidth - (surfaceWidth / 2) + val newRight: Float = halfWidth + (surfaceWidth / 2) + super.onMeasure( + MeasureSpec.makeMeasureSpec( + newRight.toInt() - newLeft.toInt(), + MeasureSpec.EXACTLY + ), + heightMeasureSpec + ) + return + } + } + super.onMeasure(widthMeasureSpec, heightMeasureSpec) + } +} diff --git a/src/android/app/src/main/res/drawable/ic_sudachi.xml b/src/android/app/src/main/res/drawable/ic_sudachi.xml new file mode 100644 index 0000000..5e2a8ef --- /dev/null +++ b/src/android/app/src/main/res/drawable/ic_sudachi.xml @@ -0,0 +1,22 @@ + + + + diff --git a/src/android/app/src/main/res/drawable/ic_sudachi_full.xml b/src/android/app/src/main/res/drawable/ic_sudachi_full.xml new file mode 100644 index 0000000..04e4584 --- /dev/null +++ b/src/android/app/src/main/res/drawable/ic_sudachi_full.xml @@ -0,0 +1,12 @@ + + + + diff --git a/src/android/app/src/main/res/drawable/ic_sudachi_title.xml b/src/android/app/src/main/res/drawable/ic_sudachi_title.xml new file mode 100644 index 0000000..b733e52 --- /dev/null +++ b/src/android/app/src/main/res/drawable/ic_sudachi_title.xml @@ -0,0 +1,24 @@ + + + + + + + + diff --git a/src/android/app/src/main/res/values-night/sudachi_colors.xml b/src/android/app/src/main/res/values-night/sudachi_colors.xml new file mode 100644 index 0000000..465204f --- /dev/null +++ b/src/android/app/src/main/res/values-night/sudachi_colors.xml @@ -0,0 +1,37 @@ + + + + #A7DDEC + #003399 + #31323F + #D1E4FF + #BAC8DB + #253140 + #3B4858 + #D6E4F7 + #D6BEE5 + #3A2948 + #524060 + #F2DAFF + #FFB4AB + #93000A + #690005 + #FFDAD6 + #1A1C1E + #E2E2E6 + #1B1B1D + #E2E2E6 + #26282C + #C3C7CF + #8C9199 + #1A1C1E + #E2E2E6 + #0062A2 + #000000 + #9DCAFF + #42474E + + #840099 + #005AE1 + + diff --git a/src/android/app/src/main/res/values/sudachi_colors.xml b/src/android/app/src/main/res/values/sudachi_colors.xml new file mode 100644 index 0000000..7f291c5 --- /dev/null +++ b/src/android/app/src/main/res/values/sudachi_colors.xml @@ -0,0 +1,37 @@ + + + + #990E00 + #FFFFFF + #EEDEDD + #400200 + #775650 + #FFFFFF + #FFDAD4 + #2C1511 + #6F5C2E + #FFFFFF + #FAE0A6 + #251A00 + #BA1A1A + #FFDAD6 + #FFFFFF + #410002 + #FFFBFF + #201A19 + #FFFBFF + #201A19 + #F5DDD9 + #534340 + #857370 + #FBEEEB + #362F2D + #FFB4A6 + #000000 + #B52612 + #D8C2BE + + #99FFE1 + #76C5FF + + diff --git a/src/dedicated_room/sudachi_room.cpp b/src/dedicated_room/sudachi_room.cpp new file mode 100644 index 0000000..169813c --- /dev/null +++ b/src/dedicated_room/sudachi_room.cpp @@ -0,0 +1,403 @@ +// SPDX-FileCopyrightText: Copyright 2017 Citra Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include +#include +#include +#include +#include +#include +#include + +#ifdef _WIN32 +// windows.h needs to be included before shellapi.h +#include + +#include +#endif + +#include +#include "common/common_types.h" +#include "common/detached_tasks.h" +#include "common/fs/file.h" +#include "common/fs/fs.h" +#include "common/fs/path_util.h" +#include "common/logging/backend.h" +#include "common/logging/log.h" +#include "common/scm_rev.h" +#include "common/settings.h" +#include "common/string_util.h" +#include "core/core.h" +#include "network/announce_multiplayer_session.h" +#include "network/network.h" +#include "network/room.h" +#include "network/verify_user.h" + +#ifdef ENABLE_WEB_SERVICE +#include "web_service/verify_user_jwt.h" +#endif + +#undef _UNICODE +#include +#ifndef _MSC_VER +#include +#endif + +static void PrintHelp(const char* argv0) { + LOG_INFO(Network, + "Usage: {}" + " [options] \n" + "--room-name The name of the room\n" + "--room-description The room description\n" + "--bind-address The bind address for the room\n" + "--port The port used for the room\n" + "--max_members The maximum number of players for this room\n" + "--password The password for the room\n" + "--preferred-game The preferred game for this room\n" + "--preferred-game-id The preferred game-id for this room\n" + "--username The username used for announce\n" + "--token The token used for announce\n" + "--web-api-url sudachi Web API url\n" + "--ban-list-file The file for storing the room ban list\n" + "--log-file The file for storing the room log\n" + "--enable-sudachi-mods Allow sudachi Community Moderators to moderate on your room\n" + "-h, --help Display this help and exit\n" + "-v, --version Output version information and exit\n", + argv0); +} + +static void PrintVersion() { + LOG_INFO(Network, "sudachi dedicated room {} {} Libnetwork: {}", Common::g_scm_branch, + Common::g_scm_desc, Network::network_version); +} + +/// The magic text at the beginning of a sudachi-room ban list file. +static constexpr char BanListMagic[] = "SudachiRoom-BanList-1"; + +static constexpr char token_delimiter{':'}; + +static void PadToken(std::string& token) { + std::size_t outlen = 0; + + std::array output{}; + std::array roundtrip{}; + for (size_t i = 0; i < 3; i++) { + mbedtls_base64_decode(output.data(), output.size(), &outlen, + reinterpret_cast(token.c_str()), + token.length()); + mbedtls_base64_encode(roundtrip.data(), roundtrip.size(), &outlen, output.data(), outlen); + if (memcmp(roundtrip.data(), token.data(), token.size()) == 0) { + break; + } + token.push_back('='); + } +} + +static std::string UsernameFromDisplayToken(const std::string& display_token) { + std::size_t outlen; + + std::array output{}; + mbedtls_base64_decode(output.data(), output.size(), &outlen, + reinterpret_cast(display_token.c_str()), + display_token.length()); + std::string decoded_display_token(reinterpret_cast(&output), outlen); + return decoded_display_token.substr(0, decoded_display_token.find(token_delimiter)); +} + +static std::string TokenFromDisplayToken(const std::string& display_token) { + std::size_t outlen; + + std::array output{}; + mbedtls_base64_decode(output.data(), output.size(), &outlen, + reinterpret_cast(display_token.c_str()), + display_token.length()); + std::string decoded_display_token(reinterpret_cast(&output), outlen); + return decoded_display_token.substr(decoded_display_token.find(token_delimiter) + 1); +} + +static Network::Room::BanList LoadBanList(const std::string& path) { + std::ifstream file; + Common::FS::OpenFileStream(file, path, std::ios_base::in); + if (!file || file.eof()) { + LOG_ERROR(Network, "Could not open ban list!"); + return {}; + } + std::string magic; + std::getline(file, magic); + if (magic != BanListMagic) { + LOG_ERROR(Network, "Ban list is not valid!"); + return {}; + } + + // false = username ban list, true = ip ban list + bool ban_list_type = false; + Network::Room::UsernameBanList username_ban_list; + Network::Room::IPBanList ip_ban_list; + while (!file.eof()) { + std::string line; + std::getline(file, line); + line.erase(std::remove(line.begin(), line.end(), '\0'), line.end()); + line = Common::StripSpaces(line); + if (line.empty()) { + // An empty line marks start of the IP ban list + ban_list_type = true; + continue; + } + if (ban_list_type) { + ip_ban_list.emplace_back(line); + } else { + username_ban_list.emplace_back(line); + } + } + + return {username_ban_list, ip_ban_list}; +} + +static void SaveBanList(const Network::Room::BanList& ban_list, const std::string& path) { + std::ofstream file; + Common::FS::OpenFileStream(file, path, std::ios_base::out); + if (!file) { + LOG_ERROR(Network, "Could not save ban list!"); + return; + } + + file << BanListMagic << "\n"; + + // Username ban list + for (const auto& username : ban_list.first) { + file << username << "\n"; + } + file << "\n"; + + // IP ban list + for (const auto& ip : ban_list.second) { + file << ip << "\n"; + } +} + +static void InitializeLogging(const std::string& log_file) { + Common::Log::Initialize(); + Common::Log::SetColorConsoleBackendEnabled(true); + Common::Log::Start(); +} + +/// Application entry point +int main(int argc, char** argv) { + Common::DetachedTasks detached_tasks; + int option_index = 0; + char* endarg; + + std::string room_name; + std::string room_description; + std::string password; + std::string preferred_game; + std::string username; + std::string token; + std::string web_api_url; + std::string ban_list_file; + std::string log_file = "sudachi-room.log"; + std::string bind_address; + u64 preferred_game_id = 0; + u32 port = Network::DefaultRoomPort; + u32 max_members = 16; + bool enable_sudachi_mods = false; + + static struct option long_options[] = { + {"room-name", required_argument, 0, 'n'}, + {"room-description", required_argument, 0, 'd'}, + {"bind-address", required_argument, 0, 's'}, + {"port", required_argument, 0, 'p'}, + {"max_members", required_argument, 0, 'm'}, + {"password", required_argument, 0, 'w'}, + {"preferred-game", required_argument, 0, 'g'}, + {"preferred-game-id", required_argument, 0, 'i'}, + {"username", optional_argument, 0, 'u'}, + {"token", required_argument, 0, 't'}, + {"web-api-url", required_argument, 0, 'a'}, + {"ban-list-file", required_argument, 0, 'b'}, + {"log-file", required_argument, 0, 'l'}, + {"enable-sudachi-mods", no_argument, 0, 'e'}, + {"help", no_argument, 0, 'h'}, + {"version", no_argument, 0, 'v'}, + {0, 0, 0, 0}, + }; + + InitializeLogging(log_file); + + while (optind < argc) { + int arg = + getopt_long(argc, argv, "n:d:s:p:m:w:g:u:t:a:i:l:hv", long_options, &option_index); + if (arg != -1) { + switch (static_cast(arg)) { + case 'n': + room_name.assign(optarg); + break; + case 'd': + room_description.assign(optarg); + break; + case 's': + bind_address.assign(optarg); + break; + case 'p': + port = strtoul(optarg, &endarg, 0); + break; + case 'm': + max_members = strtoul(optarg, &endarg, 0); + break; + case 'w': + password.assign(optarg); + break; + case 'g': + preferred_game.assign(optarg); + break; + case 'i': + preferred_game_id = strtoull(optarg, &endarg, 16); + break; + case 'u': + username.assign(optarg); + break; + case 't': + token.assign(optarg); + break; + case 'a': + web_api_url.assign(optarg); + break; + case 'b': + ban_list_file.assign(optarg); + break; + case 'l': + log_file.assign(optarg); + break; + case 'e': + enable_sudachi_mods = true; + break; + case 'h': + PrintHelp(argv[0]); + return 0; + case 'v': + PrintVersion(); + return 0; + } + } + } + + if (room_name.empty()) { + LOG_ERROR(Network, "Room name is empty!"); + PrintHelp(argv[0]); + return -1; + } + if (preferred_game.empty()) { + LOG_ERROR(Network, "Preferred game is empty!"); + PrintHelp(argv[0]); + return -1; + } + if (preferred_game_id == 0) { + LOG_ERROR(Network, + "preferred-game-id not set!\nThis should get set to allow users to find your " + "room.\nSet with --preferred-game-id id"); + } + if (max_members > Network::MaxConcurrentConnections || max_members < 2) { + LOG_ERROR(Network, "max_members needs to be in the range 2 - {}!", + Network::MaxConcurrentConnections); + PrintHelp(argv[0]); + return -1; + } + if (bind_address.empty()) { + LOG_INFO(Network, "Bind address is empty: defaulting to 0.0.0.0"); + } + if (port > UINT16_MAX) { + LOG_ERROR(Network, "Port needs to be in the range 0 - 65535!"); + PrintHelp(argv[0]); + return -1; + } + if (ban_list_file.empty()) { + LOG_ERROR(Network, "Ban list file not set!\nThis should get set to load and save room ban " + "list.\nSet with --ban-list-file "); + } + bool announce = true; + if (token.empty() && announce) { + announce = false; + LOG_INFO(Network, "Token is empty: Hosting a private room"); + } + if (web_api_url.empty() && announce) { + announce = false; + LOG_INFO(Network, "Endpoint url is empty: Hosting a private room"); + } + if (announce) { + if (username.empty()) { + LOG_INFO(Network, "Hosting a public room"); + Settings::values.web_api_url = web_api_url; + PadToken(token); + Settings::values.sudachi_username = UsernameFromDisplayToken(token); + username = Settings::values.sudachi_username.GetValue(); + Settings::values.sudachi_token = TokenFromDisplayToken(token); + } else { + LOG_INFO(Network, "Hosting a public room"); + Settings::values.web_api_url = web_api_url; + Settings::values.sudachi_username = username; + Settings::values.sudachi_token = token; + } + } + if (!announce && enable_sudachi_mods) { + enable_sudachi_mods = false; + LOG_INFO(Network, "Can not enable sudachi Moderators for private rooms"); + } + + // Load the ban list + Network::Room::BanList ban_list; + if (!ban_list_file.empty()) { + ban_list = LoadBanList(ban_list_file); + } + + std::unique_ptr verify_backend; + if (announce) { +#ifdef ENABLE_WEB_SERVICE + verify_backend = + std::make_unique(Settings::values.web_api_url.GetValue()); +#else + LOG_INFO(Network, + "sudachi Web Services is not available with this build: validation is disabled."); + verify_backend = std::make_unique(); +#endif + } else { + verify_backend = std::make_unique(); + } + + Network::RoomNetwork network{}; + network.Init(); + if (auto room = network.GetRoom().lock()) { + AnnounceMultiplayerRoom::GameInfo preferred_game_info{.name = preferred_game, + .id = preferred_game_id}; + if (!room->Create(room_name, room_description, bind_address, static_cast(port), + password, max_members, username, preferred_game_info, + std::move(verify_backend), ban_list, enable_sudachi_mods)) { + LOG_INFO(Network, "Failed to create room: "); + return -1; + } + LOG_INFO(Network, "Room is open. Close with Q+Enter..."); + auto announce_session = std::make_unique(network); + if (announce) { + announce_session->Start(); + } + while (room->GetState() == Network::Room::State::Open) { + std::string in; + std::cin >> in; + if (in.size() > 0) { + break; + } + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + } + if (announce) { + announce_session->Stop(); + } + announce_session.reset(); + // Save the ban list + if (!ban_list_file.empty()) { + SaveBanList(room->GetBanList(), ban_list_file); + } + room->Destroy(); + } + network.Shutdown(); + detached_tasks.WaitForAllTasks(); + return 0; +} diff --git a/src/dedicated_room/sudachi_room.rc b/src/dedicated_room/sudachi_room.rc new file mode 100644 index 0000000..3b767fe --- /dev/null +++ b/src/dedicated_room/sudachi_room.rc @@ -0,0 +1,20 @@ +// SPDX-FileCopyrightText: 2017 Citra Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include "winresrc.h" +///////////////////////////////////////////////////////////////////////////// +// +// Icon +// + +// Icon with lowest ID value placed first to ensure application icon +// remains consistent on all systems. +SUDACHI_ICON ICON "../../dist/sudachi.ico" + + +///////////////////////////////////////////////////////////////////////////// +// +// RT_MANIFEST +// + +0 RT_MANIFEST "../../dist/sudachi.manifest" diff --git a/src/sudachi/CMakeLists.txt b/src/sudachi/CMakeLists.txt new file mode 100644 index 0000000..a86f902 --- /dev/null +++ b/src/sudachi/CMakeLists.txt @@ -0,0 +1,480 @@ +# SPDX-FileCopyrightText: 2018 sudachi Emulator Project +# SPDX-License-Identifier: GPL-2.0-or-later + +set(CMAKE_AUTOMOC ON) +set(CMAKE_AUTORCC ON) +set(CMAKE_AUTOUIC ON) +set(CMAKE_INCLUDE_CURRENT_DIR ON) + +# Set the RPATH for Qt Libraries +# This must be done before the `sudachi` target is created +if (SUDACHI_USE_BUNDLED_QT AND (${CMAKE_SYSTEM_NAME} STREQUAL "Linux")) + set(CMAKE_BUILD_RPATH "${CMAKE_BINARY_DIR}/bin/lib/") +endif() + +add_executable(sudachi + Info.plist + about_dialog.cpp + about_dialog.h + aboutdialog.ui + applets/qt_amiibo_settings.cpp + applets/qt_amiibo_settings.h + applets/qt_amiibo_settings.ui + applets/qt_controller.cpp + applets/qt_controller.h + applets/qt_controller.ui + applets/qt_error.cpp + applets/qt_error.h + applets/qt_profile_select.cpp + applets/qt_profile_select.h + applets/qt_software_keyboard.cpp + applets/qt_software_keyboard.h + applets/qt_software_keyboard.ui + applets/qt_web_browser.cpp + applets/qt_web_browser.h + applets/qt_web_browser_scripts.h + bootmanager.cpp + bootmanager.h + compatdb.ui + compatibility_list.cpp + compatibility_list.h + configuration/configuration_shared.cpp + configuration/configuration_shared.h + configuration/configure.ui + configuration/configure_applets.cpp + configuration/configure_applets.h + configuration/configure_applets.ui + configuration/configure_audio.cpp + configuration/configure_audio.h + configuration/configure_audio.ui + configuration/configure_camera.cpp + configuration/configure_camera.h + configuration/configure_camera.ui + configuration/configure_cpu.cpp + configuration/configure_cpu.h + configuration/configure_cpu.ui + configuration/configure_cpu_debug.cpp + configuration/configure_cpu_debug.h + configuration/configure_cpu_debug.ui + configuration/configure_debug.cpp + configuration/configure_debug.h + configuration/configure_debug.ui + configuration/configure_debug_controller.cpp + configuration/configure_debug_controller.h + configuration/configure_debug_controller.ui + configuration/configure_debug_tab.cpp + configuration/configure_debug_tab.h + configuration/configure_debug_tab.ui + configuration/configure_dialog.cpp + configuration/configure_dialog.h + configuration/configure_filesystem.cpp + configuration/configure_filesystem.h + configuration/configure_filesystem.ui + configuration/configure_general.cpp + configuration/configure_general.h + configuration/configure_general.ui + configuration/configure_graphics.cpp + configuration/configure_graphics.h + configuration/configure_graphics.ui + configuration/configure_graphics_advanced.cpp + configuration/configure_graphics_advanced.h + configuration/configure_graphics_advanced.ui + configuration/configure_hotkeys.cpp + configuration/configure_hotkeys.h + configuration/configure_hotkeys.ui + configuration/configure_input.cpp + configuration/configure_input.h + configuration/configure_input.ui + configuration/configure_input_advanced.cpp + configuration/configure_input_advanced.h + configuration/configure_input_advanced.ui + configuration/configure_input_per_game.cpp + configuration/configure_input_per_game.h + configuration/configure_input_per_game.ui + configuration/configure_input_player.cpp + configuration/configure_input_player.h + configuration/configure_input_player.ui + configuration/configure_input_player_widget.cpp + configuration/configure_input_player_widget.h + configuration/configure_input_profile_dialog.cpp + configuration/configure_input_profile_dialog.h + configuration/configure_input_profile_dialog.ui + configuration/configure_linux_tab.cpp + configuration/configure_linux_tab.h + configuration/configure_linux_tab.ui + configuration/configure_mouse_panning.cpp + configuration/configure_mouse_panning.h + configuration/configure_mouse_panning.ui + configuration/configure_motion_touch.cpp + configuration/configure_motion_touch.h + configuration/configure_motion_touch.ui + configuration/configure_per_game.cpp + configuration/configure_per_game.h + configuration/configure_per_game.ui + configuration/configure_per_game_addons.cpp + configuration/configure_per_game_addons.h + configuration/configure_per_game_addons.ui + configuration/configure_profile_manager.cpp + configuration/configure_profile_manager.h + configuration/configure_profile_manager.ui + configuration/configure_ringcon.cpp + configuration/configure_ringcon.h + configuration/configure_ringcon.ui + configuration/configure_network.cpp + configuration/configure_network.h + configuration/configure_network.ui + configuration/configure_system.cpp + configuration/configure_system.h + configuration/configure_system.ui + configuration/configure_tas.cpp + configuration/configure_tas.h + configuration/configure_tas.ui + configuration/configure_touch_from_button.cpp + configuration/configure_touch_from_button.h + configuration/configure_touch_from_button.ui + configuration/configure_touchscreen_advanced.cpp + configuration/configure_touchscreen_advanced.h + configuration/configure_touchscreen_advanced.ui + configuration/configure_touch_widget.h + configuration/configure_ui.cpp + configuration/configure_ui.h + configuration/configure_ui.ui + configuration/configure_vibration.cpp + configuration/configure_vibration.h + configuration/configure_vibration.ui + configuration/configure_web.cpp + configuration/configure_web.h + configuration/configure_web.ui + configuration/input_profiles.cpp + configuration/input_profiles.h + configuration/shared_translation.cpp + configuration/shared_translation.h + configuration/shared_widget.cpp + configuration/shared_widget.h + configuration/qt_config.cpp + configuration/qt_config.h + debugger/console.cpp + debugger/console.h + debugger/controller.cpp + debugger/controller.h + debugger/profiler.cpp + debugger/profiler.h + debugger/wait_tree.cpp + debugger/wait_tree.h + discord.h + game_list.cpp + game_list.h + game_list_p.h + game_list_worker.cpp + game_list_worker.h + hotkeys.cpp + hotkeys.h + install_dialog.cpp + install_dialog.h + loading_screen.cpp + loading_screen.h + loading_screen.ui + main.cpp + main.h + main.ui + multiplayer/chat_room.cpp + multiplayer/chat_room.h + multiplayer/chat_room.ui + multiplayer/client_room.h + multiplayer/client_room.cpp + multiplayer/client_room.ui + multiplayer/direct_connect.cpp + multiplayer/direct_connect.h + multiplayer/direct_connect.ui + multiplayer/host_room.cpp + multiplayer/host_room.h + multiplayer/host_room.ui + multiplayer/lobby.cpp + multiplayer/lobby.h + multiplayer/lobby.ui + multiplayer/lobby_p.h + multiplayer/message.cpp + multiplayer/message.h + multiplayer/moderation_dialog.cpp + multiplayer/moderation_dialog.h + multiplayer/moderation_dialog.ui + multiplayer/state.cpp + multiplayer/state.h + multiplayer/validation.h + play_time_manager.cpp + play_time_manager.h + precompiled_headers.h + qt_common.cpp + qt_common.h + startup_checks.cpp + startup_checks.h + uisettings.cpp + uisettings.h + util/clickable_label.cpp + util/clickable_label.h + util/controller_navigation.cpp + util/controller_navigation.h + util/limitable_input_dialog.cpp + util/limitable_input_dialog.h + util/overlay_dialog.cpp + util/overlay_dialog.h + util/overlay_dialog.ui + util/sequence_dialog/sequence_dialog.cpp + util/sequence_dialog/sequence_dialog.h + util/url_request_interceptor.cpp + util/url_request_interceptor.h + util/util.cpp + util/util.h + vk_device_info.cpp + vk_device_info.h + compatdb.cpp + compatdb.h + sudachi.qrc + sudachi.rc +) + +if (SUDACHI_CRASH_DUMPS) + target_sources(sudachi PRIVATE + breakpad.cpp + breakpad.h + ) + + target_link_libraries(sudachi PRIVATE libbreakpad_client) + target_compile_definitions(sudachi PRIVATE SUDACHI_CRASH_DUMPS) +endif() + +if (CMAKE_CXX_COMPILER_ID STREQUAL "Clang") + target_compile_definitions(sudachi PRIVATE + $<$,15>:CANNOT_EXPLICITLY_INSTANTIATE> + ) +endif() + +file(GLOB COMPAT_LIST + ${PROJECT_BINARY_DIR}/dist/compatibility_list/compatibility_list.qrc + ${PROJECT_BINARY_DIR}/dist/compatibility_list/compatibility_list.json) +file(GLOB_RECURSE ICONS ${PROJECT_SOURCE_DIR}/dist/icons/*) +file(GLOB_RECURSE THEMES ${PROJECT_SOURCE_DIR}/dist/qt_themes/*) + +if (ENABLE_QT_TRANSLATION) + set(SUDACHI_QT_LANGUAGES "${PROJECT_SOURCE_DIR}/dist/languages" CACHE PATH "Path to the translation bundle for the Qt frontend") + option(GENERATE_QT_TRANSLATION "Generate en.ts as the translation source file" OFF) + option(WORKAROUND_BROKEN_LUPDATE "Run lupdate directly through CMake if Qt's convenience wrappers don't work" OFF) + + # Update source TS file if enabled + if (GENERATE_QT_TRANSLATION) + get_target_property(SRCS sudachi SOURCES) + # these calls to qt_create_translation also creates a rule to generate en.qm which conflicts with providing english plurals + # so we have to set a OUTPUT_LOCATION so that we don't have multiple rules to generate en.qm + set_source_files_properties(${SUDACHI_QT_LANGUAGES}/en.ts PROPERTIES OUTPUT_LOCATION "${CMAKE_CURRENT_BINARY_DIR}/translations") + if (WORKAROUND_BROKEN_LUPDATE) + add_custom_command(OUTPUT ${SUDACHI_QT_LANGUAGES}/en.ts + COMMAND lupdate + -source-language en_US + -target-language en_US + ${SRCS} + ${UIS} + -ts ${SUDACHI_QT_LANGUAGES}/en.ts + DEPENDS + ${SRCS} + ${UIS} + WORKING_DIRECTORY + ${CMAKE_CURRENT_SOURCE_DIR} + ) + else() + qt_create_translation(QM_FILES + ${SRCS} + ${UIS} + ${SUDACHI_QT_LANGUAGES}/en.ts + OPTIONS + -source-language en_US + -target-language en_US + ) + endif() + + # Generate plurals into dist/english_plurals/generated_en.ts so it can be used to revise dist/english_plurals/en.ts + set(GENERATED_PLURALS_FILE ${PROJECT_SOURCE_DIR}/dist/english_plurals/generated_en.ts) + set_source_files_properties(${GENERATED_PLURALS_FILE} PROPERTIES OUTPUT_LOCATION "${CMAKE_CURRENT_BINARY_DIR}/plurals") + if (WORKAROUND_BROKEN_LUPDATE) + add_custom_command(OUTPUT ${GENERATED_PLURALS_FILE} + COMMAND lupdate + -source-language en_US + -target-language en_US + ${SRCS} + ${UIS} + -ts ${GENERATED_PLURALS_FILE} + DEPENDS + ${SRCS} + ${UIS} + WORKING_DIRECTORY + ${CMAKE_CURRENT_SOURCE_DIR} + ) + else() + qt_create_translation(QM_FILES ${SRCS} ${UIS} ${GENERATED_PLURALS_FILE} OPTIONS -pluralonly -source-language en_US -target-language en_US) + endif() + + add_custom_target(translation ALL DEPENDS ${SUDACHI_QT_LANGUAGES}/en.ts ${GENERATED_PLURALS_FILE}) + endif() + + # Find all TS files except en.ts + file(GLOB_RECURSE LANGUAGES_TS ${SUDACHI_QT_LANGUAGES}/*.ts) + list(REMOVE_ITEM LANGUAGES_TS ${SUDACHI_QT_LANGUAGES}/en.ts) + + # Compile TS files to QM files + qt_add_translation(LANGUAGES_QM ${LANGUAGES_TS}) + + # Compile english plurals TS file to en.qm + qt_add_translation(LANGUAGES_QM ${PROJECT_SOURCE_DIR}/dist/english_plurals/en.ts) + + # Build a QRC file from the QM file list + set(LANGUAGES_QRC ${CMAKE_CURRENT_BINARY_DIR}/languages.qrc) + file(WRITE ${LANGUAGES_QRC} "\n") + foreach (QM ${LANGUAGES_QM}) + get_filename_component(QM_FILE ${QM} NAME) + file(APPEND ${LANGUAGES_QRC} "${QM_FILE}\n") + endforeach (QM) + file(APPEND ${LANGUAGES_QRC} "") + + # Add the QRC file to package in all QM files + qt_add_resources(LANGUAGES ${LANGUAGES_QRC}) +else() + set(LANGUAGES) +endif() + +target_sources(sudachi + PRIVATE + ${COMPAT_LIST} + ${ICONS} + ${LANGUAGES} + ${THEMES} +) + +if (APPLE) + set(MACOSX_ICON "../../dist/sudachi.icns") + set_source_files_properties(${MACOSX_ICON} PROPERTIES MACOSX_PACKAGE_LOCATION Resources) + target_sources(sudachi PRIVATE ${MACOSX_ICON}) + set_target_properties(sudachi PROPERTIES MACOSX_BUNDLE TRUE) + set_target_properties(sudachi PROPERTIES MACOSX_BUNDLE_INFO_PLIST ${CMAKE_CURRENT_SOURCE_DIR}/Info.plist) + + if (NOT USE_SYSTEM_MOLTENVK) + set(MOLTENVK_PLATFORM "macOS") + set(MOLTENVK_VERSION "v1.2.7") + download_moltenvk_external(${MOLTENVK_PLATFORM} ${MOLTENVK_VERSION}) + endif() + find_library(MOLTENVK_LIBRARY MoltenVK REQUIRED) + message(STATUS "Using MoltenVK at ${MOLTENVK_LIBRARY}.") + set_source_files_properties(${MOLTENVK_LIBRARY} PROPERTIES MACOSX_PACKAGE_LOCATION Frameworks + XCODE_FILE_ATTRIBUTES "CodeSignOnCopy") + target_sources(sudachi PRIVATE ${MOLTENVK_LIBRARY}) + +elseif(WIN32) + # compile as a win32 gui application instead of a console application + if (QT_VERSION VERSION_GREATER_EQUAL 6) + target_link_libraries(sudachi PRIVATE Qt6::EntryPointPrivate) + else() + target_link_libraries(sudachi PRIVATE Qt5::WinMain) + endif() + if(MSVC) + target_link_libraries(sudachi PRIVATE version.lib) + set_target_properties(sudachi PROPERTIES LINK_FLAGS_RELEASE "/SUBSYSTEM:WINDOWS") + elseif(MINGW) + set_target_properties(sudachi PROPERTIES LINK_FLAGS_RELEASE "-Wl,--subsystem,windows") + endif() +endif() + +target_link_libraries(sudachi PRIVATE common core input_common frontend_common network video_core) +target_link_libraries(sudachi PRIVATE Boost::headers glad Qt${QT_MAJOR_VERSION}::Widgets) +target_link_libraries(sudachi PRIVATE ${PLATFORM_LIBRARIES} Threads::Threads) + +target_link_libraries(sudachi PRIVATE Vulkan::Headers) +if (NOT WIN32) + target_include_directories(sudachi PRIVATE ${Qt${QT_MAJOR_VERSION}Gui_PRIVATE_INCLUDE_DIRS}) +endif() +if (UNIX AND NOT APPLE) + target_link_libraries(sudachi PRIVATE Qt${QT_MAJOR_VERSION}::DBus) +endif() + +target_compile_definitions(sudachi PRIVATE + # Use QStringBuilder for string concatenation to reduce + # the overall number of temporary strings created. + -DQT_USE_QSTRINGBUILDER + + # Disable implicit conversions from/to C strings + -DQT_NO_CAST_FROM_ASCII + -DQT_NO_CAST_TO_ASCII + + # Disable implicit type narrowing in signal/slot connect() calls. + -DQT_NO_NARROWING_CONVERSIONS_IN_CONNECT + + # Disable unsafe overloads of QProcess' start() function. + -DQT_NO_PROCESS_COMBINED_ARGUMENT_START + + # Disable implicit QString->QUrl conversions to enforce use of proper resolving functions. + -DQT_NO_URL_CAST_FROM_STRING +) + +if (SUDACHI_ENABLE_COMPATIBILITY_REPORTING) + target_compile_definitions(sudachi PRIVATE -DSUDACHI_ENABLE_COMPATIBILITY_REPORTING) +endif() + +if (USE_DISCORD_PRESENCE) + target_sources(sudachi PUBLIC + discord_impl.cpp + discord_impl.h + ) + target_link_libraries(sudachi PRIVATE DiscordRPC::discord-rpc httplib::httplib Qt${QT_MAJOR_VERSION}::Network) + target_compile_definitions(sudachi PRIVATE -DUSE_DISCORD_PRESENCE) +endif() + +if (ENABLE_WEB_SERVICE) + target_compile_definitions(sudachi PRIVATE -DENABLE_WEB_SERVICE) +endif() + +if (SUDACHI_USE_QT_MULTIMEDIA) + target_link_libraries(sudachi PRIVATE Qt${QT_MAJOR_VERSION}::Multimedia) + target_compile_definitions(sudachi PRIVATE -DSUDACHI_USE_QT_MULTIMEDIA) +endif () + +if (SUDACHI_USE_QT_WEB_ENGINE) + target_link_libraries(sudachi PRIVATE Qt${QT_MAJOR_VERSION}::WebEngineCore Qt${QT_MAJOR_VERSION}::WebEngineWidgets) + target_compile_definitions(sudachi PRIVATE -DSUDACHI_USE_QT_WEB_ENGINE) +endif () + +if(UNIX AND NOT APPLE) + install(TARGETS sudachi) +endif() + +if (WIN32 AND QT_VERSION VERSION_GREATER_EQUAL 6) + set(SUDACHI_EXE_DIR "$") + add_custom_command(TARGET sudachi POST_BUILD COMMAND ${WINDEPLOYQT_EXECUTABLE} "${SUDACHI_EXE_DIR}/sudachi.exe" --dir "${SUDACHI_EXE_DIR}" --libdir "${SUDACHI_EXE_DIR}" --plugindir "${SUDACHI_EXE_DIR}/plugins" --no-compiler-runtime --no-opengl-sw --no-system-d3d-compiler --no-translations --verbose 0) +endif() + +if (SUDACHI_USE_BUNDLED_QT AND QT_VERSION VERSION_LESS 6) + include(CopySudachiQt5Deps) + copy_sudachi_Qt5_deps(sudachi) +endif() + +if (ENABLE_SDL2) + target_link_libraries(sudachi PRIVATE SDL2::SDL2) + target_compile_definitions(sudachi PRIVATE HAVE_SDL2) +endif() + +if (MSVC) + include(CopySudachiSDLDeps) + include(CopySudachiFFmpegDeps) + copy_sudachi_SDL_deps(sudachi) + copy_sudachi_FFmpeg_deps(sudachi) +endif() + +if (NOT APPLE AND ENABLE_OPENGL) + target_compile_definitions(sudachi PRIVATE HAS_OPENGL) +endif() + +if (ARCHITECTURE_x86_64 OR ARCHITECTURE_arm64) + target_link_libraries(sudachi PRIVATE dynarmic::dynarmic) +endif() + +if (SUDACHI_USE_PRECOMPILED_HEADERS) + target_precompile_headers(sudachi PRIVATE precompiled_headers.h) +endif() + +create_target_directory_groups(sudachi) diff --git a/src/sudachi/Info.plist b/src/sudachi/Info.plist new file mode 100644 index 0000000..faa668f --- /dev/null +++ b/src/sudachi/Info.plist @@ -0,0 +1,48 @@ + + + + + + + + CFBundleDevelopmentRegion + English + CFBundleExecutable + ${EXECUTABLE_NAME} + CFBundleGetInfoString + + CFBundleIconFile + sudachi.icns + CFBundleIdentifier + com.sudachi-emu.sudachi + CFBundleInfoDictionaryVersion + 6.0 + CFBundleLongVersionString + + CFBundleName + sudachi + CFBundlePackageType + APPL + CFBundleShortVersionString + + CFBundleSignature + ???? + CFBundleVersion + + CSResourcesFileMapped + + LSApplicationCategoryType + public.app-category.games + LSRequiresCarbon + + NSHumanReadableCopyright + + NSPrincipalClass + NSApplication + NSHighResolutionCapable + True + + diff --git a/src/sudachi/about_dialog.cpp b/src/sudachi/about_dialog.cpp new file mode 100644 index 0000000..805b94b --- /dev/null +++ b/src/sudachi/about_dialog.cpp @@ -0,0 +1,32 @@ +// SPDX-FileCopyrightText: Copyright 2018 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include +#include +#include "common/scm_rev.h" +#include "ui_aboutdialog.h" +#include "sudachi/about_dialog.h" + +AboutDialog::AboutDialog(QWidget* parent) + : QDialog(parent), ui{std::make_unique()} { + const auto branch_name = std::string(Common::g_scm_branch); + const auto description = std::string(Common::g_scm_desc); + const auto build_id = std::string(Common::g_build_id); + + const auto sudachi_build = fmt::format("Sudachi Development Build | {}-{}", branch_name, description); + const auto override_build = + fmt::format(fmt::runtime(std::string(Common::g_title_bar_format_idle)), build_id); + const auto& sudachi_build_version = override_build.empty() ? sudachi_build : override_build; + + ui->setupUi(this); + // Try and request the icon from Qt theme (Linux?) + const QIcon sudachi_logo = QIcon::fromTheme(QStringLiteral("org.sudachi_emu.sudachi")); + if (!sudachi_logo.isNull()) { + ui->labelLogo->setPixmap(sudachi_logo.pixmap(200)); + } + ui->labelBuildInfo->setText( + ui->labelBuildInfo->text().arg(QString::fromStdString(sudachi_build_version), + QString::fromUtf8(Common::g_build_date).left(10))); +} + +AboutDialog::~AboutDialog() = default; diff --git a/src/sudachi/about_dialog.h b/src/sudachi/about_dialog.h new file mode 100644 index 0000000..06841b6 --- /dev/null +++ b/src/sudachi/about_dialog.h @@ -0,0 +1,22 @@ +// SPDX-FileCopyrightText: Copyright 2018 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include +#include + +namespace Ui { +class AboutDialog; +} + +class AboutDialog : public QDialog { + Q_OBJECT + +public: + explicit AboutDialog(QWidget* parent); + ~AboutDialog() override; + +private: + std::unique_ptr ui; +}; diff --git a/src/sudachi/aboutdialog.ui b/src/sudachi/aboutdialog.ui new file mode 100644 index 0000000..0f962be --- /dev/null +++ b/src/sudachi/aboutdialog.ui @@ -0,0 +1,204 @@ + + + AboutDialog + + + + 0 + 0 + 616 + 294 + + + + About sudachi + + + + + + + + + + + 0 + 0 + + + + + 200 + 200 + + + + + + + :/icons/default/256x256/sudachi.png + + + true + + + + + + + Qt::Orientation::Vertical + + + + 20 + 40 + + + + + + + + + + + + + 0 + 0 + + + + <html><head/><body><p><span style=" font-size:28pt;">sudachi</span></p></body></html> + + + + + + + + 0 + 0 + + + + <html><head/><body><p>%1 (%2)</p></body></html> + + + + + + + + 0 + 0 + + + + <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0//EN" "http://www.w3.org/TR/REC-html40/strict.dtd"> +<html><head><meta name="qrichtext" content="1" /><style type="text/css"> +p, li { white-space: pre-wrap; } +</style></head><body style=" font-family:'Ubuntu'; font-size:11pt; font-weight:400; font-style:normal;"> +<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-family:'MS Shell Dlg 2'; font-size:12pt;">sudachi is an experimental open-source emulator for the Nintendo Switch licensed under GPLv3.0+.</span></p> +<p style="-qt-paragraph-type:empty; margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px; font-family:'MS Shell Dlg 2'; font-size:8pt;"><br /></p> +<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-family:'MS Shell Dlg 2'; font-size:12pt;">This software should not be used to play games you have not legally obtained.</span></p></body></html> + + + Qt::AlignmentFlag::AlignLeading|Qt::AlignmentFlag::AlignLeft|Qt::AlignmentFlag::AlignVCenter + + + true + + + + + + + Qt::Orientation::Vertical + + + + 20 + 40 + + + + + + + + <html><head/><body><p><a href="https://sudachi-emu.org/"><span style=" text-decoration: underline; color:#039be5;">Website</span></a> | <a href="https://github.com/sudachi-emu"><span style=" text-decoration: underline; color:#039be5;">Source Code</span></a> | <a href="https://github.com/sudachi-emu/sudachi/graphs/contributors"><span style=" text-decoration: underline; color:#039be5;">Contributors</span></a> | <a href="https://github.com/sudachi-emu/sudachi/blob/master/LICENSE.txt"><span style=" text-decoration: underline; color:#039be5;">License</span></a></p></body></html> + + + true + + + + + + + + 0 + 0 + + + + <html><head/><body><p><span style=" font-size:7pt;">&quot;Nintendo Switch&quot; is a trademark of Nintendo. sudachi is not affiliated with Nintendo in any way.</span></p></body></html> + + + + + + + + + + + Qt::Orientation::Horizontal + + + QDialogButtonBox::StandardButton::Ok + + + + + + + + + + + + buttonBox + accepted() + AboutDialog + accept() + + + 20 + 20 + + + 20 + 20 + + + + + buttonBox + rejected() + AboutDialog + reject() + + + 20 + 20 + + + 20 + 20 + + + + + diff --git a/src/sudachi/applets/qt_amiibo_settings.cpp b/src/sudachi/applets/qt_amiibo_settings.cpp new file mode 100644 index 0000000..76d5b68 --- /dev/null +++ b/src/sudachi/applets/qt_amiibo_settings.cpp @@ -0,0 +1,274 @@ +// SPDX-FileCopyrightText: Copyright 2022 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include +#include +#include +#include + +#include "common/assert.h" +#include "common/string_util.h" +#include "core/hle/service/nfc/common/device.h" +#include "core/hle/service/nfp/nfp_result.h" +#include "input_common/drivers/virtual_amiibo.h" +#include "input_common/main.h" +#include "ui_qt_amiibo_settings.h" +#ifdef ENABLE_WEB_SERVICE +#include "web_service/web_backend.h" +#endif +#include "sudachi/applets/qt_amiibo_settings.h" +#include "sudachi/main.h" + +QtAmiiboSettingsDialog::QtAmiiboSettingsDialog(QWidget* parent, + Core::Frontend::CabinetParameters parameters_, + InputCommon::InputSubsystem* input_subsystem_, + std::shared_ptr nfp_device_) + : QDialog(parent), ui(std::make_unique()), + input_subsystem{input_subsystem_}, nfp_device{std::move(nfp_device_)}, + parameters(std::move(parameters_)) { + ui->setupUi(this); + + LoadInfo(); + + resize(0, 0); +} + +QtAmiiboSettingsDialog::~QtAmiiboSettingsDialog() = default; + +int QtAmiiboSettingsDialog::exec() { + if (!is_initialized) { + return QDialog::Rejected; + } + return QDialog::exec(); +} + +std::string QtAmiiboSettingsDialog::GetName() const { + return ui->amiiboCustomNameValue->text().toStdString(); +} + +void QtAmiiboSettingsDialog::LoadInfo() { + if (input_subsystem->GetVirtualAmiibo()->ReloadAmiibo() != + InputCommon::VirtualAmiibo::Info::Success) { + return; + } + + if (nfp_device->GetCurrentState() != Service::NFC::DeviceState::TagFound && + nfp_device->GetCurrentState() != Service::NFC::DeviceState::TagMounted) { + return; + } + nfp_device->Mount(Service::NFP::ModelType::Amiibo, Service::NFP::MountTarget::All); + + LoadAmiiboInfo(); + LoadAmiiboData(); + LoadAmiiboGameInfo(); + + ui->amiiboDirectoryValue->setText( + QString::fromStdString(input_subsystem->GetVirtualAmiibo()->GetLastFilePath())); + + SetSettingsDescription(); + is_initialized = true; +} + +void QtAmiiboSettingsDialog::LoadAmiiboInfo() { + Service::NFP::ModelInfo model_info{}; + const auto model_result = nfp_device->GetModelInfo(model_info); + + if (model_result.IsFailure()) { + ui->amiiboImageLabel->setVisible(false); + ui->amiiboInfoGroup->setVisible(false); + return; + } + + const auto amiibo_id = + fmt::format("{:04x}{:02x}{:02x}{:04x}{:02x}02", Common::swap16(model_info.character_id), + model_info.character_variant, model_info.amiibo_type, model_info.model_number, + model_info.series); + + LOG_DEBUG(Frontend, "Loading amiibo id {}", amiibo_id); + // Note: This function is not being used until we host the images on our server + // LoadAmiiboApiInfo(amiibo_id); + ui->amiiboImageLabel->setVisible(false); + ui->amiiboInfoGroup->setVisible(false); +} + +void QtAmiiboSettingsDialog::LoadAmiiboApiInfo(std::string_view amiibo_id) { +#ifdef ENABLE_WEB_SERVICE + // TODO: Host this data on our website + WebService::Client client{"https://amiiboapi.com", {}, {}}; + WebService::Client image_client{"https://raw.githubusercontent.com", {}, {}}; + const auto url_path = fmt::format("/api/amiibo/?id={}", amiibo_id); + + const auto amiibo_json = client.GetJson(url_path, true).returned_data; + if (amiibo_json.empty()) { + ui->amiiboImageLabel->setVisible(false); + ui->amiiboInfoGroup->setVisible(false); + return; + } + + std::string amiibo_series{}; + std::string amiibo_name{}; + std::string amiibo_image_url{}; + std::string amiibo_type{}; + + const auto parsed_amiibo_json_json = nlohmann::json::parse(amiibo_json).at("amiibo"); + parsed_amiibo_json_json.at("amiiboSeries").get_to(amiibo_series); + parsed_amiibo_json_json.at("name").get_to(amiibo_name); + parsed_amiibo_json_json.at("image").get_to(amiibo_image_url); + parsed_amiibo_json_json.at("type").get_to(amiibo_type); + + ui->amiiboSeriesValue->setText(QString::fromStdString(amiibo_series)); + ui->amiiboNameValue->setText(QString::fromStdString(amiibo_name)); + ui->amiiboTypeValue->setText(QString::fromStdString(amiibo_type)); + + if (amiibo_image_url.size() < 34) { + ui->amiiboImageLabel->setVisible(false); + } + + const auto image_url_path = amiibo_image_url.substr(34, amiibo_image_url.size() - 34); + const auto image_data = image_client.GetImage(image_url_path, true).returned_data; + + if (image_data.empty()) { + ui->amiiboImageLabel->setVisible(false); + } + + QPixmap pixmap; + pixmap.loadFromData(reinterpret_cast(image_data.data()), + static_cast(image_data.size())); + pixmap = pixmap.scaled(250, 350, Qt::AspectRatioMode::KeepAspectRatio, + Qt::TransformationMode::SmoothTransformation); + ui->amiiboImageLabel->setPixmap(pixmap); +#endif +} + +void QtAmiiboSettingsDialog::LoadAmiiboData() { + Service::NFP::RegisterInfo register_info{}; + Service::NFP::CommonInfo common_info{}; + const auto register_result = nfp_device->GetRegisterInfo(register_info); + const auto common_result = nfp_device->GetCommonInfo(common_info); + + if (register_result.IsFailure()) { + ui->creationDateValue->setDisabled(true); + ui->modificationDateValue->setDisabled(true); + ui->amiiboCustomNameValue->setReadOnly(false); + ui->amiiboOwnerValue->setReadOnly(false); + return; + } + + if (parameters.mode == Service::NFP::CabinetMode::StartNicknameAndOwnerSettings) { + ui->creationDateValue->setDisabled(true); + ui->modificationDateValue->setDisabled(true); + } + + const auto amiibo_name = std::string(register_info.amiibo_name.data()); + const auto owner_name = + Common::UTF16ToUTF8(register_info.mii_char_info.GetNickname().data.data()); + const auto creation_date = + QDate(register_info.creation_date.year, register_info.creation_date.month, + register_info.creation_date.day); + + ui->amiiboCustomNameValue->setText(QString::fromStdString(amiibo_name)); + ui->amiiboOwnerValue->setText(QString::fromStdString(owner_name)); + ui->amiiboCustomNameValue->setReadOnly(true); + ui->amiiboOwnerValue->setReadOnly(true); + ui->creationDateValue->setDate(creation_date); + + if (common_result.IsFailure()) { + ui->modificationDateValue->setDisabled(true); + return; + } + + const auto modification_date = + QDate(common_info.last_write_date.year, common_info.last_write_date.month, + common_info.last_write_date.day); + ui->modificationDateValue->setDate(modification_date); +} + +void QtAmiiboSettingsDialog::LoadAmiiboGameInfo() { + u32 application_area_id{}; + const auto application_result = nfp_device->GetApplicationAreaId(application_area_id); + + if (application_result.IsFailure()) { + ui->gameIdValue->setVisible(false); + ui->gameIdLabel->setText(tr("No game data present")); + return; + } + + SetGameDataName(application_area_id); +} + +void QtAmiiboSettingsDialog::SetGameDataName(u32 application_area_id) { + static constexpr std::array, 12> game_name_list = { + // 3ds, wii u + std::pair{0x10110E00, "Super Smash Bros (3DS/WiiU)"}, + {0x00132600, "Mario & Luigi: Paper Jam"}, + {0x0014F000, "Animal Crossing: Happy Home Designer"}, + {0x00152600, "Chibi-Robo!: Zip Lash"}, + {0x10161f00, "Mario Party 10"}, + {0x1019C800, "The Legend of Zelda: Twilight Princess HD"}, + // switch + {0x10162B00, "Splatoon 2"}, + {0x1016e100, "Shovel Knight: Treasure Trove"}, + {0x1019C800, "The Legend of Zelda: Breath of the Wild"}, + {0x34F80200, "Super Smash Bros. Ultimate"}, + {0x38600500, "Splatoon 3"}, + {0x3B440400, "The Legend of Zelda: Link's Awakening"}, + }; + + for (const auto& [game_id, game_name] : game_name_list) { + if (application_area_id == game_id) { + ui->gameIdValue->setText(QString::fromStdString(game_name)); + return; + } + } + + const auto application_area_string = fmt::format("{:016x}", application_area_id); + ui->gameIdValue->setText(QString::fromStdString(application_area_string)); +} + +void QtAmiiboSettingsDialog::SetSettingsDescription() { + switch (parameters.mode) { + case Service::NFP::CabinetMode::StartFormatter: + ui->cabinetActionDescriptionLabel->setText( + tr("The following amiibo data will be formatted:")); + break; + case Service::NFP::CabinetMode::StartGameDataEraser: + ui->cabinetActionDescriptionLabel->setText(tr("The following game data will removed:")); + break; + case Service::NFP::CabinetMode::StartNicknameAndOwnerSettings: + ui->cabinetActionDescriptionLabel->setText(tr("Set nickname and owner:")); + break; + case Service::NFP::CabinetMode::StartRestorer: + ui->cabinetActionDescriptionLabel->setText(tr("Do you wish to restore this amiibo?")); + break; + } +} + +QtAmiiboSettings::QtAmiiboSettings(GMainWindow& parent) { + connect(this, &QtAmiiboSettings::MainWindowShowAmiiboSettings, &parent, + &GMainWindow::AmiiboSettingsShowDialog, Qt::QueuedConnection); + connect(this, &QtAmiiboSettings::MainWindowRequestExit, &parent, + &GMainWindow::AmiiboSettingsRequestExit, Qt::QueuedConnection); + connect(&parent, &GMainWindow::AmiiboSettingsFinished, this, + &QtAmiiboSettings::MainWindowFinished, Qt::QueuedConnection); +} + +QtAmiiboSettings::~QtAmiiboSettings() = default; + +void QtAmiiboSettings::Close() const { + callback = {}; + emit MainWindowRequestExit(); +} + +void QtAmiiboSettings::ShowCabinetApplet( + const Core::Frontend::CabinetCallback& callback_, + const Core::Frontend::CabinetParameters& parameters, + std::shared_ptr nfp_device) const { + callback = std::move(callback_); + emit MainWindowShowAmiiboSettings(parameters, nfp_device); +} + +void QtAmiiboSettings::MainWindowFinished(bool is_success, const std::string& name) { + if (callback) { + callback(is_success, name); + } +} diff --git a/src/sudachi/applets/qt_amiibo_settings.h b/src/sudachi/applets/qt_amiibo_settings.h new file mode 100644 index 0000000..e0e2764 --- /dev/null +++ b/src/sudachi/applets/qt_amiibo_settings.h @@ -0,0 +1,85 @@ +// SPDX-FileCopyrightText: Copyright 2022 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include +#include +#include +#include "core/frontend/applets/cabinet.h" + +class GMainWindow; +class QCheckBox; +class QComboBox; +class QDialogButtonBox; +class QGroupBox; +class QLabel; + +namespace InputCommon { +class InputSubsystem; +} + +namespace Ui { +class QtAmiiboSettingsDialog; +} + +namespace Service::NFC { +class NfcDevice; +} // namespace Service::NFC + +class QtAmiiboSettingsDialog final : public QDialog { + Q_OBJECT + +public: + explicit QtAmiiboSettingsDialog(QWidget* parent, Core::Frontend::CabinetParameters parameters_, + InputCommon::InputSubsystem* input_subsystem_, + std::shared_ptr nfp_device_); + ~QtAmiiboSettingsDialog() override; + + int exec() override; + + std::string GetName() const; + +private: + void LoadInfo(); + void LoadAmiiboInfo(); + void LoadAmiiboApiInfo(std::string_view amiibo_id); + void LoadAmiiboData(); + void LoadAmiiboGameInfo(); + void SetGameDataName(u32 application_area_id); + void SetSettingsDescription(); + + std::unique_ptr ui; + + InputCommon::InputSubsystem* input_subsystem; + std::shared_ptr nfp_device; + + // Parameters sent in from the backend HLE applet. + Core::Frontend::CabinetParameters parameters; + + // If false amiibo settings failed to load + bool is_initialized{}; +}; + +class QtAmiiboSettings final : public QObject, public Core::Frontend::CabinetApplet { + Q_OBJECT + +public: + explicit QtAmiiboSettings(GMainWindow& parent); + ~QtAmiiboSettings() override; + + void Close() const override; + void ShowCabinetApplet(const Core::Frontend::CabinetCallback& callback_, + const Core::Frontend::CabinetParameters& parameters, + std::shared_ptr nfp_device) const override; + +signals: + void MainWindowShowAmiiboSettings(const Core::Frontend::CabinetParameters& parameters, + std::shared_ptr nfp_device) const; + void MainWindowRequestExit() const; + +private: + void MainWindowFinished(bool is_success, const std::string& name); + + mutable Core::Frontend::CabinetCallback callback; +}; diff --git a/src/sudachi/applets/qt_amiibo_settings.ui b/src/sudachi/applets/qt_amiibo_settings.ui new file mode 100644 index 0000000..f377a6e --- /dev/null +++ b/src/sudachi/applets/qt_amiibo_settings.ui @@ -0,0 +1,494 @@ + + + QtAmiiboSettingsDialog + + + + 0 + 0 + 839 + 500 + + + + Amiibo Settings + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + 10 + + + 20 + + + 15 + + + 0 + + + 15 + + + + + + 12 + 75 + true + + + + + + + Qt::AlignLeading|Qt::AlignLeft|Qt::AlignVCenter + + + false + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + 20 + + + 15 + + + 15 + + + + + + 250 + 350 + + + + + 236 + 350 + + + + + + + Qt::AlignCenter + + + + + + + 0 + + + 8 + + + 15 + + + + + Amiibo Info + + + + + + + + Series + + + + + + + + 0 + 0 + + + + true + + + + + + + Type + + + + + + + + 0 + 0 + + + + true + + + + + + + Name + + + + + + + + 0 + 0 + + + + true + + + + + + + + + + + + Amiibo Data + + + + + + + + Custom Name + + + + + + + + 0 + 0 + + + + 10 + + + + + + + Owner + + + + + + + + 0 + 0 + + + + 10 + + + + + + + Creation Date + + + + + + + true + + + + 1970 + 1 + 1 + + + + dd/MM/yyyy + + + + + + + Modification Date + + + + + + + true + + + + 1970 + 1 + 1 + + + + dd/MM/yyyy + + + + + + + + + + + + + 500 + 0 + + + + Game Data + + + + + + Game Id + + + + + + + + 0 + 0 + + + + true + + + + + + + + + + + 500 + 0 + + + + Mount Amiibo + + + + + + ... + + + + + + + Qt::Horizontal + + + QSizePolicy::Maximum + + + + 60 + 20 + + + + + + + + File Path + + + + + + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + + + + + + + + 15 + + + 15 + + + 8 + + + 20 + + + 8 + + + + + true + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + + + + + + + + + + + + + buttonBox + accepted() + QtAmiiboSettingsDialog + accept() + + + buttonBox + rejected() + QtAmiiboSettingsDialog + reject() + + + diff --git a/src/sudachi/applets/qt_controller.cpp b/src/sudachi/applets/qt_controller.cpp new file mode 100644 index 0000000..39780fa --- /dev/null +++ b/src/sudachi/applets/qt_controller.cpp @@ -0,0 +1,778 @@ +// SPDX-FileCopyrightText: Copyright 2020 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include +#include + +#include "common/assert.h" +#include "common/settings.h" +#include "common/settings_enums.h" +#include "common/string_util.h" +#include "core/core.h" +#include "core/hle/service/sm/sm.h" +#include "hid_core/frontend/emulated_controller.h" +#include "hid_core/hid_core.h" +#include "hid_core/hid_types.h" +#include "hid_core/resources/npad/npad.h" +#include "ui_qt_controller.h" +#include "sudachi/applets/qt_controller.h" +#include "sudachi/configuration/configure_input.h" +#include "sudachi/configuration/configure_input_profile_dialog.h" +#include "sudachi/configuration/configure_motion_touch.h" +#include "sudachi/configuration/configure_vibration.h" +#include "sudachi/configuration/input_profiles.h" +#include "sudachi/main.h" +#include "sudachi/util/controller_navigation.h" + +namespace { + +void UpdateController(Core::HID::EmulatedController* controller, + Core::HID::NpadStyleIndex controller_type, bool connected) { + if (controller->IsConnected(true)) { + controller->Disconnect(); + } + controller->SetNpadStyleIndex(controller_type); + if (connected) { + controller->Connect(true); + } +} + +// Returns true if the given controller type is compatible with the given parameters. +bool IsControllerCompatible(Core::HID::NpadStyleIndex controller_type, + Core::Frontend::ControllerParameters parameters) { + switch (controller_type) { + case Core::HID::NpadStyleIndex::Fullkey: + return parameters.allow_pro_controller; + case Core::HID::NpadStyleIndex::JoyconDual: + return parameters.allow_dual_joycons; + case Core::HID::NpadStyleIndex::JoyconLeft: + return parameters.allow_left_joycon; + case Core::HID::NpadStyleIndex::JoyconRight: + return parameters.allow_right_joycon; + case Core::HID::NpadStyleIndex::Handheld: + return parameters.enable_single_mode && parameters.allow_handheld; + case Core::HID::NpadStyleIndex::GameCube: + return parameters.allow_gamecube_controller; + default: + return false; + } +} + +} // namespace + +QtControllerSelectorDialog::QtControllerSelectorDialog( + QWidget* parent, Core::Frontend::ControllerParameters parameters_, + InputCommon::InputSubsystem* input_subsystem_, Core::System& system_) + : QDialog(parent), ui(std::make_unique()), + parameters(std::move(parameters_)), input_subsystem{input_subsystem_}, + input_profiles(std::make_unique()), system{system_} { + ui->setupUi(this); + + player_widgets = { + ui->widgetPlayer1, ui->widgetPlayer2, ui->widgetPlayer3, ui->widgetPlayer4, + ui->widgetPlayer5, ui->widgetPlayer6, ui->widgetPlayer7, ui->widgetPlayer8, + }; + + player_groupboxes = { + ui->groupPlayer1Connected, ui->groupPlayer2Connected, ui->groupPlayer3Connected, + ui->groupPlayer4Connected, ui->groupPlayer5Connected, ui->groupPlayer6Connected, + ui->groupPlayer7Connected, ui->groupPlayer8Connected, + }; + + connected_controller_icons = { + ui->controllerPlayer1, ui->controllerPlayer2, ui->controllerPlayer3, ui->controllerPlayer4, + ui->controllerPlayer5, ui->controllerPlayer6, ui->controllerPlayer7, ui->controllerPlayer8, + }; + + led_patterns_boxes = {{ + {ui->checkboxPlayer1LED1, ui->checkboxPlayer1LED2, ui->checkboxPlayer1LED3, + ui->checkboxPlayer1LED4}, + {ui->checkboxPlayer2LED1, ui->checkboxPlayer2LED2, ui->checkboxPlayer2LED3, + ui->checkboxPlayer2LED4}, + {ui->checkboxPlayer3LED1, ui->checkboxPlayer3LED2, ui->checkboxPlayer3LED3, + ui->checkboxPlayer3LED4}, + {ui->checkboxPlayer4LED1, ui->checkboxPlayer4LED2, ui->checkboxPlayer4LED3, + ui->checkboxPlayer4LED4}, + {ui->checkboxPlayer5LED1, ui->checkboxPlayer5LED2, ui->checkboxPlayer5LED3, + ui->checkboxPlayer5LED4}, + {ui->checkboxPlayer6LED1, ui->checkboxPlayer6LED2, ui->checkboxPlayer6LED3, + ui->checkboxPlayer6LED4}, + {ui->checkboxPlayer7LED1, ui->checkboxPlayer7LED2, ui->checkboxPlayer7LED3, + ui->checkboxPlayer7LED4}, + {ui->checkboxPlayer8LED1, ui->checkboxPlayer8LED2, ui->checkboxPlayer8LED3, + ui->checkboxPlayer8LED4}, + }}; + + explain_text_labels = { + ui->labelPlayer1Explain, ui->labelPlayer2Explain, ui->labelPlayer3Explain, + ui->labelPlayer4Explain, ui->labelPlayer5Explain, ui->labelPlayer6Explain, + ui->labelPlayer7Explain, ui->labelPlayer8Explain, + }; + + emulated_controllers = { + ui->comboPlayer1Emulated, ui->comboPlayer2Emulated, ui->comboPlayer3Emulated, + ui->comboPlayer4Emulated, ui->comboPlayer5Emulated, ui->comboPlayer6Emulated, + ui->comboPlayer7Emulated, ui->comboPlayer8Emulated, + }; + + player_labels = { + ui->labelPlayer1, ui->labelPlayer2, ui->labelPlayer3, ui->labelPlayer4, + ui->labelPlayer5, ui->labelPlayer6, ui->labelPlayer7, ui->labelPlayer8, + }; + + connected_controller_labels = { + ui->labelConnectedPlayer1, ui->labelConnectedPlayer2, ui->labelConnectedPlayer3, + ui->labelConnectedPlayer4, ui->labelConnectedPlayer5, ui->labelConnectedPlayer6, + ui->labelConnectedPlayer7, ui->labelConnectedPlayer8, + }; + + connected_controller_checkboxes = { + ui->checkboxPlayer1Connected, ui->checkboxPlayer2Connected, ui->checkboxPlayer3Connected, + ui->checkboxPlayer4Connected, ui->checkboxPlayer5Connected, ui->checkboxPlayer6Connected, + ui->checkboxPlayer7Connected, ui->checkboxPlayer8Connected, + }; + + ui->labelError->setVisible(false); + + // Setup/load everything prior to setting up connections. + // This avoids unintentionally changing the states of elements while loading them in. + SetSupportedControllers(); + DisableUnsupportedPlayers(); + + for (std::size_t player_index = 0; player_index < NUM_PLAYERS; ++player_index) { + SetEmulatedControllers(player_index); + } + + LoadConfiguration(); + + controller_navigation = new ControllerNavigation(system.HIDCore(), this); + + for (std::size_t i = 0; i < NUM_PLAYERS; ++i) { + SetExplainText(i); + UpdateControllerIcon(i); + UpdateLEDPattern(i); + UpdateBorderColor(i); + + connect(player_groupboxes[i], &QGroupBox::toggled, [this, i](bool checked) { + // Reconnect current controller if it was the last one checked + // (player number was reduced by more than one) + const bool reconnect_first = !checked && i < player_groupboxes.size() - 1 && + player_groupboxes[i + 1]->isChecked(); + + // Ensures that connecting a controller changes the number of players + if (connected_controller_checkboxes[i]->isChecked() != checked) { + // Ensures that the players are always connected in sequential order + PropagatePlayerNumberChanged(i, checked, reconnect_first); + } + }); + connect(connected_controller_checkboxes[i], &QCheckBox::clicked, [this, i](bool checked) { + // Reconnect current controller if it was the last one checked + // (player number was reduced by more than one) + const bool reconnect_first = !checked && + i < connected_controller_checkboxes.size() - 1 && + connected_controller_checkboxes[i + 1]->isChecked(); + + // Ensures that the players are always connected in sequential order + PropagatePlayerNumberChanged(i, checked, reconnect_first); + }); + + connect(emulated_controllers[i], qOverload(&QComboBox::currentIndexChanged), + [this, i](int) { + UpdateControllerIcon(i); + UpdateControllerState(i); + UpdateLEDPattern(i); + CheckIfParametersMet(); + }); + + connect(connected_controller_checkboxes[i], &QCheckBox::stateChanged, [this, i](int state) { + player_groupboxes[i]->setChecked(state == Qt::Checked); + UpdateControllerIcon(i); + UpdateControllerState(i); + UpdateLEDPattern(i); + UpdateBorderColor(i); + CheckIfParametersMet(); + }); + + if (i == 0) { + connect(emulated_controllers[i], qOverload(&QComboBox::currentIndexChanged), + [this, i](int index) { + UpdateDockedState(GetControllerTypeFromIndex(index, i) == + Core::HID::NpadStyleIndex::Handheld); + }); + } + } + + connect(ui->vibrationButton, &QPushButton::clicked, this, + &QtControllerSelectorDialog::CallConfigureVibrationDialog); + + connect(ui->motionButton, &QPushButton::clicked, this, + &QtControllerSelectorDialog::CallConfigureMotionTouchDialog); + + connect(ui->inputConfigButton, &QPushButton::clicked, this, + &QtControllerSelectorDialog::CallConfigureInputProfileDialog); + + connect(ui->buttonBox, &QDialogButtonBox::accepted, this, + &QtControllerSelectorDialog::ApplyConfiguration); + + connect(controller_navigation, &ControllerNavigation::TriggerKeyboardEvent, + [this](Qt::Key key) { + QKeyEvent* event = new QKeyEvent(QEvent::KeyPress, key, Qt::NoModifier); + QCoreApplication::postEvent(this, event); + }); + + // Enhancement: Check if the parameters have already been met before disconnecting controllers. + // If all the parameters are met AND only allows a single player, + // stop the constructor here as we do not need to continue. + if (CheckIfParametersMet() && parameters.enable_single_mode) { + return; + } + + // If keep_controllers_connected is false, forcefully disconnect all controllers + if (!parameters.keep_controllers_connected) { + for (auto player : player_groupboxes) { + player->setChecked(false); + } + } + + resize(0, 0); +} + +QtControllerSelectorDialog::~QtControllerSelectorDialog() { + controller_navigation->UnloadController(); + system.HIDCore().DisableAllControllerConfiguration(); +} + +int QtControllerSelectorDialog::exec() { + if (parameters_met && parameters.enable_single_mode) { + return QDialog::Accepted; + } + return QDialog::exec(); +} + +void QtControllerSelectorDialog::ApplyConfiguration() { + const bool pre_docked_mode = Settings::IsDockedMode(); + const bool docked_mode_selected = ui->radioDocked->isChecked(); + Settings::values.use_docked_mode.SetValue( + docked_mode_selected ? Settings::ConsoleMode::Docked : Settings::ConsoleMode::Handheld); + OnDockedModeChanged(pre_docked_mode, docked_mode_selected, system); + + Settings::values.vibration_enabled.SetValue(ui->vibrationGroup->isChecked()); + Settings::values.motion_enabled.SetValue(ui->motionGroup->isChecked()); +} + +void QtControllerSelectorDialog::LoadConfiguration() { + system.HIDCore().EnableAllControllerConfiguration(); + + const auto* handheld = system.HIDCore().GetEmulatedController(Core::HID::NpadIdType::Handheld); + for (std::size_t index = 0; index < NUM_PLAYERS; ++index) { + const auto* controller = system.HIDCore().GetEmulatedControllerByIndex(index); + const auto connected = + controller->IsConnected(true) || (index == 0 && handheld->IsConnected(true)); + player_groupboxes[index]->setChecked(connected); + connected_controller_checkboxes[index]->setChecked(connected); + emulated_controllers[index]->setCurrentIndex( + GetIndexFromControllerType(controller->GetNpadStyleIndex(true), index)); + } + + UpdateDockedState(handheld->IsConnected(true)); + + ui->vibrationGroup->setChecked(Settings::values.vibration_enabled.GetValue()); + ui->motionGroup->setChecked(Settings::values.motion_enabled.GetValue()); +} + +void QtControllerSelectorDialog::CallConfigureVibrationDialog() { + ConfigureVibration dialog(this, system.HIDCore()); + + dialog.setWindowFlags(Qt::Dialog | Qt::CustomizeWindowHint | Qt::WindowTitleHint | + Qt::WindowSystemMenuHint); + dialog.setWindowModality(Qt::WindowModal); + + if (dialog.exec() == QDialog::Accepted) { + dialog.ApplyConfiguration(); + } +} + +void QtControllerSelectorDialog::CallConfigureMotionTouchDialog() { + ConfigureMotionTouch dialog(this, input_subsystem); + + dialog.setWindowFlags(Qt::Dialog | Qt::CustomizeWindowHint | Qt::WindowTitleHint | + Qt::WindowSystemMenuHint); + dialog.setWindowModality(Qt::WindowModal); + + if (dialog.exec() == QDialog::Accepted) { + dialog.ApplyConfiguration(); + } +} + +void QtControllerSelectorDialog::CallConfigureInputProfileDialog() { + ConfigureInputProfileDialog dialog(this, input_subsystem, input_profiles.get(), system); + + dialog.setWindowFlags(Qt::Dialog | Qt::CustomizeWindowHint | Qt::WindowTitleHint | + Qt::WindowSystemMenuHint); + dialog.setWindowModality(Qt::WindowModal); + dialog.exec(); +} + +void QtControllerSelectorDialog::keyPressEvent(QKeyEvent* evt) { + const auto num_connected_players = static_cast( + std::count_if(player_groupboxes.begin(), player_groupboxes.end(), + [](const QGroupBox* player) { return player->isChecked(); })); + + const auto min_supported_players = parameters.enable_single_mode ? 1 : parameters.min_players; + const auto max_supported_players = parameters.enable_single_mode ? 1 : parameters.max_players; + + if ((evt->key() == Qt::Key_Enter || evt->key() == Qt::Key_Return) && !parameters_met) { + // Display error message when trying to validate using "Enter" and "OK" button is disabled + ui->labelError->setVisible(true); + return; + } else if (evt->key() == Qt::Key_Left && num_connected_players > min_supported_players) { + // Remove a player if possible + connected_controller_checkboxes[num_connected_players - 1]->setChecked(false); + return; + } else if (evt->key() == Qt::Key_Right && num_connected_players < max_supported_players) { + // Add a player, if possible + ui->labelError->setVisible(false); + connected_controller_checkboxes[num_connected_players]->setChecked(true); + return; + } + QDialog::keyPressEvent(evt); +} + +bool QtControllerSelectorDialog::CheckIfParametersMet() { + // Here, we check and validate the current configuration against all applicable parameters. + const auto num_connected_players = static_cast( + std::count_if(player_groupboxes.begin(), player_groupboxes.end(), + [](const QGroupBox* player) { return player->isChecked(); })); + + const auto min_supported_players = parameters.enable_single_mode ? 1 : parameters.min_players; + const auto max_supported_players = parameters.enable_single_mode ? 1 : parameters.max_players; + + // First, check against the number of connected players. + if (num_connected_players < min_supported_players || + num_connected_players > max_supported_players) { + parameters_met = false; + ui->buttonBox->button(QDialogButtonBox::Ok)->setEnabled(parameters_met); + return parameters_met; + } + + // Next, check against all connected controllers. + const auto all_controllers_compatible = [this] { + for (std::size_t index = 0; index < NUM_PLAYERS; ++index) { + // Skip controllers that are not used, we only care about the currently connected ones. + if (!player_groupboxes[index]->isChecked() || !player_groupboxes[index]->isEnabled()) { + continue; + } + + const auto compatible = IsControllerCompatible( + GetControllerTypeFromIndex(emulated_controllers[index]->currentIndex(), index), + parameters); + + // If any controller is found to be incompatible, return false early. + if (!compatible) { + return false; + } + } + + // Reaching here means all currently connected controllers are compatible. + return true; + }(); + + parameters_met = all_controllers_compatible; + ui->buttonBox->button(QDialogButtonBox::Ok)->setEnabled(parameters_met); + return parameters_met; +} + +void QtControllerSelectorDialog::SetSupportedControllers() { + const QString theme = [] { + if (QIcon::themeName().contains(QStringLiteral("dark"))) { + return QStringLiteral("_dark"); + } else if (QIcon::themeName().contains(QStringLiteral("midnight"))) { + return QStringLiteral("_midnight"); + } else { + return QString{}; + } + }(); + + if (parameters.enable_single_mode && parameters.allow_handheld) { + ui->controllerSupported1->setStyleSheet( + QStringLiteral("image: url(:/controller/applet_handheld%0); ").arg(theme)); + } else { + ui->controllerSupported1->setStyleSheet( + QStringLiteral("image: url(:/controller/applet_handheld%0_disabled); ").arg(theme)); + } + + if (parameters.allow_dual_joycons) { + ui->controllerSupported2->setStyleSheet( + QStringLiteral("image: url(:/controller/applet_dual_joycon%0); ").arg(theme)); + } else { + ui->controllerSupported2->setStyleSheet( + QStringLiteral("image: url(:/controller/applet_dual_joycon%0_disabled); ").arg(theme)); + } + + if (parameters.allow_left_joycon) { + ui->controllerSupported3->setStyleSheet( + QStringLiteral("image: url(:/controller/applet_joycon_left%0); ").arg(theme)); + } else { + ui->controllerSupported3->setStyleSheet( + QStringLiteral("image: url(:/controller/applet_joycon_left%0_disabled); ").arg(theme)); + } + + if (parameters.allow_right_joycon) { + ui->controllerSupported4->setStyleSheet( + QStringLiteral("image: url(:/controller/applet_joycon_right%0); ").arg(theme)); + } else { + ui->controllerSupported4->setStyleSheet( + QStringLiteral("image: url(:/controller/applet_joycon_right%0_disabled); ").arg(theme)); + } + + if (parameters.allow_pro_controller || parameters.allow_gamecube_controller) { + ui->controllerSupported5->setStyleSheet( + QStringLiteral("image: url(:/controller/applet_pro_controller%0); ").arg(theme)); + } else { + ui->controllerSupported5->setStyleSheet( + QStringLiteral("image: url(:/controller/applet_pro_controller%0_disabled); ") + .arg(theme)); + } + + // enable_single_mode overrides min_players and max_players. + if (parameters.enable_single_mode) { + ui->numberSupportedLabel->setText(QStringLiteral("1")); + return; + } + + if (parameters.min_players == parameters.max_players) { + ui->numberSupportedLabel->setText(QStringLiteral("%1").arg(parameters.max_players)); + } else { + ui->numberSupportedLabel->setText( + QStringLiteral("%1 - %2").arg(parameters.min_players).arg(parameters.max_players)); + } +} + +void QtControllerSelectorDialog::SetEmulatedControllers(std::size_t player_index) { + const auto npad_style_set = system.HIDCore().GetSupportedStyleTag(); + auto& pairs = index_controller_type_pairs[player_index]; + + pairs.clear(); + emulated_controllers[player_index]->clear(); + + const auto add_item = [&](Core::HID::NpadStyleIndex controller_type, + const QString& controller_name) { + pairs.emplace_back(emulated_controllers[player_index]->count(), controller_type); + emulated_controllers[player_index]->addItem(controller_name); + }; + + if (npad_style_set.fullkey == 1) { + add_item(Core::HID::NpadStyleIndex::Fullkey, tr("Pro Controller")); + } + + if (npad_style_set.joycon_dual == 1) { + add_item(Core::HID::NpadStyleIndex::JoyconDual, tr("Dual Joycons")); + } + + if (npad_style_set.joycon_left == 1) { + add_item(Core::HID::NpadStyleIndex::JoyconLeft, tr("Left Joycon")); + } + + if (npad_style_set.joycon_right == 1) { + add_item(Core::HID::NpadStyleIndex::JoyconRight, tr("Right Joycon")); + } + + if (player_index == 0 && npad_style_set.handheld == 1) { + add_item(Core::HID::NpadStyleIndex::Handheld, tr("Handheld")); + } + + if (npad_style_set.gamecube == 1) { + add_item(Core::HID::NpadStyleIndex::GameCube, tr("GameCube Controller")); + } + + // Disable all unsupported controllers + if (!Settings::values.enable_all_controllers) { + return; + } + + if (npad_style_set.palma == 1) { + add_item(Core::HID::NpadStyleIndex::Pokeball, tr("Poke Ball Plus")); + } + + if (npad_style_set.lark == 1) { + add_item(Core::HID::NpadStyleIndex::NES, tr("NES Controller")); + } + + if (npad_style_set.lucia == 1) { + add_item(Core::HID::NpadStyleIndex::SNES, tr("SNES Controller")); + } + + if (npad_style_set.lagoon == 1) { + add_item(Core::HID::NpadStyleIndex::N64, tr("N64 Controller")); + } + + if (npad_style_set.lager == 1) { + add_item(Core::HID::NpadStyleIndex::SegaGenesis, tr("Sega Genesis")); + } +} + +Core::HID::NpadStyleIndex QtControllerSelectorDialog::GetControllerTypeFromIndex( + int index, std::size_t player_index) const { + const auto& pairs = index_controller_type_pairs[player_index]; + + const auto it = std::find_if(pairs.begin(), pairs.end(), + [index](const auto& pair) { return pair.first == index; }); + + if (it == pairs.end()) { + return Core::HID::NpadStyleIndex::Fullkey; + } + + return it->second; +} + +int QtControllerSelectorDialog::GetIndexFromControllerType(Core::HID::NpadStyleIndex type, + std::size_t player_index) const { + const auto& pairs = index_controller_type_pairs[player_index]; + + const auto it = std::find_if(pairs.begin(), pairs.end(), + [type](const auto& pair) { return pair.second == type; }); + + if (it == pairs.end()) { + return 0; + } + + return it->first; +} + +void QtControllerSelectorDialog::UpdateControllerIcon(std::size_t player_index) { + if (!player_groupboxes[player_index]->isChecked()) { + connected_controller_icons[player_index]->setStyleSheet(QString{}); + player_labels[player_index]->show(); + return; + } + + const QString stylesheet = [this, player_index] { + switch (GetControllerTypeFromIndex(emulated_controllers[player_index]->currentIndex(), + player_index)) { + case Core::HID::NpadStyleIndex::Fullkey: + case Core::HID::NpadStyleIndex::GameCube: + return QStringLiteral("image: url(:/controller/applet_pro_controller%0); "); + case Core::HID::NpadStyleIndex::JoyconDual: + return QStringLiteral("image: url(:/controller/applet_dual_joycon%0); "); + case Core::HID::NpadStyleIndex::JoyconLeft: + return QStringLiteral("image: url(:/controller/applet_joycon_left%0); "); + case Core::HID::NpadStyleIndex::JoyconRight: + return QStringLiteral("image: url(:/controller/applet_joycon_right%0); "); + case Core::HID::NpadStyleIndex::Handheld: + return QStringLiteral("image: url(:/controller/applet_handheld%0); "); + default: + return QString{}; + } + }(); + + if (stylesheet.isEmpty()) { + connected_controller_icons[player_index]->setStyleSheet(QString{}); + player_labels[player_index]->show(); + return; + } + + const QString theme = [] { + if (QIcon::themeName().contains(QStringLiteral("dark"))) { + return QStringLiteral("_dark"); + } else if (QIcon::themeName().contains(QStringLiteral("midnight"))) { + return QStringLiteral("_midnight"); + } else { + return QString{}; + } + }(); + + connected_controller_icons[player_index]->setStyleSheet(stylesheet.arg(theme)); + player_labels[player_index]->hide(); +} + +void QtControllerSelectorDialog::UpdateControllerState(std::size_t player_index) { + auto* controller = system.HIDCore().GetEmulatedControllerByIndex(player_index); + + const auto controller_type = GetControllerTypeFromIndex( + emulated_controllers[player_index]->currentIndex(), player_index); + const auto player_connected = player_groupboxes[player_index]->isChecked() && + controller_type != Core::HID::NpadStyleIndex::Handheld; + + if (controller->GetNpadStyleIndex(true) == controller_type && + controller->IsConnected(true) == player_connected) { + return; + } + + // Disconnect the controller first. + UpdateController(controller, controller_type, false); + + // Handheld + if (player_index == 0) { + if (controller_type == Core::HID::NpadStyleIndex::Handheld) { + auto* handheld = + system.HIDCore().GetEmulatedController(Core::HID::NpadIdType::Handheld); + UpdateController(handheld, Core::HID::NpadStyleIndex::Handheld, + player_groupboxes[player_index]->isChecked()); + } + } + + UpdateController(controller, controller_type, player_connected); +} + +void QtControllerSelectorDialog::UpdateLEDPattern(std::size_t player_index) { + if (!player_groupboxes[player_index]->isChecked() || + GetControllerTypeFromIndex(emulated_controllers[player_index]->currentIndex(), + player_index) == Core::HID::NpadStyleIndex::Handheld) { + led_patterns_boxes[player_index][0]->setChecked(false); + led_patterns_boxes[player_index][1]->setChecked(false); + led_patterns_boxes[player_index][2]->setChecked(false); + led_patterns_boxes[player_index][3]->setChecked(false); + return; + } + + const auto* controller = system.HIDCore().GetEmulatedControllerByIndex(player_index); + const auto led_pattern = controller->GetLedPattern(); + led_patterns_boxes[player_index][0]->setChecked(led_pattern.position1); + led_patterns_boxes[player_index][1]->setChecked(led_pattern.position2); + led_patterns_boxes[player_index][2]->setChecked(led_pattern.position3); + led_patterns_boxes[player_index][3]->setChecked(led_pattern.position4); +} + +void QtControllerSelectorDialog::UpdateBorderColor(std::size_t player_index) { + if (!parameters.enable_border_color || + player_index >= static_cast(parameters.max_players) || + player_groupboxes[player_index]->styleSheet().contains(QStringLiteral("QGroupBox"))) { + return; + } + + player_groupboxes[player_index]->setStyleSheet( + player_groupboxes[player_index]->styleSheet().append( + QStringLiteral("QGroupBox#groupPlayer%1Connected:checked " + "{ border: 1px solid rgba(%2, %3, %4, %5); }") + .arg(player_index + 1) + .arg(parameters.border_colors[player_index][0]) + .arg(parameters.border_colors[player_index][1]) + .arg(parameters.border_colors[player_index][2]) + .arg(parameters.border_colors[player_index][3]))); +} + +void QtControllerSelectorDialog::SetExplainText(std::size_t player_index) { + if (!parameters.enable_explain_text || + player_index >= static_cast(parameters.max_players)) { + return; + } + + explain_text_labels[player_index]->setText(QString::fromStdString( + Common::StringFromFixedZeroTerminatedBuffer(parameters.explain_text[player_index].data(), + parameters.explain_text[player_index].size()))); +} + +void QtControllerSelectorDialog::UpdateDockedState(bool is_handheld) { + // Disallow changing the console mode if the controller type is handheld. + ui->radioDocked->setEnabled(!is_handheld); + ui->radioUndocked->setEnabled(!is_handheld); + + ui->radioDocked->setChecked(Settings::IsDockedMode()); + ui->radioUndocked->setChecked(!Settings::IsDockedMode()); + + // Also force into undocked mode if the controller type is handheld. + if (is_handheld) { + ui->radioUndocked->setChecked(true); + } +} + +void QtControllerSelectorDialog::PropagatePlayerNumberChanged(size_t player_index, bool checked, + bool reconnect_current) { + connected_controller_checkboxes[player_index]->setChecked(checked); + // Hide eventual error message about number of controllers + ui->labelError->setVisible(false); + + if (checked) { + // Check all previous buttons when checked + if (player_index > 0) { + PropagatePlayerNumberChanged(player_index - 1, checked); + } + } else { + // Unchecked all following buttons when unchecked + if (player_index < connected_controller_checkboxes.size() - 1) { + PropagatePlayerNumberChanged(player_index + 1, checked); + } + } + + if (reconnect_current) { + connected_controller_checkboxes[player_index]->setCheckState(Qt::Checked); + } +} + +void QtControllerSelectorDialog::DisableUnsupportedPlayers() { + const auto max_supported_players = parameters.enable_single_mode ? 1 : parameters.max_players; + + switch (max_supported_players) { + case 0: + default: + ASSERT(false); + return; + case 1: + ui->widgetSpacer->hide(); + ui->widgetSpacer2->hide(); + ui->widgetSpacer3->hide(); + ui->widgetSpacer4->hide(); + break; + case 2: + ui->widgetSpacer->hide(); + ui->widgetSpacer2->hide(); + ui->widgetSpacer3->hide(); + break; + case 3: + ui->widgetSpacer->hide(); + ui->widgetSpacer2->hide(); + break; + case 4: + ui->widgetSpacer->hide(); + break; + case 5: + case 6: + case 7: + case 8: + break; + } + + for (std::size_t index = max_supported_players; index < NUM_PLAYERS; ++index) { + auto* controller = system.HIDCore().GetEmulatedControllerByIndex(index); + // Disconnect any unsupported players here and disable or hide them if applicable. + UpdateController(controller, controller->GetNpadStyleIndex(true), false); + // Hide the player widgets when max_supported_controllers is less than or equal to 4. + if (max_supported_players <= 4) { + player_widgets[index]->hide(); + } + + // Disable and hide the following to prevent these from interaction. + player_widgets[index]->setDisabled(true); + connected_controller_checkboxes[index]->setDisabled(true); + connected_controller_labels[index]->hide(); + connected_controller_checkboxes[index]->hide(); + } +} + +QtControllerSelector::QtControllerSelector(GMainWindow& parent) { + connect(this, &QtControllerSelector::MainWindowReconfigureControllers, &parent, + &GMainWindow::ControllerSelectorReconfigureControllers, Qt::QueuedConnection); + connect(this, &QtControllerSelector::MainWindowRequestExit, &parent, + &GMainWindow::ControllerSelectorRequestExit, Qt::QueuedConnection); + connect(&parent, &GMainWindow::ControllerSelectorReconfigureFinished, this, + &QtControllerSelector::MainWindowReconfigureFinished, Qt::QueuedConnection); +} + +QtControllerSelector::~QtControllerSelector() = default; + +void QtControllerSelector::Close() const { + callback = {}; + emit MainWindowRequestExit(); +} + +void QtControllerSelector::ReconfigureControllers( + ReconfigureCallback callback_, const Core::Frontend::ControllerParameters& parameters) const { + callback = std::move(callback_); + emit MainWindowReconfigureControllers(parameters); +} + +void QtControllerSelector::MainWindowReconfigureFinished(bool is_success) { + if (callback) { + callback(is_success); + } +} diff --git a/src/sudachi/applets/qt_controller.h b/src/sudachi/applets/qt_controller.h new file mode 100644 index 0000000..7830894 --- /dev/null +++ b/src/sudachi/applets/qt_controller.h @@ -0,0 +1,183 @@ +// SPDX-FileCopyrightText: Copyright 2020 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include +#include +#include +#include "core/frontend/applets/controller.h" + +class GMainWindow; +class QCheckBox; +class QComboBox; +class QDialogButtonBox; +class QGroupBox; +class QLabel; + +class InputProfiles; + +namespace InputCommon { +class InputSubsystem; +} + +namespace Ui { +class QtControllerSelectorDialog; +} + +namespace Core { +class System; +} + +namespace Core::HID { +class HIDCore; +enum class NpadStyleIndex : u8; +} // namespace Core::HID + +class ControllerNavigation; + +class QtControllerSelectorDialog final : public QDialog { + Q_OBJECT + +public: + explicit QtControllerSelectorDialog(QWidget* parent, + Core::Frontend::ControllerParameters parameters_, + InputCommon::InputSubsystem* input_subsystem_, + Core::System& system_); + ~QtControllerSelectorDialog() override; + + int exec() override; + + void keyPressEvent(QKeyEvent* evt) override; + +private: + // Applies the current configuration. + void ApplyConfiguration(); + + // Loads the current input configuration into the frontend applet. + void LoadConfiguration(); + + // Initializes the "Configure Vibration" Dialog. + void CallConfigureVibrationDialog(); + + // Initializes the "Configure Motion / Touch" Dialog. + void CallConfigureMotionTouchDialog(); + + // Initializes the "Create Input Profile" Dialog. + void CallConfigureInputProfileDialog(); + + // Checks the current configuration against the given parameters. + // This sets and returns the value of parameters_met. + bool CheckIfParametersMet(); + + // Sets the controller icons for "Supported Controller Types". + void SetSupportedControllers(); + + // Sets the emulated controllers per player. + void SetEmulatedControllers(std::size_t player_index); + + // Gets the Controller Type for a given controller combobox index per player. + Core::HID::NpadStyleIndex GetControllerTypeFromIndex(int index, std::size_t player_index) const; + + // Gets the controller combobox index for a given Controller Type per player. + int GetIndexFromControllerType(Core::HID::NpadStyleIndex type, std::size_t player_index) const; + + // Updates the controller icons per player. + void UpdateControllerIcon(std::size_t player_index); + + // Updates the controller state (type and connection status) per player. + void UpdateControllerState(std::size_t player_index); + + // Updates the LED pattern per player. + void UpdateLEDPattern(std::size_t player_index); + + // Updates the border color per player. + void UpdateBorderColor(std::size_t player_index); + + // Sets the "Explain Text" per player. + void SetExplainText(std::size_t player_index); + + // Updates the console mode. + void UpdateDockedState(bool is_handheld); + + // Enable preceding controllers or disable following ones + void PropagatePlayerNumberChanged(size_t player_index, bool checked, + bool reconnect_current = false); + + // Disables and disconnects unsupported players based on the given parameters. + void DisableUnsupportedPlayers(); + + std::unique_ptr ui; + + // Parameters sent in from the backend HLE applet. + Core::Frontend::ControllerParameters parameters; + + InputCommon::InputSubsystem* input_subsystem; + + std::unique_ptr input_profiles; + + Core::System& system; + + ControllerNavigation* controller_navigation = nullptr; + + // This is true if and only if all parameters are met. Otherwise, this is false. + // This determines whether the "OK" button can be clicked to exit the applet. + bool parameters_met{false}; + + static constexpr std::size_t NUM_PLAYERS = 8; + + // Widgets encapsulating the groupboxes and comboboxes per player. + std::array player_widgets; + + // Groupboxes encapsulating the controller icons and LED patterns per player. + std::array player_groupboxes; + + // Icons for currently connected controllers/players. + std::array connected_controller_icons; + + // Labels that represent the player numbers in place of the controller icons. + std::array player_labels; + + // LED patterns for currently connected controllers/players. + std::array, NUM_PLAYERS> led_patterns_boxes; + + // Labels representing additional information known as "Explain Text" per player. + std::array explain_text_labels; + + // Comboboxes with a list of emulated controllers per player. + std::array emulated_controllers; + + /// Pairs of emulated controller index and Controller Type enum per player. + std::array>, NUM_PLAYERS> + index_controller_type_pairs; + + // Labels representing the number of connected controllers + // above the "Connected Controllers" checkboxes. + std::array connected_controller_labels; + + // Checkboxes representing the "Connected Controllers". + std::array connected_controller_checkboxes; +}; + +class QtControllerSelector final : public QObject, public Core::Frontend::ControllerApplet { + Q_OBJECT + +public: + explicit QtControllerSelector(GMainWindow& parent); + ~QtControllerSelector() override; + + void Close() const override; + void ReconfigureControllers( + ReconfigureCallback callback_, + const Core::Frontend::ControllerParameters& parameters) const override; + +signals: + void MainWindowReconfigureControllers( + const Core::Frontend::ControllerParameters& parameters) const; + void MainWindowRequestExit() const; + +private: + void MainWindowReconfigureFinished(bool is_success); + + mutable ReconfigureCallback callback; +}; diff --git a/src/sudachi/applets/qt_controller.ui b/src/sudachi/applets/qt_controller.ui new file mode 100644 index 0000000..6f7cb3c --- /dev/null +++ b/src/sudachi/applets/qt_controller.ui @@ -0,0 +1,2699 @@ + + + QtControllerSelectorDialog + + + + 0 + 0 + 839 + 630 + + + + Controller Applet + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + 10 + + + 0 + + + 10 + + + 0 + + + 10 + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + 70 + 70 + + + + + 70 + 70 + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + 70 + 70 + + + + + 70 + 70 + + + + + 75 + true + + + + Supported Controller Types: + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + true + + + + + + + + + + + 0 + 0 + + + + + 70 + 70 + + + + + 70 + 70 + + + + + + + + + + + + 70 + 70 + + + + + 70 + 70 + + + + + + + + + + + + 70 + 70 + + + + + 70 + 70 + + + + + + + + + + + + 70 + 70 + + + + + 70 + 70 + + + + + + + + + + + + 70 + 70 + + + + + 70 + 70 + + + + + + + + + + + + 70 + 70 + + + + + 70 + 70 + + + + + 0 + + + 0 + + + 16 + + + 14 + + + 16 + + + + + + 75 + true + + + + Players: + + + Qt::AlignCenter + + + false + + + + + + + + 14 + + + + 1 - 8 + + + Qt::AlignCenter + + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + 5 + + + + + + 5 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + 100 + 100 + + + + + 100 + 100 + + + + + + + true + + + false + + + + 7 + + + 14 + + + 7 + + + 14 + + + 4 + + + + + + + + + 16 + + + + + P4 + + + + + + + + + + false + + + + 0 + 10 + + + + + 4 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + + + + + + + + + + + + + + + + + + + 0 + 10 + + + + + 150 + 16777215 + + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Qt::AlignCenter + + + + + + + + + + + Pro Controller + + + + + Dual Joycons + + + + + Left Joycon + + + + + Right Joycon + + + + + + + + + Use Current Config + + + + + + + + + + + + 5 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + 100 + 100 + + + + + 100 + 100 + + + + + + + true + + + false + + + + 7 + + + 14 + + + 7 + + + 14 + + + 4 + + + + + + + + + 16 + + + + + P2 + + + + + + + + + + false + + + + 0 + 10 + + + + + 4 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + + + + + + + + + + + + + + + + + + + 0 + 10 + + + + + 150 + 16777215 + + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Qt::AlignCenter + + + + + + + + + + + Pro Controller + + + + + Dual Joycons + + + + + Left Joycon + + + + + Right Joycon + + + + + + + + + Use Current Config + + + + + + + + + + + + 5 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + 100 + 100 + + + + + 100 + 100 + + + + + + + true + + + false + + + + 7 + + + 14 + + + 7 + + + 14 + + + 4 + + + + + + + + + 16 + + + + + P1 + + + + + + + + + + false + + + + 0 + 10 + + + + + 4 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Qt::LeftToRight + + + + + + + + + + + + + + + + + + + + + + + 0 + 10 + + + + + 150 + 16777215 + + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Qt::AlignCenter + + + + + + + + + + + Pro Controller + + + + + Dual Joycons + + + + + Left Joycon + + + + + Right Joycon + + + + + Handheld + + + + + + + + + Use Current Config + + + + + + + + + + + + 25 + 0 + + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Qt::Horizontal + + + + 25 + 20 + + + + + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Qt::Horizontal + + + + 0 + 20 + + + + + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Qt::Horizontal + + + + 0 + 20 + + + + + + + + + + + + 5 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + 100 + 100 + + + + + 100 + 100 + + + + + + + true + + + false + + + + 7 + + + 14 + + + 7 + + + 14 + + + 4 + + + + + + + + + 16 + + + + + P3 + + + + + + + + + + false + + + + 0 + 10 + + + + + 4 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + + + + + + + + + + + + + + + + + + + 0 + 10 + + + + + 150 + 16777215 + + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Qt::AlignCenter + + + + + + + + + + + Pro Controller + + + + + Dual Joycons + + + + + Left Joycon + + + + + Right Joycon + + + + + + + + + Use Current Config + + + + + + + + + + + + 0 + 25 + + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Qt::Vertical + + + + 20 + 25 + + + + + + + + + + + + 5 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + 100 + 100 + + + + + 100 + 100 + + + + + + + true + + + false + + + + 7 + + + 14 + + + 7 + + + 14 + + + 4 + + + + + + + + + 16 + + + + + P7 + + + + + + + + + + false + + + + 0 + 10 + + + + + 4 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + + + + + + + + + + + + + + + + + + + 0 + 10 + + + + + 150 + 16777215 + + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Qt::AlignCenter + + + + + + + + + + + Pro Controller + + + + + Dual Joycons + + + + + Left Joycon + + + + + Right Joycon + + + + + + + + + Use Current Config + + + + + + + + + + + + 5 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + 100 + 100 + + + + + 100 + 100 + + + + + + + true + + + false + + + + 7 + + + 14 + + + 7 + + + 14 + + + 4 + + + + + + + + + 16 + + + + + P8 + + + + + + + + + + false + + + + 0 + 10 + + + + + 4 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + + + + + + + + + + + + + + + + + + + 0 + 10 + + + + + 150 + 16777215 + + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Qt::AlignCenter + + + + + + + + + + + Pro Controller + + + + + Dual Joycons + + + + + Left Joycon + + + + + Right Joycon + + + + + + + + + Use Current Config + + + + + + + + + + + + 5 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + 100 + 100 + + + + + 100 + 100 + + + + + + + true + + + false + + + + 7 + + + 14 + + + 7 + + + 14 + + + 4 + + + + + + + + + 16 + + + + + P5 + + + + + + + + + + false + + + + 0 + 10 + + + + + 4 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Qt::LeftToRight + + + + + + + + + + + + + + + + + + + + + + + 0 + 10 + + + + + 150 + 16777215 + + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Qt::AlignCenter + + + + + + + + + + + Pro Controller + + + + + Dual Joycons + + + + + Left Joycon + + + + + Right Joycon + + + + + + + + + Use Current Config + + + + + + + + + + + + 5 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + 100 + 100 + + + + + 100 + 100 + + + + + + + true + + + false + + + + 7 + + + 14 + + + 7 + + + 14 + + + 4 + + + + + + + + + 16 + + + + + P6 + + + + + + + + + + false + + + + 0 + 10 + + + + + 4 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + + + + + + + + + + + + + + + + + + + 0 + 10 + + + + + 150 + 16777215 + + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Qt::AlignCenter + + + + + + + + + + + Pro Controller + + + + + Dual Joycons + + + + + Left Joycon + + + + + Right Joycon + + + + + + + + + Use Current Config + + + + + + + + + + + + 0 + 25 + + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Qt::Vertical + + + + 20 + 25 + + + + + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Qt::Horizontal + + + + 0 + 20 + + + + + + + + + + + + 25 + 0 + + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Qt::Horizontal + + + + 25 + 20 + + + + + + + + + + + + 0 + 25 + + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Qt::Vertical + + + + 20 + 25 + + + + + + + + + + + + + + + + + 15 + + + 15 + + + 8 + + + 15 + + + 15 + + + + + + 16777215 + 16777215 + + + + Console Mode + + + + 6 + + + 8 + + + 6 + + + 3 + + + 6 + + + + + Docked + + + true + + + + + + + Handheld + + + + + + + + + + Vibration + + + true + + + + 3 + + + 3 + + + 3 + + + 3 + + + + + + 68 + 0 + + + + + 68 + 16777215 + + + + min-width: 68px; + + + Configure + + + + + + + + + + Motion + + + true + + + + 3 + + + 3 + + + 3 + + + 3 + + + + + + 68 + 0 + + + + + 68 + 16777215 + + + + min-width: 68px; + + + Configure + + + + + + + + + + Profiles + + + + 3 + + + 3 + + + 3 + + + 3 + + + + + + 68 + 16777215 + + + + min-width: 68px; + + + Create + + + + + + + + + + + 5 + + + 0 + + + 0 + + + 0 + + + 3 + + + + + + + + + + + + Controllers + + + + + + + + + + + + + + 1 + + + Qt::AlignCenter + + + + + + + + + + + + + + Qt::LeftToRight + + + false + + + + + + + 2 + + + Qt::AlignCenter + + + + + + + 4 + + + Qt::AlignCenter + + + + + + + 3 + + + Qt::AlignCenter + + + + + + + Connected + + + + + + + + + + + + + + 5 + + + Qt::AlignCenter + + + + + + + + + + + + + + 7 + + + Qt::AlignCenter + + + + + + + + + + + + + + 6 + + + Qt::AlignCenter + + + + + + + 8 + + + Qt::AlignCenter + + + + + + + + + + + + + + + + + Qt::Horizontal + + + + 0 + 20 + + + + + + + + + 7 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + true + + + QLabel { color : red; } + + + Not enough controllers + + + Qt::AlignCenter + + + 0 + + + + + + + true + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + + + + + + + + + + + + + + + + buttonBox + accepted() + QtControllerSelectorDialog + accept() + + + buttonBox + rejected() + QtControllerSelectorDialog + reject() + + + diff --git a/src/sudachi/applets/qt_error.cpp b/src/sudachi/applets/qt_error.cpp new file mode 100644 index 0000000..9712a11 --- /dev/null +++ b/src/sudachi/applets/qt_error.cpp @@ -0,0 +1,68 @@ +// SPDX-FileCopyrightText: Copyright 2019 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include +#include "sudachi/applets/qt_error.h" +#include "sudachi/main.h" + +QtErrorDisplay::QtErrorDisplay(GMainWindow& parent) { + connect(this, &QtErrorDisplay::MainWindowDisplayError, &parent, + &GMainWindow::ErrorDisplayDisplayError, Qt::QueuedConnection); + connect(this, &QtErrorDisplay::MainWindowRequestExit, &parent, + &GMainWindow::ErrorDisplayRequestExit, Qt::QueuedConnection); + connect(&parent, &GMainWindow::ErrorDisplayFinished, this, + &QtErrorDisplay::MainWindowFinishedError, Qt::DirectConnection); +} + +QtErrorDisplay::~QtErrorDisplay() = default; + +void QtErrorDisplay::Close() const { + callback = {}; + emit MainWindowRequestExit(); +} + +void QtErrorDisplay::ShowError(Result error, FinishedCallback finished) const { + callback = std::move(finished); + emit MainWindowDisplayError( + tr("Error Code: %1-%2 (0x%3)") + .arg(static_cast(error.GetModule()) + 2000, 4, 10, QChar::fromLatin1('0')) + .arg(error.GetDescription(), 4, 10, QChar::fromLatin1('0')) + .arg(error.raw, 8, 16, QChar::fromLatin1('0')), + tr("An error has occurred.\nPlease try again or contact the developer of the software.")); +} + +void QtErrorDisplay::ShowErrorWithTimestamp(Result error, std::chrono::seconds time, + FinishedCallback finished) const { + callback = std::move(finished); + + const QDateTime date_time = QDateTime::fromSecsSinceEpoch(time.count()); + emit MainWindowDisplayError( + tr("Error Code: %1-%2 (0x%3)") + .arg(static_cast(error.GetModule()) + 2000, 4, 10, QChar::fromLatin1('0')) + .arg(error.GetDescription(), 4, 10, QChar::fromLatin1('0')) + .arg(error.raw, 8, 16, QChar::fromLatin1('0')), + tr("An error occurred on %1 at %2.\nPlease try again or contact the developer of the " + "software.") + .arg(date_time.toString(QStringLiteral("dddd, MMMM d, yyyy"))) + .arg(date_time.toString(QStringLiteral("h:mm:ss A")))); +} + +void QtErrorDisplay::ShowCustomErrorText(Result error, std::string dialog_text, + std::string fullscreen_text, + FinishedCallback finished) const { + callback = std::move(finished); + emit MainWindowDisplayError( + tr("Error Code: %1-%2 (0x%3)") + .arg(static_cast(error.GetModule()) + 2000, 4, 10, QChar::fromLatin1('0')) + .arg(error.GetDescription(), 4, 10, QChar::fromLatin1('0')) + .arg(error.raw, 8, 16, QChar::fromLatin1('0')), + tr("An error has occurred.\n\n%1\n\n%2") + .arg(QString::fromStdString(dialog_text)) + .arg(QString::fromStdString(fullscreen_text))); +} + +void QtErrorDisplay::MainWindowFinishedError() { + if (callback) { + callback(); + } +} diff --git a/src/sudachi/applets/qt_error.h b/src/sudachi/applets/qt_error.h new file mode 100644 index 0000000..ba810e5 --- /dev/null +++ b/src/sudachi/applets/qt_error.h @@ -0,0 +1,34 @@ +// SPDX-FileCopyrightText: Copyright 2019 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include + +#include "core/frontend/applets/error.h" + +class GMainWindow; + +class QtErrorDisplay final : public QObject, public Core::Frontend::ErrorApplet { + Q_OBJECT + +public: + explicit QtErrorDisplay(GMainWindow& parent); + ~QtErrorDisplay() override; + + void Close() const override; + void ShowError(Result error, FinishedCallback finished) const override; + void ShowErrorWithTimestamp(Result error, std::chrono::seconds time, + FinishedCallback finished) const override; + void ShowCustomErrorText(Result error, std::string dialog_text, std::string fullscreen_text, + FinishedCallback finished) const override; + +signals: + void MainWindowDisplayError(QString error_code, QString error_text) const; + void MainWindowRequestExit() const; + +private: + void MainWindowFinishedError(); + + mutable FinishedCallback callback; +}; diff --git a/src/sudachi/applets/qt_profile_select.cpp b/src/sudachi/applets/qt_profile_select.cpp new file mode 100644 index 0000000..3979d1f --- /dev/null +++ b/src/sudachi/applets/qt_profile_select.cpp @@ -0,0 +1,260 @@ +// SPDX-FileCopyrightText: Copyright 2018 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "common/fs/path_util.h" +#include "common/string_util.h" +#include "core/constants.h" +#include "core/core.h" +#include "core/hle/service/acc/profile_manager.h" +#include "sudachi/applets/qt_profile_select.h" +#include "sudachi/main.h" +#include "sudachi/util/controller_navigation.h" + +namespace { +QString FormatUserEntryText(const QString& username, Common::UUID uuid) { + return QtProfileSelectionDialog::tr( + "%1\n%2", "%1 is the profile username, %2 is the formatted UUID (e.g. " + "00112233-4455-6677-8899-AABBCCDDEEFF))") + .arg(username, QString::fromStdString(uuid.FormattedString())); +} + +QString GetImagePath(Common::UUID uuid) { + const auto path = + Common::FS::GetSudachiPath(Common::FS::SudachiPath::NANDDir) / + fmt::format("system/save/8000000000000010/su/avators/{}.jpg", uuid.FormattedString()); + return QString::fromStdString(Common::FS::PathToUTF8String(path)); +} + +QPixmap GetIcon(Common::UUID uuid) { + QPixmap icon{GetImagePath(uuid)}; + + if (!icon) { + icon.fill(Qt::black); + icon.loadFromData(Core::Constants::ACCOUNT_BACKUP_JPEG.data(), + static_cast(Core::Constants::ACCOUNT_BACKUP_JPEG.size())); + } + + return icon.scaled(64, 64, Qt::IgnoreAspectRatio, Qt::SmoothTransformation); +} +} // Anonymous namespace + +QtProfileSelectionDialog::QtProfileSelectionDialog( + Core::System& system, QWidget* parent, + const Core::Frontend::ProfileSelectParameters& parameters) + : QDialog(parent), profile_manager{system.GetProfileManager()} { + outer_layout = new QVBoxLayout; + + instruction_label = new QLabel(); + + scroll_area = new QScrollArea; + + buttons = new QDialogButtonBox(QDialogButtonBox::Cancel | QDialogButtonBox::Ok); + connect(buttons, &QDialogButtonBox::accepted, this, &QtProfileSelectionDialog::accept); + connect(buttons, &QDialogButtonBox::rejected, this, &QtProfileSelectionDialog::reject); + + outer_layout->addWidget(instruction_label); + outer_layout->addWidget(scroll_area); + outer_layout->addWidget(buttons); + + layout = new QVBoxLayout; + tree_view = new QTreeView; + item_model = new QStandardItemModel(tree_view); + tree_view->setModel(item_model); + controller_navigation = new ControllerNavigation(system.HIDCore(), this); + + tree_view->setAlternatingRowColors(true); + tree_view->setSelectionMode(QHeaderView::SingleSelection); + tree_view->setSelectionBehavior(QHeaderView::SelectRows); + tree_view->setVerticalScrollMode(QHeaderView::ScrollPerPixel); + tree_view->setHorizontalScrollMode(QHeaderView::ScrollPerPixel); + tree_view->setSortingEnabled(true); + tree_view->setEditTriggers(QHeaderView::NoEditTriggers); + tree_view->setUniformRowHeights(true); + tree_view->setIconSize({64, 64}); + tree_view->setContextMenuPolicy(Qt::NoContextMenu); + + item_model->insertColumns(0, 1); + item_model->setHeaderData(0, Qt::Horizontal, tr("Users")); + + // We must register all custom types with the Qt Automoc system so that we are able to use it + // with signals/slots. In this case, QList falls under the umbrella of custom types. + qRegisterMetaType>("QList"); + + layout->setContentsMargins(0, 0, 0, 0); + layout->setSpacing(0); + layout->addWidget(tree_view); + + scroll_area->setLayout(layout); + + connect(tree_view, &QTreeView::clicked, this, &QtProfileSelectionDialog::SelectUser); + connect(tree_view, &QTreeView::doubleClicked, this, &QtProfileSelectionDialog::accept); + connect(controller_navigation, &ControllerNavigation::TriggerKeyboardEvent, + [this](Qt::Key key) { + if (!this->isActiveWindow()) { + return; + } + QKeyEvent* event = new QKeyEvent(QEvent::KeyPress, key, Qt::NoModifier); + QCoreApplication::postEvent(tree_view, event); + SelectUser(tree_view->currentIndex()); + }); + + const auto& profiles = profile_manager.GetAllUsers(); + for (const auto& user : profiles) { + Service::Account::ProfileBase profile{}; + if (!profile_manager.GetProfileBase(user, profile)) + continue; + + const auto username = Common::StringFromFixedZeroTerminatedBuffer( + reinterpret_cast(profile.username.data()), profile.username.size()); + + list_items.push_back(QList{new QStandardItem{ + GetIcon(user), FormatUserEntryText(QString::fromStdString(username), user)}}); + } + + for (const auto& item : list_items) + item_model->appendRow(item); + + setLayout(outer_layout); + SetWindowTitle(parameters); + SetDialogPurpose(parameters); + resize(550, 400); +} + +QtProfileSelectionDialog::~QtProfileSelectionDialog() { + controller_navigation->UnloadController(); +}; + +int QtProfileSelectionDialog::exec() { + // Skip profile selection when there's only one. + if (profile_manager.GetUserCount() == 1) { + user_index = 0; + return QDialog::Accepted; + } + return QDialog::exec(); +} + +void QtProfileSelectionDialog::accept() { + QDialog::accept(); +} + +void QtProfileSelectionDialog::reject() { + user_index = 0; + QDialog::reject(); +} + +int QtProfileSelectionDialog::GetIndex() const { + return user_index; +} + +void QtProfileSelectionDialog::SelectUser(const QModelIndex& index) { + user_index = index.row(); +} + +void QtProfileSelectionDialog::SetWindowTitle( + const Core::Frontend::ProfileSelectParameters& parameters) { + using Service::AM::Frontend::UiMode; + switch (parameters.mode) { + case UiMode::UserCreator: + case UiMode::UserCreatorForStarter: + setWindowTitle(tr("Profile Creator")); + return; + case UiMode::EnsureNetworkServiceAccountAvailable: + setWindowTitle(tr("Profile Selector")); + return; + case UiMode::UserIconEditor: + setWindowTitle(tr("Profile Icon Editor")); + return; + case UiMode::UserNicknameEditor: + setWindowTitle(tr("Profile Nickname Editor")); + return; + case UiMode::NintendoAccountAuthorizationRequestContext: + case UiMode::IntroduceExternalNetworkServiceAccount: + case UiMode::IntroduceExternalNetworkServiceAccountForRegistration: + case UiMode::NintendoAccountNnidLinker: + case UiMode::LicenseRequirementsForNetworkService: + case UiMode::LicenseRequirementsForNetworkServiceWithUserContextImpl: + case UiMode::UserCreatorForImmediateNaLoginTest: + case UiMode::UserQualificationPromoter: + case UiMode::UserSelector: + default: + setWindowTitle(tr("Profile Selector")); + } +} + +void QtProfileSelectionDialog::SetDialogPurpose( + const Core::Frontend::ProfileSelectParameters& parameters) { + using Service::AM::Frontend::UserSelectionPurpose; + + switch (parameters.purpose) { + case UserSelectionPurpose::GameCardRegistration: + instruction_label->setText(tr("Who will receive the points?")); + return; + case UserSelectionPurpose::EShopLaunch: + instruction_label->setText(tr("Who is using Nintendo eShop?")); + return; + case UserSelectionPurpose::EShopItemShow: + instruction_label->setText(tr("Who is making this purchase?")); + return; + case UserSelectionPurpose::PicturePost: + instruction_label->setText(tr("Who is posting?")); + return; + case UserSelectionPurpose::NintendoAccountLinkage: + instruction_label->setText(tr("Select a user to link to a Nintendo Account.")); + return; + case UserSelectionPurpose::SettingsUpdate: + instruction_label->setText(tr("Change settings for which user?")); + return; + case UserSelectionPurpose::SaveDataDeletion: + instruction_label->setText(tr("Format data for which user?")); + return; + case UserSelectionPurpose::UserMigration: + instruction_label->setText(tr("Which user will be transferred to another console?")); + return; + case UserSelectionPurpose::SaveDataTransfer: + instruction_label->setText(tr("Send save data for which user?")); + return; + case UserSelectionPurpose::General: + default: + instruction_label->setText(tr("Select a user:")); + return; + } +} + +QtProfileSelector::QtProfileSelector(GMainWindow& parent) { + connect(this, &QtProfileSelector::MainWindowSelectProfile, &parent, + &GMainWindow::ProfileSelectorSelectProfile, Qt::QueuedConnection); + connect(this, &QtProfileSelector::MainWindowRequestExit, &parent, + &GMainWindow::ProfileSelectorRequestExit, Qt::QueuedConnection); + connect(&parent, &GMainWindow::ProfileSelectorFinishedSelection, this, + &QtProfileSelector::MainWindowFinishedSelection, Qt::DirectConnection); +} + +QtProfileSelector::~QtProfileSelector() = default; + +void QtProfileSelector::Close() const { + callback = {}; + emit MainWindowRequestExit(); +} + +void QtProfileSelector::SelectProfile( + SelectProfileCallback callback_, + const Core::Frontend::ProfileSelectParameters& parameters) const { + callback = std::move(callback_); + emit MainWindowSelectProfile(parameters); +} + +void QtProfileSelector::MainWindowFinishedSelection(std::optional uuid) { + if (callback) { + callback(uuid); + } +} diff --git a/src/sudachi/applets/qt_profile_select.h b/src/sudachi/applets/qt_profile_select.h new file mode 100644 index 0000000..3d656a5 --- /dev/null +++ b/src/sudachi/applets/qt_profile_select.h @@ -0,0 +1,87 @@ +// SPDX-FileCopyrightText: Copyright 2018 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include +#include +#include +#include "core/frontend/applets/profile_select.h" + +class ControllerNavigation; +class GMainWindow; +class QDialogButtonBox; +class QGraphicsScene; +class QLabel; +class QScrollArea; +class QStandardItem; +class QStandardItemModel; +class QTreeView; +class QVBoxLayout; + +namespace Core { +class System; +} + +namespace Service::Account { +class ProfileManager; +} + +class QtProfileSelectionDialog final : public QDialog { + Q_OBJECT + +public: + explicit QtProfileSelectionDialog(Core::System& system, QWidget* parent, + const Core::Frontend::ProfileSelectParameters& parameters); + ~QtProfileSelectionDialog() override; + + int exec() override; + void accept() override; + void reject() override; + + int GetIndex() const; + +private: + void SelectUser(const QModelIndex& index); + + void SetWindowTitle(const Core::Frontend::ProfileSelectParameters& parameters); + void SetDialogPurpose(const Core::Frontend::ProfileSelectParameters& parameters); + + int user_index = 0; + + QVBoxLayout* layout; + QTreeView* tree_view; + QStandardItemModel* item_model; + QGraphicsScene* scene; + + std::vector> list_items; + + QVBoxLayout* outer_layout; + QLabel* instruction_label; + QScrollArea* scroll_area; + QDialogButtonBox* buttons; + + Service::Account::ProfileManager& profile_manager; + ControllerNavigation* controller_navigation = nullptr; +}; + +class QtProfileSelector final : public QObject, public Core::Frontend::ProfileSelectApplet { + Q_OBJECT + +public: + explicit QtProfileSelector(GMainWindow& parent); + ~QtProfileSelector() override; + + void Close() const override; + void SelectProfile(SelectProfileCallback callback_, + const Core::Frontend::ProfileSelectParameters& parameters) const override; + +signals: + void MainWindowSelectProfile(const Core::Frontend::ProfileSelectParameters& parameters) const; + void MainWindowRequestExit() const; + +private: + void MainWindowFinishedSelection(std::optional uuid); + + mutable SelectProfileCallback callback; +}; diff --git a/src/sudachi/applets/qt_software_keyboard.cpp b/src/sudachi/applets/qt_software_keyboard.cpp new file mode 100644 index 0000000..94931a2 --- /dev/null +++ b/src/sudachi/applets/qt_software_keyboard.cpp @@ -0,0 +1,1674 @@ +// SPDX-FileCopyrightText: Copyright 2021 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include +#include +#include + +#include "common/logging/log.h" +#include "common/settings.h" +#include "common/string_util.h" +#include "core/core.h" +#include "hid_core/frontend/emulated_controller.h" +#include "hid_core/frontend/input_interpreter.h" +#include "hid_core/hid_core.h" +#include "hid_core/hid_types.h" +#include "ui_qt_software_keyboard.h" +#include "sudachi/applets/qt_software_keyboard.h" +#include "sudachi/main.h" +#include "sudachi/util/overlay_dialog.h" + +namespace { + +using namespace Service::AM::Frontend; + +constexpr float BASE_HEADER_FONT_SIZE = 23.0f; +constexpr float BASE_SUB_FONT_SIZE = 17.0f; +constexpr float BASE_EDITOR_FONT_SIZE = 26.0f; +constexpr float BASE_CHAR_BUTTON_FONT_SIZE = 28.0f; +constexpr float BASE_LABEL_BUTTON_FONT_SIZE = 18.0f; +constexpr float BASE_ICON_BUTTON_SIZE = 36.0f; +[[maybe_unused]] constexpr float BASE_WIDTH = 1280.0f; +constexpr float BASE_HEIGHT = 720.0f; + +} // Anonymous namespace + +QtSoftwareKeyboardDialog::QtSoftwareKeyboardDialog( + QWidget* parent, Core::System& system_, bool is_inline_, + Core::Frontend::KeyboardInitializeParameters initialize_parameters_) + : QDialog(parent), ui{std::make_unique()}, system{system_}, + is_inline{is_inline_}, initialize_parameters{std::move(initialize_parameters_)} { + ui->setupUi(this); + + setWindowFlags(Qt::Dialog | Qt::FramelessWindowHint | Qt::WindowTitleHint | + Qt::WindowSystemMenuHint | Qt::CustomizeWindowHint); + setWindowModality(Qt::WindowModal); + setAttribute(Qt::WA_DeleteOnClose); + setAttribute(Qt::WA_TranslucentBackground); + + keyboard_buttons = {{ + {{ + { + ui->button_1, + ui->button_2, + ui->button_3, + ui->button_4, + ui->button_5, + ui->button_6, + ui->button_7, + ui->button_8, + ui->button_9, + ui->button_0, + ui->button_minus, + ui->button_backspace, + }, + { + ui->button_q, + ui->button_w, + ui->button_e, + ui->button_r, + ui->button_t, + ui->button_y, + ui->button_u, + ui->button_i, + ui->button_o, + ui->button_p, + ui->button_slash, + ui->button_return, + }, + { + ui->button_a, + ui->button_s, + ui->button_d, + ui->button_f, + ui->button_g, + ui->button_h, + ui->button_j, + ui->button_k, + ui->button_l, + ui->button_colon, + ui->button_apostrophe, + ui->button_return, + }, + { + ui->button_z, + ui->button_x, + ui->button_c, + ui->button_v, + ui->button_b, + ui->button_n, + ui->button_m, + ui->button_comma, + ui->button_dot, + ui->button_question, + ui->button_exclamation, + ui->button_ok, + }, + { + ui->button_shift, + ui->button_shift, + ui->button_space, + ui->button_space, + ui->button_space, + ui->button_space, + ui->button_space, + ui->button_space, + ui->button_space, + ui->button_space, + ui->button_space, + ui->button_ok, + }, + }}, + {{ + { + ui->button_hash, + ui->button_left_bracket, + ui->button_right_bracket, + ui->button_dollar, + ui->button_percent, + ui->button_circumflex, + ui->button_ampersand, + ui->button_asterisk, + ui->button_left_parenthesis, + ui->button_right_parenthesis, + ui->button_underscore, + ui->button_backspace_shift, + }, + { + ui->button_q_shift, + ui->button_w_shift, + ui->button_e_shift, + ui->button_r_shift, + ui->button_t_shift, + ui->button_y_shift, + ui->button_u_shift, + ui->button_i_shift, + ui->button_o_shift, + ui->button_p_shift, + ui->button_at, + ui->button_return_shift, + }, + { + ui->button_a_shift, + ui->button_s_shift, + ui->button_d_shift, + ui->button_f_shift, + ui->button_g_shift, + ui->button_h_shift, + ui->button_j_shift, + ui->button_k_shift, + ui->button_l_shift, + ui->button_semicolon, + ui->button_quotation, + ui->button_return_shift, + }, + { + ui->button_z_shift, + ui->button_x_shift, + ui->button_c_shift, + ui->button_v_shift, + ui->button_b_shift, + ui->button_n_shift, + ui->button_m_shift, + ui->button_less_than, + ui->button_greater_than, + ui->button_plus, + ui->button_equal, + ui->button_ok_shift, + }, + { + ui->button_shift_shift, + ui->button_shift_shift, + ui->button_space_shift, + ui->button_space_shift, + ui->button_space_shift, + ui->button_space_shift, + ui->button_space_shift, + ui->button_space_shift, + ui->button_space_shift, + ui->button_space_shift, + ui->button_space_shift, + ui->button_ok_shift, + }, + }}, + }}; + + numberpad_buttons = {{ + { + ui->button_1_num, + ui->button_2_num, + ui->button_3_num, + ui->button_backspace_num, + }, + { + ui->button_4_num, + ui->button_5_num, + ui->button_6_num, + ui->button_ok_num, + }, + { + ui->button_7_num, + ui->button_8_num, + ui->button_9_num, + ui->button_ok_num, + }, + { + ui->button_left_optional_num, + ui->button_0_num, + ui->button_right_optional_num, + ui->button_ok_num, + }, + }}; + + all_buttons = { + ui->button_1, + ui->button_2, + ui->button_3, + ui->button_4, + ui->button_5, + ui->button_6, + ui->button_7, + ui->button_8, + ui->button_9, + ui->button_0, + ui->button_minus, + ui->button_backspace, + ui->button_q, + ui->button_w, + ui->button_e, + ui->button_r, + ui->button_t, + ui->button_y, + ui->button_u, + ui->button_i, + ui->button_o, + ui->button_p, + ui->button_slash, + ui->button_return, + ui->button_a, + ui->button_s, + ui->button_d, + ui->button_f, + ui->button_g, + ui->button_h, + ui->button_j, + ui->button_k, + ui->button_l, + ui->button_colon, + ui->button_apostrophe, + ui->button_z, + ui->button_x, + ui->button_c, + ui->button_v, + ui->button_b, + ui->button_n, + ui->button_m, + ui->button_comma, + ui->button_dot, + ui->button_question, + ui->button_exclamation, + ui->button_ok, + ui->button_shift, + ui->button_space, + ui->button_hash, + ui->button_left_bracket, + ui->button_right_bracket, + ui->button_dollar, + ui->button_percent, + ui->button_circumflex, + ui->button_ampersand, + ui->button_asterisk, + ui->button_left_parenthesis, + ui->button_right_parenthesis, + ui->button_underscore, + ui->button_backspace_shift, + ui->button_q_shift, + ui->button_w_shift, + ui->button_e_shift, + ui->button_r_shift, + ui->button_t_shift, + ui->button_y_shift, + ui->button_u_shift, + ui->button_i_shift, + ui->button_o_shift, + ui->button_p_shift, + ui->button_at, + ui->button_return_shift, + ui->button_a_shift, + ui->button_s_shift, + ui->button_d_shift, + ui->button_f_shift, + ui->button_g_shift, + ui->button_h_shift, + ui->button_j_shift, + ui->button_k_shift, + ui->button_l_shift, + ui->button_semicolon, + ui->button_quotation, + ui->button_z_shift, + ui->button_x_shift, + ui->button_c_shift, + ui->button_v_shift, + ui->button_b_shift, + ui->button_n_shift, + ui->button_m_shift, + ui->button_less_than, + ui->button_greater_than, + ui->button_plus, + ui->button_equal, + ui->button_ok_shift, + ui->button_shift_shift, + ui->button_space_shift, + ui->button_1_num, + ui->button_2_num, + ui->button_3_num, + ui->button_backspace_num, + ui->button_4_num, + ui->button_5_num, + ui->button_6_num, + ui->button_ok_num, + ui->button_7_num, + ui->button_8_num, + ui->button_9_num, + ui->button_left_optional_num, + ui->button_0_num, + ui->button_right_optional_num, + }; + + SetupMouseHover(); + + if (!initialize_parameters.ok_text.empty()) { + ui->button_ok->setText(QString::fromStdU16String(initialize_parameters.ok_text)); + } + + ui->label_header->setText(QString::fromStdU16String(initialize_parameters.header_text)); + ui->label_sub->setText(QString::fromStdU16String(initialize_parameters.sub_text)); + + ui->button_left_optional_num->setText(QChar{initialize_parameters.left_optional_symbol_key}); + ui->button_right_optional_num->setText(QChar{initialize_parameters.right_optional_symbol_key}); + + current_text = initialize_parameters.initial_text; + cursor_position = initialize_parameters.initial_cursor_position; + + SetTextDrawType(); + + for (auto* button : all_buttons) { + connect(button, &QPushButton::clicked, this, [this, button](bool) { + if (is_inline) { + InlineKeyboardButtonClicked(button); + } else { + NormalKeyboardButtonClicked(button); + } + }); + } + + // TODO (Morph): Remove this when InputInterpreter no longer relies on the HID backend + if (system.IsPoweredOn()) { + input_interpreter = std::make_unique(system); + } +} + +QtSoftwareKeyboardDialog::~QtSoftwareKeyboardDialog() { + StopInputThread(); +} + +void QtSoftwareKeyboardDialog::ShowNormalKeyboard(QPoint pos, QSize size) { + if (isVisible()) { + return; + } + + MoveAndResizeWindow(pos, size); + + SetKeyboardType(); + SetPasswordMode(); + SetControllerImage(); + DisableKeyboardButtons(); + SetBackspaceOkEnabled(); + + open(); +} + +void QtSoftwareKeyboardDialog::ShowTextCheckDialog( + Service::AM::Frontend::SwkbdTextCheckResult text_check_result, + std::u16string text_check_message) { + switch (text_check_result) { + case SwkbdTextCheckResult::Success: + case SwkbdTextCheckResult::Silent: + default: + break; + case SwkbdTextCheckResult::Failure: { + StopInputThread(); + + OverlayDialog dialog(this, system, QString{}, QString::fromStdU16String(text_check_message), + QString{}, tr("OK"), Qt::AlignCenter); + dialog.exec(); + + StartInputThread(); + break; + } + case SwkbdTextCheckResult::Confirm: { + StopInputThread(); + + OverlayDialog dialog(this, system, QString{}, QString::fromStdU16String(text_check_message), + tr("Cancel"), tr("OK"), Qt::AlignCenter); + if (dialog.exec() != QDialog::Accepted) { + StartInputThread(); + break; + } + + const auto text = ui->topOSK->currentIndex() == 1 ? ui->text_edit_osk->toPlainText() + : ui->line_edit_osk->text(); + auto text_str = Common::U16StringFromBuffer(text.utf16(), text.size()); + + emit SubmitNormalText(SwkbdResult::Ok, std::move(text_str), true); + break; + } + } +} + +void QtSoftwareKeyboardDialog::ShowInlineKeyboard( + Core::Frontend::InlineAppearParameters appear_parameters, QPoint pos, QSize size) { + MoveAndResizeWindow(pos, size); + + ui->topOSK->setStyleSheet(QStringLiteral("background: rgba(0, 0, 0, 0);")); + + ui->headerOSK->hide(); + ui->subOSK->hide(); + ui->inputOSK->hide(); + ui->charactersOSK->hide(); + ui->inputBoxOSK->hide(); + ui->charactersBoxOSK->hide(); + + initialize_parameters.max_text_length = appear_parameters.max_text_length; + initialize_parameters.min_text_length = appear_parameters.min_text_length; + initialize_parameters.type = appear_parameters.type; + initialize_parameters.key_disable_flags = appear_parameters.key_disable_flags; + initialize_parameters.enable_backspace_button = appear_parameters.enable_backspace_button; + initialize_parameters.enable_return_button = appear_parameters.enable_return_button; + initialize_parameters.disable_cancel_button = appear_parameters.disable_cancel_button; + + SetKeyboardType(); + SetControllerImage(); + DisableKeyboardButtons(); + SetBackspaceOkEnabled(); + + open(); +} + +void QtSoftwareKeyboardDialog::HideInlineKeyboard() { + StopInputThread(); + QDialog::hide(); +} + +void QtSoftwareKeyboardDialog::InlineTextChanged( + Core::Frontend::InlineTextParameters text_parameters) { + current_text = text_parameters.input_text; + cursor_position = text_parameters.cursor_position; + + SetBackspaceOkEnabled(); +} + +void QtSoftwareKeyboardDialog::ExitKeyboard() { + StopInputThread(); + QDialog::done(QDialog::Accepted); +} + +void QtSoftwareKeyboardDialog::open() { + QDialog::open(); + + row = 0; + column = 0; + + switch (bottom_osk_index) { + case BottomOSKIndex::LowerCase: + case BottomOSKIndex::UpperCase: { + const auto* const curr_button = + keyboard_buttons[static_cast(bottom_osk_index)][row][column]; + + // This is a workaround for setFocus() randomly not showing focus in the UI + QCursor::setPos(curr_button->mapToGlobal(curr_button->rect().center())); + break; + } + case BottomOSKIndex::NumberPad: { + const auto* const curr_button = numberpad_buttons[row][column]; + + // This is a workaround for setFocus() randomly not showing focus in the UI + QCursor::setPos(curr_button->mapToGlobal(curr_button->rect().center())); + break; + } + default: + break; + } + + StartInputThread(); +} + +void QtSoftwareKeyboardDialog::reject() { + // Pressing the ESC key in a dialog calls QDialog::reject(). + // We will override this behavior to the "Cancel" action on the software keyboard. + TranslateButtonPress(Core::HID::NpadButton::X); +} + +void QtSoftwareKeyboardDialog::keyPressEvent(QKeyEvent* event) { + if (!is_inline) { + QDialog::keyPressEvent(event); + return; + } + + const auto entered_key = event->key(); + + switch (entered_key) { + case Qt::Key_Escape: + QDialog::keyPressEvent(event); + return; + case Qt::Key_Backspace: + switch (bottom_osk_index) { + case BottomOSKIndex::LowerCase: + ui->button_backspace->click(); + break; + case BottomOSKIndex::UpperCase: + ui->button_backspace_shift->click(); + break; + case BottomOSKIndex::NumberPad: + ui->button_backspace_num->click(); + break; + default: + break; + } + return; + case Qt::Key_Return: + switch (bottom_osk_index) { + case BottomOSKIndex::LowerCase: + ui->button_ok->click(); + break; + case BottomOSKIndex::UpperCase: + ui->button_ok_shift->click(); + break; + case BottomOSKIndex::NumberPad: + ui->button_ok_num->click(); + break; + default: + break; + } + return; + case Qt::Key_Left: + MoveTextCursorDirection(Direction::Left); + return; + case Qt::Key_Right: + MoveTextCursorDirection(Direction::Right); + return; + default: + break; + } + + const auto entered_text = event->text(); + + if (entered_text.isEmpty()) { + return; + } + + InlineTextInsertString(Common::U16StringFromBuffer(entered_text.utf16(), entered_text.size())); +} + +void QtSoftwareKeyboardDialog::MoveAndResizeWindow(QPoint pos, QSize size) { + QDialog::move(pos); + QDialog::resize(size); + + // High DPI + const float dpi_scale = screen()->logicalDotsPerInch() / 96.0f; + + RescaleKeyboardElements(size.width(), size.height(), dpi_scale); +} + +void QtSoftwareKeyboardDialog::RescaleKeyboardElements(float width, float height, float dpi_scale) { + const auto header_font_size = BASE_HEADER_FONT_SIZE * (height / BASE_HEIGHT) / dpi_scale; + const auto sub_font_size = BASE_SUB_FONT_SIZE * (height / BASE_HEIGHT) / dpi_scale; + const auto editor_font_size = BASE_EDITOR_FONT_SIZE * (height / BASE_HEIGHT) / dpi_scale; + const auto char_button_font_size = + BASE_CHAR_BUTTON_FONT_SIZE * (height / BASE_HEIGHT) / dpi_scale; + const auto label_button_font_size = + BASE_LABEL_BUTTON_FONT_SIZE * (height / BASE_HEIGHT) / dpi_scale; + + QFont header_font(QStringLiteral("MS Shell Dlg 2"), header_font_size, QFont::Normal); + QFont sub_font(QStringLiteral("MS Shell Dlg 2"), sub_font_size, QFont::Normal); + QFont editor_font(QStringLiteral("MS Shell Dlg 2"), editor_font_size, QFont::Normal); + QFont char_button_font(QStringLiteral("MS Shell Dlg 2"), char_button_font_size, QFont::Normal); + QFont label_button_font(QStringLiteral("MS Shell Dlg 2"), label_button_font_size, + QFont::Normal); + + ui->label_header->setFont(header_font); + ui->label_sub->setFont(sub_font); + ui->line_edit_osk->setFont(editor_font); + ui->text_edit_osk->setFont(editor_font); + ui->label_characters->setFont(sub_font); + ui->label_characters_box->setFont(sub_font); + + ui->label_shift->setFont(label_button_font); + ui->label_shift_shift->setFont(label_button_font); + ui->label_cancel->setFont(label_button_font); + ui->label_cancel_shift->setFont(label_button_font); + ui->label_cancel_num->setFont(label_button_font); + ui->label_enter->setFont(label_button_font); + ui->label_enter_shift->setFont(label_button_font); + ui->label_enter_num->setFont(label_button_font); + + for (auto* button : all_buttons) { + if (button == ui->button_return || button == ui->button_return_shift) { + button->setFont(label_button_font); + continue; + } + + if (button == ui->button_space || button == ui->button_space_shift) { + button->setFont(label_button_font); + continue; + } + + if (button == ui->button_shift || button == ui->button_shift_shift) { + button->setFont(label_button_font); + button->setIconSize(QSize(BASE_ICON_BUTTON_SIZE, BASE_ICON_BUTTON_SIZE) * + (height / BASE_HEIGHT)); + continue; + } + + if (button == ui->button_backspace || button == ui->button_backspace_shift || + button == ui->button_backspace_num) { + button->setFont(label_button_font); + button->setIconSize(QSize(BASE_ICON_BUTTON_SIZE, BASE_ICON_BUTTON_SIZE) * + (height / BASE_HEIGHT)); + continue; + } + + if (button == ui->button_ok || button == ui->button_ok_shift || + button == ui->button_ok_num) { + button->setFont(label_button_font); + continue; + } + + button->setFont(char_button_font); + } +} + +void QtSoftwareKeyboardDialog::SetKeyboardType() { + switch (initialize_parameters.type) { + case SwkbdType::Normal: + case SwkbdType::Qwerty: + case SwkbdType::Unknown3: + case SwkbdType::Latin: + case SwkbdType::SimplifiedChinese: + case SwkbdType::TraditionalChinese: + case SwkbdType::Korean: + default: { + bottom_osk_index = BottomOSKIndex::LowerCase; + ui->bottomOSK->setCurrentIndex(static_cast(bottom_osk_index)); + + ui->verticalLayout_2->setStretch(0, 320); + ui->verticalLayout_2->setStretch(1, 400); + + ui->gridLineOSK->setRowStretch(5, 94); + ui->gridBoxOSK->setRowStretch(2, 81); + break; + } + case SwkbdType::NumberPad: { + bottom_osk_index = BottomOSKIndex::NumberPad; + ui->bottomOSK->setCurrentIndex(static_cast(bottom_osk_index)); + + ui->verticalLayout_2->setStretch(0, 370); + ui->verticalLayout_2->setStretch(1, 350); + + ui->gridLineOSK->setRowStretch(5, 144); + ui->gridBoxOSK->setRowStretch(2, 131); + break; + } + } +} + +void QtSoftwareKeyboardDialog::SetPasswordMode() { + switch (initialize_parameters.password_mode) { + case SwkbdPasswordMode::Disabled: + default: + ui->line_edit_osk->setEchoMode(QLineEdit::Normal); + break; + case SwkbdPasswordMode::Enabled: + ui->line_edit_osk->setEchoMode(QLineEdit::Password); + break; + } +} + +void QtSoftwareKeyboardDialog::SetTextDrawType() { + switch (initialize_parameters.text_draw_type) { + case SwkbdTextDrawType::Line: + case SwkbdTextDrawType::DownloadCode: { + ui->topOSK->setCurrentIndex(0); + + if (initialize_parameters.max_text_length <= 10) { + ui->gridLineOSK->setColumnStretch(0, 390); + ui->gridLineOSK->setColumnStretch(1, 500); + ui->gridLineOSK->setColumnStretch(2, 390); + } else { + ui->gridLineOSK->setColumnStretch(0, 130); + ui->gridLineOSK->setColumnStretch(1, 1020); + ui->gridLineOSK->setColumnStretch(2, 130); + } + + if (is_inline) { + return; + } + + connect(ui->line_edit_osk, &QLineEdit::textChanged, [this](const QString& changed_string) { + const auto is_valid = ValidateInputText(changed_string); + + const auto text_length = static_cast(changed_string.length()); + + ui->label_characters->setText(QStringLiteral("%1/%2") + .arg(text_length) + .arg(initialize_parameters.max_text_length)); + + ui->button_ok->setEnabled(is_valid); + ui->button_ok_shift->setEnabled(is_valid); + ui->button_ok_num->setEnabled(is_valid); + + ui->line_edit_osk->setFocus(); + }); + + connect(ui->line_edit_osk, &QLineEdit::cursorPositionChanged, + [this](int old_cursor_position, int new_cursor_position) { + ui->button_backspace->setEnabled( + initialize_parameters.enable_backspace_button && new_cursor_position > 0); + ui->button_backspace_shift->setEnabled( + initialize_parameters.enable_backspace_button && new_cursor_position > 0); + ui->button_backspace_num->setEnabled( + initialize_parameters.enable_backspace_button && new_cursor_position > 0); + + ui->line_edit_osk->setFocus(); + }); + + connect( + ui->line_edit_osk, &QLineEdit::returnPressed, this, + [this] { TranslateButtonPress(Core::HID::NpadButton::Plus); }, Qt::QueuedConnection); + + ui->line_edit_osk->setPlaceholderText( + QString::fromStdU16String(initialize_parameters.guide_text)); + ui->line_edit_osk->setText(QString::fromStdU16String(initialize_parameters.initial_text)); + ui->line_edit_osk->setMaxLength(initialize_parameters.max_text_length); + ui->line_edit_osk->setCursorPosition(initialize_parameters.initial_cursor_position); + + ui->label_characters->setText(QStringLiteral("%1/%2") + .arg(initialize_parameters.initial_text.size()) + .arg(initialize_parameters.max_text_length)); + break; + } + case SwkbdTextDrawType::Box: + default: { + ui->topOSK->setCurrentIndex(1); + + if (is_inline) { + return; + } + + connect(ui->text_edit_osk, &QTextEdit::textChanged, [this] { + if (static_cast(ui->text_edit_osk->toPlainText().length()) > + initialize_parameters.max_text_length) { + auto text_cursor = ui->text_edit_osk->textCursor(); + ui->text_edit_osk->setTextCursor(text_cursor); + text_cursor.deletePreviousChar(); + } + + const auto is_valid = ValidateInputText(ui->text_edit_osk->toPlainText()); + + const auto text_length = static_cast(ui->text_edit_osk->toPlainText().length()); + + ui->label_characters_box->setText(QStringLiteral("%1/%2") + .arg(text_length) + .arg(initialize_parameters.max_text_length)); + + ui->button_ok->setEnabled(is_valid); + ui->button_ok_shift->setEnabled(is_valid); + ui->button_ok_num->setEnabled(is_valid); + + ui->text_edit_osk->setFocus(); + }); + + connect(ui->text_edit_osk, &QTextEdit::cursorPositionChanged, [this] { + const auto new_cursor_position = ui->text_edit_osk->textCursor().position(); + + ui->button_backspace->setEnabled(initialize_parameters.enable_backspace_button && + new_cursor_position > 0); + ui->button_backspace_shift->setEnabled(initialize_parameters.enable_backspace_button && + new_cursor_position > 0); + ui->button_backspace_num->setEnabled(initialize_parameters.enable_backspace_button && + new_cursor_position > 0); + + ui->text_edit_osk->setFocus(); + }); + + ui->text_edit_osk->setPlaceholderText( + QString::fromStdU16String(initialize_parameters.guide_text)); + ui->text_edit_osk->setText(QString::fromStdU16String(initialize_parameters.initial_text)); + ui->text_edit_osk->moveCursor(initialize_parameters.initial_cursor_position == 0 + ? QTextCursor::Start + : QTextCursor::End); + + ui->label_characters_box->setText(QStringLiteral("%1/%2") + .arg(initialize_parameters.initial_text.size()) + .arg(initialize_parameters.max_text_length)); + break; + } + } +} + +void QtSoftwareKeyboardDialog::SetControllerImage() { + const auto* handheld = system.HIDCore().GetEmulatedController(Core::HID::NpadIdType::Handheld); + const auto* player_1 = system.HIDCore().GetEmulatedController(Core::HID::NpadIdType::Player1); + const auto controller_type = + handheld->IsConnected() ? handheld->GetNpadStyleIndex() : player_1->GetNpadStyleIndex(); + + const QString theme = [] { + if (QIcon::themeName().contains(QStringLiteral("dark")) || + QIcon::themeName().contains(QStringLiteral("midnight"))) { + return QStringLiteral("_dark"); + } else { + return QString{}; + } + }(); + + switch (controller_type) { + case Core::HID::NpadStyleIndex::Fullkey: + case Core::HID::NpadStyleIndex::GameCube: + ui->icon_controller->setStyleSheet( + QStringLiteral("image: url(:/overlay/controller_pro%1.png);").arg(theme)); + ui->icon_controller_shift->setStyleSheet( + QStringLiteral("image: url(:/overlay/controller_pro%1.png);").arg(theme)); + ui->icon_controller_num->setStyleSheet( + QStringLiteral("image: url(:/overlay/controller_pro%1.png);").arg(theme)); + break; + case Core::HID::NpadStyleIndex::JoyconDual: + ui->icon_controller->setStyleSheet( + QStringLiteral("image: url(:/overlay/controller_dual_joycon%1.png);").arg(theme)); + ui->icon_controller_shift->setStyleSheet( + QStringLiteral("image: url(:/overlay/controller_dual_joycon%1.png);").arg(theme)); + ui->icon_controller_num->setStyleSheet( + QStringLiteral("image: url(:/overlay/controller_dual_joycon%1.png);").arg(theme)); + break; + case Core::HID::NpadStyleIndex::JoyconLeft: + ui->icon_controller->setStyleSheet( + QStringLiteral("image: url(:/overlay/controller_single_joycon_left%1.png);") + .arg(theme)); + ui->icon_controller_shift->setStyleSheet( + QStringLiteral("image: url(:/overlay/controller_single_joycon_left%1.png);") + .arg(theme)); + ui->icon_controller_num->setStyleSheet( + QStringLiteral("image: url(:/overlay/controller_single_joycon_left%1.png);") + .arg(theme)); + break; + case Core::HID::NpadStyleIndex::JoyconRight: + ui->icon_controller->setStyleSheet( + QStringLiteral("image: url(:/overlay/controller_single_joycon_right%1.png);") + .arg(theme)); + ui->icon_controller_shift->setStyleSheet( + QStringLiteral("image: url(:/overlay/controller_single_joycon_right%1.png);") + .arg(theme)); + ui->icon_controller_num->setStyleSheet( + QStringLiteral("image: url(:/overlay/controller_single_joycon_right%1.png);") + .arg(theme)); + break; + case Core::HID::NpadStyleIndex::Handheld: + ui->icon_controller->setStyleSheet( + QStringLiteral("image: url(:/overlay/controller_handheld%1.png);").arg(theme)); + ui->icon_controller_shift->setStyleSheet( + QStringLiteral("image: url(:/overlay/controller_handheld%1.png);").arg(theme)); + ui->icon_controller_num->setStyleSheet( + QStringLiteral("image: url(:/overlay/controller_handheld%1.png);").arg(theme)); + break; + default: + break; + } +} + +void QtSoftwareKeyboardDialog::DisableKeyboardButtons() { + switch (bottom_osk_index) { + case BottomOSKIndex::LowerCase: + case BottomOSKIndex::UpperCase: + default: { + for (const auto& keys : keyboard_buttons) { + for (const auto& rows : keys) { + for (auto* button : rows) { + if (!button) { + continue; + } + + button->setEnabled(true); + } + } + } + + const auto& key_disable_flags = initialize_parameters.key_disable_flags; + + ui->button_space->setDisabled(key_disable_flags.space); + ui->button_space_shift->setDisabled(key_disable_flags.space); + + ui->button_at->setDisabled(key_disable_flags.at || key_disable_flags.username); + + ui->button_percent->setDisabled(key_disable_flags.percent || key_disable_flags.username); + + ui->button_slash->setDisabled(key_disable_flags.slash); + + ui->button_1->setDisabled(key_disable_flags.numbers); + ui->button_2->setDisabled(key_disable_flags.numbers); + ui->button_3->setDisabled(key_disable_flags.numbers); + ui->button_4->setDisabled(key_disable_flags.numbers); + ui->button_5->setDisabled(key_disable_flags.numbers); + ui->button_6->setDisabled(key_disable_flags.numbers); + ui->button_7->setDisabled(key_disable_flags.numbers); + ui->button_8->setDisabled(key_disable_flags.numbers); + ui->button_9->setDisabled(key_disable_flags.numbers); + ui->button_0->setDisabled(key_disable_flags.numbers); + + ui->button_return->setEnabled(initialize_parameters.enable_return_button); + ui->button_return_shift->setEnabled(initialize_parameters.enable_return_button); + break; + } + case BottomOSKIndex::NumberPad: { + for (const auto& rows : numberpad_buttons) { + for (auto* button : rows) { + if (!button) { + continue; + } + + button->setEnabled(true); + } + } + + const auto enable_left_optional = initialize_parameters.left_optional_symbol_key != '\0'; + const auto enable_right_optional = initialize_parameters.right_optional_symbol_key != '\0'; + + ui->button_left_optional_num->setEnabled(enable_left_optional); + ui->button_left_optional_num->setVisible(enable_left_optional); + + ui->button_right_optional_num->setEnabled(enable_right_optional); + ui->button_right_optional_num->setVisible(enable_right_optional); + break; + } + } +} + +void QtSoftwareKeyboardDialog::SetBackspaceOkEnabled() { + if (is_inline) { + ui->button_ok->setEnabled(current_text.size() >= initialize_parameters.min_text_length); + ui->button_ok_shift->setEnabled(current_text.size() >= + initialize_parameters.min_text_length); + ui->button_ok_num->setEnabled(current_text.size() >= initialize_parameters.min_text_length); + + ui->button_backspace->setEnabled(initialize_parameters.enable_backspace_button && + cursor_position > 0); + ui->button_backspace_shift->setEnabled(initialize_parameters.enable_backspace_button && + cursor_position > 0); + ui->button_backspace_num->setEnabled(initialize_parameters.enable_backspace_button && + cursor_position > 0); + } else { + const auto text_length = [this] { + if (ui->topOSK->currentIndex() == 1) { + return static_cast(ui->text_edit_osk->toPlainText().length()); + } else { + return static_cast(ui->line_edit_osk->text().length()); + } + }(); + + const auto normal_cursor_position = [this] { + if (ui->topOSK->currentIndex() == 1) { + return ui->text_edit_osk->textCursor().position(); + } else { + return ui->line_edit_osk->cursorPosition(); + } + }(); + + ui->button_ok->setEnabled(text_length >= initialize_parameters.min_text_length); + ui->button_ok_shift->setEnabled(text_length >= initialize_parameters.min_text_length); + ui->button_ok_num->setEnabled(text_length >= initialize_parameters.min_text_length); + + ui->button_backspace->setEnabled(initialize_parameters.enable_backspace_button && + normal_cursor_position > 0); + ui->button_backspace_shift->setEnabled(initialize_parameters.enable_backspace_button && + normal_cursor_position > 0); + ui->button_backspace_num->setEnabled(initialize_parameters.enable_backspace_button && + normal_cursor_position > 0); + } +} + +bool QtSoftwareKeyboardDialog::ValidateInputText(const QString& input_text) { + const auto& key_disable_flags = initialize_parameters.key_disable_flags; + + const auto input_text_length = static_cast(input_text.length()); + + if (input_text_length < initialize_parameters.min_text_length || + input_text_length > initialize_parameters.max_text_length) { + return false; + } + + if (key_disable_flags.space && input_text.contains(QLatin1Char{' '})) { + return false; + } + + if ((key_disable_flags.at || key_disable_flags.username) && + input_text.contains(QLatin1Char{'@'})) { + return false; + } + + if ((key_disable_flags.percent || key_disable_flags.username) && + input_text.contains(QLatin1Char{'%'})) { + return false; + } + + if (key_disable_flags.slash && input_text.contains(QLatin1Char{'/'})) { + return false; + } + + if ((key_disable_flags.backslash || key_disable_flags.username) && + input_text.contains(QLatin1Char('\\'))) { + return false; + } + + if (key_disable_flags.numbers && + std::any_of(input_text.begin(), input_text.end(), [](QChar c) { return c.isDigit(); })) { + return false; + } + + if (bottom_osk_index == BottomOSKIndex::NumberPad && + std::any_of(input_text.begin(), input_text.end(), [this](QChar c) { + return !c.isDigit() && c != QChar{initialize_parameters.left_optional_symbol_key} && + c != QChar{initialize_parameters.right_optional_symbol_key}; + })) { + return false; + } + + return true; +} + +void QtSoftwareKeyboardDialog::ChangeBottomOSKIndex() { + switch (bottom_osk_index) { + case BottomOSKIndex::LowerCase: + bottom_osk_index = BottomOSKIndex::UpperCase; + ui->bottomOSK->setCurrentIndex(static_cast(bottom_osk_index)); + + ui->button_shift_shift->setStyleSheet( + QStringLiteral("image: url(:/overlay/osk_button_shift_lock_off.png);" + "\nimage-position: left;")); + + ui->button_shift_shift->setIconSize(ui->button_shift->iconSize()); + ui->button_backspace_shift->setIconSize(ui->button_backspace->iconSize()); + break; + case BottomOSKIndex::UpperCase: + if (caps_lock_enabled) { + caps_lock_enabled = false; + + ui->button_shift_shift->setStyleSheet( + QStringLiteral("image: url(:/overlay/osk_button_shift_lock_off.png);" + "\nimage-position: left;")); + + ui->button_shift_shift->setIconSize(ui->button_shift->iconSize()); + ui->button_backspace_shift->setIconSize(ui->button_backspace->iconSize()); + + ui->label_shift_shift->setText(QStringLiteral("Caps Lock")); + + bottom_osk_index = BottomOSKIndex::LowerCase; + ui->bottomOSK->setCurrentIndex(static_cast(bottom_osk_index)); + } else { + caps_lock_enabled = true; + + ui->button_shift_shift->setStyleSheet( + QStringLiteral("image: url(:/overlay/osk_button_shift_lock_on.png);" + "\nimage-position: left;")); + + ui->button_shift_shift->setIconSize(ui->button_shift->iconSize()); + ui->button_backspace_shift->setIconSize(ui->button_backspace->iconSize()); + + ui->label_shift_shift->setText(QStringLiteral("Caps Lock Off")); + } + break; + case BottomOSKIndex::NumberPad: + default: + break; + } +} + +void QtSoftwareKeyboardDialog::NormalKeyboardButtonClicked(QPushButton* button) { + if (button == ui->button_ampersand) { + if (ui->topOSK->currentIndex() == 1) { + ui->text_edit_osk->insertPlainText(QStringLiteral("&")); + } else { + ui->line_edit_osk->insert(QStringLiteral("&")); + } + return; + } + + if (button == ui->button_return || button == ui->button_return_shift) { + if (ui->topOSK->currentIndex() == 1) { + ui->text_edit_osk->insertPlainText(QStringLiteral("\n")); + } else { + ui->line_edit_osk->insert(QStringLiteral("\n")); + } + return; + } + + if (button == ui->button_space || button == ui->button_space_shift) { + if (ui->topOSK->currentIndex() == 1) { + ui->text_edit_osk->insertPlainText(QStringLiteral(" ")); + } else { + ui->line_edit_osk->insert(QStringLiteral(" ")); + } + return; + } + + if (button == ui->button_shift || button == ui->button_shift_shift) { + ChangeBottomOSKIndex(); + return; + } + + if (button == ui->button_backspace || button == ui->button_backspace_shift || + button == ui->button_backspace_num) { + if (ui->topOSK->currentIndex() == 1) { + auto text_cursor = ui->text_edit_osk->textCursor(); + ui->text_edit_osk->setTextCursor(text_cursor); + text_cursor.deletePreviousChar(); + } else { + ui->line_edit_osk->backspace(); + } + return; + } + + if (button == ui->button_ok || button == ui->button_ok_shift || button == ui->button_ok_num) { + const auto text = ui->topOSK->currentIndex() == 1 ? ui->text_edit_osk->toPlainText() + : ui->line_edit_osk->text(); + auto text_str = Common::U16StringFromBuffer(text.utf16(), text.size()); + + emit SubmitNormalText(SwkbdResult::Ok, std::move(text_str)); + return; + } + + if (ui->topOSK->currentIndex() == 1) { + ui->text_edit_osk->insertPlainText(button->text()); + } else { + ui->line_edit_osk->insert(button->text()); + } + + // Revert the keyboard to lowercase if the shift key is active. + if (bottom_osk_index == BottomOSKIndex::UpperCase && !caps_lock_enabled) { + // This is set to true since ChangeBottomOSKIndex will change bottom_osk_index to LowerCase + // if bottom_osk_index is UpperCase and caps_lock_enabled is true. + caps_lock_enabled = true; + ChangeBottomOSKIndex(); + } +} + +void QtSoftwareKeyboardDialog::InlineKeyboardButtonClicked(QPushButton* button) { + if (!button->isEnabled()) { + return; + } + + if (button == ui->button_ampersand) { + InlineTextInsertString(u"&"); + return; + } + + if (button == ui->button_return || button == ui->button_return_shift) { + InlineTextInsertString(u"\n"); + return; + } + + if (button == ui->button_space || button == ui->button_space_shift) { + InlineTextInsertString(u" "); + return; + } + + if (button == ui->button_shift || button == ui->button_shift_shift) { + ChangeBottomOSKIndex(); + return; + } + + if (button == ui->button_backspace || button == ui->button_backspace_shift || + button == ui->button_backspace_num) { + if (cursor_position <= 0 || current_text.empty()) { + cursor_position = 0; + return; + } + + --cursor_position; + + current_text.erase(cursor_position, 1); + + SetBackspaceOkEnabled(); + + emit SubmitInlineText(SwkbdReplyType::ChangedString, current_text, cursor_position); + return; + } + + if (button == ui->button_ok || button == ui->button_ok_shift || button == ui->button_ok_num) { + emit SubmitInlineText(SwkbdReplyType::DecidedEnter, current_text, cursor_position); + return; + } + + const auto button_text = button->text(); + InlineTextInsertString(Common::U16StringFromBuffer(button_text.utf16(), button_text.size())); + + // Revert the keyboard to lowercase if the shift key is active. + if (bottom_osk_index == BottomOSKIndex::UpperCase && !caps_lock_enabled) { + // This is set to true since ChangeBottomOSKIndex will change bottom_osk_index to LowerCase + // if bottom_osk_index is UpperCase and caps_lock_enabled is true. + caps_lock_enabled = true; + ChangeBottomOSKIndex(); + } +} + +void QtSoftwareKeyboardDialog::InlineTextInsertString(std::u16string_view string) { + if ((current_text.size() + string.size()) > initialize_parameters.max_text_length) { + return; + } + + current_text.insert(cursor_position, string); + + cursor_position += static_cast(string.size()); + + SetBackspaceOkEnabled(); + + emit SubmitInlineText(SwkbdReplyType::ChangedString, current_text, cursor_position); +} + +void QtSoftwareKeyboardDialog::SetupMouseHover() { + // setFocus() has a bug where continuously changing focus will cause the focus UI to + // mysteriously disappear. A workaround we have found is using the mouse to hover over + // the buttons to act in place of the button focus. As a result, we will have to set + // a blank cursor when hovering over all the buttons and set a no focus policy so the + // buttons do not stay in focus in addition to the mouse hover. + for (auto* button : all_buttons) { + button->setCursor(QCursor(Qt::BlankCursor)); + button->setFocusPolicy(Qt::NoFocus); + } +} + +template +void QtSoftwareKeyboardDialog::HandleButtonPressedOnce() { + const auto f = [this](Core::HID::NpadButton button) { + if (input_interpreter->IsButtonPressedOnce(button)) { + TranslateButtonPress(button); + } + }; + + (f(T), ...); +} + +template +void QtSoftwareKeyboardDialog::HandleButtonHold() { + const auto f = [this](Core::HID::NpadButton button) { + if (input_interpreter->IsButtonHeld(button)) { + TranslateButtonPress(button); + } + }; + + (f(T), ...); +} + +void QtSoftwareKeyboardDialog::TranslateButtonPress(Core::HID::NpadButton button) { + switch (button) { + case Core::HID::NpadButton::A: + switch (bottom_osk_index) { + case BottomOSKIndex::LowerCase: + case BottomOSKIndex::UpperCase: + keyboard_buttons[static_cast(bottom_osk_index)][row][column]->click(); + break; + case BottomOSKIndex::NumberPad: + numberpad_buttons[row][column]->click(); + break; + default: + break; + } + break; + case Core::HID::NpadButton::B: + switch (bottom_osk_index) { + case BottomOSKIndex::LowerCase: + ui->button_backspace->click(); + break; + case BottomOSKIndex::UpperCase: + ui->button_backspace_shift->click(); + break; + case BottomOSKIndex::NumberPad: + ui->button_backspace_num->click(); + break; + default: + break; + } + break; + case Core::HID::NpadButton::X: + if (is_inline) { + emit SubmitInlineText(SwkbdReplyType::DecidedCancel, current_text, cursor_position); + } else { + const auto text = ui->topOSK->currentIndex() == 1 ? ui->text_edit_osk->toPlainText() + : ui->line_edit_osk->text(); + auto text_str = Common::U16StringFromBuffer(text.utf16(), text.size()); + + emit SubmitNormalText(SwkbdResult::Cancel, std::move(text_str)); + } + break; + case Core::HID::NpadButton::Y: + switch (bottom_osk_index) { + case BottomOSKIndex::LowerCase: + ui->button_space->click(); + break; + case BottomOSKIndex::UpperCase: + ui->button_space_shift->click(); + break; + case BottomOSKIndex::NumberPad: + default: + break; + } + break; + case Core::HID::NpadButton::StickL: + case Core::HID::NpadButton::StickR: + switch (bottom_osk_index) { + case BottomOSKIndex::LowerCase: + ui->button_shift->click(); + break; + case BottomOSKIndex::UpperCase: + ui->button_shift_shift->click(); + break; + case BottomOSKIndex::NumberPad: + default: + break; + } + break; + case Core::HID::NpadButton::L: + MoveTextCursorDirection(Direction::Left); + break; + case Core::HID::NpadButton::R: + MoveTextCursorDirection(Direction::Right); + break; + case Core::HID::NpadButton::Plus: + switch (bottom_osk_index) { + case BottomOSKIndex::LowerCase: + ui->button_ok->click(); + break; + case BottomOSKIndex::UpperCase: + ui->button_ok_shift->click(); + break; + case BottomOSKIndex::NumberPad: + ui->button_ok_num->click(); + break; + default: + break; + } + break; + case Core::HID::NpadButton::Left: + case Core::HID::NpadButton::StickLLeft: + case Core::HID::NpadButton::StickRLeft: + MoveButtonDirection(Direction::Left); + break; + case Core::HID::NpadButton::Up: + case Core::HID::NpadButton::StickLUp: + case Core::HID::NpadButton::StickRUp: + MoveButtonDirection(Direction::Up); + break; + case Core::HID::NpadButton::Right: + case Core::HID::NpadButton::StickLRight: + case Core::HID::NpadButton::StickRRight: + MoveButtonDirection(Direction::Right); + break; + case Core::HID::NpadButton::Down: + case Core::HID::NpadButton::StickLDown: + case Core::HID::NpadButton::StickRDown: + MoveButtonDirection(Direction::Down); + break; + default: + break; + } +} + +void QtSoftwareKeyboardDialog::MoveButtonDirection(Direction direction) { + // Changes the row or column index depending on the direction. + auto move_direction = [this, direction](std::size_t max_rows, std::size_t max_columns) { + switch (direction) { + case Direction::Left: + column = (column + max_columns - 1) % max_columns; + break; + case Direction::Up: + row = (row + max_rows - 1) % max_rows; + break; + case Direction::Right: + column = (column + 1) % max_columns; + break; + case Direction::Down: + row = (row + 1) % max_rows; + break; + default: + break; + } + }; + + // Store the initial row and column. + const auto initial_row = row; + const auto initial_column = column; + + switch (bottom_osk_index) { + case BottomOSKIndex::LowerCase: + case BottomOSKIndex::UpperCase: { + const auto index = static_cast(bottom_osk_index); + + const auto* const prev_button = keyboard_buttons[index][row][column]; + move_direction(NUM_ROWS_NORMAL, NUM_COLUMNS_NORMAL); + auto* curr_button = keyboard_buttons[index][row][column]; + + while (!curr_button || !curr_button->isEnabled() || curr_button == prev_button) { + // If we returned back to where we started from, break the loop. + if (row == initial_row && column == initial_column) { + break; + } + + move_direction(NUM_ROWS_NORMAL, NUM_COLUMNS_NORMAL); + curr_button = keyboard_buttons[index][row][column]; + } + + // This is a workaround for setFocus() randomly not showing focus in the UI + QCursor::setPos(curr_button->mapToGlobal(curr_button->rect().center())); + break; + } + case BottomOSKIndex::NumberPad: { + const auto* const prev_button = numberpad_buttons[row][column]; + move_direction(NUM_ROWS_NUMPAD, NUM_COLUMNS_NUMPAD); + auto* curr_button = numberpad_buttons[row][column]; + + while (!curr_button || !curr_button->isEnabled() || curr_button == prev_button) { + // If we returned back to where we started from, break the loop. + if (row == initial_row && column == initial_column) { + break; + } + + move_direction(NUM_ROWS_NUMPAD, NUM_COLUMNS_NUMPAD); + curr_button = numberpad_buttons[row][column]; + } + + // This is a workaround for setFocus() randomly not showing focus in the UI + QCursor::setPos(curr_button->mapToGlobal(curr_button->rect().center())); + break; + } + default: + break; + } +} + +void QtSoftwareKeyboardDialog::MoveTextCursorDirection(Direction direction) { + switch (direction) { + case Direction::Left: + if (is_inline) { + if (cursor_position <= 0) { + cursor_position = 0; + } else { + --cursor_position; + emit SubmitInlineText(SwkbdReplyType::MovedCursor, current_text, cursor_position); + } + } else { + if (ui->topOSK->currentIndex() == 1) { + ui->text_edit_osk->moveCursor(QTextCursor::Left); + } else { + ui->line_edit_osk->setCursorPosition(ui->line_edit_osk->cursorPosition() - 1); + } + } + break; + case Direction::Right: + if (is_inline) { + if (cursor_position >= static_cast(current_text.size())) { + cursor_position = static_cast(current_text.size()); + } else { + ++cursor_position; + emit SubmitInlineText(SwkbdReplyType::MovedCursor, current_text, cursor_position); + } + } else { + if (ui->topOSK->currentIndex() == 1) { + ui->text_edit_osk->moveCursor(QTextCursor::Right); + } else { + ui->line_edit_osk->setCursorPosition(ui->line_edit_osk->cursorPosition() + 1); + } + } + break; + default: + break; + } +} + +void QtSoftwareKeyboardDialog::StartInputThread() { + if (input_thread_running) { + return; + } + + input_thread_running = true; + + input_thread = std::thread(&QtSoftwareKeyboardDialog::InputThread, this); +} + +void QtSoftwareKeyboardDialog::StopInputThread() { + input_thread_running = false; + + if (input_thread.joinable()) { + input_thread.join(); + } + + if (input_interpreter) { + input_interpreter->ResetButtonStates(); + } +} + +void QtSoftwareKeyboardDialog::InputThread() { + while (input_thread_running) { + input_interpreter->PollInput(); + + HandleButtonPressedOnce< + Core::HID::NpadButton::A, Core::HID::NpadButton::B, Core::HID::NpadButton::X, + Core::HID::NpadButton::Y, Core::HID::NpadButton::StickL, Core::HID::NpadButton::StickR, + Core::HID::NpadButton::L, Core::HID::NpadButton::R, Core::HID::NpadButton::Plus, + Core::HID::NpadButton::Left, Core::HID::NpadButton::Up, Core::HID::NpadButton::Right, + Core::HID::NpadButton::Down, Core::HID::NpadButton::StickLLeft, + Core::HID::NpadButton::StickLUp, Core::HID::NpadButton::StickLRight, + Core::HID::NpadButton::StickLDown, Core::HID::NpadButton::StickRLeft, + Core::HID::NpadButton::StickRUp, Core::HID::NpadButton::StickRRight, + Core::HID::NpadButton::StickRDown>(); + + HandleButtonHold(); + + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + } +} + +QtSoftwareKeyboard::QtSoftwareKeyboard(GMainWindow& main_window) { + connect(this, &QtSoftwareKeyboard::MainWindowInitializeKeyboard, &main_window, + &GMainWindow::SoftwareKeyboardInitialize, Qt::QueuedConnection); + connect(this, &QtSoftwareKeyboard::MainWindowShowNormalKeyboard, &main_window, + &GMainWindow::SoftwareKeyboardShowNormal, Qt::QueuedConnection); + connect(this, &QtSoftwareKeyboard::MainWindowShowTextCheckDialog, &main_window, + &GMainWindow::SoftwareKeyboardShowTextCheck, Qt::QueuedConnection); + connect(this, &QtSoftwareKeyboard::MainWindowShowInlineKeyboard, &main_window, + &GMainWindow::SoftwareKeyboardShowInline, Qt::QueuedConnection); + connect(this, &QtSoftwareKeyboard::MainWindowHideInlineKeyboard, &main_window, + &GMainWindow::SoftwareKeyboardHideInline, Qt::QueuedConnection); + connect(this, &QtSoftwareKeyboard::MainWindowInlineTextChanged, &main_window, + &GMainWindow::SoftwareKeyboardInlineTextChanged, Qt::QueuedConnection); + connect(this, &QtSoftwareKeyboard::MainWindowExitKeyboard, &main_window, + &GMainWindow::SoftwareKeyboardExit, Qt::QueuedConnection); + connect(&main_window, &GMainWindow::SoftwareKeyboardSubmitNormalText, this, + &QtSoftwareKeyboard::SubmitNormalText, Qt::QueuedConnection); + connect(&main_window, &GMainWindow::SoftwareKeyboardSubmitInlineText, this, + &QtSoftwareKeyboard::SubmitInlineText, Qt::QueuedConnection); +} + +QtSoftwareKeyboard::~QtSoftwareKeyboard() = default; + +void QtSoftwareKeyboard::InitializeKeyboard( + bool is_inline, Core::Frontend::KeyboardInitializeParameters initialize_parameters, + SubmitNormalCallback submit_normal_callback_, SubmitInlineCallback submit_inline_callback_) { + if (is_inline) { + submit_inline_callback = std::move(submit_inline_callback_); + } else { + submit_normal_callback = std::move(submit_normal_callback_); + } + + LOG_INFO(Service_AM, + "\nKeyboardInitializeParameters:" + "\nok_text={}" + "\nheader_text={}" + "\nsub_text={}" + "\nguide_text={}" + "\ninitial_text={}" + "\nmax_text_length={}" + "\nmin_text_length={}" + "\ninitial_cursor_position={}" + "\ntype={}" + "\npassword_mode={}" + "\ntext_draw_type={}" + "\nkey_disable_flags={}" + "\nuse_blur_background={}" + "\nenable_backspace_button={}" + "\nenable_return_button={}" + "\ndisable_cancel_button={}", + Common::UTF16ToUTF8(initialize_parameters.ok_text), + Common::UTF16ToUTF8(initialize_parameters.header_text), + Common::UTF16ToUTF8(initialize_parameters.sub_text), + Common::UTF16ToUTF8(initialize_parameters.guide_text), + Common::UTF16ToUTF8(initialize_parameters.initial_text), + initialize_parameters.max_text_length, initialize_parameters.min_text_length, + initialize_parameters.initial_cursor_position, initialize_parameters.type, + initialize_parameters.password_mode, initialize_parameters.text_draw_type, + initialize_parameters.key_disable_flags.raw, initialize_parameters.use_blur_background, + initialize_parameters.enable_backspace_button, + initialize_parameters.enable_return_button, + initialize_parameters.disable_cancel_button); + + emit MainWindowInitializeKeyboard(is_inline, std::move(initialize_parameters)); +} + +void QtSoftwareKeyboard::ShowNormalKeyboard() const { + emit MainWindowShowNormalKeyboard(); +} + +void QtSoftwareKeyboard::ShowTextCheckDialog( + Service::AM::Frontend::SwkbdTextCheckResult text_check_result, + std::u16string text_check_message) const { + emit MainWindowShowTextCheckDialog(text_check_result, std::move(text_check_message)); +} + +void QtSoftwareKeyboard::ShowInlineKeyboard( + Core::Frontend::InlineAppearParameters appear_parameters) const { + LOG_INFO(Service_AM, + "\nInlineAppearParameters:" + "\nmax_text_length={}" + "\nmin_text_length={}" + "\nkey_top_scale_x={}" + "\nkey_top_scale_y={}" + "\nkey_top_translate_x={}" + "\nkey_top_translate_y={}" + "\ntype={}" + "\nkey_disable_flags={}" + "\nkey_top_as_floating={}" + "\nenable_backspace_button={}" + "\nenable_return_button={}" + "\ndisable_cancel_button={}", + appear_parameters.max_text_length, appear_parameters.min_text_length, + appear_parameters.key_top_scale_x, appear_parameters.key_top_scale_y, + appear_parameters.key_top_translate_x, appear_parameters.key_top_translate_y, + appear_parameters.type, appear_parameters.key_disable_flags.raw, + appear_parameters.key_top_as_floating, appear_parameters.enable_backspace_button, + appear_parameters.enable_return_button, appear_parameters.disable_cancel_button); + + emit MainWindowShowInlineKeyboard(std::move(appear_parameters)); +} + +void QtSoftwareKeyboard::HideInlineKeyboard() const { + emit MainWindowHideInlineKeyboard(); +} + +void QtSoftwareKeyboard::InlineTextChanged( + Core::Frontend::InlineTextParameters text_parameters) const { + LOG_INFO(Service_AM, + "\nInlineTextParameters:" + "\ninput_text={}" + "\ncursor_position={}", + Common::UTF16ToUTF8(text_parameters.input_text), text_parameters.cursor_position); + + emit MainWindowInlineTextChanged(std::move(text_parameters)); +} + +void QtSoftwareKeyboard::ExitKeyboard() const { + emit MainWindowExitKeyboard(); +} + +void QtSoftwareKeyboard::SubmitNormalText(Service::AM::Frontend::SwkbdResult result, + std::u16string submitted_text, bool confirmed) const { + submit_normal_callback(result, submitted_text, confirmed); +} + +void QtSoftwareKeyboard::SubmitInlineText(Service::AM::Frontend::SwkbdReplyType reply_type, + std::u16string submitted_text, + s32 cursor_position) const { + submit_inline_callback(reply_type, submitted_text, cursor_position); +} diff --git a/src/sudachi/applets/qt_software_keyboard.h b/src/sudachi/applets/qt_software_keyboard.h new file mode 100644 index 0000000..91e8997 --- /dev/null +++ b/src/sudachi/applets/qt_software_keyboard.h @@ -0,0 +1,287 @@ +// SPDX-FileCopyrightText: Copyright 2021 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include +#include +#include +#include + +#include +#include + +#include "core/frontend/applets/software_keyboard.h" + +class InputInterpreter; + +namespace Core { +class System; +} + +namespace Core::HID { +enum class NpadButton : u64; +} + +namespace Ui { +class QtSoftwareKeyboardDialog; +} + +class GMainWindow; + +class QtSoftwareKeyboardDialog final : public QDialog { + Q_OBJECT + +public: + QtSoftwareKeyboardDialog(QWidget* parent, Core::System& system_, bool is_inline_, + Core::Frontend::KeyboardInitializeParameters initialize_parameters_); + ~QtSoftwareKeyboardDialog() override; + + void ShowNormalKeyboard(QPoint pos, QSize size); + + void ShowTextCheckDialog(Service::AM::Frontend::SwkbdTextCheckResult text_check_result, + std::u16string text_check_message); + + void ShowInlineKeyboard(Core::Frontend::InlineAppearParameters appear_parameters, QPoint pos, + QSize size); + + void HideInlineKeyboard(); + + void InlineTextChanged(Core::Frontend::InlineTextParameters text_parameters); + + void ExitKeyboard(); + +signals: + void SubmitNormalText(Service::AM::Frontend::SwkbdResult result, std::u16string submitted_text, + bool confirmed = false) const; + + void SubmitInlineText(Service::AM::Frontend::SwkbdReplyType reply_type, + std::u16string submitted_text, s32 cursor_position) const; + +public slots: + void open() override; + void reject() override; + +protected: + /// We override the keyPressEvent for inputting text into the inline software keyboard. + void keyPressEvent(QKeyEvent* event) override; + +private: + enum class Direction { + Left, + Up, + Right, + Down, + }; + + enum class BottomOSKIndex { + LowerCase, + UpperCase, + NumberPad, + }; + + /** + * Moves and resizes the window to a specified position and size. + * + * @param pos Top-left window position + * @param size Window size + */ + void MoveAndResizeWindow(QPoint pos, QSize size); + + /** + * Rescales all keyboard elements to account for High DPI displays. + * + * @param width Window width + * @param height Window height + * @param dpi_scale Display scaling factor + */ + void RescaleKeyboardElements(float width, float height, float dpi_scale); + + /// Sets the keyboard type based on initialize_parameters. + void SetKeyboardType(); + + /// Sets the password mode based on initialize_parameters. + void SetPasswordMode(); + + /// Sets the text draw type based on initialize_parameters. + void SetTextDrawType(); + + /// Sets the controller image at the bottom left of the software keyboard. + void SetControllerImage(); + + /// Disables buttons based on initialize_parameters. + void DisableKeyboardButtons(); + + /// Changes whether the backspace or/and ok buttons should be enabled or disabled. + void SetBackspaceOkEnabled(); + + /** + * Validates the input text sent in based on the parameters in initialize_parameters. + * + * @param input_text Input text + * + * @returns True if the input text is valid, false otherwise. + */ + bool ValidateInputText(const QString& input_text); + + /// Switches between LowerCase and UpperCase (Shift and Caps Lock) + void ChangeBottomOSKIndex(); + + /// Processes a keyboard button click from the UI as normal keyboard input. + void NormalKeyboardButtonClicked(QPushButton* button); + + /// Processes a keyboard button click from the UI as inline keyboard input. + void InlineKeyboardButtonClicked(QPushButton* button); + + /** + * Inserts a string of arbitrary length into the current_text at the current cursor position. + * This is only used for the inline software keyboard. + */ + void InlineTextInsertString(std::u16string_view string); + + /// Setup the mouse hover workaround for "focusing" buttons. This should only be called once. + void SetupMouseHover(); + + /** + * Handles button presses and converts them into keyboard input. + * + * @tparam HIDButton The list of buttons that can be converted into keyboard input. + */ + template + void HandleButtonPressedOnce(); + + /** + * Handles button holds and converts them into keyboard input. + * + * @tparam HIDButton The list of buttons that can be converted into keyboard input. + */ + template + void HandleButtonHold(); + + /** + * Translates a button press to focus or click a keyboard button. + * + * @param button The button press to process. + */ + void TranslateButtonPress(Core::HID::NpadButton button); + + /** + * Moves the focus of a button in a certain direction. + * + * @param direction The direction to move. + */ + void MoveButtonDirection(Direction direction); + + /** + * Moves the text cursor in a certain direction. + * + * @param direction The direction to move. + */ + void MoveTextCursorDirection(Direction direction); + + void StartInputThread(); + void StopInputThread(); + + /// The thread where input is being polled and processed. + void InputThread(); + + std::unique_ptr ui; + + Core::System& system; + + // True if it is the inline software keyboard. + bool is_inline; + + // Common software keyboard initialize parameters. + Core::Frontend::KeyboardInitializeParameters initialize_parameters; + + // Used only by the inline software keyboard since the QLineEdit or QTextEdit is hidden. + std::u16string current_text; + s32 cursor_position{0}; + + static constexpr std::size_t NUM_ROWS_NORMAL = 5; + static constexpr std::size_t NUM_COLUMNS_NORMAL = 12; + static constexpr std::size_t NUM_ROWS_NUMPAD = 4; + static constexpr std::size_t NUM_COLUMNS_NUMPAD = 4; + + // Stores the normal keyboard layout. + std::array, NUM_ROWS_NORMAL>, 2> + keyboard_buttons; + // Stores the numberpad keyboard layout. + std::array, NUM_ROWS_NUMPAD> numberpad_buttons; + + // Contains a set of all buttons used in keyboard_buttons and numberpad_buttons. + std::array all_buttons; + + std::size_t row{0}; + std::size_t column{0}; + + BottomOSKIndex bottom_osk_index{BottomOSKIndex::LowerCase}; + std::atomic caps_lock_enabled{false}; + + std::unique_ptr input_interpreter; + + std::thread input_thread; + + std::atomic input_thread_running{}; +}; + +class QtSoftwareKeyboard final : public QObject, public Core::Frontend::SoftwareKeyboardApplet { + Q_OBJECT + +public: + explicit QtSoftwareKeyboard(GMainWindow& parent); + ~QtSoftwareKeyboard() override; + + void Close() const override { + ExitKeyboard(); + } + + void InitializeKeyboard(bool is_inline, + Core::Frontend::KeyboardInitializeParameters initialize_parameters, + SubmitNormalCallback submit_normal_callback_, + SubmitInlineCallback submit_inline_callback_) override; + + void ShowNormalKeyboard() const override; + + void ShowTextCheckDialog(Service::AM::Frontend::SwkbdTextCheckResult text_check_result, + std::u16string text_check_message) const override; + + void ShowInlineKeyboard( + Core::Frontend::InlineAppearParameters appear_parameters) const override; + + void HideInlineKeyboard() const override; + + void InlineTextChanged(Core::Frontend::InlineTextParameters text_parameters) const override; + + void ExitKeyboard() const override; + +signals: + void MainWindowInitializeKeyboard( + bool is_inline, Core::Frontend::KeyboardInitializeParameters initialize_parameters) const; + + void MainWindowShowNormalKeyboard() const; + + void MainWindowShowTextCheckDialog( + Service::AM::Frontend::SwkbdTextCheckResult text_check_result, + std::u16string text_check_message) const; + + void MainWindowShowInlineKeyboard( + Core::Frontend::InlineAppearParameters appear_parameters) const; + + void MainWindowHideInlineKeyboard() const; + + void MainWindowInlineTextChanged(Core::Frontend::InlineTextParameters text_parameters) const; + + void MainWindowExitKeyboard() const; + +private: + void SubmitNormalText(Service::AM::Frontend::SwkbdResult result, std::u16string submitted_text, + bool confirmed) const; + + void SubmitInlineText(Service::AM::Frontend::SwkbdReplyType reply_type, + std::u16string submitted_text, s32 cursor_position) const; + + mutable SubmitNormalCallback submit_normal_callback; + mutable SubmitInlineCallback submit_inline_callback; +}; diff --git a/src/sudachi/applets/qt_software_keyboard.ui b/src/sudachi/applets/qt_software_keyboard.ui new file mode 100644 index 0000000..9661cb2 --- /dev/null +++ b/src/sudachi/applets/qt_software_keyboard.ui @@ -0,0 +1,3541 @@ + + + QtSoftwareKeyboardDialog + + + + 0 + 0 + 1280 + 720 + + + + Software Keyboard + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + 0 + + + + + 0 + 100 + + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + 0 + + + 0 + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + 17 + + + + 0/32 + + + Qt::AlignLeading|Qt::AlignLeft|Qt::AlignVCenter + + + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + + + + 26 + 50 + false + + + + Qt::StrongFocus + + + + + + 32 + + + Enter Text + + + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Qt::Horizontal + + + + 127 + 20 + + + + + + + + + 23 + + + + + + + + + + + Qt::Horizontal + + + + 127 + 20 + + + + + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Qt::Horizontal + + + + 127 + 20 + + + + + + + + + 17 + + + + + + + + + + + Qt::Horizontal + + + + 127 + 20 + + + + + + + + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + true + + + + 17 + + + + 0/500 + + + + + + + + + + + 0 + + + 14 + + + 9 + + + 14 + + + 9 + + + + + + 26 + + + + Qt::StrongFocus + + + <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0//EN" "http://www.w3.org/TR/REC-html40/strict.dtd"> +<html><head><meta name="qrichtext" content="1" /><style type="text/css"> +p, li { white-space: pre-wrap; } +</style></head><body style=" font-family:'MS Shell Dlg 2'; font-size:26pt; font-weight:400; font-style:normal;"> +<p style="-qt-paragraph-type:empty; margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><br /></p></body></html> + + + + + + + + + + + + + + + + 0 + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + 0 + + + + + + 0 + + + 2 + + + 0 + + + 0 + + + 0 + + + + + + + + + + + + Qt::Horizontal + + + + 0 + 20 + + + + + + + + + + + Qt::Horizontal + + + + 0 + 20 + + + + + + + + + + + Qt::Horizontal + + + + 0 + 20 + + + + + + + + + + + Qt::Horizontal + + + + 0 + 20 + + + + + + + + + + + Qt::Horizontal + + + + 0 + 20 + + + + + + + + + + + Qt::Horizontal + + + + 0 + 20 + + + + + + + + + 18 + + + + Shift + + + + + + + Qt::Horizontal + + + + 0 + 20 + + + + + + + + + + + Qt::Horizontal + + + + 0 + 20 + + + + + + + + + 18 + + + + Cancel + + + + + + + Qt::Horizontal + + + + 0 + 20 + + + + + + + + + + + Qt::Horizontal + + + + 0 + 20 + + + + + + + + + 18 + + + + Enter + + + + + + + Qt::Horizontal + + + + 0 + 20 + + + + + + + + + + + + 1 + 1 + + + + + 28 + + + + - + + + + + + + + 1 + 1 + + + + + 28 + + + + ' + + + + + + + + 1 + 1 + + + + + 28 + + + + / + + + + + + + + 1 + 1 + + + + + 28 + + + + ! + + + + + + + + 1 + 1 + + + + + 28 + + + + 7 + + + + + + + + 1 + 1 + + + + + 28 + + + + 8 + + + + + + + + 1 + 1 + + + + + 28 + + + + 0 + + + + + + + + 1 + 1 + + + + + 28 + + + + 9 + + + + + + + + 1 + 1 + + + + + 28 + + + + w + + + + + + + + 1 + 1 + + + + + 28 + + + + r + + + + + + + + 1 + 1 + + + + + 28 + + + + e + + + + + + + + 1 + 1 + + + + + 28 + + + + q + + + + + + + + 1 + 1 + + + + + 28 + + + + u + + + + + + + + 1 + 1 + + + + + 28 + + + + y + + + + + + + + 1 + 1 + + + + + 28 + + + + t + + + + + + + + 1 + 1 + + + + + 28 + + + + o + + + + + + + + 1 + 1 + + + + + 28 + + + + p + + + + + + + + 1 + 1 + + + + + 28 + + + + i + + + + + + + + 1 + 1 + + + + + 28 + + + + a + + + + + + + + 1 + 1 + + + + + 28 + + + + s + + + + + + + + 1 + 1 + + + + + 28 + + + + d + + + + + + + + 1 + 1 + + + + + 28 + + + + f + + + + + + + + 1 + 1 + + + + + 28 + + + + h + + + + + + + + 1 + 1 + + + + + 28 + + + + j + + + + + + + + 1 + 1 + + + + + 28 + + + + g + + + + + + + + 1 + 1 + + + + + 28 + + + + k + + + + + + + + 1 + 1 + + + + + 28 + + + + l + + + + + + + + 1 + 1 + + + + + 28 + + + + : + + + + + + + + 1 + 1 + + + + + 18 + + + + Return + + + + + + + + 1 + 1 + + + + + 18 + + + + OK + + + + + + + + 1 + 1 + + + + + 28 + + + + z + + + + + + + + 1 + 1 + + + + + 28 + + + + c + + + + + + + + 1 + 1 + + + + + 28 + + + + x + + + + + + + + 1 + 1 + + + + + 28 + + + + v + + + + + + + + 1 + 1 + + + + + 28 + + + + m + + + + + + + + 1 + 1 + + + + + 28 + + + + , + + + + + + + + 1 + 1 + + + + + 28 + + + + n + + + + + + + + 1 + 1 + + + + + 28 + + + + b + + + + + + + + 1 + 1 + + + + + 18 + + + + + + + true + + + false + + + + + + + + 1 + 1 + + + + + 28 + + + + ? + + + + + + + + 1 + 1 + + + + + 28 + + + + . + + + + + + + + 1 + 1 + + + + + 28 + + + + 1 + + + + + + + + 1 + 1 + + + + + 28 + + + + 3 + + + + + + + + 1 + 1 + + + + + 28 + + + + 4 + + + + + + + + 1 + 1 + + + + + 28 + + + + 2 + + + + + + + + 1 + 1 + + + + + 28 + + + + 6 + + + + + + + + 1 + 1 + + + + + 28 + + + + 5 + + + + + + + + 1 + 1 + + + + + 18 + + + + Space + + + + + + + + 1 + 1 + + + + + 18 + + + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Qt::Vertical + + + + 20 + 0 + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + 0 + + + + + + 0 + + + 2 + + + 0 + + + 0 + + + 0 + + + + + + + + + + + + Qt::Horizontal + + + + 0 + 20 + + + + + + + + + + + Qt::Horizontal + + + + 0 + 20 + + + + + + + + + + + Qt::Horizontal + + + + 0 + 20 + + + + + + + + + + + Qt::Horizontal + + + + 0 + 20 + + + + + + + + + + + Qt::Horizontal + + + + 0 + 20 + + + + + + + + + + + Qt::Horizontal + + + + 0 + 20 + + + + + + + + + 18 + + + + Caps Lock + + + + + + + Qt::Horizontal + + + + 0 + 20 + + + + + + + + + + + Qt::Horizontal + + + + 0 + 20 + + + + + + + + + 18 + + + + Cancel + + + + + + + Qt::Horizontal + + + + 0 + 20 + + + + + + + + + + + Qt::Horizontal + + + + 0 + 20 + + + + + + + + + 18 + + + + Enter + + + + + + + Qt::Horizontal + + + + 0 + 20 + + + + + + + + + + + + 1 + 1 + + + + + 28 + + + + _ + + + + + + + + 1 + 1 + + + + + 28 + + + + " + + + + + + + + 1 + 1 + + + + + 28 + + + + @ + + + + + + + + 1 + 1 + + + + + 28 + + + + = + + + + + + + + 1 + 1 + + + + + 28 + + + + && + + + + + + + + 1 + 1 + + + + + 28 + + + + * + + + + + + + + 1 + 1 + + + + + 28 + + + + ) + + + + + + + + 1 + 1 + + + + + 28 + + + + ( + + + + + + + + 1 + 1 + + + + + 28 + + + + W + + + + + + + + 1 + 1 + + + + + 28 + + + + R + + + + + + + + 1 + 1 + + + + + 28 + + + + E + + + + + + + + 1 + 1 + + + + + 28 + + + + Q + + + + + + + + 1 + 1 + + + + + 28 + + + + U + + + + + + + + 1 + 1 + + + + + 28 + + + + Y + + + + + + + + 1 + 1 + + + + + 28 + + + + T + + + + + + + + 1 + 1 + + + + + 28 + + + + O + + + + + + + + 1 + 1 + + + + + 28 + + + + P + + + + + + + + 1 + 1 + + + + + 28 + + + + I + + + + + + + + 1 + 1 + + + + + 28 + + + + A + + + + + + + + 1 + 1 + + + + + 28 + + + + S + + + + + + + + 1 + 1 + + + + + 28 + + + + D + + + + + + + + 1 + 1 + + + + + 28 + + + + F + + + + + + + + 1 + 1 + + + + + 28 + + + + H + + + + + + + + 1 + 1 + + + + + 28 + + + + J + + + + + + + + 1 + 1 + + + + + 28 + + + + G + + + + + + + + 1 + 1 + + + + + 28 + + + + K + + + + + + + + 1 + 1 + + + + + 28 + + + + L + + + + + + + + 1 + 1 + + + + + 28 + + + + ; + + + + + + + + 1 + 1 + + + + + 18 + + + + Return + + + + + + + + 1 + 1 + + + + + 18 + + + + OK + + + + + + + + 1 + 1 + + + + + 28 + + + + Z + + + + + + + + 1 + 1 + + + + + 28 + + + + C + + + + + + + + 1 + 1 + + + + + 28 + + + + X + + + + + + + + 1 + 1 + + + + + 28 + + + + V + + + + + + + + 1 + 1 + + + + + 28 + + + + M + + + + + + + + 1 + 1 + + + + + 28 + + + + < + + + + + + + + 1 + 1 + + + + + 28 + + + + N + + + + + + + + 1 + 1 + + + + + 28 + + + + B + + + + + + + + 1 + 1 + + + + + 18 + + + + + + + true + + + false + + + + + + + + 1 + 1 + + + + + 28 + + + + + + + + + + + + + 1 + 1 + + + + + 28 + + + + > + + + + + + + + 1 + 1 + + + + + 28 + + + + # + + + + + + + + 1 + 1 + + + + + 28 + + + + ] + + + + + + + + 1 + 1 + + + + + 28 + + + + $ + + + + + + + + 1 + 1 + + + + + 28 + + + + [ + + + + + + + + 1 + 1 + + + + + 28 + + + + ^ + + + + + + + + 1 + 1 + + + + + 28 + + + + % + + + + + + + + 1 + 1 + + + + + 18 + + + + Space + + + + + + + + 1 + 1 + + + + + 18 + + + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Qt::Vertical + + + + 20 + 0 + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + 0 + + + 0 + + + 0 + + + + + + 1 + 1 + + + + + 18 + + + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Qt::Vertical + + + + 20 + 0 + + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + + + + + + Qt::Horizontal + + + + 0 + 20 + + + + + + + + + + + Qt::Horizontal + + + + 0 + 20 + + + + + + + + + + + Qt::Horizontal + + + + 0 + 20 + + + + + + + + + + + Qt::Horizontal + + + + 0 + 20 + + + + + + + + + + + Qt::Horizontal + + + + 0 + 20 + + + + + + + + + + + Qt::Horizontal + + + + 0 + 20 + + + + + + + + + 18 + + + + Cancel + + + + + + + Qt::Horizontal + + + + 0 + 20 + + + + + + + + + + + Qt::Horizontal + + + + 0 + 20 + + + + + + + + + 18 + + + + Enter + + + + + + + Qt::Horizontal + + + + 0 + 20 + + + + + + + + + + + + 1 + 1 + + + + + 28 + + + + 6 + + + + + + + + 1 + 1 + + + + + 28 + + + + 4 + + + + + + + + 1 + 1 + + + + + 28 + + + + 9 + + + + + + + + 1 + 1 + + + + + 28 + + + + 5 + + + + + + + + 1 + 1 + + + + + 18 + + + + OK + + + + + + + + 1 + 1 + + + + + 28 + + + + 7 + + + + + + + + 1 + 1 + + + + + 28 + + + + 8 + + + + + + + + 1 + 1 + + + + + 28 + + + + 2 + + + + + + + + 1 + 1 + + + + + 28 + + + + 1 + + + + + + + + 1 + 1 + + + + + 28 + + + + + + + + + + + + 1 + 1 + + + + + 28 + + + + 0 + + + + + + + + 1 + 1 + + + + + 28 + + + + + + + + + + + + 1 + 1 + + + + + 28 + + + + 3 + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Qt::Vertical + + + + 20 + 0 + + + + + + + + + + + + + + + + + button_1 + button_2 + button_3 + button_4 + button_5 + button_6 + button_7 + button_8 + button_9 + button_0 + button_minus + button_backspace + button_q + button_w + button_e + button_r + button_t + button_y + button_u + button_i + button_o + button_p + button_slash + button_return + button_a + button_s + button_d + button_f + button_g + button_h + button_j + button_k + button_l + button_colon + button_apostrophe + button_z + button_x + button_c + button_v + button_b + button_n + button_m + button_comma + button_dot + button_question + button_exclamation + button_ok + button_shift + button_space + button_hash + button_left_bracket + button_right_bracket + button_dollar + button_percent + button_circumflex + button_ampersand + button_asterisk + button_left_parenthesis + button_right_parenthesis + button_underscore + button_backspace_shift + button_q_shift + button_w_shift + button_e_shift + button_r_shift + button_t_shift + button_y_shift + button_u_shift + button_i_shift + button_o_shift + button_p_shift + button_at + button_return_shift + button_a_shift + button_s_shift + button_d_shift + button_f_shift + button_g_shift + button_h_shift + button_j_shift + button_k_shift + button_l_shift + button_semicolon + button_quotation + button_z_shift + button_x_shift + button_c_shift + button_v_shift + button_b_shift + button_n_shift + button_m_shift + button_less_than + button_greater_than + button_plus + button_equal + button_ok_shift + button_shift_shift + button_space_shift + button_1_num + button_2_num + button_3_num + button_backspace_num + button_4_num + button_5_num + button_6_num + button_ok_num + button_7_num + button_8_num + button_9_num + button_left_optional_num + button_0_num + button_right_optional_num + + + + + + diff --git a/src/sudachi/applets/qt_web_browser.cpp b/src/sudachi/applets/qt_web_browser.cpp new file mode 100644 index 0000000..afdbf75 --- /dev/null +++ b/src/sudachi/applets/qt_web_browser.cpp @@ -0,0 +1,449 @@ +// SPDX-FileCopyrightText: Copyright 2020 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#ifdef SUDACHI_USE_QT_WEB_ENGINE +#include + +#include +#include + +#include +#include +#include +#include +#include + +#include "hid_core/frontend/input_interpreter.h" +#include "sudachi/applets/qt_web_browser_scripts.h" +#endif + +#include "common/fs/path_util.h" +#include "core/core.h" +#include "input_common/drivers/keyboard.h" +#include "sudachi/applets/qt_web_browser.h" +#include "sudachi/main.h" +#include "sudachi/util/url_request_interceptor.h" + +#ifdef SUDACHI_USE_QT_WEB_ENGINE + +namespace { + +constexpr int HIDButtonToKey(Core::HID::NpadButton button) { + switch (button) { + case Core::HID::NpadButton::Left: + case Core::HID::NpadButton::StickLLeft: + return Qt::Key_Left; + case Core::HID::NpadButton::Up: + case Core::HID::NpadButton::StickLUp: + return Qt::Key_Up; + case Core::HID::NpadButton::Right: + case Core::HID::NpadButton::StickLRight: + return Qt::Key_Right; + case Core::HID::NpadButton::Down: + case Core::HID::NpadButton::StickLDown: + return Qt::Key_Down; + default: + return 0; + } +} + +} // Anonymous namespace + +QtNXWebEngineView::QtNXWebEngineView(QWidget* parent, Core::System& system, + InputCommon::InputSubsystem* input_subsystem_) + : QWebEngineView(parent), input_subsystem{input_subsystem_}, + url_interceptor(std::make_unique()), + input_interpreter(std::make_unique(system)), + default_profile{QWebEngineProfile::defaultProfile()}, global_settings{ + default_profile->settings()} { + default_profile->setPersistentStoragePath(QString::fromStdString(Common::FS::PathToUTF8String( + Common::FS::GetSudachiPath(Common::FS::SudachiPath::SudachiDir) / "qtwebengine"))); + + QWebEngineScript gamepad; + QWebEngineScript window_nx; + + gamepad.setName(QStringLiteral("gamepad_script.js")); + window_nx.setName(QStringLiteral("window_nx_script.js")); + + gamepad.setSourceCode(QString::fromStdString(GAMEPAD_SCRIPT)); + window_nx.setSourceCode(QString::fromStdString(WINDOW_NX_SCRIPT)); + + gamepad.setInjectionPoint(QWebEngineScript::DocumentCreation); + window_nx.setInjectionPoint(QWebEngineScript::DocumentCreation); + + gamepad.setWorldId(QWebEngineScript::MainWorld); + window_nx.setWorldId(QWebEngineScript::MainWorld); + + gamepad.setRunsOnSubFrames(true); + window_nx.setRunsOnSubFrames(true); + + default_profile->scripts()->insert(gamepad); + default_profile->scripts()->insert(window_nx); + + default_profile->setUrlRequestInterceptor(url_interceptor.get()); + + global_settings->setAttribute(QWebEngineSettings::LocalContentCanAccessRemoteUrls, true); + global_settings->setAttribute(QWebEngineSettings::FullScreenSupportEnabled, true); + global_settings->setAttribute(QWebEngineSettings::AllowRunningInsecureContent, true); + global_settings->setAttribute(QWebEngineSettings::FocusOnNavigationEnabled, true); + global_settings->setAttribute(QWebEngineSettings::AllowWindowActivationFromJavaScript, true); + global_settings->setAttribute(QWebEngineSettings::ShowScrollBars, false); + + global_settings->setFontFamily(QWebEngineSettings::StandardFont, QStringLiteral("Roboto")); + + connect( + page(), &QWebEnginePage::windowCloseRequested, page(), + [this] { + if (page()->url() == url_interceptor->GetRequestedURL()) { + SetFinished(true); + SetExitReason(Service::AM::Frontend::WebExitReason::WindowClosed); + } + }, + Qt::QueuedConnection); +} + +QtNXWebEngineView::~QtNXWebEngineView() { + SetFinished(true); + StopInputThread(); +} + +void QtNXWebEngineView::LoadLocalWebPage(const std::string& main_url, + const std::string& additional_args) { + is_local = true; + + LoadExtractedFonts(); + FocusFirstLinkElement(); + SetUserAgent(UserAgent::WebApplet); + SetFinished(false); + SetExitReason(Service::AM::Frontend::WebExitReason::EndButtonPressed); + SetLastURL("http://localhost/"); + StartInputThread(); + + load(QUrl(QUrl::fromLocalFile(QString::fromStdString(main_url)).toString() + + QString::fromStdString(additional_args))); +} + +void QtNXWebEngineView::LoadExternalWebPage(const std::string& main_url, + const std::string& additional_args) { + is_local = false; + + FocusFirstLinkElement(); + SetUserAgent(UserAgent::WebApplet); + SetFinished(false); + SetExitReason(Service::AM::Frontend::WebExitReason::EndButtonPressed); + SetLastURL("http://localhost/"); + StartInputThread(); + + load(QUrl(QString::fromStdString(main_url) + QString::fromStdString(additional_args))); +} + +void QtNXWebEngineView::SetUserAgent(UserAgent user_agent) { + const QString user_agent_str = [user_agent] { + switch (user_agent) { + case UserAgent::WebApplet: + default: + return QStringLiteral("WebApplet"); + case UserAgent::ShopN: + return QStringLiteral("ShopN"); + case UserAgent::LoginApplet: + return QStringLiteral("LoginApplet"); + case UserAgent::ShareApplet: + return QStringLiteral("ShareApplet"); + case UserAgent::LobbyApplet: + return QStringLiteral("LobbyApplet"); + case UserAgent::WifiWebAuthApplet: + return QStringLiteral("WifiWebAuthApplet"); + } + }(); + + QWebEngineProfile::defaultProfile()->setHttpUserAgent( + QStringLiteral("Mozilla/5.0 (Nintendo Switch; %1) AppleWebKit/606.4 " + "(KHTML, like Gecko) NF/6.0.1.15.4 NintendoBrowser/5.1.0.20389") + .arg(user_agent_str)); +} + +bool QtNXWebEngineView::IsFinished() const { + return finished; +} + +void QtNXWebEngineView::SetFinished(bool finished_) { + finished = finished_; +} + +Service::AM::Frontend::WebExitReason QtNXWebEngineView::GetExitReason() const { + return exit_reason; +} + +void QtNXWebEngineView::SetExitReason(Service::AM::Frontend::WebExitReason exit_reason_) { + exit_reason = exit_reason_; +} + +const std::string& QtNXWebEngineView::GetLastURL() const { + return last_url; +} + +void QtNXWebEngineView::SetLastURL(std::string last_url_) { + last_url = std::move(last_url_); +} + +QString QtNXWebEngineView::GetCurrentURL() const { + return url_interceptor->GetRequestedURL().toString(); +} + +void QtNXWebEngineView::hide() { + SetFinished(true); + StopInputThread(); + + QWidget::hide(); +} + +void QtNXWebEngineView::keyPressEvent(QKeyEvent* event) { + if (is_local) { + input_subsystem->GetKeyboard()->PressKey(event->key()); + } +} + +void QtNXWebEngineView::keyReleaseEvent(QKeyEvent* event) { + if (is_local) { + input_subsystem->GetKeyboard()->ReleaseKey(event->key()); + } +} + +template +void QtNXWebEngineView::HandleWindowFooterButtonPressedOnce() { + const auto f = [this](Core::HID::NpadButton button) { + if (input_interpreter->IsButtonPressedOnce(button)) { + const auto button_index = std::countr_zero(static_cast(button)); + + page()->runJavaScript( + QStringLiteral("sudachi_key_callbacks[%1] == null;").arg(button_index), + [this, button](const QVariant& variant) { + if (variant.toBool()) { + switch (button) { + case Core::HID::NpadButton::A: + SendMultipleKeyPressEvents(); + break; + case Core::HID::NpadButton::B: + SendKeyPressEvent(Qt::Key_B); + break; + case Core::HID::NpadButton::X: + SendKeyPressEvent(Qt::Key_X); + break; + case Core::HID::NpadButton::Y: + SendKeyPressEvent(Qt::Key_Y); + break; + default: + break; + } + } + }); + + page()->runJavaScript( + QStringLiteral("if (sudachi_key_callbacks[%1] != null) { sudachi_key_callbacks[%1](); }") + .arg(button_index)); + } + }; + + (f(T), ...); +} + +template +void QtNXWebEngineView::HandleWindowKeyButtonPressedOnce() { + const auto f = [this](Core::HID::NpadButton button) { + if (input_interpreter->IsButtonPressedOnce(button)) { + SendKeyPressEvent(HIDButtonToKey(button)); + } + }; + + (f(T), ...); +} + +template +void QtNXWebEngineView::HandleWindowKeyButtonHold() { + const auto f = [this](Core::HID::NpadButton button) { + if (input_interpreter->IsButtonHeld(button)) { + SendKeyPressEvent(HIDButtonToKey(button)); + } + }; + + (f(T), ...); +} + +void QtNXWebEngineView::SendKeyPressEvent(int key) { + if (key == 0) { + return; + } + + QCoreApplication::postEvent(focusProxy(), + new QKeyEvent(QKeyEvent::KeyPress, key, Qt::NoModifier)); + QCoreApplication::postEvent(focusProxy(), + new QKeyEvent(QKeyEvent::KeyRelease, key, Qt::NoModifier)); +} + +void QtNXWebEngineView::StartInputThread() { + if (input_thread_running) { + return; + } + + input_thread_running = true; + input_thread = std::thread(&QtNXWebEngineView::InputThread, this); +} + +void QtNXWebEngineView::StopInputThread() { + if (is_local) { + QWidget::releaseKeyboard(); + } + + input_thread_running = false; + if (input_thread.joinable()) { + input_thread.join(); + } +} + +void QtNXWebEngineView::InputThread() { + // Wait for 1 second before allowing any inputs to be processed. + std::this_thread::sleep_for(std::chrono::seconds(1)); + + if (is_local) { + QWidget::grabKeyboard(); + } + + while (input_thread_running) { + input_interpreter->PollInput(); + + HandleWindowFooterButtonPressedOnce(); + + HandleWindowKeyButtonPressedOnce< + Core::HID::NpadButton::Left, Core::HID::NpadButton::Up, Core::HID::NpadButton::Right, + Core::HID::NpadButton::Down, Core::HID::NpadButton::StickLLeft, + Core::HID::NpadButton::StickLUp, Core::HID::NpadButton::StickLRight, + Core::HID::NpadButton::StickLDown>(); + + HandleWindowKeyButtonHold< + Core::HID::NpadButton::Left, Core::HID::NpadButton::Up, Core::HID::NpadButton::Right, + Core::HID::NpadButton::Down, Core::HID::NpadButton::StickLLeft, + Core::HID::NpadButton::StickLUp, Core::HID::NpadButton::StickLRight, + Core::HID::NpadButton::StickLDown>(); + + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + } +} + +void QtNXWebEngineView::LoadExtractedFonts() { + QWebEngineScript nx_font_css; + QWebEngineScript load_nx_font; + + auto fonts_dir_str = Common::FS::PathToUTF8String( + Common::FS::GetSudachiPath(Common::FS::SudachiPath::CacheDir) / "fonts/"); + + std::replace(fonts_dir_str.begin(), fonts_dir_str.end(), '\\', '/'); + + const auto fonts_dir = QString::fromStdString(fonts_dir_str); + + nx_font_css.setName(QStringLiteral("nx_font_css.js")); + load_nx_font.setName(QStringLiteral("load_nx_font.js")); + + nx_font_css.setSourceCode( + QString::fromStdString(NX_FONT_CSS) + .arg(fonts_dir + QStringLiteral("FontStandard.ttf")) + .arg(fonts_dir + QStringLiteral("FontChineseSimplified.ttf")) + .arg(fonts_dir + QStringLiteral("FontExtendedChineseSimplified.ttf")) + .arg(fonts_dir + QStringLiteral("FontChineseTraditional.ttf")) + .arg(fonts_dir + QStringLiteral("FontKorean.ttf")) + .arg(fonts_dir + QStringLiteral("FontNintendoExtended.ttf")) + .arg(fonts_dir + QStringLiteral("FontNintendoExtended2.ttf"))); + load_nx_font.setSourceCode(QString::fromStdString(LOAD_NX_FONT)); + + nx_font_css.setInjectionPoint(QWebEngineScript::DocumentReady); + load_nx_font.setInjectionPoint(QWebEngineScript::Deferred); + + nx_font_css.setWorldId(QWebEngineScript::MainWorld); + load_nx_font.setWorldId(QWebEngineScript::MainWorld); + + nx_font_css.setRunsOnSubFrames(true); + load_nx_font.setRunsOnSubFrames(true); + + default_profile->scripts()->insert(nx_font_css); + default_profile->scripts()->insert(load_nx_font); + + connect( + url_interceptor.get(), &UrlRequestInterceptor::FrameChanged, url_interceptor.get(), + [this] { + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + page()->runJavaScript(QString::fromStdString(LOAD_NX_FONT)); + }, + Qt::QueuedConnection); +} + +void QtNXWebEngineView::FocusFirstLinkElement() { + QWebEngineScript focus_link_element; + + focus_link_element.setName(QStringLiteral("focus_link_element.js")); + focus_link_element.setSourceCode(QString::fromStdString(FOCUS_LINK_ELEMENT_SCRIPT)); + focus_link_element.setWorldId(QWebEngineScript::MainWorld); + focus_link_element.setInjectionPoint(QWebEngineScript::Deferred); + focus_link_element.setRunsOnSubFrames(true); + default_profile->scripts()->insert(focus_link_element); +} + +#endif + +QtWebBrowser::QtWebBrowser(GMainWindow& main_window) { + connect(this, &QtWebBrowser::MainWindowOpenWebPage, &main_window, + &GMainWindow::WebBrowserOpenWebPage, Qt::QueuedConnection); + connect(this, &QtWebBrowser::MainWindowRequestExit, &main_window, + &GMainWindow::WebBrowserRequestExit, Qt::QueuedConnection); + connect(&main_window, &GMainWindow::WebBrowserExtractOfflineRomFS, this, + &QtWebBrowser::MainWindowExtractOfflineRomFS, Qt::QueuedConnection); + connect(&main_window, &GMainWindow::WebBrowserClosed, this, + &QtWebBrowser::MainWindowWebBrowserClosed, Qt::QueuedConnection); +} + +QtWebBrowser::~QtWebBrowser() = default; + +void QtWebBrowser::Close() const { + callback = {}; + emit MainWindowRequestExit(); +} + +void QtWebBrowser::OpenLocalWebPage(const std::string& local_url, + ExtractROMFSCallback extract_romfs_callback_, + OpenWebPageCallback callback_) const { + extract_romfs_callback = std::move(extract_romfs_callback_); + callback = std::move(callback_); + + const auto index = local_url.find('?'); + + if (index == std::string::npos) { + emit MainWindowOpenWebPage(local_url, "", true); + } else { + emit MainWindowOpenWebPage(local_url.substr(0, index), local_url.substr(index), true); + } +} + +void QtWebBrowser::OpenExternalWebPage(const std::string& external_url, + OpenWebPageCallback callback_) const { + callback = std::move(callback_); + + const auto index = external_url.find('?'); + + if (index == std::string::npos) { + emit MainWindowOpenWebPage(external_url, "", false); + } else { + emit MainWindowOpenWebPage(external_url.substr(0, index), external_url.substr(index), + false); + } +} + +void QtWebBrowser::MainWindowExtractOfflineRomFS() { + extract_romfs_callback(); +} + +void QtWebBrowser::MainWindowWebBrowserClosed(Service::AM::Frontend::WebExitReason exit_reason, + std::string last_url) { + if (callback) { + callback(exit_reason, last_url); + } +} diff --git a/src/sudachi/applets/qt_web_browser.h b/src/sudachi/applets/qt_web_browser.h new file mode 100644 index 0000000..247d2df --- /dev/null +++ b/src/sudachi/applets/qt_web_browser.h @@ -0,0 +1,220 @@ +// SPDX-FileCopyrightText: Copyright 2020 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include +#include + +#include + +#ifdef SUDACHI_USE_QT_WEB_ENGINE +#include +#endif + +#include "core/frontend/applets/web_browser.h" + +class GMainWindow; +class InputInterpreter; +class UrlRequestInterceptor; + +namespace Core { +class System; +} + +namespace Core::HID { +enum class NpadButton : u64; +} + +namespace InputCommon { +class InputSubsystem; +} + +#ifdef SUDACHI_USE_QT_WEB_ENGINE + +enum class UserAgent { + WebApplet, + ShopN, + LoginApplet, + ShareApplet, + LobbyApplet, + WifiWebAuthApplet, +}; + +class QWebEngineProfile; +class QWebEngineSettings; + +class QtNXWebEngineView : public QWebEngineView { + Q_OBJECT + +public: + explicit QtNXWebEngineView(QWidget* parent, Core::System& system, + InputCommon::InputSubsystem* input_subsystem_); + ~QtNXWebEngineView() override; + + /** + * Loads a HTML document that exists locally. Cannot be used to load external websites. + * + * @param main_url The url to the file. + * @param additional_args Additional arguments appended to the main url. + */ + void LoadLocalWebPage(const std::string& main_url, const std::string& additional_args); + + /** + * Loads an external website. Cannot be used to load local urls. + * + * @param main_url The url to the website. + * @param additional_args Additional arguments appended to the main url. + */ + void LoadExternalWebPage(const std::string& main_url, const std::string& additional_args); + + /** + * Sets the background color of the web page. + * + * @param color The color to set. + */ + void SetBackgroundColor(QColor color); + + /** + * Sets the user agent of the web browser. + * + * @param user_agent The user agent enum. + */ + void SetUserAgent(UserAgent user_agent); + + [[nodiscard]] bool IsFinished() const; + void SetFinished(bool finished_); + + [[nodiscard]] Service::AM::Frontend::WebExitReason GetExitReason() const; + void SetExitReason(Service::AM::Frontend::WebExitReason exit_reason_); + + [[nodiscard]] const std::string& GetLastURL() const; + void SetLastURL(std::string last_url_); + + /** + * This gets the current URL that has been requested by the webpage. + * This only applies to the main frame. Sub frames and other resources are ignored. + * + * @return Currently requested URL + */ + [[nodiscard]] QString GetCurrentURL() const; + +public slots: + void hide(); + +protected: + void keyPressEvent(QKeyEvent* event) override; + void keyReleaseEvent(QKeyEvent* event) override; + +private: + /** + * Handles button presses to execute functions assigned in sudachi_key_callbacks. + * sudachi_key_callbacks contains specialized functions for the buttons in the window footer + * that can be overridden by games to achieve desired functionality. + * + * @tparam HIDButton The list of buttons contained in sudachi_key_callbacks + */ + template + void HandleWindowFooterButtonPressedOnce(); + + /** + * Handles button presses and converts them into keyboard input. + * This should only be used to convert D-Pad or Analog Stick input into arrow keys. + * + * @tparam HIDButton The list of buttons that can be converted into keyboard input. + */ + template + void HandleWindowKeyButtonPressedOnce(); + + /** + * Handles button holds and converts them into keyboard input. + * This should only be used to convert D-Pad or Analog Stick input into arrow keys. + * + * @tparam HIDButton The list of buttons that can be converted into keyboard input. + */ + template + void HandleWindowKeyButtonHold(); + + /** + * Sends a key press event to QWebEngineView. + * + * @param key Qt key code. + */ + void SendKeyPressEvent(int key); + + /** + * Sends multiple key press events to QWebEngineView. + * + * @tparam int Qt key code. + */ + template + void SendMultipleKeyPressEvents() { + (SendKeyPressEvent(T), ...); + } + + void StartInputThread(); + void StopInputThread(); + + /// The thread where input is being polled and processed. + void InputThread(); + + /// Loads the extracted fonts using JavaScript. + void LoadExtractedFonts(); + + /// Brings focus to the first available link element. + void FocusFirstLinkElement(); + + InputCommon::InputSubsystem* input_subsystem; + + std::unique_ptr url_interceptor; + + std::unique_ptr input_interpreter; + + std::thread input_thread; + + std::atomic input_thread_running{}; + + std::atomic finished{}; + + Service::AM::Frontend::WebExitReason exit_reason{ + Service::AM::Frontend::WebExitReason::EndButtonPressed}; + + std::string last_url{"http://localhost/"}; + + bool is_local{}; + + QWebEngineProfile* default_profile; + QWebEngineSettings* global_settings; +}; + +#endif + +class QtWebBrowser final : public QObject, public Core::Frontend::WebBrowserApplet { + Q_OBJECT + +public: + explicit QtWebBrowser(GMainWindow& parent); + ~QtWebBrowser() override; + + void Close() const override; + void OpenLocalWebPage(const std::string& local_url, + ExtractROMFSCallback extract_romfs_callback_, + OpenWebPageCallback callback_) const override; + + void OpenExternalWebPage(const std::string& external_url, + OpenWebPageCallback callback_) const override; + +signals: + void MainWindowOpenWebPage(const std::string& main_url, const std::string& additional_args, + bool is_local) const; + void MainWindowRequestExit() const; + +private: + void MainWindowExtractOfflineRomFS(); + + void MainWindowWebBrowserClosed(Service::AM::Frontend::WebExitReason exit_reason, + std::string last_url); + + mutable ExtractROMFSCallback extract_romfs_callback; + mutable OpenWebPageCallback callback; +}; diff --git a/src/sudachi/applets/qt_web_browser_scripts.h b/src/sudachi/applets/qt_web_browser_scripts.h new file mode 100644 index 0000000..bc58a94 --- /dev/null +++ b/src/sudachi/applets/qt_web_browser_scripts.h @@ -0,0 +1,198 @@ +// SPDX-FileCopyrightText: Copyright 2020 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +constexpr char NX_FONT_CSS[] = R"( +(function() { + css = document.createElement('style'); + css.type = 'text/css'; + css.id = 'nx_font'; + css.innerText = ` +/* FontStandard */ +@font-face { + font-family: 'FontStandard'; + src: url('%1') format('truetype'); +} + +/* FontChineseSimplified */ +@font-face { + font-family: 'FontChineseSimplified'; + src: url('%2') format('truetype'); +} + +/* FontExtendedChineseSimplified */ +@font-face { + font-family: 'FontExtendedChineseSimplified'; + src: url('%3') format('truetype'); +} + +/* FontChineseTraditional */ +@font-face { + font-family: 'FontChineseTraditional'; + src: url('%4') format('truetype'); +} + +/* FontKorean */ +@font-face { + font-family: 'FontKorean'; + src: url('%5') format('truetype'); +} + +/* FontNintendoExtended */ +@font-face { + font-family: 'NintendoExt003'; + src: url('%6') format('truetype'); +} + +/* FontNintendoExtended2 */ +@font-face { + font-family: 'NintendoExt003'; + src: url('%7') format('truetype'); +} +`; + + document.head.appendChild(css); +})(); +)"; + +constexpr char LOAD_NX_FONT[] = R"( +(function() { + var elements = document.querySelectorAll("*"); + + for (var i = 0; i < elements.length; i++) { + var style = window.getComputedStyle(elements[i], null); + if (style.fontFamily.includes("Arial") || style.fontFamily.includes("Calibri") || + style.fontFamily.includes("Century") || style.fontFamily.includes("Times New Roman")) { + elements[i].style.fontFamily = "FontStandard, FontChineseSimplified, FontExtendedChineseSimplified, FontChineseTraditional, FontKorean, NintendoExt003"; + } else { + elements[i].style.fontFamily = style.fontFamily + ", FontStandard, FontChineseSimplified, FontExtendedChineseSimplified, FontChineseTraditional, FontKorean, NintendoExt003"; + } + } +})(); +)"; + +constexpr char FOCUS_LINK_ELEMENT_SCRIPT[] = R"( +if (document.getElementsByTagName("a").length > 0) { + document.getElementsByTagName("a")[0].focus(); +} +)"; + +constexpr char GAMEPAD_SCRIPT[] = R"( +window.addEventListener("gamepadconnected", function(e) { + console.log("Gamepad connected at index %d: %s. %d buttons, %d axes.", + e.gamepad.index, e.gamepad.id, e.gamepad.buttons.length, e.gamepad.axes.length); +}); + +window.addEventListener("gamepaddisconnected", function(e) { + console.log("Gamepad disconnected from index %d: %s", e.gamepad.index, e.gamepad.id); +}); +)"; + +constexpr char WINDOW_NX_SCRIPT[] = R"( +var end_applet = false; +var sudachi_key_callbacks = []; + +(function() { + class WindowNX { + constructor() { + sudachi_key_callbacks[1] = function() { window.history.back(); }; + sudachi_key_callbacks[2] = function() { window.nx.endApplet(); }; + } + + addEventListener(type, listener, options) { + console.log("nx.addEventListener called, type=%s", type); + + window.addEventListener(type, listener, options); + } + + endApplet() { + console.log("nx.endApplet called"); + + end_applet = true; + } + + playSystemSe(system_se) { + console.log("nx.playSystemSe is not implemented, system_se=%s", system_se); + } + + sendMessage(message) { + console.log("nx.sendMessage is not implemented, message=%s", message); + } + + setCursorScrollSpeed(scroll_speed) { + console.log("nx.setCursorScrollSpeed is not implemented, scroll_speed=%d", scroll_speed); + } + } + + class WindowNXFooter { + setAssign(key, label, func, option) { + console.log("nx.footer.setAssign called, key=%s", key); + + switch (key) { + case "A": + sudachi_key_callbacks[0] = func; + break; + case "B": + sudachi_key_callbacks[1] = func; + break; + case "X": + sudachi_key_callbacks[2] = func; + break; + case "Y": + sudachi_key_callbacks[3] = func; + break; + case "L": + sudachi_key_callbacks[6] = func; + break; + case "R": + sudachi_key_callbacks[7] = func; + break; + } + } + + setFixed(kind) { + console.log("nx.footer.setFixed is not implemented, kind=%s", kind); + } + + unsetAssign(key) { + console.log("nx.footer.unsetAssign called, key=%s", key); + + switch (key) { + case "A": + sudachi_key_callbacks[0] = function() {}; + break; + case "B": + sudachi_key_callbacks[1] = function() {}; + break; + case "X": + sudachi_key_callbacks[2] = function() {}; + break; + case "Y": + sudachi_key_callbacks[3] = function() {}; + break; + case "L": + sudachi_key_callbacks[6] = function() {}; + break; + case "R": + sudachi_key_callbacks[7] = function() {}; + break; + } + } + } + + class WindowNXPlayReport { + incrementCounter(counter_id) { + console.log("nx.playReport.incrementCounter is not implemented, counter_id=%d", counter_id); + } + + setCounterSetIdentifier(counter_id) { + console.log("nx.playReport.setCounterSetIdentifier is not implemented, counter_id=%d", counter_id); + } + } + + window.nx = new WindowNX(); + window.nx.footer = new WindowNXFooter(); + window.nx.playReport = new WindowNXPlayReport(); +})(); +)"; diff --git a/src/sudachi/bootmanager.cpp b/src/sudachi/bootmanager.cpp new file mode 100644 index 0000000..21714b9 --- /dev/null +++ b/src/sudachi/bootmanager.cpp @@ -0,0 +1,1140 @@ +// SPDX-FileCopyrightText: 2014 Citra Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include "common/settings_enums.h" +#include "uisettings.h" +#if (QT_VERSION < QT_VERSION_CHECK(6, 0, 0)) && SUDACHI_USE_QT_MULTIMEDIA +#include +#include +#include +#endif +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifdef HAS_OPENGL +#include +#include +#endif + +#include "common/microprofile.h" +#include "common/polyfill_thread.h" +#include "common/scm_rev.h" +#include "common/settings.h" +#include "common/settings_input.h" +#include "common/thread.h" +#include "core/core.h" +#include "core/cpu_manager.h" +#include "core/frontend/framebuffer_layout.h" +#include "core/frontend/graphics_context.h" +#include "input_common/drivers/camera.h" +#include "input_common/drivers/keyboard.h" +#include "input_common/drivers/mouse.h" +#include "input_common/drivers/tas_input.h" +#include "input_common/drivers/touch_screen.h" +#include "input_common/main.h" +#include "video_core/gpu.h" +#include "video_core/rasterizer_interface.h" +#include "video_core/renderer_base.h" +#include "sudachi/bootmanager.h" +#include "sudachi/main.h" +#include "sudachi/qt_common.h" + +class QObject; +class QPaintEngine; +class QSurface; + +constexpr int default_mouse_constrain_timeout = 10; + +EmuThread::EmuThread(Core::System& system) : m_system{system} {} + +EmuThread::~EmuThread() = default; + +void EmuThread::run() { + const char* name = "EmuControlThread"; + MicroProfileOnThreadCreate(name); + Common::SetCurrentThreadName(name); + + auto& gpu = m_system.GPU(); + auto stop_token = m_stop_source.get_token(); + + m_system.RegisterHostThread(); + + // Main process has been loaded. Make the context current to this thread and begin GPU and CPU + // execution. + gpu.ObtainContext(); + + emit LoadProgress(VideoCore::LoadCallbackStage::Prepare, 0, 0); + if (Settings::values.use_disk_shader_cache.GetValue()) { + m_system.Renderer().ReadRasterizer()->LoadDiskResources( + m_system.GetApplicationProcessProgramID(), stop_token, + [this](VideoCore::LoadCallbackStage stage, std::size_t value, std::size_t total) { + emit LoadProgress(stage, value, total); + }); + } + emit LoadProgress(VideoCore::LoadCallbackStage::Complete, 0, 0); + + gpu.ReleaseContext(); + gpu.Start(); + + m_system.GetCpuManager().OnGpuReady(); + + if (m_system.DebuggerEnabled()) { + m_system.InitializeDebugger(); + } + + while (!stop_token.stop_requested()) { + std::unique_lock lk{m_should_run_mutex}; + if (m_should_run) { + m_system.Run(); + m_stopped.Reset(); + + Common::CondvarWait(m_should_run_cv, lk, stop_token, [&] { return !m_should_run; }); + } else { + m_system.Pause(); + m_stopped.Set(); + + EmulationPaused(lk); + Common::CondvarWait(m_should_run_cv, lk, stop_token, [&] { return m_should_run; }); + EmulationResumed(lk); + } + } + + // Shutdown the main emulated process + m_system.DetachDebugger(); + m_system.ShutdownMainProcess(); + +#if MICROPROFILE_ENABLED + MicroProfileOnThreadExit(); +#endif +} + +// Unlock while emitting signals so that the main thread can +// continue pumping events. + +void EmuThread::EmulationPaused(std::unique_lock& lk) { + lk.unlock(); + emit DebugModeEntered(); + lk.lock(); +} + +void EmuThread::EmulationResumed(std::unique_lock& lk) { + lk.unlock(); + emit DebugModeLeft(); + lk.lock(); +} + +#ifdef HAS_OPENGL +class OpenGLSharedContext : public Core::Frontend::GraphicsContext { +public: + /// Create the original context that should be shared from + explicit OpenGLSharedContext(QSurface* surface_) : surface{surface_} { + QSurfaceFormat format; + format.setVersion(4, 6); + format.setProfile(QSurfaceFormat::CompatibilityProfile); + format.setOption(QSurfaceFormat::FormatOption::DeprecatedFunctions); + if (Settings::values.renderer_debug) { + format.setOption(QSurfaceFormat::FormatOption::DebugContext); + } + // TODO: expose a setting for buffer value (ie default/single/double/triple) + format.setSwapBehavior(QSurfaceFormat::DefaultSwapBehavior); + format.setSwapInterval(0); + + context = std::make_unique(); + context->setFormat(format); + if (!context->create()) { + LOG_ERROR(Frontend, "Unable to create main openGL context"); + } + } + + /// Create the shared contexts for rendering and presentation + explicit OpenGLSharedContext(QOpenGLContext* share_context, QSurface* main_surface = nullptr) { + + // disable vsync for any shared contexts + auto format = share_context->format(); + const int swap_interval = + Settings::values.vsync_mode.GetValue() == Settings::VSyncMode::Immediate ? 0 : 1; + + format.setSwapInterval(main_surface ? swap_interval : 0); + + context = std::make_unique(); + context->setShareContext(share_context); + context->setFormat(format); + if (!context->create()) { + LOG_ERROR(Frontend, "Unable to create shared openGL context"); + } + + if (!main_surface) { + offscreen_surface = std::make_unique(nullptr); + offscreen_surface->setFormat(format); + offscreen_surface->create(); + surface = offscreen_surface.get(); + } else { + surface = main_surface; + } + } + + ~OpenGLSharedContext() { + DoneCurrent(); + } + + void SwapBuffers() override { + context->swapBuffers(surface); + } + + void MakeCurrent() override { + // We can't track the current state of the underlying context in this wrapper class because + // Qt may make the underlying context not current for one reason or another. In particular, + // the WebBrowser uses GL, so it seems to conflict if we aren't careful. + // Instead of always just making the context current (which does not have any caching to + // check if the underlying context is already current) we can check for the current context + // in the thread local data by calling `currentContext()` and checking if its ours. + if (QOpenGLContext::currentContext() != context.get()) { + context->makeCurrent(surface); + } + } + + void DoneCurrent() override { + context->doneCurrent(); + } + + QOpenGLContext* GetShareContext() { + return context.get(); + } + + const QOpenGLContext* GetShareContext() const { + return context.get(); + } + +private: + // Avoid using Qt parent system here since we might move the QObjects to new threads + // As a note, this means we should avoid using slots/signals with the objects too + std::unique_ptr context; + std::unique_ptr offscreen_surface{}; + QSurface* surface; +}; +#endif + +class DummyContext : public Core::Frontend::GraphicsContext {}; + +class RenderWidget : public QWidget { +public: + explicit RenderWidget(GRenderWindow* parent) : QWidget(parent), render_window(parent) { + setAttribute(Qt::WA_NativeWindow); + setAttribute(Qt::WA_PaintOnScreen); + if (QtCommon::GetWindowSystemType() == Core::Frontend::WindowSystemType::Wayland) { + setAttribute(Qt::WA_DontCreateNativeAncestors); + } + } + + virtual ~RenderWidget() = default; + + QPaintEngine* paintEngine() const override { + return nullptr; + } + +private: + GRenderWindow* render_window; +}; + +struct OpenGLRenderWidget : public RenderWidget { + explicit OpenGLRenderWidget(GRenderWindow* parent) : RenderWidget(parent) { + windowHandle()->setSurfaceType(QWindow::OpenGLSurface); + } + + void SetContext(std::unique_ptr&& context_) { + context = std::move(context_); + } + +private: + std::unique_ptr context; +}; + +struct VulkanRenderWidget : public RenderWidget { + explicit VulkanRenderWidget(GRenderWindow* parent) : RenderWidget(parent) { + windowHandle()->setSurfaceType(QWindow::VulkanSurface); + } +}; + +struct NullRenderWidget : public RenderWidget { + explicit NullRenderWidget(GRenderWindow* parent) : RenderWidget(parent) {} +}; + +GRenderWindow::GRenderWindow(GMainWindow* parent, EmuThread* emu_thread_, + std::shared_ptr input_subsystem_, + Core::System& system_) + : QWidget(parent), + emu_thread(emu_thread_), input_subsystem{std::move(input_subsystem_)}, system{system_} { + setWindowTitle(QStringLiteral("sudachi %1 | %2-%3") + .arg(QString::fromUtf8(Common::g_build_name), + QString::fromUtf8(Common::g_scm_branch), + QString::fromUtf8(Common::g_scm_desc))); + setAttribute(Qt::WA_AcceptTouchEvents); + auto* layout = new QHBoxLayout(this); + layout->setContentsMargins(0, 0, 0, 0); + setLayout(layout); + input_subsystem->Initialize(); + this->setMouseTracking(true); + + strict_context_required = QGuiApplication::platformName() == QStringLiteral("wayland") || + QGuiApplication::platformName() == QStringLiteral("wayland-egl"); + + connect(this, &GRenderWindow::FirstFrameDisplayed, parent, &GMainWindow::OnLoadComplete); + connect(this, &GRenderWindow::ExecuteProgramSignal, parent, &GMainWindow::OnExecuteProgram, + Qt::QueuedConnection); + connect(this, &GRenderWindow::ExitSignal, parent, &GMainWindow::OnExit, Qt::QueuedConnection); + connect(this, &GRenderWindow::TasPlaybackStateChanged, parent, &GMainWindow::OnTasStateChanged); + + mouse_constrain_timer.setInterval(default_mouse_constrain_timeout); + connect(&mouse_constrain_timer, &QTimer::timeout, this, &GRenderWindow::ConstrainMouse); +} + +void GRenderWindow::ExecuteProgram(std::size_t program_index) { + emit ExecuteProgramSignal(program_index); +} + +void GRenderWindow::Exit() { + emit ExitSignal(); +} + +GRenderWindow::~GRenderWindow() { + input_subsystem->Shutdown(); +} + +void GRenderWindow::OnFrameDisplayed() { + input_subsystem->GetTas()->UpdateThread(); + const InputCommon::TasInput::TasState new_tas_state = + std::get<0>(input_subsystem->GetTas()->GetStatus()); + + if (!first_frame) { + last_tas_state = new_tas_state; + first_frame = true; + emit FirstFrameDisplayed(); + } + + if (new_tas_state != last_tas_state) { + last_tas_state = new_tas_state; + emit TasPlaybackStateChanged(); + } +} + +bool GRenderWindow::IsShown() const { + return !isMinimized(); +} + +// On Qt 5.0+, this correctly gets the size of the framebuffer (pixels). +// +// Older versions get the window size (density independent pixels), +// and hence, do not support DPI scaling ("retina" displays). +// The result will be a viewport that is smaller than the extent of the window. +void GRenderWindow::OnFramebufferSizeChanged() { + // Screen changes potentially incur a change in screen DPI, hence we should update the + // framebuffer size + const qreal pixel_ratio = windowPixelRatio(); + const u32 width = this->width() * pixel_ratio; + const u32 height = this->height() * pixel_ratio; + UpdateCurrentFramebufferLayout(width, height); +} + +void GRenderWindow::BackupGeometry() { + geometry = QWidget::saveGeometry(); +} + +void GRenderWindow::RestoreGeometry() { + // We don't want to back up the geometry here (obviously) + QWidget::restoreGeometry(geometry); +} + +void GRenderWindow::restoreGeometry(const QByteArray& geometry_) { + // Make sure users of this class don't need to deal with backing up the geometry themselves + QWidget::restoreGeometry(geometry_); + BackupGeometry(); +} + +QByteArray GRenderWindow::saveGeometry() { + // If we are a top-level widget, store the current geometry + // otherwise, store the last backup + if (parent() == nullptr) { + return QWidget::saveGeometry(); + } + + return geometry; +} + +qreal GRenderWindow::windowPixelRatio() const { + return devicePixelRatioF(); +} + +std::pair GRenderWindow::ScaleTouch(const QPointF& pos) const { + const qreal pixel_ratio = windowPixelRatio(); + return {static_cast(std::max(std::round(pos.x() * pixel_ratio), qreal{0.0})), + static_cast(std::max(std::round(pos.y() * pixel_ratio), qreal{0.0}))}; +} + +void GRenderWindow::closeEvent(QCloseEvent* event) { + emit Closed(); + QWidget::closeEvent(event); +} + +void GRenderWindow::leaveEvent(QEvent* event) { + if (Settings::values.mouse_panning) { + const QRect& rect = QWidget::geometry(); + QPoint position = QCursor::pos(); + + qint32 x = qBound(rect.left(), position.x(), rect.right()); + qint32 y = qBound(rect.top(), position.y(), rect.bottom()); + // Only start the timer if the mouse has left the window bound. + // The leave event is also triggered when the window looses focus. + if (x != position.x() || y != position.y()) { + mouse_constrain_timer.start(); + } + event->accept(); + } +} + +int GRenderWindow::QtKeyToSwitchKey(Qt::Key qt_key) { + static constexpr std::array, 106> key_map = { + std::pair{Qt::Key_A, Settings::NativeKeyboard::A}, + {Qt::Key_A, Settings::NativeKeyboard::A}, + {Qt::Key_B, Settings::NativeKeyboard::B}, + {Qt::Key_C, Settings::NativeKeyboard::C}, + {Qt::Key_D, Settings::NativeKeyboard::D}, + {Qt::Key_E, Settings::NativeKeyboard::E}, + {Qt::Key_F, Settings::NativeKeyboard::F}, + {Qt::Key_G, Settings::NativeKeyboard::G}, + {Qt::Key_H, Settings::NativeKeyboard::H}, + {Qt::Key_I, Settings::NativeKeyboard::I}, + {Qt::Key_J, Settings::NativeKeyboard::J}, + {Qt::Key_K, Settings::NativeKeyboard::K}, + {Qt::Key_L, Settings::NativeKeyboard::L}, + {Qt::Key_M, Settings::NativeKeyboard::M}, + {Qt::Key_N, Settings::NativeKeyboard::N}, + {Qt::Key_O, Settings::NativeKeyboard::O}, + {Qt::Key_P, Settings::NativeKeyboard::P}, + {Qt::Key_Q, Settings::NativeKeyboard::Q}, + {Qt::Key_R, Settings::NativeKeyboard::R}, + {Qt::Key_S, Settings::NativeKeyboard::S}, + {Qt::Key_T, Settings::NativeKeyboard::T}, + {Qt::Key_U, Settings::NativeKeyboard::U}, + {Qt::Key_V, Settings::NativeKeyboard::V}, + {Qt::Key_W, Settings::NativeKeyboard::W}, + {Qt::Key_X, Settings::NativeKeyboard::X}, + {Qt::Key_Y, Settings::NativeKeyboard::Y}, + {Qt::Key_Z, Settings::NativeKeyboard::Z}, + {Qt::Key_1, Settings::NativeKeyboard::N1}, + {Qt::Key_2, Settings::NativeKeyboard::N2}, + {Qt::Key_3, Settings::NativeKeyboard::N3}, + {Qt::Key_4, Settings::NativeKeyboard::N4}, + {Qt::Key_5, Settings::NativeKeyboard::N5}, + {Qt::Key_6, Settings::NativeKeyboard::N6}, + {Qt::Key_7, Settings::NativeKeyboard::N7}, + {Qt::Key_8, Settings::NativeKeyboard::N8}, + {Qt::Key_9, Settings::NativeKeyboard::N9}, + {Qt::Key_0, Settings::NativeKeyboard::N0}, + {Qt::Key_Return, Settings::NativeKeyboard::Return}, + {Qt::Key_Escape, Settings::NativeKeyboard::Escape}, + {Qt::Key_Backspace, Settings::NativeKeyboard::Backspace}, + {Qt::Key_Tab, Settings::NativeKeyboard::Tab}, + {Qt::Key_Space, Settings::NativeKeyboard::Space}, + {Qt::Key_Minus, Settings::NativeKeyboard::Minus}, + {Qt::Key_Plus, Settings::NativeKeyboard::Plus}, + {Qt::Key_questiondown, Settings::NativeKeyboard::Plus}, + {Qt::Key_BracketLeft, Settings::NativeKeyboard::OpenBracket}, + {Qt::Key_BraceLeft, Settings::NativeKeyboard::OpenBracket}, + {Qt::Key_BracketRight, Settings::NativeKeyboard::CloseBracket}, + {Qt::Key_BraceRight, Settings::NativeKeyboard::CloseBracket}, + {Qt::Key_Bar, Settings::NativeKeyboard::Pipe}, + {Qt::Key_Dead_Tilde, Settings::NativeKeyboard::Tilde}, + {Qt::Key_Ntilde, Settings::NativeKeyboard::Semicolon}, + {Qt::Key_Semicolon, Settings::NativeKeyboard::Semicolon}, + {Qt::Key_Apostrophe, Settings::NativeKeyboard::Quote}, + {Qt::Key_Dead_Grave, Settings::NativeKeyboard::Backquote}, + {Qt::Key_Comma, Settings::NativeKeyboard::Comma}, + {Qt::Key_Period, Settings::NativeKeyboard::Period}, + {Qt::Key_Slash, Settings::NativeKeyboard::Slash}, + {Qt::Key_CapsLock, Settings::NativeKeyboard::CapsLockKey}, + {Qt::Key_F1, Settings::NativeKeyboard::F1}, + {Qt::Key_F2, Settings::NativeKeyboard::F2}, + {Qt::Key_F3, Settings::NativeKeyboard::F3}, + {Qt::Key_F4, Settings::NativeKeyboard::F4}, + {Qt::Key_F5, Settings::NativeKeyboard::F5}, + {Qt::Key_F6, Settings::NativeKeyboard::F6}, + {Qt::Key_F7, Settings::NativeKeyboard::F7}, + {Qt::Key_F8, Settings::NativeKeyboard::F8}, + {Qt::Key_F9, Settings::NativeKeyboard::F9}, + {Qt::Key_F10, Settings::NativeKeyboard::F10}, + {Qt::Key_F11, Settings::NativeKeyboard::F11}, + {Qt::Key_F12, Settings::NativeKeyboard::F12}, + {Qt::Key_Print, Settings::NativeKeyboard::PrintScreen}, + {Qt::Key_ScrollLock, Settings::NativeKeyboard::ScrollLockKey}, + {Qt::Key_Pause, Settings::NativeKeyboard::Pause}, + {Qt::Key_Insert, Settings::NativeKeyboard::Insert}, + {Qt::Key_Home, Settings::NativeKeyboard::Home}, + {Qt::Key_PageUp, Settings::NativeKeyboard::PageUp}, + {Qt::Key_Delete, Settings::NativeKeyboard::Delete}, + {Qt::Key_End, Settings::NativeKeyboard::End}, + {Qt::Key_PageDown, Settings::NativeKeyboard::PageDown}, + {Qt::Key_Right, Settings::NativeKeyboard::Right}, + {Qt::Key_Left, Settings::NativeKeyboard::Left}, + {Qt::Key_Down, Settings::NativeKeyboard::Down}, + {Qt::Key_Up, Settings::NativeKeyboard::Up}, + {Qt::Key_NumLock, Settings::NativeKeyboard::NumLockKey}, + // Numpad keys are missing here + {Qt::Key_F13, Settings::NativeKeyboard::F13}, + {Qt::Key_F14, Settings::NativeKeyboard::F14}, + {Qt::Key_F15, Settings::NativeKeyboard::F15}, + {Qt::Key_F16, Settings::NativeKeyboard::F16}, + {Qt::Key_F17, Settings::NativeKeyboard::F17}, + {Qt::Key_F18, Settings::NativeKeyboard::F18}, + {Qt::Key_F19, Settings::NativeKeyboard::F19}, + {Qt::Key_F20, Settings::NativeKeyboard::F20}, + {Qt::Key_F21, Settings::NativeKeyboard::F21}, + {Qt::Key_F22, Settings::NativeKeyboard::F22}, + {Qt::Key_F23, Settings::NativeKeyboard::F23}, + {Qt::Key_F24, Settings::NativeKeyboard::F24}, + // {Qt::..., Settings::NativeKeyboard::KPComma}, + // {Qt::..., Settings::NativeKeyboard::Ro}, + {Qt::Key_Hiragana_Katakana, Settings::NativeKeyboard::KatakanaHiragana}, + {Qt::Key_yen, Settings::NativeKeyboard::Yen}, + {Qt::Key_Henkan, Settings::NativeKeyboard::Henkan}, + {Qt::Key_Muhenkan, Settings::NativeKeyboard::Muhenkan}, + // {Qt::..., Settings::NativeKeyboard::NumPadCommaPc98}, + {Qt::Key_Hangul, Settings::NativeKeyboard::HangulEnglish}, + {Qt::Key_Hangul_Hanja, Settings::NativeKeyboard::Hanja}, + {Qt::Key_Katakana, Settings::NativeKeyboard::KatakanaKey}, + {Qt::Key_Hiragana, Settings::NativeKeyboard::HiraganaKey}, + {Qt::Key_Zenkaku_Hankaku, Settings::NativeKeyboard::ZenkakuHankaku}, + // Modifier keys are handled by the modifier property + }; + + for (const auto& [qkey, nkey] : key_map) { + if (qt_key == qkey) { + return nkey; + } + } + + return Settings::NativeKeyboard::None; +} + +int GRenderWindow::QtModifierToSwitchModifier(Qt::KeyboardModifiers qt_modifiers) { + int modifier = 0; + + if ((qt_modifiers & Qt::KeyboardModifier::ShiftModifier) != 0) { + modifier |= 1 << Settings::NativeKeyboard::LeftShift; + } + if ((qt_modifiers & Qt::KeyboardModifier::ControlModifier) != 0) { + modifier |= 1 << Settings::NativeKeyboard::LeftControl; + } + if ((qt_modifiers & Qt::KeyboardModifier::AltModifier) != 0) { + modifier |= 1 << Settings::NativeKeyboard::LeftAlt; + } + if ((qt_modifiers & Qt::KeyboardModifier::MetaModifier) != 0) { + modifier |= 1 << Settings::NativeKeyboard::LeftMeta; + } + + // TODO: These keys can't be obtained with Qt::KeyboardModifier + + // if ((qt_modifiers & 0x10) != 0) { + // modifier |= 1 << Settings::NativeKeyboard::RightShift; + // } + // if ((qt_modifiers & 0x20) != 0) { + // modifier |= 1 << Settings::NativeKeyboard::RightControl; + // } + // if ((qt_modifiers & 0x40) != 0) { + // modifier |= 1 << Settings::NativeKeyboard::RightAlt; + // } + // if ((qt_modifiers & 0x80) != 0) { + // modifier |= 1 << Settings::NativeKeyboard::RightMeta; + // } + // if ((qt_modifiers & 0x100) != 0) { + // modifier |= 1 << Settings::NativeKeyboard::CapsLock; + // } + // if ((qt_modifiers & 0x200) != 0) { + // modifier |= 1 << Settings::NativeKeyboard::NumLock; + // } + // if ((qt_modifiers & ???) != 0) { + // modifier |= 1 << Settings::NativeKeyboard::ScrollLock; + // } + // if ((qt_modifiers & ???) != 0) { + // modifier |= 1 << Settings::NativeKeyboard::Katakana; + // } + // if ((qt_modifiers & ???) != 0) { + // modifier |= 1 << Settings::NativeKeyboard::Hiragana; + // } + return modifier; +} + +void GRenderWindow::keyPressEvent(QKeyEvent* event) { + /** + * This feature can be enhanced with the following functions, but they do not provide + * cross-platform behavior. + * + * event->nativeVirtualKey() can distinguish between keys on the numpad. + * event->nativeModifiers() can distinguish between left and right keys and numlock, + * capslock, scroll lock. + */ + if (!event->isAutoRepeat()) { + const auto modifier = QtModifierToSwitchModifier(event->modifiers()); + const auto key = QtKeyToSwitchKey(Qt::Key(event->key())); + input_subsystem->GetKeyboard()->SetKeyboardModifiers(modifier); + input_subsystem->GetKeyboard()->PressKeyboardKey(key); + // This is used for gamepads that can have any key mapped + input_subsystem->GetKeyboard()->PressKey(event->key()); + } +} + +void GRenderWindow::keyReleaseEvent(QKeyEvent* event) { + /** + * This feature can be enhanced with the following functions, but they do not provide + * cross-platform behavior. + * + * event->nativeVirtualKey() can distinguish between keys on the numpad. + * event->nativeModifiers() can distinguish between left and right buttons and numlock, + * capslock, scroll lock. + */ + if (!event->isAutoRepeat()) { + const auto modifier = QtModifierToSwitchModifier(event->modifiers()); + const auto key = QtKeyToSwitchKey(Qt::Key(event->key())); + input_subsystem->GetKeyboard()->SetKeyboardModifiers(modifier); + input_subsystem->GetKeyboard()->ReleaseKeyboardKey(key); + // This is used for gamepads that can have any key mapped + input_subsystem->GetKeyboard()->ReleaseKey(event->key()); + } +} + +InputCommon::MouseButton GRenderWindow::QtButtonToMouseButton(Qt::MouseButton button) { + switch (button) { + case Qt::LeftButton: + return InputCommon::MouseButton::Left; + case Qt::RightButton: + return InputCommon::MouseButton::Right; + case Qt::MiddleButton: + return InputCommon::MouseButton::Wheel; + case Qt::BackButton: + return InputCommon::MouseButton::Backward; + case Qt::ForwardButton: + return InputCommon::MouseButton::Forward; + case Qt::TaskButton: + return InputCommon::MouseButton::Task; + default: + return InputCommon::MouseButton::Extra; + } +} + +void GRenderWindow::mousePressEvent(QMouseEvent* event) { + // Touch input is handled in TouchBeginEvent + if (event->source() == Qt::MouseEventSynthesizedBySystem) { + return; + } + // Qt sometimes returns the parent coordinates. To avoid this we read the global mouse + // coordinates and map them to the current render area + const auto pos = mapFromGlobal(QCursor::pos()); + const auto [x, y] = ScaleTouch(pos); + const auto [touch_x, touch_y] = MapToTouchScreen(x, y); + const auto button = QtButtonToMouseButton(event->button()); + + input_subsystem->GetMouse()->PressMouseButton(button); + input_subsystem->GetMouse()->PressButton(pos.x(), pos.y(), button); + input_subsystem->GetMouse()->PressTouchButton(touch_x, touch_y, button); + + emit MouseActivity(); +} + +void GRenderWindow::mouseMoveEvent(QMouseEvent* event) { + // Touch input is handled in TouchUpdateEvent + if (event->source() == Qt::MouseEventSynthesizedBySystem) { + return; + } + // Qt sometimes returns the parent coordinates. To avoid this we read the global mouse + // coordinates and map them to the current render area + const auto pos = mapFromGlobal(QCursor::pos()); + const auto [x, y] = ScaleTouch(pos); + const auto [touch_x, touch_y] = MapToTouchScreen(x, y); + const int center_x = width() / 2; + const int center_y = height() / 2; + + input_subsystem->GetMouse()->MouseMove(touch_x, touch_y); + input_subsystem->GetMouse()->TouchMove(touch_x, touch_y); + input_subsystem->GetMouse()->Move(pos.x(), pos.y(), center_x, center_y); + + // Center mouse for mouse panning + if (Settings::values.mouse_panning && !Settings::values.mouse_enabled) { + QCursor::setPos(mapToGlobal(QPoint{center_x, center_y})); + } + + // Constrain mouse for mouse emulation with mouse panning + if (Settings::values.mouse_panning && Settings::values.mouse_enabled) { + const auto [clamped_mouse_x, clamped_mouse_y] = ClipToTouchScreen(x, y); + QCursor::setPos(mapToGlobal( + QPoint{static_cast(clamped_mouse_x), static_cast(clamped_mouse_y)})); + } + + mouse_constrain_timer.stop(); + emit MouseActivity(); +} + +void GRenderWindow::mouseReleaseEvent(QMouseEvent* event) { + // Touch input is handled in TouchEndEvent + if (event->source() == Qt::MouseEventSynthesizedBySystem) { + return; + } + + const auto button = QtButtonToMouseButton(event->button()); + input_subsystem->GetMouse()->ReleaseButton(button); +} + +void GRenderWindow::ConstrainMouse() { + if (emu_thread == nullptr || !Settings::values.mouse_panning) { + mouse_constrain_timer.stop(); + return; + } + if (!this->isActiveWindow()) { + mouse_constrain_timer.stop(); + return; + } + + if (Settings::values.mouse_enabled) { + const auto pos = mapFromGlobal(QCursor::pos()); + const int new_pos_x = std::clamp(pos.x(), 0, width()); + const int new_pos_y = std::clamp(pos.y(), 0, height()); + + QCursor::setPos(mapToGlobal(QPoint{new_pos_x, new_pos_y})); + return; + } + + const int center_x = width() / 2; + const int center_y = height() / 2; + + QCursor::setPos(mapToGlobal(QPoint{center_x, center_y})); +} + +void GRenderWindow::wheelEvent(QWheelEvent* event) { + const int x = event->angleDelta().x(); + const int y = event->angleDelta().y(); + input_subsystem->GetMouse()->MouseWheelChange(x, y); +} + +void GRenderWindow::TouchBeginEvent(const QTouchEvent* event) { + QList touch_points = event->touchPoints(); + for (const auto& touch_point : touch_points) { + const auto [x, y] = ScaleTouch(touch_point.pos()); + const auto [touch_x, touch_y] = MapToTouchScreen(x, y); + input_subsystem->GetTouchScreen()->TouchPressed(touch_x, touch_y, touch_point.id()); + } +} + +void GRenderWindow::TouchUpdateEvent(const QTouchEvent* event) { + QList touch_points = event->touchPoints(); + input_subsystem->GetTouchScreen()->ClearActiveFlag(); + for (const auto& touch_point : touch_points) { + const auto [x, y] = ScaleTouch(touch_point.pos()); + const auto [touch_x, touch_y] = MapToTouchScreen(x, y); + input_subsystem->GetTouchScreen()->TouchMoved(touch_x, touch_y, touch_point.id()); + } + input_subsystem->GetTouchScreen()->ReleaseInactiveTouch(); +} + +void GRenderWindow::TouchEndEvent() { + input_subsystem->GetTouchScreen()->ReleaseAllTouch(); +} + +void GRenderWindow::InitializeCamera() { +#if (QT_VERSION < QT_VERSION_CHECK(6, 0, 0)) && SUDACHI_USE_QT_MULTIMEDIA + constexpr auto camera_update_ms = std::chrono::milliseconds{50}; // (50ms, 20Hz) + if (!Settings::values.enable_ir_sensor) { + return; + } + + bool camera_found = false; + const QList cameras = QCameraInfo::availableCameras(); + for (const QCameraInfo& cameraInfo : cameras) { + if (Settings::values.ir_sensor_device.GetValue() == cameraInfo.deviceName().toStdString() || + Settings::values.ir_sensor_device.GetValue() == "Auto") { + camera = std::make_unique(cameraInfo); + if (!camera->isCaptureModeSupported(QCamera::CaptureMode::CaptureViewfinder) && + !camera->isCaptureModeSupported(QCamera::CaptureMode::CaptureStillImage)) { + LOG_ERROR(Frontend, + "Camera doesn't support CaptureViewfinder or CaptureStillImage"); + continue; + } + camera_found = true; + break; + } + } + + if (!camera_found) { + return; + } + + camera_capture = std::make_unique(camera.get()); + + if (!camera_capture->isCaptureDestinationSupported( + QCameraImageCapture::CaptureDestination::CaptureToBuffer)) { + LOG_ERROR(Frontend, "Camera doesn't support saving to buffer"); + return; + } + + const auto camera_width = input_subsystem->GetCamera()->getImageWidth(); + const auto camera_height = input_subsystem->GetCamera()->getImageHeight(); + camera_data.resize(camera_width * camera_height); + camera_capture->setCaptureDestination(QCameraImageCapture::CaptureDestination::CaptureToBuffer); + connect(camera_capture.get(), &QCameraImageCapture::imageCaptured, this, + &GRenderWindow::OnCameraCapture); + camera->unload(); + if (camera->isCaptureModeSupported(QCamera::CaptureMode::CaptureViewfinder)) { + camera->setCaptureMode(QCamera::CaptureViewfinder); + } else if (camera->isCaptureModeSupported(QCamera::CaptureMode::CaptureStillImage)) { + camera->setCaptureMode(QCamera::CaptureStillImage); + } + camera->load(); + camera->start(); + + pending_camera_snapshots = 0; + is_virtual_camera = false; + + camera_timer = std::make_unique(); + connect(camera_timer.get(), &QTimer::timeout, [this] { RequestCameraCapture(); }); + // This timer should be dependent of camera resolution 5ms for every 100 pixels + camera_timer->start(camera_update_ms); +#endif +} + +void GRenderWindow::FinalizeCamera() { +#if (QT_VERSION < QT_VERSION_CHECK(6, 0, 0)) && SUDACHI_USE_QT_MULTIMEDIA + if (camera_timer) { + camera_timer->stop(); + } + if (camera) { + camera->unload(); + } +#endif +} + +void GRenderWindow::RequestCameraCapture() { +#if (QT_VERSION < QT_VERSION_CHECK(6, 0, 0)) && SUDACHI_USE_QT_MULTIMEDIA + if (!Settings::values.enable_ir_sensor) { + return; + } + + // If the camera doesn't capture, test for virtual cameras + if (pending_camera_snapshots > 5) { + is_virtual_camera = true; + } + // Virtual cameras like obs need to reset the camera every capture + if (is_virtual_camera) { + camera->stop(); + camera->start(); + } + + pending_camera_snapshots++; + camera_capture->capture(); +#endif +} + +void GRenderWindow::OnCameraCapture(int requestId, const QImage& img) { +#if (QT_VERSION < QT_VERSION_CHECK(6, 0, 0)) && SUDACHI_USE_QT_MULTIMEDIA + // TODO: Capture directly in the format and resolution needed + const auto camera_width = input_subsystem->GetCamera()->getImageWidth(); + const auto camera_height = input_subsystem->GetCamera()->getImageHeight(); + const auto converted = + img.scaled(static_cast(camera_width), static_cast(camera_height), + Qt::AspectRatioMode::IgnoreAspectRatio, + Qt::TransformationMode::SmoothTransformation) + .mirrored(false, true); + if (camera_data.size() != camera_width * camera_height) { + camera_data.resize(camera_width * camera_height); + } + std::memcpy(camera_data.data(), converted.bits(), camera_width * camera_height * sizeof(u32)); + input_subsystem->GetCamera()->SetCameraData(camera_width, camera_height, camera_data); + pending_camera_snapshots = 0; +#endif +} + +bool GRenderWindow::event(QEvent* event) { + if (event->type() == QEvent::TouchBegin) { + TouchBeginEvent(static_cast(event)); + return true; + } else if (event->type() == QEvent::TouchUpdate) { + TouchUpdateEvent(static_cast(event)); + return true; + } else if (event->type() == QEvent::TouchEnd || event->type() == QEvent::TouchCancel) { + TouchEndEvent(); + return true; + } + + return QWidget::event(event); +} + +void GRenderWindow::focusOutEvent(QFocusEvent* event) { + QWidget::focusOutEvent(event); + input_subsystem->GetKeyboard()->ReleaseAllKeys(); + input_subsystem->GetMouse()->ReleaseAllButtons(); + input_subsystem->GetTouchScreen()->ReleaseAllTouch(); +} + +void GRenderWindow::resizeEvent(QResizeEvent* event) { + QWidget::resizeEvent(event); + OnFramebufferSizeChanged(); +} + +std::unique_ptr GRenderWindow::CreateSharedContext() const { +#ifdef HAS_OPENGL + if (Settings::values.renderer_backend.GetValue() == Settings::RendererBackend::OpenGL) { + auto c = static_cast(main_context.get()); + // Bind the shared contexts to the main surface in case the backend wants to take over + // presentation + return std::make_unique(c->GetShareContext(), + child_widget->windowHandle()); + } +#endif + return std::make_unique(); +} + +bool GRenderWindow::InitRenderTarget() { + ReleaseRenderTarget(); + + { + // Create a dummy render widget so that Qt + // places the render window at the correct position. + const RenderWidget dummy_widget{this}; + } + + first_frame = false; + + switch (Settings::values.renderer_backend.GetValue()) { + case Settings::RendererBackend::OpenGL: + if (!InitializeOpenGL()) { + return false; + } + break; + case Settings::RendererBackend::Vulkan: + if (!InitializeVulkan()) { + return false; + } + break; + case Settings::RendererBackend::Null: + InitializeNull(); + break; + } + + // Update the Window System information with the new render target + window_info = QtCommon::GetWindowSystemInfo(child_widget->windowHandle()); + + child_widget->resize(Layout::ScreenUndocked::Width, Layout::ScreenUndocked::Height); + layout()->addWidget(child_widget); + // Reset minimum required size to avoid resizing issues on the main window after restarting. + setMinimumSize(1, 1); + + resize(Layout::ScreenUndocked::Width, Layout::ScreenUndocked::Height); + + OnMinimalClientAreaChangeRequest(GetActiveConfig().min_client_area_size); + OnFramebufferSizeChanged(); + BackupGeometry(); + + if (Settings::values.renderer_backend.GetValue() == Settings::RendererBackend::OpenGL) { + if (!LoadOpenGL()) { + return false; + } + } + + return true; +} + +void GRenderWindow::ReleaseRenderTarget() { + if (child_widget) { + layout()->removeWidget(child_widget); + child_widget->deleteLater(); + child_widget = nullptr; + } + main_context.reset(); +} + +void GRenderWindow::CaptureScreenshot(const QString& screenshot_path) { + auto& renderer = system.Renderer(); + + if (renderer.IsScreenshotPending()) { + LOG_WARNING(Render, + "A screenshot is already requested or in progress, ignoring the request"); + return; + } + + const Layout::FramebufferLayout layout{[]() { + u32 height = UISettings::values.screenshot_height.GetValue(); + if (height == 0) { + height = Settings::IsDockedMode() ? Layout::ScreenDocked::Height + : Layout::ScreenUndocked::Height; + height *= Settings::values.resolution_info.up_factor; + } + const u32 width = + UISettings::CalculateWidth(height, Settings::values.aspect_ratio.GetValue()); + return Layout::DefaultFrameLayout(width, height); + }()}; + + screenshot_image = QImage(QSize(layout.width, layout.height), QImage::Format_RGB32); + renderer.RequestScreenshot( + screenshot_image.bits(), + [=, this](bool invert_y) { + const std::string std_screenshot_path = screenshot_path.toStdString(); + if (screenshot_image.mirrored(false, invert_y).save(screenshot_path)) { + LOG_INFO(Frontend, "Screenshot saved to \"{}\"", std_screenshot_path); + } else { + LOG_ERROR(Frontend, "Failed to save screenshot to \"{}\"", std_screenshot_path); + } + }, + layout); +} + +bool GRenderWindow::IsLoadingComplete() const { + return first_frame; +} + +void GRenderWindow::OnMinimalClientAreaChangeRequest(std::pair minimal_size) { + setMinimumSize(minimal_size.first, minimal_size.second); +} + +bool GRenderWindow::InitializeOpenGL() { +#ifdef HAS_OPENGL + if (!QOpenGLContext::supportsThreadedOpenGL()) { + QMessageBox::warning(this, tr("OpenGL not available!"), + tr("OpenGL shared contexts are not supported.")); + return false; + } + + // TODO: One of these flags might be interesting: WA_OpaquePaintEvent, WA_NoBackground, + // WA_DontShowOnScreen, WA_DeleteOnClose + auto child = new OpenGLRenderWidget(this); + child_widget = child; + child_widget->windowHandle()->create(); + auto context = std::make_shared(child->windowHandle()); + main_context = context; + child->SetContext( + std::make_unique(context->GetShareContext(), child->windowHandle())); + + return true; +#else + QMessageBox::warning(this, tr("OpenGL not available!"), + tr("sudachi has not been compiled with OpenGL support.")); + return false; +#endif +} + +bool GRenderWindow::InitializeVulkan() { + auto child = new VulkanRenderWidget(this); + child_widget = child; + child_widget->windowHandle()->create(); + main_context = std::make_unique(); + + return true; +} + +void GRenderWindow::InitializeNull() { + child_widget = new NullRenderWidget(this); + main_context = std::make_unique(); +} + +bool GRenderWindow::LoadOpenGL() { + auto context = CreateSharedContext(); + auto scope = context->Acquire(); + if (!gladLoadGL()) { + QMessageBox::warning( + this, tr("Error while initializing OpenGL!"), + tr("Your GPU may not support OpenGL, or you do not have the latest graphics driver.")); + return false; + } + + const QString renderer = + QString::fromUtf8(reinterpret_cast(glGetString(GL_RENDERER))); + + if (!GLAD_GL_VERSION_4_6) { + LOG_ERROR(Frontend, "GPU does not support OpenGL 4.6: {}", renderer.toStdString()); + QMessageBox::warning(this, tr("Error while initializing OpenGL 4.6!"), + tr("Your GPU may not support OpenGL 4.6, or you do not have the " + "latest graphics driver.

GL Renderer:
%1") + .arg(renderer)); + return false; + } + + QStringList unsupported_gl_extensions = GetUnsupportedGLExtensions(); + if (!unsupported_gl_extensions.empty()) { + QMessageBox::warning( + this, tr("Error while initializing OpenGL!"), + tr("Your GPU may not support one or more required OpenGL extensions. Please ensure you " + "have the latest graphics driver.

GL Renderer:
%1

Unsupported " + "extensions:
%2") + .arg(renderer) + .arg(unsupported_gl_extensions.join(QStringLiteral("
")))); + return false; + } + return true; +} + +QStringList GRenderWindow::GetUnsupportedGLExtensions() const { + QStringList unsupported_ext; + + // Extensions required to support some texture formats. + if (!GLAD_GL_EXT_texture_compression_s3tc) { + unsupported_ext.append(QStringLiteral("EXT_texture_compression_s3tc")); + } + if (!GLAD_GL_ARB_texture_compression_rgtc) { + unsupported_ext.append(QStringLiteral("ARB_texture_compression_rgtc")); + } + + if (!unsupported_ext.empty()) { + const std::string gl_renderer{reinterpret_cast(glGetString(GL_RENDERER))}; + LOG_ERROR(Frontend, "GPU does not support all required extensions: {}", gl_renderer); + } + for (const QString& ext : unsupported_ext) { + LOG_ERROR(Frontend, "Unsupported GL extension: {}", ext.toStdString()); + } + + return unsupported_ext; +} + +void GRenderWindow::OnEmulationStarting(EmuThread* emu_thread_) { + emu_thread = emu_thread_; +} + +void GRenderWindow::OnEmulationStopping() { + emu_thread = nullptr; +} + +void GRenderWindow::showEvent(QShowEvent* event) { + QWidget::showEvent(event); + + // windowHandle() is not initialized until the Window is shown, so we connect it here. + connect(windowHandle(), &QWindow::screenChanged, this, &GRenderWindow::OnFramebufferSizeChanged, + Qt::UniqueConnection); +} + +bool GRenderWindow::eventFilter(QObject* object, QEvent* event) { + if (event->type() == QEvent::HoverMove) { + if (Settings::values.mouse_panning || Settings::values.mouse_enabled) { + auto* hover_event = static_cast(event); + mouseMoveEvent(hover_event); + return false; + } + emit MouseActivity(); + } + return false; +} diff --git a/src/sudachi/bootmanager.h b/src/sudachi/bootmanager.h new file mode 100644 index 0000000..52fcb47 --- /dev/null +++ b/src/sudachi/bootmanager.h @@ -0,0 +1,280 @@ +// SPDX-FileCopyrightText: 2014 Citra Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "common/common_types.h" +#include "common/logging/log.h" +#include "common/polyfill_thread.h" +#include "common/thread.h" +#include "core/frontend/emu_window.h" + +class GMainWindow; +class QCamera; +class QCameraImageCapture; +class QCloseEvent; +class QFocusEvent; +class QKeyEvent; +class QMouseEvent; +class QObject; +class QResizeEvent; +class QShowEvent; +class QTouchEvent; +class QWheelEvent; + +namespace Core { +class System; +} // namespace Core + +namespace InputCommon { +class InputSubsystem; +enum class MouseButton; +} // namespace InputCommon + +namespace InputCommon::TasInput { +enum class TasState; +} // namespace InputCommon::TasInput + +namespace VideoCore { +enum class LoadCallbackStage; +} // namespace VideoCore + +class EmuThread final : public QThread { + Q_OBJECT + +public: + explicit EmuThread(Core::System& system); + ~EmuThread() override; + + /** + * Start emulation (on new thread) + * @warning Only call when not running! + */ + void run() override; + + /** + * Sets whether the emulation thread should run or not + * @param should_run Boolean value, set the emulation thread to running if true + */ + void SetRunning(bool should_run) { + // TODO: Prevent other threads from modifying the state until we finish. + { + // Notify the running thread to change state. + std::unique_lock run_lk{m_should_run_mutex}; + m_should_run = should_run; + m_should_run_cv.notify_one(); + } + + // Wait until paused, if pausing. + if (!should_run) { + m_stopped.Wait(); + } + } + + /** + * Check if the emulation thread is running or not + * @return True if the emulation thread is running, otherwise false + */ + bool IsRunning() const { + return m_should_run; + } + + /** + * Requests for the emulation thread to immediately stop running + */ + void ForceStop() { + LOG_WARNING(Frontend, "Force stopping EmuThread"); + m_stop_source.request_stop(); + } + +private: + void EmulationPaused(std::unique_lock& lk); + void EmulationResumed(std::unique_lock& lk); + +private: + Core::System& m_system; + + std::stop_source m_stop_source; + std::mutex m_should_run_mutex; + std::condition_variable_any m_should_run_cv; + Common::Event m_stopped; + bool m_should_run{true}; + +signals: + /** + * Emitted when the CPU has halted execution + * + * @warning When connecting to this signal from other threads, make sure to specify either + * Qt::QueuedConnection (invoke slot within the destination object's message thread) or even + * Qt::BlockingQueuedConnection (additionally block source thread until slot returns) + */ + void DebugModeEntered(); + + /** + * Emitted right before the CPU continues execution + * + * @warning When connecting to this signal from other threads, make sure to specify either + * Qt::QueuedConnection (invoke slot within the destination object's message thread) or even + * Qt::BlockingQueuedConnection (additionally block source thread until slot returns) + */ + void DebugModeLeft(); + + void LoadProgress(VideoCore::LoadCallbackStage stage, std::size_t value, std::size_t total); +}; + +class GRenderWindow : public QWidget, public Core::Frontend::EmuWindow { + Q_OBJECT + +public: + explicit GRenderWindow(GMainWindow* parent, EmuThread* emu_thread_, + std::shared_ptr input_subsystem_, + Core::System& system_); + ~GRenderWindow() override; + + // EmuWindow implementation. + void OnFrameDisplayed() override; + bool IsShown() const override; + std::unique_ptr CreateSharedContext() const override; + + void BackupGeometry(); + void RestoreGeometry(); + void restoreGeometry(const QByteArray& geometry_); // overridden + QByteArray saveGeometry(); // overridden + + qreal windowPixelRatio() const; + + std::pair ScaleTouch(const QPointF& pos) const; + + void closeEvent(QCloseEvent* event) override; + void leaveEvent(QEvent* event) override; + + void resizeEvent(QResizeEvent* event) override; + + /// Converts a Qt keyboard key into NativeKeyboard key + static int QtKeyToSwitchKey(Qt::Key qt_keys); + + /// Converts a Qt modifier keys into NativeKeyboard modifier keys + static int QtModifierToSwitchModifier(Qt::KeyboardModifiers qt_modifiers); + + void keyPressEvent(QKeyEvent* event) override; + void keyReleaseEvent(QKeyEvent* event) override; + + /// Converts a Qt mouse button into MouseInput mouse button + static InputCommon::MouseButton QtButtonToMouseButton(Qt::MouseButton button); + + void mousePressEvent(QMouseEvent* event) override; + void mouseMoveEvent(QMouseEvent* event) override; + void mouseReleaseEvent(QMouseEvent* event) override; + void wheelEvent(QWheelEvent* event) override; + + void InitializeCamera(); + void FinalizeCamera(); + + bool event(QEvent* event) override; + + void focusOutEvent(QFocusEvent* event) override; + + bool InitRenderTarget(); + + /// Destroy the previous run's child_widget which should also destroy the child_window + void ReleaseRenderTarget(); + + bool IsLoadingComplete() const; + + void CaptureScreenshot(const QString& screenshot_path); + + /** + * Instructs the window to re-launch the application using the specified program_index. + * @param program_index Specifies the index within the application of the program to launch. + */ + void ExecuteProgram(std::size_t program_index); + + /// Instructs the window to exit the application. + void Exit(); + +public slots: + void OnEmulationStarting(EmuThread* emu_thread_); + void OnEmulationStopping(); + void OnFramebufferSizeChanged(); + +signals: + /// Emitted when the window is closed + void Closed(); + void FirstFrameDisplayed(); + void ExecuteProgramSignal(std::size_t program_index); + void ExitSignal(); + void MouseActivity(); + void TasPlaybackStateChanged(); + +private: + void TouchBeginEvent(const QTouchEvent* event); + void TouchUpdateEvent(const QTouchEvent* event); + void TouchEndEvent(); + void ConstrainMouse(); + + void RequestCameraCapture(); + void OnCameraCapture(int requestId, const QImage& img); + + void OnMinimalClientAreaChangeRequest(std::pair minimal_size) override; + + bool InitializeOpenGL(); + bool InitializeVulkan(); + void InitializeNull(); + bool LoadOpenGL(); + QStringList GetUnsupportedGLExtensions() const; + + EmuThread* emu_thread; + std::shared_ptr input_subsystem; + + // Main context that will be shared with all other contexts that are requested. + // If this is used in a shared context setting, then this should not be used directly, but + // should instead be shared from + std::shared_ptr main_context; + + /// Temporary storage of the screenshot taken + QImage screenshot_image; + + QByteArray geometry; + + QWidget* child_widget = nullptr; + + bool first_frame = false; + InputCommon::TasInput::TasState last_tas_state; + +#if (QT_VERSION < QT_VERSION_CHECK(6, 0, 0)) && SUDACHI_USE_QT_MULTIMEDIA + bool is_virtual_camera; + int pending_camera_snapshots; + std::vector camera_data; + std::unique_ptr camera; + std::unique_ptr camera_capture; + std::unique_ptr camera_timer; +#endif + + QTimer mouse_constrain_timer; + + Core::System& system; + +protected: + void showEvent(QShowEvent* event) override; + bool eventFilter(QObject* object, QEvent* event) override; +}; diff --git a/src/sudachi/breakpad.cpp b/src/sudachi/breakpad.cpp new file mode 100644 index 0000000..7b6ed0c --- /dev/null +++ b/src/sudachi/breakpad.cpp @@ -0,0 +1,77 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include +#include + +#if defined(_WIN32) +#include +#elif defined(__linux__) +#include +#else +#error Minidump creation not supported on this platform +#endif + +#include "common/fs/fs_paths.h" +#include "common/fs/path_util.h" +#include "sudachi/breakpad.h" + +namespace Breakpad { + +static void PruneDumpDirectory(const std::filesystem::path& dump_path) { + // Code in this function should be exception-safe. + struct Entry { + std::filesystem::path path; + std::filesystem::file_time_type last_write_time; + }; + std::vector existing_dumps; + + // Get existing entries. + std::error_code ec; + std::filesystem::directory_iterator dir(dump_path, ec); + for (auto& entry : dir) { + if (entry.is_regular_file()) { + existing_dumps.push_back(Entry{ + .path = entry.path(), + .last_write_time = entry.last_write_time(ec), + }); + } + } + + // Sort descending by creation date. + std::ranges::stable_sort(existing_dumps, [](const auto& a, const auto& b) { + return a.last_write_time > b.last_write_time; + }); + + // Delete older dumps. + for (size_t i = 5; i < existing_dumps.size(); i++) { + std::filesystem::remove(existing_dumps[i].path, ec); + } +} + +#if defined(__linux__) +[[noreturn]] bool DumpCallback(const google_breakpad::MinidumpDescriptor& descriptor, void* context, + bool succeeded) { + // Prevent time- and space-consuming core dumps from being generated, as we have + // already generated a minidump and a core file will not be useful anyway. + _exit(1); +} +#endif + +void InstallCrashHandler() { + // Write crash dumps to profile directory. + const auto dump_path = GetSudachiPath(Common::FS::SudachiPath::CrashDumpsDir); + PruneDumpDirectory(dump_path); + +#if defined(_WIN32) + // TODO: If we switch to MinGW builds for Windows, this needs to be wrapped in a C API. + static google_breakpad::ExceptionHandler eh{dump_path, nullptr, nullptr, nullptr, + google_breakpad::ExceptionHandler::HANDLER_ALL}; +#elif defined(__linux__) + static google_breakpad::MinidumpDescriptor descriptor{dump_path}; + static google_breakpad::ExceptionHandler eh{descriptor, nullptr, DumpCallback, + nullptr, true, -1}; +#endif +} + +} // namespace Breakpad diff --git a/src/sudachi/breakpad.h b/src/sudachi/breakpad.h new file mode 100644 index 0000000..0e20751 --- /dev/null +++ b/src/sudachi/breakpad.h @@ -0,0 +1,10 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +namespace Breakpad { + +void InstallCrashHandler(); + +} diff --git a/src/sudachi/compatdb.cpp b/src/sudachi/compatdb.cpp new file mode 100644 index 0000000..5062c07 --- /dev/null +++ b/src/sudachi/compatdb.cpp @@ -0,0 +1,210 @@ +// SPDX-FileCopyrightText: 2017 Citra Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include +#include +#include +#include +#include "common/logging/log.h" +#include "common/telemetry.h" +#include "core/telemetry_session.h" +#include "ui_compatdb.h" +#include "sudachi/compatdb.h" + +CompatDB::CompatDB(Core::TelemetrySession& telemetry_session_, QWidget* parent) + : QWizard(parent, Qt::WindowTitleHint | Qt::WindowCloseButtonHint | Qt::WindowSystemMenuHint), + ui{std::make_unique()}, telemetry_session{telemetry_session_} { + ui->setupUi(this); + + connect(ui->radioButton_GameBoot_Yes, &QRadioButton::clicked, this, &CompatDB::EnableNext); + connect(ui->radioButton_GameBoot_No, &QRadioButton::clicked, this, &CompatDB::EnableNext); + connect(ui->radioButton_Gameplay_Yes, &QRadioButton::clicked, this, &CompatDB::EnableNext); + connect(ui->radioButton_Gameplay_No, &QRadioButton::clicked, this, &CompatDB::EnableNext); + connect(ui->radioButton_NoFreeze_Yes, &QRadioButton::clicked, this, &CompatDB::EnableNext); + connect(ui->radioButton_NoFreeze_No, &QRadioButton::clicked, this, &CompatDB::EnableNext); + connect(ui->radioButton_Complete_Yes, &QRadioButton::clicked, this, &CompatDB::EnableNext); + connect(ui->radioButton_Complete_No, &QRadioButton::clicked, this, &CompatDB::EnableNext); + connect(ui->radioButton_Graphical_Major, &QRadioButton::clicked, this, &CompatDB::EnableNext); + connect(ui->radioButton_Graphical_Minor, &QRadioButton::clicked, this, &CompatDB::EnableNext); + connect(ui->radioButton_Graphical_No, &QRadioButton::clicked, this, &CompatDB::EnableNext); + connect(ui->radioButton_Audio_Major, &QRadioButton::clicked, this, &CompatDB::EnableNext); + connect(ui->radioButton_Audio_Minor, &QRadioButton::clicked, this, &CompatDB::EnableNext); + connect(ui->radioButton_Audio_No, &QRadioButton::clicked, this, &CompatDB::EnableNext); + + connect(button(NextButton), &QPushButton::clicked, this, &CompatDB::Submit); + connect(&testcase_watcher, &QFutureWatcher::finished, this, + &CompatDB::OnTestcaseSubmitted); +} + +CompatDB::~CompatDB() = default; + +enum class CompatDBPage { + Intro = 0, + GameBoot = 1, + GamePlay = 2, + Freeze = 3, + Completion = 4, + Graphical = 5, + Audio = 6, + Final = 7, +}; + +void CompatDB::Submit() { + QButtonGroup* compatibility_GameBoot = new QButtonGroup(this); + compatibility_GameBoot->addButton(ui->radioButton_GameBoot_Yes, 0); + compatibility_GameBoot->addButton(ui->radioButton_GameBoot_No, 1); + + QButtonGroup* compatibility_Gameplay = new QButtonGroup(this); + compatibility_Gameplay->addButton(ui->radioButton_Gameplay_Yes, 0); + compatibility_Gameplay->addButton(ui->radioButton_Gameplay_No, 1); + + QButtonGroup* compatibility_NoFreeze = new QButtonGroup(this); + compatibility_NoFreeze->addButton(ui->radioButton_NoFreeze_Yes, 0); + compatibility_NoFreeze->addButton(ui->radioButton_NoFreeze_No, 1); + + QButtonGroup* compatibility_Complete = new QButtonGroup(this); + compatibility_Complete->addButton(ui->radioButton_Complete_Yes, 0); + compatibility_Complete->addButton(ui->radioButton_Complete_No, 1); + + QButtonGroup* compatibility_Graphical = new QButtonGroup(this); + compatibility_Graphical->addButton(ui->radioButton_Graphical_Major, 0); + compatibility_Graphical->addButton(ui->radioButton_Graphical_Minor, 1); + compatibility_Graphical->addButton(ui->radioButton_Graphical_No, 2); + + QButtonGroup* compatibility_Audio = new QButtonGroup(this); + compatibility_Audio->addButton(ui->radioButton_Audio_Major, 0); + compatibility_Graphical->addButton(ui->radioButton_Audio_Minor, 1); + compatibility_Audio->addButton(ui->radioButton_Audio_No, 2); + + const int compatibility = static_cast(CalculateCompatibility()); + + switch ((static_cast(currentId()))) { + case CompatDBPage::Intro: + break; + case CompatDBPage::GameBoot: + if (compatibility_GameBoot->checkedId() == -1) { + button(NextButton)->setEnabled(false); + } + break; + case CompatDBPage::GamePlay: + if (compatibility_Gameplay->checkedId() == -1) { + button(NextButton)->setEnabled(false); + } + break; + case CompatDBPage::Freeze: + if (compatibility_NoFreeze->checkedId() == -1) { + button(NextButton)->setEnabled(false); + } + break; + case CompatDBPage::Completion: + if (compatibility_Complete->checkedId() == -1) { + button(NextButton)->setEnabled(false); + } + break; + case CompatDBPage::Graphical: + if (compatibility_Graphical->checkedId() == -1) { + button(NextButton)->setEnabled(false); + } + break; + case CompatDBPage::Audio: + if (compatibility_Audio->checkedId() == -1) { + button(NextButton)->setEnabled(false); + } + break; + case CompatDBPage::Final: + back(); + LOG_INFO(Frontend, "Compatibility Rating: {}", compatibility); + telemetry_session.AddField(Common::Telemetry::FieldType::UserFeedback, "Compatibility", + compatibility); + + button(NextButton)->setEnabled(false); + button(NextButton)->setText(tr("Submitting")); + button(CancelButton)->setVisible(false); + + testcase_watcher.setFuture( + QtConcurrent::run([this] { return telemetry_session.SubmitTestcase(); })); + break; + default: + LOG_ERROR(Frontend, "Unexpected page: {}", currentId()); + break; + } +} + +int CompatDB::nextId() const { + switch ((static_cast(currentId()))) { + case CompatDBPage::Intro: + return static_cast(CompatDBPage::GameBoot); + case CompatDBPage::GameBoot: + if (ui->radioButton_GameBoot_No->isChecked()) { + return static_cast(CompatDBPage::Final); + } + return static_cast(CompatDBPage::GamePlay); + case CompatDBPage::GamePlay: + if (ui->radioButton_Gameplay_No->isChecked()) { + return static_cast(CompatDBPage::Final); + } + return static_cast(CompatDBPage::Freeze); + case CompatDBPage::Freeze: + if (ui->radioButton_NoFreeze_No->isChecked()) { + return static_cast(CompatDBPage::Final); + } + return static_cast(CompatDBPage::Completion); + case CompatDBPage::Completion: + if (ui->radioButton_Complete_No->isChecked()) { + return static_cast(CompatDBPage::Final); + } + return static_cast(CompatDBPage::Graphical); + case CompatDBPage::Graphical: + return static_cast(CompatDBPage::Audio); + case CompatDBPage::Audio: + return static_cast(CompatDBPage::Final); + case CompatDBPage::Final: + return -1; + default: + LOG_ERROR(Frontend, "Unexpected page: {}", currentId()); + return static_cast(CompatDBPage::Intro); + } +} + +CompatibilityStatus CompatDB::CalculateCompatibility() const { + if (ui->radioButton_GameBoot_No->isChecked()) { + return CompatibilityStatus::WontBoot; + } + + if (ui->radioButton_Gameplay_No->isChecked()) { + return CompatibilityStatus::IntroMenu; + } + + if (ui->radioButton_NoFreeze_No->isChecked() || ui->radioButton_Complete_No->isChecked()) { + return CompatibilityStatus::Ingame; + } + + if (ui->radioButton_Graphical_Major->isChecked() || ui->radioButton_Audio_Major->isChecked()) { + return CompatibilityStatus::Ingame; + } + + if (ui->radioButton_Graphical_Minor->isChecked() || ui->radioButton_Audio_Minor->isChecked()) { + return CompatibilityStatus::Playable; + } + + return CompatibilityStatus::Perfect; +} + +void CompatDB::OnTestcaseSubmitted() { + if (!testcase_watcher.result()) { + QMessageBox::critical(this, tr("Communication error"), + tr("An error occurred while sending the Testcase")); + button(NextButton)->setEnabled(true); + button(NextButton)->setText(tr("Next")); + button(CancelButton)->setVisible(true); + } else { + next(); + // older versions of QT don't support the "NoCancelButtonOnLastPage" option, this is a + // workaround + button(CancelButton)->setVisible(false); + } +} + +void CompatDB::EnableNext() { + button(NextButton)->setEnabled(true); +} diff --git a/src/sudachi/compatdb.h b/src/sudachi/compatdb.h new file mode 100644 index 0000000..37e1127 --- /dev/null +++ b/src/sudachi/compatdb.h @@ -0,0 +1,43 @@ +// SPDX-FileCopyrightText: 2017 Citra Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include +#include +#include +#include "core/telemetry_session.h" + +namespace Ui { +class CompatDB; +} + +enum class CompatibilityStatus { + Perfect = 0, + Playable = 1, + // Unused: Okay = 2, + Ingame = 3, + IntroMenu = 4, + WontBoot = 5, +}; + +class CompatDB : public QWizard { + Q_OBJECT + +public: + explicit CompatDB(Core::TelemetrySession& telemetry_session_, QWidget* parent = nullptr); + ~CompatDB(); + int nextId() const override; + +private: + QFutureWatcher testcase_watcher; + + std::unique_ptr ui; + + void Submit(); + CompatibilityStatus CalculateCompatibility() const; + void OnTestcaseSubmitted(); + void EnableNext(); + + Core::TelemetrySession& telemetry_session; +}; diff --git a/src/sudachi/compatdb.ui b/src/sudachi/compatdb.ui new file mode 100644 index 0000000..2b75960 --- /dev/null +++ b/src/sudachi/compatdb.ui @@ -0,0 +1,398 @@ + + + CompatDB + + + + 0 + 0 + 600 + 482 + + + + + 500 + 410 + + + + Report Compatibility + + + QWizard::DisabledBackButtonOnLastPage|QWizard::HelpButtonOnRight|QWizard::NoBackButtonOnStartPage + + + + Report Game Compatibility + + + 0 + + + + + + <html><head/><body><p><span style=" font-size:10pt;">Should you choose to submit a test case to the </span><a href="https://sudachi-emu.org/game/"><span style=" font-size:10pt; text-decoration: underline; color:#0000ff;">sudachi Compatibility List</span></a><span style=" font-size:10pt;">, The following information will be collected and displayed on the site:</span></p><ul style="margin-top: 0px; margin-bottom: 0px; margin-left: 0px; margin-right: 0px; -qt-list-indent: 1;"><li style=" margin-top:12px; margin-bottom:12px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">Hardware Information (CPU / GPU / Operating System)</li><li style=" margin-top:12px; margin-bottom:12px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">Which version of sudachi you are running</li><li style=" margin-top:12px; margin-bottom:12px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">The connected sudachi account</li></ul></body></html> + + + true + + + true + + + + + + + Qt::Vertical + + + + 20 + 0 + + + + + + + + + Report Game Compatibility + + + 1 + + + + + + + 10 + + + + <html><head/><body><p>Does the game boot?</p></body></html> + + + true + + + + + + + Qt::Vertical + + + + 20 + 0 + + + + + + + + Yes The game starts to output video or audio + + + + + + + No The game doesn't get past the "Launching..." screen + + + + + + + + Report Game Compatibility + + + 2 + + + + + + Yes The game gets past the intro/menu and into gameplay + + + + + + + No The game crashes or freezes while loading or using the menu + + + + + + + + 10 + + + + <html><head/><body><p>Does the game reach gameplay?</p></body></html> + + + true + + + + + + + Qt::Vertical + + + + 20 + 0 + + + + + + + + + Report Game Compatibility + + + 3 + + + + + + Yes The game works without crashes + + + + + + + No The game crashes or freezes during gameplay + + + + + + + + 10 + + + + <html><head/><body><p>Does the game work without crashing, freezing or locking up during gameplay?</p></body></html> + + + true + + + + + + + Qt::Vertical + + + + 20 + 0 + + + + + + + + + Report Game Compatibility + + + 4 + + + + + + Yes The game can be finished without any workarounds + + + + + + + No The game can't progress past a certain area + + + + + + + + 10 + + + + <html><head/><body><p>Is the game completely playable from start to finish?</p></body></html> + + + true + + + + + + + Qt::Vertical + + + + 20 + 0 + + + + + + + + + Report Game Compatibility + + + 5 + + + + + + Major The game has major graphical errors + + + + + + + Minor The game has minor graphical errors + + + + + + + None Everything is rendered as it looks on the Nintendo Switch + + + + + + + + 10 + + + + <html><head/><body><p>Does the game have any graphical glitches?</p></body></html> + + + true + + + + + + + Qt::Vertical + + + + 20 + 0 + + + + + + + + + Report Game Compatibility + + + 6 + + + + + + Major The game has major audio errors + + + + + + + Minor The game has minor audio errors + + + + + + + None Audio is played perfectly + + + + + + + + 10 + + + + <html><head/><body><p>Does the game have any audio glitches / missing effects?</p></body></html> + + + true + + + + + + + Qt::Vertical + + + + 20 + 0 + + + + + + + + + Thank you for your submission! + + + 7 + + + + + + diff --git a/src/sudachi/compatibility_list.cpp b/src/sudachi/compatibility_list.cpp new file mode 100644 index 0000000..4bd0c5a --- /dev/null +++ b/src/sudachi/compatibility_list.cpp @@ -0,0 +1,17 @@ +// SPDX-FileCopyrightText: Copyright 2018 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include + +#include + +#include "sudachi/compatibility_list.h" + +CompatibilityList::const_iterator FindMatchingCompatibilityEntry( + const CompatibilityList& compatibility_list, u64 program_id) { + return std::find_if(compatibility_list.begin(), compatibility_list.end(), + [program_id](const auto& element) { + std::string pid = fmt::format("{:016X}", program_id); + return element.first == pid; + }); +} diff --git a/src/sudachi/compatibility_list.h b/src/sudachi/compatibility_list.h new file mode 100644 index 0000000..00e4b5e --- /dev/null +++ b/src/sudachi/compatibility_list.h @@ -0,0 +1,16 @@ +// SPDX-FileCopyrightText: Copyright 2018 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include +#include + +#include + +#include "common/common_types.h" + +using CompatibilityList = std::unordered_map>; + +CompatibilityList::const_iterator FindMatchingCompatibilityEntry( + const CompatibilityList& compatibility_list, u64 program_id); diff --git a/src/sudachi/configuration/config.cpp b/src/sudachi/configuration/config.cpp new file mode 100644 index 0000000..c9f3399 --- /dev/null +++ b/src/sudachi/configuration/config.cpp @@ -0,0 +1,1309 @@ +// SPDX-FileCopyrightText: 2014 Citra Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include +#include +#include +#include +#include "common/fs/fs.h" +#include "common/fs/path_util.h" +#include "common/settings.h" +#include "common/settings_common.h" +#include "common/settings_enums.h" +#include "core/core.h" +#include "core/hle/service/acc/profile_manager.h" +#include "core/hle/service/hid/controllers/npad.h" +#include "input_common/main.h" +#include "network/network.h" +#include "sudachi/configuration/config.h" + +namespace FS = Common::FS; + +Config::Config(const std::string& config_name, ConfigType config_type) + : type(config_type), global{config_type == ConfigType::GlobalConfig} { + Initialize(config_name); +} + +Config::~Config() { + if (global) { + Save(); + } +} + +const std::array Config::default_buttons = { + Qt::Key_C, Qt::Key_X, Qt::Key_V, Qt::Key_Z, Qt::Key_F, + Qt::Key_G, Qt::Key_Q, Qt::Key_E, Qt::Key_R, Qt::Key_T, + Qt::Key_M, Qt::Key_N, Qt::Key_Left, Qt::Key_Up, Qt::Key_Right, + Qt::Key_Down, Qt::Key_Q, Qt::Key_E, 0, 0, + Qt::Key_Q, Qt::Key_E, +}; + +const std::array Config::default_motions = { + Qt::Key_7, + Qt::Key_8, +}; + +const std::array, Settings::NativeAnalog::NumAnalogs> Config::default_analogs{{ + { + Qt::Key_W, + Qt::Key_S, + Qt::Key_A, + Qt::Key_D, + }, + { + Qt::Key_I, + Qt::Key_K, + Qt::Key_J, + Qt::Key_L, + }, +}}; + +const std::array Config::default_stick_mod = { + Qt::Key_Shift, + 0, +}; + +const std::array Config::default_ringcon_analogs{{ + Qt::Key_A, + Qt::Key_D, +}}; + +const std::map Config::anti_aliasing_texts_map = { + {Settings::AntiAliasing::None, QStringLiteral(QT_TRANSLATE_NOOP("GMainWindow", "None"))}, + {Settings::AntiAliasing::Fxaa, QStringLiteral(QT_TRANSLATE_NOOP("GMainWindow", "FXAA"))}, + {Settings::AntiAliasing::Smaa, QStringLiteral(QT_TRANSLATE_NOOP("GMainWindow", "SMAA"))}, +}; + +const std::map Config::scaling_filter_texts_map = { + {Settings::ScalingFilter::NearestNeighbor, + QStringLiteral(QT_TRANSLATE_NOOP("GMainWindow", "Nearest"))}, + {Settings::ScalingFilter::Bilinear, + QStringLiteral(QT_TRANSLATE_NOOP("GMainWindow", "Bilinear"))}, + {Settings::ScalingFilter::Bicubic, QStringLiteral(QT_TRANSLATE_NOOP("GMainWindow", "Bicubic"))}, + {Settings::ScalingFilter::Gaussian, + QStringLiteral(QT_TRANSLATE_NOOP("GMainWindow", "Gaussian"))}, + {Settings::ScalingFilter::ScaleForce, + QStringLiteral(QT_TRANSLATE_NOOP("GMainWindow", "ScaleForce"))}, + {Settings::ScalingFilter::Fsr, QStringLiteral(QT_TRANSLATE_NOOP("GMainWindow", "FSR"))}, +}; + +const std::map Config::use_docked_mode_texts_map = { + {Settings::ConsoleMode::Docked, QStringLiteral(QT_TRANSLATE_NOOP("GMainWindow", "Docked"))}, + {Settings::ConsoleMode::Handheld, QStringLiteral(QT_TRANSLATE_NOOP("GMainWindow", "Handheld"))}, +}; + +const std::map Config::gpu_accuracy_texts_map = { + {Settings::GpuAccuracy::Normal, QStringLiteral(QT_TRANSLATE_NOOP("GMainWindow", "Normal"))}, + {Settings::GpuAccuracy::High, QStringLiteral(QT_TRANSLATE_NOOP("GMainWindow", "High"))}, + {Settings::GpuAccuracy::Extreme, QStringLiteral(QT_TRANSLATE_NOOP("GMainWindow", "Extreme"))}, +}; + +const std::map Config::renderer_backend_texts_map = { + {Settings::RendererBackend::Vulkan, QStringLiteral(QT_TRANSLATE_NOOP("GMainWindow", "Vulkan"))}, + {Settings::RendererBackend::OpenGL, QStringLiteral(QT_TRANSLATE_NOOP("GMainWindow", "OpenGL"))}, + {Settings::RendererBackend::Null, QStringLiteral(QT_TRANSLATE_NOOP("GMainWindow", "Null"))}, +}; + +const std::map Config::shader_backend_texts_map = { + {Settings::ShaderBackend::Glsl, QStringLiteral(QT_TRANSLATE_NOOP("GMainWindow", "GLSL"))}, + {Settings::ShaderBackend::Glasm, QStringLiteral(QT_TRANSLATE_NOOP("GMainWindow", "GLASM"))}, + {Settings::ShaderBackend::SpirV, QStringLiteral(QT_TRANSLATE_NOOP("GMainWindow", "SPIRV"))}, +}; + +// This shouldn't have anything except static initializers (no functions). So +// QKeySequence(...).toString() is NOT ALLOWED HERE. +// This must be in alphabetical order according to action name as it must have the same order as +// UISetting::values.shortcuts, which is alphabetically ordered. +// clang-format off +const std::array Config::default_hotkeys{{ + {QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Audio Mute/Unmute")), QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Main Window")), {QStringLiteral("Ctrl+M"), QStringLiteral("Home+Dpad_Right"), Qt::WindowShortcut, false}}, + {QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Audio Volume Down")), QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Main Window")), {QStringLiteral("-"), QStringLiteral("Home+Dpad_Down"), Qt::ApplicationShortcut, true}}, + {QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Audio Volume Up")), QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Main Window")), {QStringLiteral("="), QStringLiteral("Home+Dpad_Up"), Qt::ApplicationShortcut, true}}, + {QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Capture Screenshot")), QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Main Window")), {QStringLiteral("Ctrl+P"), QStringLiteral("Screenshot"), Qt::WidgetWithChildrenShortcut, false}}, + {QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Change Adapting Filter")), QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Main Window")), {QStringLiteral("F8"), QStringLiteral("Home+L"), Qt::ApplicationShortcut, false}}, + {QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Change Docked Mode")), QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Main Window")), {QStringLiteral("F10"), QStringLiteral("Home+X"), Qt::ApplicationShortcut, false}}, + {QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Change GPU Accuracy")), QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Main Window")), {QStringLiteral("F9"), QStringLiteral("Home+R"), Qt::ApplicationShortcut, false}}, + {QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Continue/Pause Emulation")), QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Main Window")), {QStringLiteral("F4"), QStringLiteral("Home+Plus"), Qt::WindowShortcut, false}}, + {QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Exit Fullscreen")), QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Main Window")), {QStringLiteral("Esc"), QStringLiteral(""), Qt::WindowShortcut, false}}, + {QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Exit sudachi")), QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Main Window")), {QStringLiteral("Ctrl+Q"), QStringLiteral("Home+Minus"), Qt::WindowShortcut, false}}, + {QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Fullscreen")), QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Main Window")), {QStringLiteral("F11"), QStringLiteral("Home+B"), Qt::WindowShortcut, false}}, + {QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Load File")), QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Main Window")), {QStringLiteral("Ctrl+O"), QStringLiteral(""), Qt::WidgetWithChildrenShortcut, false}}, + {QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Load/Remove Amiibo")), QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Main Window")), {QStringLiteral("F2"), QStringLiteral("Home+A"), Qt::WidgetWithChildrenShortcut, false}}, + {QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Restart Emulation")), QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Main Window")), {QStringLiteral("F6"), QStringLiteral("R+Plus+Minus"), Qt::WindowShortcut, false}}, + {QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Stop Emulation")), QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Main Window")), {QStringLiteral("F5"), QStringLiteral("L+Plus+Minus"), Qt::WindowShortcut, false}}, + {QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "TAS Record")), QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Main Window")), {QStringLiteral("Ctrl+F7"), QStringLiteral(""), Qt::ApplicationShortcut, false}}, + {QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "TAS Reset")), QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Main Window")), {QStringLiteral("Ctrl+F6"), QStringLiteral(""), Qt::ApplicationShortcut, false}}, + {QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "TAS Start/Stop")), QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Main Window")), {QStringLiteral("Ctrl+F5"), QStringLiteral(""), Qt::ApplicationShortcut, false}}, + {QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Toggle Filter Bar")), QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Main Window")), {QStringLiteral("Ctrl+F"), QStringLiteral(""), Qt::WindowShortcut, false}}, + {QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Toggle Framerate Limit")), QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Main Window")), {QStringLiteral("Ctrl+U"), QStringLiteral("Home+Y"), Qt::ApplicationShortcut, false}}, + {QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Toggle Mouse Panning")), QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Main Window")), {QStringLiteral("Ctrl+F9"), QStringLiteral(""), Qt::ApplicationShortcut, false}}, + {QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Toggle Renderdoc Capture")), QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Main Window")), {QStringLiteral(""), QStringLiteral(""), Qt::ApplicationShortcut, false}}, + {QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Toggle Status Bar")), QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Main Window")), {QStringLiteral("Ctrl+S"), QStringLiteral(""), Qt::WindowShortcut, false}}, +}}; +// clang-format on + +void Config::Initialize(const std::string& config_name) { + const auto fs_config_loc = FS::GetSudachiPath(FS::SudachiPath::ConfigDir); + const auto config_file = fmt::format("{}.ini", config_name); + + switch (type) { + case ConfigType::GlobalConfig: + qt_config_loc = FS::PathToUTF8String(fs_config_loc / config_file); + void(FS::CreateParentDir(qt_config_loc)); + qt_config = std::make_unique(QString::fromStdString(qt_config_loc), + QSettings::IniFormat); + Reload(); + break; + case ConfigType::PerGameConfig: + qt_config_loc = + FS::PathToUTF8String(fs_config_loc / "custom" / FS::ToU8String(config_file)); + void(FS::CreateParentDir(qt_config_loc)); + qt_config = std::make_unique(QString::fromStdString(qt_config_loc), + QSettings::IniFormat); + Reload(); + break; + case ConfigType::InputProfile: + qt_config_loc = FS::PathToUTF8String(fs_config_loc / "input" / config_file); + void(FS::CreateParentDir(qt_config_loc)); + qt_config = std::make_unique(QString::fromStdString(qt_config_loc), + QSettings::IniFormat); + break; + } +} + +bool Config::IsCustomConfig() { + return type == ConfigType::PerGameConfig; +} + +void Config::ReadPlayerValue(std::size_t player_index) { + const QString player_prefix = [this, player_index] { + if (type == ConfigType::InputProfile) { + return QString{}; + } else { + return QStringLiteral("player_%1_").arg(player_index); + } + }(); + + auto& player = Settings::values.players.GetValue()[player_index]; + if (IsCustomConfig()) { + const auto profile_name = + qt_config->value(QStringLiteral("%1profile_name").arg(player_prefix), QString{}) + .toString() + .toStdString(); + if (profile_name.empty()) { + // Use the global input config + player = Settings::values.players.GetValue(true)[player_index]; + return; + } + player.profile_name = profile_name; + } + + if (player_prefix.isEmpty() && Settings::IsConfiguringGlobal()) { + const auto controller = static_cast( + qt_config + ->value(QStringLiteral("%1type").arg(player_prefix), + static_cast(Settings::ControllerType::ProController)) + .toUInt()); + + if (controller == Settings::ControllerType::LeftJoycon || + controller == Settings::ControllerType::RightJoycon) { + player.controller_type = controller; + } + } else { + player.connected = + ReadSetting(QStringLiteral("%1connected").arg(player_prefix), player_index == 0) + .toBool(); + + player.controller_type = static_cast( + qt_config + ->value(QStringLiteral("%1type").arg(player_prefix), + static_cast(Settings::ControllerType::ProController)) + .toUInt()); + + player.vibration_enabled = + qt_config->value(QStringLiteral("%1vibration_enabled").arg(player_prefix), true) + .toBool(); + + player.vibration_strength = + qt_config->value(QStringLiteral("%1vibration_strength").arg(player_prefix), 100) + .toInt(); + + player.body_color_left = qt_config + ->value(QStringLiteral("%1body_color_left").arg(player_prefix), + Settings::JOYCON_BODY_NEON_BLUE) + .toUInt(); + player.body_color_right = + qt_config + ->value(QStringLiteral("%1body_color_right").arg(player_prefix), + Settings::JOYCON_BODY_NEON_RED) + .toUInt(); + player.button_color_left = + qt_config + ->value(QStringLiteral("%1button_color_left").arg(player_prefix), + Settings::JOYCON_BUTTONS_NEON_BLUE) + .toUInt(); + player.button_color_right = + qt_config + ->value(QStringLiteral("%1button_color_right").arg(player_prefix), + Settings::JOYCON_BUTTONS_NEON_RED) + .toUInt(); + } + + for (int i = 0; i < Settings::NativeButton::NumButtons; ++i) { + const std::string default_param = InputCommon::GenerateKeyboardParam(default_buttons[i]); + auto& player_buttons = player.buttons[i]; + + player_buttons = qt_config + ->value(QStringLiteral("%1").arg(player_prefix) + + QString::fromUtf8(Settings::NativeButton::mapping[i]), + QString::fromStdString(default_param)) + .toString() + .toStdString(); + if (player_buttons.empty()) { + player_buttons = default_param; + } + } + + for (int i = 0; i < Settings::NativeAnalog::NumAnalogs; ++i) { + const std::string default_param = InputCommon::GenerateAnalogParamFromKeys( + default_analogs[i][0], default_analogs[i][1], default_analogs[i][2], + default_analogs[i][3], default_stick_mod[i], 0.5f); + auto& player_analogs = player.analogs[i]; + + player_analogs = qt_config + ->value(QStringLiteral("%1").arg(player_prefix) + + QString::fromUtf8(Settings::NativeAnalog::mapping[i]), + QString::fromStdString(default_param)) + .toString() + .toStdString(); + if (player_analogs.empty()) { + player_analogs = default_param; + } + } + + for (int i = 0; i < Settings::NativeMotion::NumMotions; ++i) { + const std::string default_param = InputCommon::GenerateKeyboardParam(default_motions[i]); + auto& player_motions = player.motions[i]; + + player_motions = qt_config + ->value(QStringLiteral("%1").arg(player_prefix) + + QString::fromUtf8(Settings::NativeMotion::mapping[i]), + QString::fromStdString(default_param)) + .toString() + .toStdString(); + if (player_motions.empty()) { + player_motions = default_param; + } + } +} + +void Config::ReadDebugValues() { + for (int i = 0; i < Settings::NativeButton::NumButtons; ++i) { + const std::string default_param = InputCommon::GenerateKeyboardParam(default_buttons[i]); + auto& debug_pad_buttons = Settings::values.debug_pad_buttons[i]; + + debug_pad_buttons = qt_config + ->value(QStringLiteral("debug_pad_") + + QString::fromUtf8(Settings::NativeButton::mapping[i]), + QString::fromStdString(default_param)) + .toString() + .toStdString(); + if (debug_pad_buttons.empty()) { + debug_pad_buttons = default_param; + } + } + + for (int i = 0; i < Settings::NativeAnalog::NumAnalogs; ++i) { + const std::string default_param = InputCommon::GenerateAnalogParamFromKeys( + default_analogs[i][0], default_analogs[i][1], default_analogs[i][2], + default_analogs[i][3], default_stick_mod[i], 0.5f); + auto& debug_pad_analogs = Settings::values.debug_pad_analogs[i]; + + debug_pad_analogs = qt_config + ->value(QStringLiteral("debug_pad_") + + QString::fromUtf8(Settings::NativeAnalog::mapping[i]), + QString::fromStdString(default_param)) + .toString() + .toStdString(); + if (debug_pad_analogs.empty()) { + debug_pad_analogs = default_param; + } + } +} + +void Config::ReadTouchscreenValues() { + Settings::values.touchscreen.enabled = + ReadSetting(QStringLiteral("touchscreen_enabled"), true).toBool(); + + Settings::values.touchscreen.rotation_angle = + ReadSetting(QStringLiteral("touchscreen_angle"), 0).toUInt(); + Settings::values.touchscreen.diameter_x = + ReadSetting(QStringLiteral("touchscreen_diameter_x"), 15).toUInt(); + Settings::values.touchscreen.diameter_y = + ReadSetting(QStringLiteral("touchscreen_diameter_y"), 15).toUInt(); +} + +void Config::ReadHidbusValues() { + const std::string default_param = InputCommon::GenerateAnalogParamFromKeys( + 0, 0, default_ringcon_analogs[0], default_ringcon_analogs[1], 0, 0.05f); + auto& ringcon_analogs = Settings::values.ringcon_analogs; + + ringcon_analogs = + qt_config->value(QStringLiteral("ring_controller"), QString::fromStdString(default_param)) + .toString() + .toStdString(); + if (ringcon_analogs.empty()) { + ringcon_analogs = default_param; + } +} + +void Config::ReadAudioValues() { + qt_config->beginGroup(QStringLiteral("Audio")); + + ReadCategory(Settings::Category::Audio); + ReadCategory(Settings::Category::UiAudio); + + qt_config->endGroup(); +} + +void Config::ReadControlValues() { + qt_config->beginGroup(QStringLiteral("Controls")); + + ReadCategory(Settings::Category::Controls); + + Settings::values.players.SetGlobal(!IsCustomConfig()); + for (std::size_t p = 0; p < Settings::values.players.GetValue().size(); ++p) { + ReadPlayerValue(p); + } + + // Disable docked mode if handheld is selected + const auto controller_type = Settings::values.players.GetValue()[0].controller_type; + if (controller_type == Settings::ControllerType::Handheld) { + Settings::values.use_docked_mode.SetGlobal(!IsCustomConfig()); + Settings::values.use_docked_mode.SetValue(Settings::ConsoleMode::Handheld); + } + + if (IsCustomConfig()) { + qt_config->endGroup(); + return; + } + ReadDebugValues(); + ReadTouchscreenValues(); + ReadMotionTouchValues(); + ReadHidbusValues(); + + qt_config->endGroup(); +} + +void Config::ReadMotionTouchValues() { + int num_touch_from_button_maps = + qt_config->beginReadArray(QStringLiteral("touch_from_button_maps")); + + if (num_touch_from_button_maps > 0) { + const auto append_touch_from_button_map = [this] { + Settings::TouchFromButtonMap map; + map.name = ReadSetting(QStringLiteral("name"), QStringLiteral("default")) + .toString() + .toStdString(); + const int num_touch_maps = qt_config->beginReadArray(QStringLiteral("entries")); + map.buttons.reserve(num_touch_maps); + for (int i = 0; i < num_touch_maps; i++) { + qt_config->setArrayIndex(i); + std::string touch_mapping = + ReadSetting(QStringLiteral("bind")).toString().toStdString(); + map.buttons.emplace_back(std::move(touch_mapping)); + } + qt_config->endArray(); // entries + Settings::values.touch_from_button_maps.emplace_back(std::move(map)); + }; + + for (int i = 0; i < num_touch_from_button_maps; ++i) { + qt_config->setArrayIndex(i); + append_touch_from_button_map(); + } + } else { + Settings::values.touch_from_button_maps.emplace_back( + Settings::TouchFromButtonMap{"default", {}}); + num_touch_from_button_maps = 1; + } + qt_config->endArray(); + + Settings::values.touch_from_button_map_index = std::clamp( + Settings::values.touch_from_button_map_index.GetValue(), 0, num_touch_from_button_maps - 1); +} + +void Config::ReadCoreValues() { + qt_config->beginGroup(QStringLiteral("Core")); + + ReadCategory(Settings::Category::Core); + + qt_config->endGroup(); +} + +void Config::ReadDataStorageValues() { + qt_config->beginGroup(QStringLiteral("Data Storage")); + + FS::SetSudachiPath( + FS::SudachiPath::NANDDir, + qt_config + ->value(QStringLiteral("nand_directory"), + QString::fromStdString(FS::GetSudachiPathString(FS::SudachiPath::NANDDir))) + .toString() + .toStdString()); + FS::SetSudachiPath( + FS::SudachiPath::SDMCDir, + qt_config + ->value(QStringLiteral("sdmc_directory"), + QString::fromStdString(FS::GetSudachiPathString(FS::SudachiPath::SDMCDir))) + .toString() + .toStdString()); + FS::SetSudachiPath( + FS::SudachiPath::LoadDir, + qt_config + ->value(QStringLiteral("load_directory"), + QString::fromStdString(FS::GetSudachiPathString(FS::SudachiPath::LoadDir))) + .toString() + .toStdString()); + FS::SetSudachiPath( + FS::SudachiPath::DumpDir, + qt_config + ->value(QStringLiteral("dump_directory"), + QString::fromStdString(FS::GetSudachiPathString(FS::SudachiPath::DumpDir))) + .toString() + .toStdString()); + FS::SetSudachiPath(FS::SudachiPath::TASDir, + qt_config + ->value(QStringLiteral("tas_directory"), + QString::fromStdString(FS::GetSudachiPathString(FS::SudachiPath::TASDir))) + .toString() + .toStdString()); + + ReadCategory(Settings::Category::DataStorage); + + qt_config->endGroup(); +} + +void Config::ReadDebuggingValues() { + qt_config->beginGroup(QStringLiteral("Debugging")); + + // Intentionally not using the QT default setting as this is intended to be changed in the ini + Settings::values.record_frame_times = + qt_config->value(QStringLiteral("record_frame_times"), false).toBool(); + + ReadCategory(Settings::Category::Debugging); + ReadCategory(Settings::Category::DebuggingGraphics); + + qt_config->endGroup(); +} + +void Config::ReadServiceValues() { + qt_config->beginGroup(QStringLiteral("Services")); + + ReadCategory(Settings::Category::Services); + + qt_config->endGroup(); +} + +void Config::ReadDisabledAddOnValues() { + const auto size = qt_config->beginReadArray(QStringLiteral("DisabledAddOns")); + + for (int i = 0; i < size; ++i) { + qt_config->setArrayIndex(i); + const auto title_id = ReadSetting(QStringLiteral("title_id"), 0).toULongLong(); + std::vector out; + const auto d_size = qt_config->beginReadArray(QStringLiteral("disabled")); + for (int j = 0; j < d_size; ++j) { + qt_config->setArrayIndex(j); + out.push_back(ReadSetting(QStringLiteral("d"), QString{}).toString().toStdString()); + } + qt_config->endArray(); + Settings::values.disabled_addons.insert_or_assign(title_id, out); + } + + qt_config->endArray(); +} + +void Config::ReadMiscellaneousValues() { + qt_config->beginGroup(QStringLiteral("Miscellaneous")); + + ReadCategory(Settings::Category::Miscellaneous); + + qt_config->endGroup(); +} + +void Config::ReadPathValues() { + qt_config->beginGroup(QStringLiteral("Paths")); + + UISettings::values.roms_path = ReadSetting(QStringLiteral("romsPath")).toString(); + UISettings::values.symbols_path = ReadSetting(QStringLiteral("symbolsPath")).toString(); + UISettings::values.game_dir_deprecated = + ReadSetting(QStringLiteral("gameListRootDir"), QStringLiteral(".")).toString(); + UISettings::values.game_dir_deprecated_deepscan = + ReadSetting(QStringLiteral("gameListDeepScan"), false).toBool(); + const int gamedirs_size = qt_config->beginReadArray(QStringLiteral("gamedirs")); + for (int i = 0; i < gamedirs_size; ++i) { + qt_config->setArrayIndex(i); + UISettings::GameDir game_dir; + game_dir.path = ReadSetting(QStringLiteral("path")).toString(); + game_dir.deep_scan = ReadSetting(QStringLiteral("deep_scan"), false).toBool(); + game_dir.expanded = ReadSetting(QStringLiteral("expanded"), true).toBool(); + UISettings::values.game_dirs.append(game_dir); + } + qt_config->endArray(); + // create NAND and SD card directories if empty, these are not removable through the UI, + // also carries over old game list settings if present + if (UISettings::values.game_dirs.isEmpty()) { + UISettings::GameDir game_dir; + game_dir.path = QStringLiteral("SDMC"); + game_dir.expanded = true; + UISettings::values.game_dirs.append(game_dir); + game_dir.path = QStringLiteral("UserNAND"); + UISettings::values.game_dirs.append(game_dir); + game_dir.path = QStringLiteral("SysNAND"); + UISettings::values.game_dirs.append(game_dir); + if (UISettings::values.game_dir_deprecated != QStringLiteral(".")) { + game_dir.path = UISettings::values.game_dir_deprecated; + game_dir.deep_scan = UISettings::values.game_dir_deprecated_deepscan; + UISettings::values.game_dirs.append(game_dir); + } + } + UISettings::values.recent_files = ReadSetting(QStringLiteral("recentFiles")).toStringList(); + UISettings::values.language = ReadSetting(QStringLiteral("language"), QString{}).toString(); + + qt_config->endGroup(); +} + +void Config::ReadCpuValues() { + qt_config->beginGroup(QStringLiteral("Cpu")); + + ReadCategory(Settings::Category::Cpu); + ReadCategory(Settings::Category::CpuDebug); + ReadCategory(Settings::Category::CpuUnsafe); + + qt_config->endGroup(); +} + +void Config::ReadRendererValues() { + qt_config->beginGroup(QStringLiteral("Renderer")); + + ReadCategory(Settings::Category::Renderer); + ReadCategory(Settings::Category::RendererAdvanced); + ReadCategory(Settings::Category::RendererDebug); + + qt_config->endGroup(); +} + +void Config::ReadScreenshotValues() { + qt_config->beginGroup(QStringLiteral("Screenshots")); + + ReadCategory(Settings::Category::Screenshots); + FS::SetSudachiPath( + FS::SudachiPath::ScreenshotsDir, + qt_config + ->value(QStringLiteral("screenshot_path"), + QString::fromStdString(FS::GetSudachiPathString(FS::SudachiPath::ScreenshotsDir))) + .toString() + .toStdString()); + + qt_config->endGroup(); +} + +void Config::ReadShortcutValues() { + qt_config->beginGroup(QStringLiteral("Shortcuts")); + + for (const auto& [name, group, shortcut] : default_hotkeys) { + qt_config->beginGroup(group); + qt_config->beginGroup(name); + // No longer using ReadSetting for shortcut.second as it inaccurately returns a value of 1 + // for WidgetWithChildrenShortcut which is a value of 3. Needed to fix shortcuts the open + // a file dialog in windowed mode + UISettings::values.shortcuts.push_back( + {name, + group, + {ReadSetting(QStringLiteral("KeySeq"), shortcut.keyseq).toString(), + ReadSetting(QStringLiteral("Controller_KeySeq"), shortcut.controller_keyseq) + .toString(), + shortcut.context, ReadSetting(QStringLiteral("Repeat"), shortcut.repeat).toBool()}}); + qt_config->endGroup(); + qt_config->endGroup(); + } + + qt_config->endGroup(); +} + +void Config::ReadSystemValues() { + qt_config->beginGroup(QStringLiteral("System")); + + ReadCategory(Settings::Category::System); + ReadCategory(Settings::Category::SystemAudio); + + qt_config->endGroup(); +} + +void Config::ReadUIValues() { + qt_config->beginGroup(QStringLiteral("UI")); + + UISettings::values.theme = + ReadSetting( + QStringLiteral("theme"), + QString::fromUtf8(UISettings::themes[static_cast(default_theme)].second)) + .toString(); + + ReadUIGamelistValues(); + ReadUILayoutValues(); + ReadPathValues(); + ReadScreenshotValues(); + ReadShortcutValues(); + ReadMultiplayerValues(); + + ReadCategory(Settings::Category::Ui); + ReadCategory(Settings::Category::UiGeneral); + + qt_config->endGroup(); +} + +void Config::ReadUIGamelistValues() { + qt_config->beginGroup(QStringLiteral("UIGameList")); + + ReadCategory(Settings::Category::UiGameList); + + const int favorites_size = qt_config->beginReadArray(QStringLiteral("favorites")); + for (int i = 0; i < favorites_size; i++) { + qt_config->setArrayIndex(i); + UISettings::values.favorited_ids.append( + ReadSetting(QStringLiteral("program_id")).toULongLong()); + } + qt_config->endArray(); + + qt_config->endGroup(); +} + +void Config::ReadUILayoutValues() { + qt_config->beginGroup(QStringLiteral("UILayout")); + + UISettings::values.geometry = ReadSetting(QStringLiteral("geometry")).toByteArray(); + UISettings::values.state = ReadSetting(QStringLiteral("state")).toByteArray(); + UISettings::values.renderwindow_geometry = + ReadSetting(QStringLiteral("geometryRenderWindow")).toByteArray(); + UISettings::values.gamelist_header_state = + ReadSetting(QStringLiteral("gameListHeaderState")).toByteArray(); + UISettings::values.microprofile_geometry = + ReadSetting(QStringLiteral("microProfileDialogGeometry")).toByteArray(); + + ReadCategory(Settings::Category::UiLayout); + + qt_config->endGroup(); +} + +void Config::ReadWebServiceValues() { + qt_config->beginGroup(QStringLiteral("WebService")); + + ReadCategory(Settings::Category::WebService); + + qt_config->endGroup(); +} + +void Config::ReadMultiplayerValues() { + qt_config->beginGroup(QStringLiteral("Multiplayer")); + + ReadCategory(Settings::Category::Multiplayer); + + // Read ban list back + int size = qt_config->beginReadArray(QStringLiteral("username_ban_list")); + UISettings::values.multiplayer_ban_list.first.resize(size); + for (int i = 0; i < size; ++i) { + qt_config->setArrayIndex(i); + UISettings::values.multiplayer_ban_list.first[i] = + ReadSetting(QStringLiteral("username")).toString().toStdString(); + } + qt_config->endArray(); + size = qt_config->beginReadArray(QStringLiteral("ip_ban_list")); + UISettings::values.multiplayer_ban_list.second.resize(size); + for (int i = 0; i < size; ++i) { + qt_config->setArrayIndex(i); + UISettings::values.multiplayer_ban_list.second[i] = + ReadSetting(QStringLiteral("ip")).toString().toStdString(); + } + qt_config->endArray(); + + qt_config->endGroup(); +} + +void Config::ReadNetworkValues() { + qt_config->beginGroup(QString::fromStdString("Services")); + + ReadCategory(Settings::Category::Network); + + qt_config->endGroup(); +} + +void Config::ReadValues() { + if (global) { + ReadDataStorageValues(); + ReadDebuggingValues(); + ReadDisabledAddOnValues(); + ReadNetworkValues(); + ReadServiceValues(); + ReadUIValues(); + ReadWebServiceValues(); + ReadMiscellaneousValues(); + } + ReadControlValues(); + ReadCoreValues(); + ReadCpuValues(); + ReadRendererValues(); + ReadAudioValues(); + ReadSystemValues(); +} + +void Config::SavePlayerValue(std::size_t player_index) { + const QString player_prefix = [this, player_index] { + if (type == ConfigType::InputProfile) { + return QString{}; + } else { + return QStringLiteral("player_%1_").arg(player_index); + } + }(); + + const auto& player = Settings::values.players.GetValue()[player_index]; + if (IsCustomConfig()) { + if (player.profile_name.empty()) { + // No custom profile selected + return; + } + WriteSetting(QStringLiteral("%1profile_name").arg(player_prefix), + QString::fromStdString(player.profile_name), QString{}); + } + + WriteSetting(QStringLiteral("%1type").arg(player_prefix), + static_cast(player.controller_type), + static_cast(Settings::ControllerType::ProController)); + + if (!player_prefix.isEmpty() || !Settings::IsConfiguringGlobal()) { + WriteSetting(QStringLiteral("%1connected").arg(player_prefix), player.connected, + player_index == 0); + WriteSetting(QStringLiteral("%1vibration_enabled").arg(player_prefix), + player.vibration_enabled, true); + WriteSetting(QStringLiteral("%1vibration_strength").arg(player_prefix), + player.vibration_strength, 100); + WriteSetting(QStringLiteral("%1body_color_left").arg(player_prefix), player.body_color_left, + Settings::JOYCON_BODY_NEON_BLUE); + WriteSetting(QStringLiteral("%1body_color_right").arg(player_prefix), + player.body_color_right, Settings::JOYCON_BODY_NEON_RED); + WriteSetting(QStringLiteral("%1button_color_left").arg(player_prefix), + player.button_color_left, Settings::JOYCON_BUTTONS_NEON_BLUE); + WriteSetting(QStringLiteral("%1button_color_right").arg(player_prefix), + player.button_color_right, Settings::JOYCON_BUTTONS_NEON_RED); + } + + for (int i = 0; i < Settings::NativeButton::NumButtons; ++i) { + const std::string default_param = InputCommon::GenerateKeyboardParam(default_buttons[i]); + WriteSetting(QStringLiteral("%1").arg(player_prefix) + + QString::fromStdString(Settings::NativeButton::mapping[i]), + QString::fromStdString(player.buttons[i]), + QString::fromStdString(default_param)); + } + for (int i = 0; i < Settings::NativeAnalog::NumAnalogs; ++i) { + const std::string default_param = InputCommon::GenerateAnalogParamFromKeys( + default_analogs[i][0], default_analogs[i][1], default_analogs[i][2], + default_analogs[i][3], default_stick_mod[i], 0.5f); + WriteSetting(QStringLiteral("%1").arg(player_prefix) + + QString::fromStdString(Settings::NativeAnalog::mapping[i]), + QString::fromStdString(player.analogs[i]), + QString::fromStdString(default_param)); + } + for (int i = 0; i < Settings::NativeMotion::NumMotions; ++i) { + const std::string default_param = InputCommon::GenerateKeyboardParam(default_motions[i]); + WriteSetting(QStringLiteral("%1").arg(player_prefix) + + QString::fromStdString(Settings::NativeMotion::mapping[i]), + QString::fromStdString(player.motions[i]), + QString::fromStdString(default_param)); + } +} + +void Config::SaveDebugValues() { + for (int i = 0; i < Settings::NativeButton::NumButtons; ++i) { + const std::string default_param = InputCommon::GenerateKeyboardParam(default_buttons[i]); + WriteSetting(QStringLiteral("debug_pad_") + + QString::fromStdString(Settings::NativeButton::mapping[i]), + QString::fromStdString(Settings::values.debug_pad_buttons[i]), + QString::fromStdString(default_param)); + } + for (int i = 0; i < Settings::NativeAnalog::NumAnalogs; ++i) { + const std::string default_param = InputCommon::GenerateAnalogParamFromKeys( + default_analogs[i][0], default_analogs[i][1], default_analogs[i][2], + default_analogs[i][3], default_stick_mod[i], 0.5f); + WriteSetting(QStringLiteral("debug_pad_") + + QString::fromStdString(Settings::NativeAnalog::mapping[i]), + QString::fromStdString(Settings::values.debug_pad_analogs[i]), + QString::fromStdString(default_param)); + } +} + +void Config::SaveTouchscreenValues() { + const auto& touchscreen = Settings::values.touchscreen; + + WriteSetting(QStringLiteral("touchscreen_enabled"), touchscreen.enabled, true); + + WriteSetting(QStringLiteral("touchscreen_angle"), touchscreen.rotation_angle, 0); + WriteSetting(QStringLiteral("touchscreen_diameter_x"), touchscreen.diameter_x, 15); + WriteSetting(QStringLiteral("touchscreen_diameter_y"), touchscreen.diameter_y, 15); +} + +void Config::SaveMotionTouchValues() { + qt_config->beginWriteArray(QStringLiteral("touch_from_button_maps")); + for (std::size_t p = 0; p < Settings::values.touch_from_button_maps.size(); ++p) { + qt_config->setArrayIndex(static_cast(p)); + WriteSetting(QStringLiteral("name"), + QString::fromStdString(Settings::values.touch_from_button_maps[p].name), + QStringLiteral("default")); + qt_config->beginWriteArray(QStringLiteral("entries")); + for (std::size_t q = 0; q < Settings::values.touch_from_button_maps[p].buttons.size(); + ++q) { + qt_config->setArrayIndex(static_cast(q)); + WriteSetting( + QStringLiteral("bind"), + QString::fromStdString(Settings::values.touch_from_button_maps[p].buttons[q])); + } + qt_config->endArray(); + } + qt_config->endArray(); +} + +void Config::SaveHidbusValues() { + const std::string default_param = InputCommon::GenerateAnalogParamFromKeys( + 0, 0, default_ringcon_analogs[0], default_ringcon_analogs[1], 0, 0.05f); + WriteSetting(QStringLiteral("ring_controller"), + QString::fromStdString(Settings::values.ringcon_analogs), + QString::fromStdString(default_param)); +} + +void Config::SaveValues() { + if (global) { + SaveDataStorageValues(); + SaveDebuggingValues(); + SaveDisabledAddOnValues(); + SaveNetworkValues(); + SaveUIValues(); + SaveWebServiceValues(); + SaveMiscellaneousValues(); + } + SaveControlValues(); + SaveCoreValues(); + SaveCpuValues(); + SaveRendererValues(); + SaveAudioValues(); + SaveSystemValues(); + + qt_config->sync(); +} + +void Config::SaveAudioValues() { + qt_config->beginGroup(QStringLiteral("Audio")); + + WriteCategory(Settings::Category::Audio); + WriteCategory(Settings::Category::UiAudio); + + qt_config->endGroup(); +} + +void Config::SaveControlValues() { + qt_config->beginGroup(QStringLiteral("Controls")); + + WriteCategory(Settings::Category::Controls); + + Settings::values.players.SetGlobal(!IsCustomConfig()); + for (std::size_t p = 0; p < Settings::values.players.GetValue().size(); ++p) { + SavePlayerValue(p); + } + if (IsCustomConfig()) { + qt_config->endGroup(); + return; + } + SaveDebugValues(); + SaveTouchscreenValues(); + SaveMotionTouchValues(); + SaveHidbusValues(); + + qt_config->endGroup(); +} + +void Config::SaveCoreValues() { + qt_config->beginGroup(QStringLiteral("Core")); + + WriteCategory(Settings::Category::Core); + + qt_config->endGroup(); +} + +void Config::SaveDataStorageValues() { + qt_config->beginGroup(QStringLiteral("Data Storage")); + + WriteSetting(QStringLiteral("nand_directory"), + QString::fromStdString(FS::GetSudachiPathString(FS::SudachiPath::NANDDir)), + QString::fromStdString(FS::GetSudachiPathString(FS::SudachiPath::NANDDir))); + WriteSetting(QStringLiteral("sdmc_directory"), + QString::fromStdString(FS::GetSudachiPathString(FS::SudachiPath::SDMCDir)), + QString::fromStdString(FS::GetSudachiPathString(FS::SudachiPath::SDMCDir))); + WriteSetting(QStringLiteral("load_directory"), + QString::fromStdString(FS::GetSudachiPathString(FS::SudachiPath::LoadDir)), + QString::fromStdString(FS::GetSudachiPathString(FS::SudachiPath::LoadDir))); + WriteSetting(QStringLiteral("dump_directory"), + QString::fromStdString(FS::GetSudachiPathString(FS::SudachiPath::DumpDir)), + QString::fromStdString(FS::GetSudachiPathString(FS::SudachiPath::DumpDir))); + WriteSetting(QStringLiteral("tas_directory"), + QString::fromStdString(FS::GetSudachiPathString(FS::SudachiPath::TASDir)), + QString::fromStdString(FS::GetSudachiPathString(FS::SudachiPath::TASDir))); + + WriteCategory(Settings::Category::DataStorage); + + qt_config->endGroup(); +} + +void Config::SaveDebuggingValues() { + qt_config->beginGroup(QStringLiteral("Debugging")); + + // Intentionally not using the QT default setting as this is intended to be changed in the ini + qt_config->setValue(QStringLiteral("record_frame_times"), Settings::values.record_frame_times); + + WriteCategory(Settings::Category::Debugging); + WriteCategory(Settings::Category::DebuggingGraphics); + + qt_config->endGroup(); +} + +void Config::SaveNetworkValues() { + qt_config->beginGroup(QStringLiteral("Services")); + + WriteCategory(Settings::Category::Network); + + qt_config->endGroup(); +} + +void Config::SaveDisabledAddOnValues() { + qt_config->beginWriteArray(QStringLiteral("DisabledAddOns")); + + int i = 0; + for (const auto& elem : Settings::values.disabled_addons) { + qt_config->setArrayIndex(i); + WriteSetting(QStringLiteral("title_id"), QVariant::fromValue(elem.first), 0); + qt_config->beginWriteArray(QStringLiteral("disabled")); + for (std::size_t j = 0; j < elem.second.size(); ++j) { + qt_config->setArrayIndex(static_cast(j)); + WriteSetting(QStringLiteral("d"), QString::fromStdString(elem.second[j]), QString{}); + } + qt_config->endArray(); + ++i; + } + + qt_config->endArray(); +} + +void Config::SaveMiscellaneousValues() { + qt_config->beginGroup(QStringLiteral("Miscellaneous")); + + WriteCategory(Settings::Category::Miscellaneous); + + qt_config->endGroup(); +} + +void Config::SavePathValues() { + qt_config->beginGroup(QStringLiteral("Paths")); + + WriteSetting(QStringLiteral("romsPath"), UISettings::values.roms_path); + WriteSetting(QStringLiteral("symbolsPath"), UISettings::values.symbols_path); + qt_config->beginWriteArray(QStringLiteral("gamedirs")); + for (int i = 0; i < UISettings::values.game_dirs.size(); ++i) { + qt_config->setArrayIndex(i); + const auto& game_dir = UISettings::values.game_dirs[i]; + WriteSetting(QStringLiteral("path"), game_dir.path); + WriteSetting(QStringLiteral("deep_scan"), game_dir.deep_scan, false); + WriteSetting(QStringLiteral("expanded"), game_dir.expanded, true); + } + qt_config->endArray(); + WriteSetting(QStringLiteral("recentFiles"), UISettings::values.recent_files); + WriteSetting(QStringLiteral("language"), UISettings::values.language, QString{}); + + qt_config->endGroup(); +} + +void Config::SaveCpuValues() { + qt_config->beginGroup(QStringLiteral("Cpu")); + + WriteCategory(Settings::Category::Cpu); + WriteCategory(Settings::Category::CpuDebug); + WriteCategory(Settings::Category::CpuUnsafe); + + qt_config->endGroup(); +} + +void Config::SaveRendererValues() { + qt_config->beginGroup(QStringLiteral("Renderer")); + + WriteCategory(Settings::Category::Renderer); + WriteCategory(Settings::Category::RendererAdvanced); + WriteCategory(Settings::Category::RendererDebug); + + qt_config->endGroup(); +} + +void Config::SaveScreenshotValues() { + qt_config->beginGroup(QStringLiteral("Screenshots")); + + WriteSetting(QStringLiteral("screenshot_path"), + QString::fromStdString(FS::GetSudachiPathString(FS::SudachiPath::ScreenshotsDir))); + WriteCategory(Settings::Category::Screenshots); + + qt_config->endGroup(); +} + +void Config::SaveShortcutValues() { + qt_config->beginGroup(QStringLiteral("Shortcuts")); + + // Lengths of UISettings::values.shortcuts & default_hotkeys are same. + // However, their ordering must also be the same. + for (std::size_t i = 0; i < default_hotkeys.size(); i++) { + const auto& [name, group, shortcut] = UISettings::values.shortcuts[i]; + const auto& default_hotkey = default_hotkeys[i].shortcut; + + qt_config->beginGroup(group); + qt_config->beginGroup(name); + WriteSetting(QStringLiteral("KeySeq"), shortcut.keyseq, default_hotkey.keyseq); + WriteSetting(QStringLiteral("Controller_KeySeq"), shortcut.controller_keyseq, + default_hotkey.controller_keyseq); + WriteSetting(QStringLiteral("Context"), shortcut.context, default_hotkey.context); + WriteSetting(QStringLiteral("Repeat"), shortcut.repeat, default_hotkey.repeat); + qt_config->endGroup(); + qt_config->endGroup(); + } + + qt_config->endGroup(); +} + +void Config::SaveSystemValues() { + qt_config->beginGroup(QStringLiteral("System")); + + WriteCategory(Settings::Category::System); + WriteCategory(Settings::Category::SystemAudio); + + qt_config->endGroup(); +} + +void Config::SaveUIValues() { + qt_config->beginGroup(QStringLiteral("UI")); + + WriteCategory(Settings::Category::Ui); + WriteCategory(Settings::Category::UiGeneral); + + WriteSetting(QStringLiteral("theme"), UISettings::values.theme, + QString::fromUtf8(UISettings::themes[static_cast(default_theme)].second)); + + SaveUIGamelistValues(); + SaveUILayoutValues(); + SavePathValues(); + SaveScreenshotValues(); + SaveShortcutValues(); + SaveMultiplayerValues(); + + qt_config->endGroup(); +} + +void Config::SaveUIGamelistValues() { + qt_config->beginGroup(QStringLiteral("UIGameList")); + + WriteCategory(Settings::Category::UiGameList); + + qt_config->beginWriteArray(QStringLiteral("favorites")); + for (int i = 0; i < UISettings::values.favorited_ids.size(); i++) { + qt_config->setArrayIndex(i); + WriteSetting(QStringLiteral("program_id"), + QVariant::fromValue(UISettings::values.favorited_ids[i])); + } + qt_config->endArray(); + + qt_config->endGroup(); +} + +void Config::SaveUILayoutValues() { + qt_config->beginGroup(QStringLiteral("UILayout")); + + WriteSetting(QStringLiteral("geometry"), UISettings::values.geometry); + WriteSetting(QStringLiteral("state"), UISettings::values.state); + WriteSetting(QStringLiteral("geometryRenderWindow"), UISettings::values.renderwindow_geometry); + WriteSetting(QStringLiteral("gameListHeaderState"), UISettings::values.gamelist_header_state); + WriteSetting(QStringLiteral("microProfileDialogGeometry"), + UISettings::values.microprofile_geometry); + + WriteCategory(Settings::Category::UiLayout); + + qt_config->endGroup(); +} + +void Config::SaveWebServiceValues() { + qt_config->beginGroup(QStringLiteral("WebService")); + + WriteCategory(Settings::Category::WebService); + + qt_config->endGroup(); +} + +void Config::SaveMultiplayerValues() { + qt_config->beginGroup(QStringLiteral("Multiplayer")); + + WriteCategory(Settings::Category::Multiplayer); + + // Write ban list + qt_config->beginWriteArray(QStringLiteral("username_ban_list")); + for (std::size_t i = 0; i < UISettings::values.multiplayer_ban_list.first.size(); ++i) { + qt_config->setArrayIndex(static_cast(i)); + WriteSetting(QStringLiteral("username"), + QString::fromStdString(UISettings::values.multiplayer_ban_list.first[i])); + } + qt_config->endArray(); + qt_config->beginWriteArray(QStringLiteral("ip_ban_list")); + for (std::size_t i = 0; i < UISettings::values.multiplayer_ban_list.second.size(); ++i) { + qt_config->setArrayIndex(static_cast(i)); + WriteSetting(QStringLiteral("ip"), + QString::fromStdString(UISettings::values.multiplayer_ban_list.second[i])); + } + qt_config->endArray(); + + qt_config->endGroup(); +} + +QVariant Config::ReadSetting(const QString& name) const { + return qt_config->value(name); +} + +QVariant Config::ReadSetting(const QString& name, const QVariant& default_value) const { + QVariant result; + if (qt_config->value(name + QStringLiteral("/default"), false).toBool()) { + result = default_value; + } else { + result = qt_config->value(name, default_value); + } + return result; +} + +void Config::WriteSetting(const QString& name, const QVariant& value) { + qt_config->setValue(name, value); +} + +void Config::WriteSetting(const QString& name, const QVariant& value, + const QVariant& default_value) { + qt_config->setValue(name + QStringLiteral("/default"), value == default_value); + qt_config->setValue(name, value); +} + +void Config::WriteSetting(const QString& name, const QVariant& value, const QVariant& default_value, + bool use_global) { + if (!global) { + qt_config->setValue(name + QStringLiteral("/use_global"), use_global); + } + if (global || !use_global) { + qt_config->setValue(name + QStringLiteral("/default"), value == default_value); + qt_config->setValue(name, value); + } +} + +void Config::Reload() { + ReadValues(); + // To apply default value changes + SaveValues(); +} + +void Config::Save() { + SaveValues(); +} + +void Config::ReadControlPlayerValue(std::size_t player_index) { + qt_config->beginGroup(QStringLiteral("Controls")); + ReadPlayerValue(player_index); + qt_config->endGroup(); +} + +void Config::SaveControlPlayerValue(std::size_t player_index) { + qt_config->beginGroup(QStringLiteral("Controls")); + SavePlayerValue(player_index); + qt_config->endGroup(); +} + +void Config::ClearControlPlayerValues() { + qt_config->beginGroup(QStringLiteral("Controls")); + // If key is an empty string, all keys in the current group() are removed. + qt_config->remove(QString{}); + qt_config->endGroup(); +} + +const std::string& Config::GetConfigFilePath() const { + return qt_config_loc; +} + +static auto FindRelevantList(Settings::Category category) { + auto& map = Settings::values.linkage.by_category; + if (map.contains(category)) { + return Settings::values.linkage.by_category[category]; + } + return UISettings::values.linkage.by_category[category]; +} + +void Config::ReadCategory(Settings::Category category) { + const auto& settings = FindRelevantList(category); + std::for_each(settings.begin(), settings.end(), + [&](const auto& setting) { ReadSettingGeneric(setting); }); +} + +void Config::WriteCategory(Settings::Category category) { + const auto& settings = FindRelevantList(category); + std::for_each(settings.begin(), settings.end(), + [&](const auto& setting) { WriteSettingGeneric(setting); }); +} + +void Config::ReadSettingGeneric(Settings::BasicSetting* const setting) { + if (!setting->Save() || (!setting->Switchable() && !global)) { + return; + } + const QString name = QString::fromStdString(setting->GetLabel()); + const auto default_value = + QVariant::fromValue(QString::fromStdString(setting->DefaultToString())); + + bool use_global = true; + if (setting->Switchable() && !global) { + use_global = qt_config->value(name + QStringLiteral("/use_global"), true).value(); + setting->SetGlobal(use_global); + } + + if (global || !use_global) { + const bool is_default = + qt_config->value(name + QStringLiteral("/default"), true).value(); + if (!is_default) { + setting->LoadString( + qt_config->value(name, default_value).value().toStdString()); + } else { + // Empty string resets the Setting to default + setting->LoadString(""); + } + } +} + +void Config::WriteSettingGeneric(Settings::BasicSetting* const setting) const { + if (!setting->Save()) { + return; + } + const QVariant value = QVariant::fromValue(QString::fromStdString(setting->ToString())); + const QVariant default_value = + QVariant::fromValue(QString::fromStdString(setting->DefaultToString())); + const QString label = QString::fromStdString(setting->GetLabel()); + if (setting->Switchable()) { + if (!global) { + qt_config->setValue(label + QStringLiteral("/use_global"), setting->UsingGlobal()); + } + if (global || !setting->UsingGlobal()) { + qt_config->setValue(label + QStringLiteral("/default"), value == default_value); + qt_config->setValue(label, value); + } + } else if (global) { + qt_config->setValue(label + QStringLiteral("/default"), value == default_value); + qt_config->setValue(label, value); + } +} diff --git a/src/sudachi/configuration/config.h b/src/sudachi/configuration/config.h new file mode 100644 index 0000000..c468bc0 --- /dev/null +++ b/src/sudachi/configuration/config.h @@ -0,0 +1,179 @@ +// SPDX-FileCopyrightText: 2014 Citra Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include +#include +#include +#include +#include +#include "common/settings.h" +#include "common/settings_enums.h" +#include "sudachi/uisettings.h" + +class QSettings; + +namespace Core { +class System; +} + +class Config { +public: + enum class ConfigType { + GlobalConfig, + PerGameConfig, + InputProfile, + }; + + explicit Config(const std::string& config_name = "qt-config", + ConfigType config_type = ConfigType::GlobalConfig); + ~Config(); + + void Reload(); + void Save(); + + void ReadControlPlayerValue(std::size_t player_index); + void SaveControlPlayerValue(std::size_t player_index); + void ClearControlPlayerValues(); + + const std::string& GetConfigFilePath() const; + + static const std::array default_buttons; + static const std::array default_motions; + static const std::array, Settings::NativeAnalog::NumAnalogs> default_analogs; + static const std::array default_stick_mod; + static const std::array default_ringcon_analogs; + static const std::array + default_mouse_buttons; + static const std::array default_keyboard_keys; + static const std::array default_keyboard_mods; + static const std::array default_hotkeys; + + static const std::map anti_aliasing_texts_map; + static const std::map scaling_filter_texts_map; + static const std::map use_docked_mode_texts_map; + static const std::map gpu_accuracy_texts_map; + static const std::map renderer_backend_texts_map; + static const std::map shader_backend_texts_map; + + static constexpr UISettings::Theme default_theme{ +#ifdef _WIN32 + UISettings::Theme::DarkColorful +#else + UISettings::Theme::DefaultColorful +#endif + }; + +private: + void Initialize(const std::string& config_name); + bool IsCustomConfig(); + + void ReadValues(); + void ReadPlayerValue(std::size_t player_index); + void ReadDebugValues(); + void ReadKeyboardValues(); + void ReadMouseValues(); + void ReadTouchscreenValues(); + void ReadMotionTouchValues(); + void ReadHidbusValues(); + void ReadIrCameraValues(); + + // Read functions bases off the respective config section names. + void ReadAudioValues(); + void ReadControlValues(); + void ReadCoreValues(); + void ReadDataStorageValues(); + void ReadDebuggingValues(); + void ReadServiceValues(); + void ReadDisabledAddOnValues(); + void ReadMiscellaneousValues(); + void ReadPathValues(); + void ReadCpuValues(); + void ReadRendererValues(); + void ReadScreenshotValues(); + void ReadShortcutValues(); + void ReadSystemValues(); + void ReadUIValues(); + void ReadUIGamelistValues(); + void ReadUILayoutValues(); + void ReadWebServiceValues(); + void ReadMultiplayerValues(); + void ReadNetworkValues(); + + void SaveValues(); + void SavePlayerValue(std::size_t player_index); + void SaveDebugValues(); + void SaveMouseValues(); + void SaveTouchscreenValues(); + void SaveMotionTouchValues(); + void SaveHidbusValues(); + void SaveIrCameraValues(); + + // Save functions based off the respective config section names. + void SaveAudioValues(); + void SaveControlValues(); + void SaveCoreValues(); + void SaveDataStorageValues(); + void SaveDebuggingValues(); + void SaveNetworkValues(); + void SaveDisabledAddOnValues(); + void SaveMiscellaneousValues(); + void SavePathValues(); + void SaveCpuValues(); + void SaveRendererValues(); + void SaveScreenshotValues(); + void SaveShortcutValues(); + void SaveSystemValues(); + void SaveUIValues(); + void SaveUIGamelistValues(); + void SaveUILayoutValues(); + void SaveWebServiceValues(); + void SaveMultiplayerValues(); + + /** + * Reads a setting from the qt_config. + * + * @param name The setting's identifier + * @param default_value The value to use when the setting is not already present in the config + */ + QVariant ReadSetting(const QString& name) const; + QVariant ReadSetting(const QString& name, const QVariant& default_value) const; + + /** + * Writes a setting to the qt_config. + * + * @param name The setting's idetentifier + * @param value Value of the setting + * @param default_value Default of the setting if not present in qt_config + * @param use_global Specifies if the custom or global config should be in use, for custom + * configs + */ + void WriteSetting(const QString& name, const QVariant& value); + void WriteSetting(const QString& name, const QVariant& value, const QVariant& default_value); + void WriteSetting(const QString& name, const QVariant& value, const QVariant& default_value, + bool use_global); + + void ReadCategory(Settings::Category category); + void WriteCategory(Settings::Category category); + void ReadSettingGeneric(Settings::BasicSetting* const setting); + void WriteSettingGeneric(Settings::BasicSetting* const setting) const; + + const ConfigType type; + std::unique_ptr qt_config; + std::string qt_config_loc; + const bool global; +}; + +// These metatype declarations cannot be in common/settings.h because core is devoid of QT +Q_DECLARE_METATYPE(Settings::CpuAccuracy); +Q_DECLARE_METATYPE(Settings::GpuAccuracy); +Q_DECLARE_METATYPE(Settings::FullscreenMode); +Q_DECLARE_METATYPE(Settings::NvdecEmulation); +Q_DECLARE_METATYPE(Settings::ResolutionSetup); +Q_DECLARE_METATYPE(Settings::ScalingFilter); +Q_DECLARE_METATYPE(Settings::AntiAliasing); +Q_DECLARE_METATYPE(Settings::RendererBackend); +Q_DECLARE_METATYPE(Settings::ShaderBackend); +Q_DECLARE_METATYPE(Settings::AstcRecompression); +Q_DECLARE_METATYPE(Settings::AstcDecodeMode); diff --git a/src/sudachi/configuration/configuration_shared.cpp b/src/sudachi/configuration/configuration_shared.cpp new file mode 100644 index 0000000..b53921f --- /dev/null +++ b/src/sudachi/configuration/configuration_shared.cpp @@ -0,0 +1,19 @@ +// SPDX-FileCopyrightText: 2016 Citra Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include +#include +#include +#include "sudachi/configuration/configuration_shared.h" + +namespace ConfigurationShared { + +Tab::Tab(std::shared_ptr> group, QWidget* parent) : QWidget(parent) { + if (group != nullptr) { + group->push_back(this); + } +} + +Tab::~Tab() = default; + +} // namespace ConfigurationShared diff --git a/src/sudachi/configuration/configuration_shared.h b/src/sudachi/configuration/configuration_shared.h new file mode 100644 index 0000000..31897a6 --- /dev/null +++ b/src/sudachi/configuration/configuration_shared.h @@ -0,0 +1,27 @@ +// SPDX-FileCopyrightText: 2016 Citra Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include +#include +#include +#include +#include + +class QObject; + +namespace ConfigurationShared { + +class Tab : public QWidget { + Q_OBJECT + +public: + explicit Tab(std::shared_ptr> group, QWidget* parent = nullptr); + ~Tab(); + + virtual void ApplyConfiguration() = 0; + virtual void SetConfiguration() = 0; +}; + +} // namespace ConfigurationShared diff --git a/src/sudachi/configuration/configure.ui b/src/sudachi/configuration/configure.ui new file mode 100644 index 0000000..31409ad --- /dev/null +++ b/src/sudachi/configuration/configure.ui @@ -0,0 +1,117 @@ + + + ConfigureDialog + + + + 0 + 0 + 650 + 650 + + + + + 0 + 650 + + + + sudachi Configuration + + + + + + + + + 120 + 0 + + + + + 120 + 16777215 + + + + + + + + -1 + + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Some settings are only available when a game is not running. + + + + + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + + + + + + + + + buttonBox + accepted() + ConfigureDialog + accept() + + + 20 + 20 + + + 20 + 20 + + + + + buttonBox + rejected() + ConfigureDialog + reject() + + + 20 + 20 + + + 20 + 20 + + + + + diff --git a/src/sudachi/configuration/configure_applets.cpp b/src/sudachi/configuration/configure_applets.cpp new file mode 100644 index 0000000..1d885b1 --- /dev/null +++ b/src/sudachi/configuration/configure_applets.cpp @@ -0,0 +1,84 @@ +// SPDX-FileCopyrightText: 2024 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include "common/settings.h" +#include "core/core.h" +#include "ui_configure_applets.h" +#include "sudachi/configuration/configuration_shared.h" +#include "sudachi/configuration/configure_applets.h" +#include "sudachi/configuration/shared_widget.h" + +ConfigureApplets::ConfigureApplets(Core::System& system_, + std::shared_ptr> group_, + const ConfigurationShared::Builder& builder, QWidget* parent) + : Tab(group_, parent), ui{std::make_unique()}, system{system_} { + ui->setupUi(this); + + Setup(builder); + + SetConfiguration(); +} + +ConfigureApplets::~ConfigureApplets() = default; + +void ConfigureApplets::changeEvent(QEvent* event) { + if (event->type() == QEvent::LanguageChange) { + RetranslateUI(); + } + + QWidget::changeEvent(event); +} + +void ConfigureApplets::RetranslateUI() { + ui->retranslateUi(this); +} + +void ConfigureApplets::Setup(const ConfigurationShared::Builder& builder) { + auto& library_applets_layout = *ui->group_library_applet_modes->layout(); + std::map applets_hold{}; + + std::vector settings; + auto push = [&settings](auto& list) { + for (auto setting : list) { + settings.push_back(setting); + } + }; + + push(Settings::values.linkage.by_category[Settings::Category::LibraryApplet]); + + for (auto setting : settings) { + ConfigurationShared::Widget* widget = builder.BuildWidget(setting, apply_funcs); + + if (widget == nullptr) { + continue; + } + if (!widget->Valid()) { + widget->deleteLater(); + continue; + } + + // Untested applets + if (setting->Id() == Settings::values.data_erase_applet_mode.Id() || + setting->Id() == Settings::values.net_connect_applet_mode.Id() || + setting->Id() == Settings::values.shop_applet_mode.Id() || + setting->Id() == Settings::values.login_share_applet_mode.Id() || + setting->Id() == Settings::values.wifi_web_auth_applet_mode.Id() || + setting->Id() == Settings::values.my_page_applet_mode.Id()) { + widget->setHidden(true); + } + + applets_hold.emplace(setting->Id(), widget); + } + for (const auto& [label, widget] : applets_hold) { + library_applets_layout.addWidget(widget); + } +} + +void ConfigureApplets::SetConfiguration() {} + +void ConfigureApplets::ApplyConfiguration() { + const bool powered_on = system.IsPoweredOn(); + for (const auto& func : apply_funcs) { + func(powered_on); + } +} diff --git a/src/sudachi/configuration/configure_applets.h b/src/sudachi/configuration/configure_applets.h new file mode 100644 index 0000000..53076e4 --- /dev/null +++ b/src/sudachi/configuration/configure_applets.h @@ -0,0 +1,48 @@ +// SPDX-FileCopyrightText: 2024 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include +#include "sudachi/configuration/configuration_shared.h" + +class QCheckBox; +class QLineEdit; +class QComboBox; +class QDateTimeEdit; +namespace Core { +class System; +} + +namespace Ui { +class ConfigureApplets; +} + +namespace ConfigurationShared { +class Builder; +} + +class ConfigureApplets : public ConfigurationShared::Tab { +public: + explicit ConfigureApplets(Core::System& system_, + std::shared_ptr> group, + const ConfigurationShared::Builder& builder, + QWidget* parent = nullptr); + ~ConfigureApplets() override; + + void ApplyConfiguration() override; + void SetConfiguration() override; + +private: + void changeEvent(QEvent* event) override; + void RetranslateUI(); + + void Setup(const ConfigurationShared::Builder& builder); + + std::vector> apply_funcs{}; + + std::unique_ptr ui; + bool enabled = false; + + Core::System& system; +}; diff --git a/src/sudachi/configuration/configure_applets.ui b/src/sudachi/configuration/configure_applets.ui new file mode 100644 index 0000000..6f2ca66 --- /dev/null +++ b/src/sudachi/configuration/configure_applets.ui @@ -0,0 +1,65 @@ + + + ConfigureApplets + + + + 0 + 0 + 605 + 300 + + + + Form + + + Applets + + + + + + + + Applet mode preference + + + + + + + 0 + + + 0 + + + 0 + + + + + + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + diff --git a/src/sudachi/configuration/configure_audio.cpp b/src/sudachi/configuration/configure_audio.cpp new file mode 100644 index 0000000..53e2d82 --- /dev/null +++ b/src/sudachi/configuration/configure_audio.cpp @@ -0,0 +1,278 @@ +// SPDX-FileCopyrightText: Copyright 2018 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include +#include +#include +#include +#include + +#include "audio_core/sink/sink.h" +#include "audio_core/sink/sink_details.h" +#include "common/common_types.h" +#include "common/settings.h" +#include "common/settings_common.h" +#include "core/core.h" +#include "ui_configure_audio.h" +#include "sudachi/configuration/configuration_shared.h" +#include "sudachi/configuration/configure_audio.h" +#include "sudachi/configuration/shared_translation.h" +#include "sudachi/configuration/shared_widget.h" +#include "sudachi/uisettings.h" + +ConfigureAudio::ConfigureAudio(const Core::System& system_, + std::shared_ptr> group_, + const ConfigurationShared::Builder& builder, QWidget* parent) + : Tab(group_, parent), ui(std::make_unique()), system{system_} { + ui->setupUi(this); + Setup(builder); + + SetConfiguration(); +} + +ConfigureAudio::~ConfigureAudio() = default; + +void ConfigureAudio::Setup(const ConfigurationShared::Builder& builder) { + auto& layout = *ui->audio_widget->layout(); + + std::vector settings; + + std::map hold; + + auto push_settings = [&](Settings::Category category) { + for (auto* setting : Settings::values.linkage.by_category[category]) { + settings.push_back(setting); + } + }; + + auto push_ui_settings = [&](Settings::Category category) { + for (auto* setting : UISettings::values.linkage.by_category[category]) { + settings.push_back(setting); + } + }; + + push_settings(Settings::Category::Audio); + push_settings(Settings::Category::SystemAudio); + push_ui_settings(Settings::Category::UiAudio); + + for (auto* setting : settings) { + auto* widget = builder.BuildWidget(setting, apply_funcs); + + if (widget == nullptr) { + continue; + } + if (!widget->Valid()) { + widget->deleteLater(); + continue; + } + + hold.emplace(std::pair{setting->Id(), widget}); + + auto global_sink_match = [this] { + return static_cast(sink_combo_box->currentIndex()) == + Settings::values.sink_id.GetValue(true); + }; + if (setting->Id() == Settings::values.sink_id.Id()) { + // TODO (lat9nq): Let the system manage sink_id + sink_combo_box = widget->combobox; + InitializeAudioSinkComboBox(); + + if (Settings::IsConfiguringGlobal()) { + connect(sink_combo_box, qOverload(&QComboBox::currentIndexChanged), this, + &ConfigureAudio::UpdateAudioDevices); + } else { + restore_sink_button = ConfigurationShared::Widget::CreateRestoreGlobalButton( + Settings::values.sink_id.UsingGlobal(), widget); + widget->layout()->addWidget(restore_sink_button); + connect(restore_sink_button, &QAbstractButton::clicked, [this](bool) { + Settings::values.sink_id.SetGlobal(true); + const int sink_index = static_cast(Settings::values.sink_id.GetValue()); + sink_combo_box->setCurrentIndex(sink_index); + ConfigureAudio::UpdateAudioDevices(sink_index); + Settings::values.audio_output_device_id.SetGlobal(true); + Settings::values.audio_input_device_id.SetGlobal(true); + restore_sink_button->setVisible(false); + }); + connect(sink_combo_box, qOverload(&QComboBox::currentIndexChanged), + [this, global_sink_match](const int slot) { + Settings::values.sink_id.SetGlobal(false); + Settings::values.audio_output_device_id.SetGlobal(false); + Settings::values.audio_input_device_id.SetGlobal(false); + + restore_sink_button->setVisible(true); + restore_sink_button->setEnabled(true); + output_device_combo_box->setCurrentIndex(0); + restore_output_device_button->setVisible(true); + restore_output_device_button->setEnabled(global_sink_match()); + input_device_combo_box->setCurrentIndex(0); + restore_input_device_button->setVisible(true); + restore_input_device_button->setEnabled(global_sink_match()); + ConfigureAudio::UpdateAudioDevices(slot); + }); + } + } else if (setting->Id() == Settings::values.audio_output_device_id.Id()) { + // Keep track of output (and input) device comboboxes to populate them with system + // devices, which are determined at run time + output_device_combo_box = widget->combobox; + + if (!Settings::IsConfiguringGlobal()) { + restore_output_device_button = + ConfigurationShared::Widget::CreateRestoreGlobalButton( + Settings::values.audio_output_device_id.UsingGlobal(), widget); + restore_output_device_button->setEnabled(global_sink_match()); + restore_output_device_button->setVisible( + !Settings::values.audio_output_device_id.UsingGlobal()); + widget->layout()->addWidget(restore_output_device_button); + connect(restore_output_device_button, &QAbstractButton::clicked, [this](bool) { + Settings::values.audio_output_device_id.SetGlobal(true); + SetOutputDevicesFromDeviceID(); + restore_output_device_button->setVisible(false); + }); + connect(output_device_combo_box, qOverload(&QComboBox::currentIndexChanged), + [this, global_sink_match](int) { + if (updating_devices) { + return; + } + Settings::values.audio_output_device_id.SetGlobal(false); + restore_output_device_button->setVisible(true); + restore_output_device_button->setEnabled(global_sink_match()); + }); + } + } else if (setting->Id() == Settings::values.audio_input_device_id.Id()) { + input_device_combo_box = widget->combobox; + + if (!Settings::IsConfiguringGlobal()) { + restore_input_device_button = + ConfigurationShared::Widget::CreateRestoreGlobalButton( + Settings::values.audio_input_device_id.UsingGlobal(), widget); + widget->layout()->addWidget(restore_input_device_button); + connect(restore_input_device_button, &QAbstractButton::clicked, [this](bool) { + Settings::values.audio_input_device_id.SetGlobal(true); + SetInputDevicesFromDeviceID(); + restore_input_device_button->setVisible(false); + }); + connect(input_device_combo_box, qOverload(&QComboBox::currentIndexChanged), + [this, global_sink_match](int) { + if (updating_devices) { + return; + } + Settings::values.audio_input_device_id.SetGlobal(false); + restore_input_device_button->setVisible(true); + restore_input_device_button->setEnabled(global_sink_match()); + }); + } + } + } + + for (const auto& [id, widget] : hold) { + layout.addWidget(widget); + } +} + +void ConfigureAudio::SetConfiguration() { + SetOutputSinkFromSinkID(); + + // The device list cannot be pre-populated (nor listed) until the output sink is known. + UpdateAudioDevices(sink_combo_box->currentIndex()); + + SetOutputDevicesFromDeviceID(); + SetInputDevicesFromDeviceID(); +} + +void ConfigureAudio::SetOutputSinkFromSinkID() { + [[maybe_unused]] const QSignalBlocker blocker(sink_combo_box); + + int new_sink_index = 0; + const QString sink_id = QString::fromStdString(Settings::values.sink_id.ToString()); + for (int index = 0; index < sink_combo_box->count(); index++) { + if (sink_combo_box->itemText(index) == sink_id) { + new_sink_index = index; + break; + } + } + + sink_combo_box->setCurrentIndex(new_sink_index); +} + +void ConfigureAudio::SetOutputDevicesFromDeviceID() { + int new_device_index = 0; + + const QString output_device_id = + QString::fromStdString(Settings::values.audio_output_device_id.GetValue()); + for (int index = 0; index < output_device_combo_box->count(); index++) { + if (output_device_combo_box->itemText(index) == output_device_id) { + new_device_index = index; + break; + } + } + + output_device_combo_box->setCurrentIndex(new_device_index); +} + +void ConfigureAudio::SetInputDevicesFromDeviceID() { + int new_device_index = 0; + const QString input_device_id = + QString::fromStdString(Settings::values.audio_input_device_id.GetValue()); + for (int index = 0; index < input_device_combo_box->count(); index++) { + if (input_device_combo_box->itemText(index) == input_device_id) { + new_device_index = index; + break; + } + } + + input_device_combo_box->setCurrentIndex(new_device_index); +} + +void ConfigureAudio::ApplyConfiguration() { + const bool is_powered_on = system.IsPoweredOn(); + for (const auto& apply_func : apply_funcs) { + apply_func(is_powered_on); + } + + Settings::values.sink_id.LoadString( + sink_combo_box->itemText(sink_combo_box->currentIndex()).toStdString()); + Settings::values.audio_output_device_id.SetValue( + output_device_combo_box->itemText(output_device_combo_box->currentIndex()).toStdString()); + Settings::values.audio_input_device_id.SetValue( + input_device_combo_box->itemText(input_device_combo_box->currentIndex()).toStdString()); +} + +void ConfigureAudio::changeEvent(QEvent* event) { + if (event->type() == QEvent::LanguageChange) { + RetranslateUI(); + } + + QWidget::changeEvent(event); +} + +void ConfigureAudio::UpdateAudioDevices(int sink_index) { + updating_devices = true; + output_device_combo_box->clear(); + output_device_combo_box->addItem(QString::fromUtf8(AudioCore::Sink::auto_device_name)); + + const auto sink_id = + Settings::ToEnum(sink_combo_box->itemText(sink_index).toStdString()); + for (const auto& device : AudioCore::Sink::GetDeviceListForSink(sink_id, false)) { + output_device_combo_box->addItem(QString::fromStdString(device)); + } + + input_device_combo_box->clear(); + input_device_combo_box->addItem(QString::fromUtf8(AudioCore::Sink::auto_device_name)); + for (const auto& device : AudioCore::Sink::GetDeviceListForSink(sink_id, true)) { + input_device_combo_box->addItem(QString::fromStdString(device)); + } + updating_devices = false; +} + +void ConfigureAudio::InitializeAudioSinkComboBox() { + sink_combo_box->clear(); + sink_combo_box->addItem(QString::fromUtf8(AudioCore::Sink::auto_device_name)); + + for (const auto& id : AudioCore::Sink::GetSinkIDs()) { + sink_combo_box->addItem(QString::fromStdString(Settings::CanonicalizeEnum(id))); + } +} + +void ConfigureAudio::RetranslateUI() { + ui->retranslateUi(this); +} diff --git a/src/sudachi/configuration/configure_audio.h b/src/sudachi/configuration/configure_audio.h new file mode 100644 index 0000000..e18812e --- /dev/null +++ b/src/sudachi/configuration/configure_audio.h @@ -0,0 +1,66 @@ +// SPDX-FileCopyrightText: Copyright 2018 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include +#include +#include +#include +#include "sudachi/configuration/configuration_shared.h" + +class QComboBox; + +namespace Core { +class System; +} + +namespace Ui { +class ConfigureAudio; +} + +namespace ConfigurationShared { +class Builder; +} + +class ConfigureAudio : public ConfigurationShared::Tab { + Q_OBJECT + +public: + explicit ConfigureAudio(const Core::System& system_, + std::shared_ptr> group, + const ConfigurationShared::Builder& builder, QWidget* parent = nullptr); + ~ConfigureAudio() override; + + void ApplyConfiguration() override; + void SetConfiguration() override; + +private: + void changeEvent(QEvent* event) override; + + void InitializeAudioSinkComboBox(); + + void RetranslateUI(); + + void UpdateAudioDevices(int sink_index); + + void SetOutputSinkFromSinkID(); + void SetOutputDevicesFromDeviceID(); + void SetInputDevicesFromDeviceID(); + + void Setup(const ConfigurationShared::Builder& builder); + + std::unique_ptr ui; + + const Core::System& system; + + std::vector> apply_funcs{}; + + bool updating_devices = false; + QComboBox* sink_combo_box; + QPushButton* restore_sink_button; + QComboBox* output_device_combo_box; + QPushButton* restore_output_device_button; + QComboBox* input_device_combo_box; + QPushButton* restore_input_device_button; +}; diff --git a/src/sudachi/configuration/configure_audio.ui b/src/sudachi/configuration/configure_audio.ui new file mode 100644 index 0000000..1181aeb --- /dev/null +++ b/src/sudachi/configuration/configure_audio.ui @@ -0,0 +1,67 @@ + + + ConfigureAudio + + + + 0 + 0 + 367 + 368 + + + + Audio + + + + + + Audio + + + + + + + 16777215 + 16777213 + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + + + + + + Qt::Vertical + + + + 167 + 55 + + + + + + + + + diff --git a/src/sudachi/configuration/configure_camera.cpp b/src/sudachi/configuration/configure_camera.cpp new file mode 100644 index 0000000..343d00b --- /dev/null +++ b/src/sudachi/configuration/configure_camera.cpp @@ -0,0 +1,163 @@ +// Text : Copyright 2022 sudachi Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +#include +#include +#if (QT_VERSION < QT_VERSION_CHECK(6, 0, 0)) && SUDACHI_USE_QT_MULTIMEDIA +#include +#include +#endif +#include +#include + +#include "common/settings.h" +#include "input_common/drivers/camera.h" +#include "input_common/main.h" +#include "ui_configure_camera.h" +#include "sudachi/configuration/configure_camera.h" + +ConfigureCamera::ConfigureCamera(QWidget* parent, InputCommon::InputSubsystem* input_subsystem_) + : QDialog(parent), input_subsystem{input_subsystem_}, + ui(std::make_unique()) { + ui->setupUi(this); + + connect(ui->restore_defaults_button, &QPushButton::clicked, this, + &ConfigureCamera::RestoreDefaults); + connect(ui->preview_button, &QPushButton::clicked, this, &ConfigureCamera::PreviewCamera); + + auto blank_image = QImage(320, 240, QImage::Format::Format_RGB32); + blank_image.fill(Qt::black); + DisplayCapturedFrame(0, blank_image); + + LoadConfiguration(); + resize(0, 0); +} + +ConfigureCamera::~ConfigureCamera() = default; + +void ConfigureCamera::PreviewCamera() { +#if (QT_VERSION < QT_VERSION_CHECK(6, 0, 0)) && SUDACHI_USE_QT_MULTIMEDIA + const auto index = ui->ir_sensor_combo_box->currentIndex(); + bool camera_found = false; + const QList cameras = QCameraInfo::availableCameras(); + for (const QCameraInfo& cameraInfo : cameras) { + if (input_devices[index] == cameraInfo.deviceName().toStdString() || + input_devices[index] == "Auto") { + LOG_INFO(Frontend, "Selected Camera {} {}", cameraInfo.description().toStdString(), + cameraInfo.deviceName().toStdString()); + camera = std::make_unique(cameraInfo); + if (!camera->isCaptureModeSupported(QCamera::CaptureMode::CaptureViewfinder) && + !camera->isCaptureModeSupported(QCamera::CaptureMode::CaptureStillImage)) { + LOG_ERROR(Frontend, + "Camera doesn't support CaptureViewfinder or CaptureStillImage"); + continue; + } + camera_found = true; + break; + } + } + + // Clear previous frame + auto blank_image = QImage(320, 240, QImage::Format::Format_RGB32); + blank_image.fill(Qt::black); + DisplayCapturedFrame(0, blank_image); + + if (!camera_found) { + return; + } + + camera_capture = std::make_unique(camera.get()); + + if (!camera_capture->isCaptureDestinationSupported( + QCameraImageCapture::CaptureDestination::CaptureToBuffer)) { + LOG_ERROR(Frontend, "Camera doesn't support saving to buffer"); + return; + } + + camera_capture->setCaptureDestination(QCameraImageCapture::CaptureDestination::CaptureToBuffer); + connect(camera_capture.get(), &QCameraImageCapture::imageCaptured, this, + &ConfigureCamera::DisplayCapturedFrame); + camera->unload(); + if (camera->isCaptureModeSupported(QCamera::CaptureMode::CaptureViewfinder)) { + camera->setCaptureMode(QCamera::CaptureViewfinder); + } else if (camera->isCaptureModeSupported(QCamera::CaptureMode::CaptureStillImage)) { + camera->setCaptureMode(QCamera::CaptureStillImage); + } + camera->load(); + camera->start(); + + pending_snapshots = 0; + is_virtual_camera = false; + + camera_timer = std::make_unique(); + connect(camera_timer.get(), &QTimer::timeout, [this] { + // If the camera doesn't capture, test for virtual cameras + if (pending_snapshots > 5) { + is_virtual_camera = true; + } + // Virtual cameras like obs need to reset the camera every capture + if (is_virtual_camera) { + camera->stop(); + camera->start(); + } + pending_snapshots++; + camera_capture->capture(); + }); + + camera_timer->start(250); +#endif +} + +void ConfigureCamera::DisplayCapturedFrame(int requestId, const QImage& img) { + LOG_INFO(Frontend, "ImageCaptured {} {}", img.width(), img.height()); + const auto converted = img.scaled(320, 240, Qt::AspectRatioMode::IgnoreAspectRatio, + Qt::TransformationMode::SmoothTransformation); + ui->preview_box->setPixmap(QPixmap::fromImage(converted)); + pending_snapshots = 0; +} + +void ConfigureCamera::changeEvent(QEvent* event) { + if (event->type() == QEvent::LanguageChange) { + RetranslateUI(); + } + + QDialog::changeEvent(event); +} + +void ConfigureCamera::RetranslateUI() { + ui->retranslateUi(this); +} + +void ConfigureCamera::ApplyConfiguration() { + const auto index = ui->ir_sensor_combo_box->currentIndex(); + Settings::values.ir_sensor_device.SetValue(input_devices[index]); +} + +void ConfigureCamera::LoadConfiguration() { + input_devices.clear(); + ui->ir_sensor_combo_box->clear(); + input_devices.push_back("Auto"); + ui->ir_sensor_combo_box->addItem(tr("Auto")); +#if (QT_VERSION < QT_VERSION_CHECK(6, 0, 0)) && SUDACHI_USE_QT_MULTIMEDIA + const auto cameras = QCameraInfo::availableCameras(); + for (const QCameraInfo& cameraInfo : cameras) { + input_devices.push_back(cameraInfo.deviceName().toStdString()); + ui->ir_sensor_combo_box->addItem(cameraInfo.description()); + } +#endif + + const auto current_device = Settings::values.ir_sensor_device.GetValue(); + + const auto devices_it = std::find_if( + input_devices.begin(), input_devices.end(), + [current_device](const std::string& device) { return device == current_device; }); + const int device_index = + devices_it != input_devices.end() + ? static_cast(std::distance(input_devices.begin(), devices_it)) + : 0; + ui->ir_sensor_combo_box->setCurrentIndex(device_index); +} + +void ConfigureCamera::RestoreDefaults() { + ui->ir_sensor_combo_box->setCurrentIndex(0); +} diff --git a/src/sudachi/configuration/configure_camera.h b/src/sudachi/configuration/configure_camera.h new file mode 100644 index 0000000..7911709 --- /dev/null +++ b/src/sudachi/configuration/configure_camera.h @@ -0,0 +1,56 @@ +// Text : Copyright 2022 sudachi Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include +#include + +class QTimer; +class QCamera; +class QCameraImageCapture; + +namespace InputCommon { +class InputSubsystem; +} // namespace InputCommon + +namespace Ui { +class ConfigureCamera; +} + +class ConfigureCamera : public QDialog { + Q_OBJECT + +public: + explicit ConfigureCamera(QWidget* parent, InputCommon::InputSubsystem* input_subsystem_); + ~ConfigureCamera() override; + + void ApplyConfiguration(); + +private: + void changeEvent(QEvent* event) override; + void RetranslateUI(); + + /// Load configuration settings. + void LoadConfiguration(); + + /// Restore all buttons to their default values. + void RestoreDefaults(); + + void DisplayCapturedFrame(int requestId, const QImage& img); + + /// Loads and signals the current selected camera to display a frame + void PreviewCamera(); + + InputCommon::InputSubsystem* input_subsystem; + + bool is_virtual_camera; + int pending_snapshots; +#if (QT_VERSION < QT_VERSION_CHECK(6, 0, 0)) && SUDACHI_USE_QT_MULTIMEDIA + std::unique_ptr camera; + std::unique_ptr camera_capture; +#endif + std::unique_ptr camera_timer; + std::vector input_devices; + std::unique_ptr ui; +}; diff --git a/src/sudachi/configuration/configure_camera.ui b/src/sudachi/configuration/configure_camera.ui new file mode 100644 index 0000000..976a9b1 --- /dev/null +++ b/src/sudachi/configuration/configure_camera.ui @@ -0,0 +1,170 @@ + + + ConfigureCamera + + + + 0 + 0 + 298 + 339 + + + + Configure Infrared Camera + + + + + + + 280 + 0 + + + + Select where the image of the emulated camera comes from. It may be a virtual camera or a real camera. + + + true + + + + + + + Qt::Vertical + + + QSizePolicy::Fixed + + + + 20 + 20 + + + + + + + + Camera Image Source: + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Input device: + + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + Preview + + + + + + + 320 + 240 + + + + Resolution: 320*240 + + + + + + + Click to preview + + + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + + Restore Defaults + + + + + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + + + + + + + + + buttonBox + accepted() + ConfigureCamera + accept() + + + buttonBox + rejected() + ConfigureCamera + reject() + + + diff --git a/src/sudachi/configuration/configure_cpu.cpp b/src/sudachi/configuration/configure_cpu.cpp new file mode 100644 index 0000000..3a34469 --- /dev/null +++ b/src/sudachi/configuration/configure_cpu.cpp @@ -0,0 +1,114 @@ +// SPDX-FileCopyrightText: Copyright 2020 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include +#include +#include +#include +#include "common/common_types.h" +#include "common/settings.h" +#include "common/settings_enums.h" +#include "configuration/shared_widget.h" +#include "core/core.h" +#include "ui_configure_cpu.h" +#include "sudachi/configuration/configuration_shared.h" +#include "sudachi/configuration/configure_cpu.h" + +ConfigureCpu::ConfigureCpu(const Core::System& system_, + std::shared_ptr> group_, + const ConfigurationShared::Builder& builder, QWidget* parent) + : Tab(group_, parent), ui{std::make_unique()}, system{system_}, + combobox_translations(builder.ComboboxTranslations()) { + ui->setupUi(this); + + Setup(builder); + + SetConfiguration(); + + connect(accuracy_combobox, qOverload(&QComboBox::currentIndexChanged), this, + &ConfigureCpu::UpdateGroup); + + connect(backend_combobox, qOverload(&QComboBox::currentIndexChanged), this, + &ConfigureCpu::UpdateGroup); + +#ifdef HAS_NCE + ui->backend_group->setVisible(true); +#endif +} + +ConfigureCpu::~ConfigureCpu() = default; + +void ConfigureCpu::SetConfiguration() {} +void ConfigureCpu::Setup(const ConfigurationShared::Builder& builder) { + auto* accuracy_layout = ui->widget_accuracy->layout(); + auto* backend_layout = ui->widget_backend->layout(); + auto* unsafe_layout = ui->unsafe_widget->layout(); + std::map unsafe_hold{}; + + std::vector settings; + const auto push = [&](Settings::Category category) { + for (const auto setting : Settings::values.linkage.by_category[category]) { + settings.push_back(setting); + } + }; + + push(Settings::Category::Cpu); + push(Settings::Category::CpuUnsafe); + + for (const auto setting : settings) { + auto* widget = builder.BuildWidget(setting, apply_funcs); + + if (widget == nullptr) { + continue; + } + if (!widget->Valid()) { + widget->deleteLater(); + continue; + } + + if (setting->Id() == Settings::values.cpu_accuracy.Id()) { + // Keep track of cpu_accuracy combobox to display/hide the unsafe settings + accuracy_layout->addWidget(widget); + accuracy_combobox = widget->combobox; + } else if (setting->Id() == Settings::values.cpu_backend.Id()) { + backend_layout->addWidget(widget); + backend_combobox = widget->combobox; + } else { + // Presently, all other settings here are unsafe checkboxes + unsafe_hold.insert({setting->Id(), widget}); + } + } + + for (const auto& [label, widget] : unsafe_hold) { + unsafe_layout->addWidget(widget); + } + + UpdateGroup(accuracy_combobox->currentIndex()); + UpdateGroup(backend_combobox->currentIndex()); +} + +void ConfigureCpu::UpdateGroup(int index) { + const auto accuracy = static_cast( + combobox_translations.at(Settings::EnumMetadata::Index())[index] + .first); + ui->unsafe_group->setVisible(accuracy == Settings::CpuAccuracy::Unsafe); +} + +void ConfigureCpu::ApplyConfiguration() { + const bool is_powered_on = system.IsPoweredOn(); + for (const auto& apply_func : apply_funcs) { + apply_func(is_powered_on); + } +} + +void ConfigureCpu::changeEvent(QEvent* event) { + if (event->type() == QEvent::LanguageChange) { + RetranslateUI(); + } + + QWidget::changeEvent(event); +} + +void ConfigureCpu::RetranslateUI() { + ui->retranslateUi(this); +} diff --git a/src/sudachi/configuration/configure_cpu.h b/src/sudachi/configuration/configure_cpu.h new file mode 100644 index 0000000..aba6ed4 --- /dev/null +++ b/src/sudachi/configuration/configure_cpu.h @@ -0,0 +1,55 @@ +// SPDX-FileCopyrightText: Copyright 2020 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include +#include +#include +#include "sudachi/configuration/configuration_shared.h" +#include "sudachi/configuration/shared_translation.h" + +class QComboBox; + +namespace Core { +class System; +} + +namespace Ui { +class ConfigureCpu; +} + +namespace ConfigurationShared { +class Builder; +} + +class ConfigureCpu : public ConfigurationShared::Tab { + Q_OBJECT + +public: + explicit ConfigureCpu(const Core::System& system_, + std::shared_ptr> group, + const ConfigurationShared::Builder& builder, QWidget* parent = nullptr); + ~ConfigureCpu() override; + + void ApplyConfiguration() override; + void SetConfiguration() override; + +private: + void changeEvent(QEvent* event) override; + void RetranslateUI(); + + void UpdateGroup(int index); + + void Setup(const ConfigurationShared::Builder& builder); + + std::unique_ptr ui; + + const Core::System& system; + + const ConfigurationShared::ComboboxTranslationMap& combobox_translations; + std::vector> apply_funcs{}; + + QComboBox* accuracy_combobox; + QComboBox* backend_combobox; +}; diff --git a/src/sudachi/configuration/configure_cpu.ui b/src/sudachi/configuration/configure_cpu.ui new file mode 100644 index 0000000..13fd436 --- /dev/null +++ b/src/sudachi/configuration/configure_cpu.ui @@ -0,0 +1,151 @@ + + + ConfigureCpu + + + + 0 + 0 + 448 + 439 + + + + Form + + + CPU + + + + + + 0 + + + + + General + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + + + We recommend setting accuracy to "Auto". + + + false + + + + + + + + + + CPU Backend + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + + false + + + + + + + Unsafe CPU Optimization Settings + + + + + + These settings reduce accuracy for speed. + + + false + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + + + + + + Qt::Vertical + + + QSizePolicy::Expanding + + + + 20 + 40 + + + + + + + + + + + diff --git a/src/sudachi/configuration/configure_cpu_debug.cpp b/src/sudachi/configuration/configure_cpu_debug.cpp new file mode 100644 index 0000000..a779fea --- /dev/null +++ b/src/sudachi/configuration/configure_cpu_debug.cpp @@ -0,0 +1,78 @@ +// SPDX-FileCopyrightText: Copyright 2020 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include "common/settings.h" +#include "core/core.h" +#include "ui_configure_cpu_debug.h" +#include "sudachi/configuration/configure_cpu_debug.h" + +ConfigureCpuDebug::ConfigureCpuDebug(const Core::System& system_, QWidget* parent) + : QWidget(parent), ui{std::make_unique()}, system{system_} { + ui->setupUi(this); + + SetConfiguration(); +} + +ConfigureCpuDebug::~ConfigureCpuDebug() = default; + +void ConfigureCpuDebug::SetConfiguration() { + const bool runtime_lock = !system.IsPoweredOn(); + + ui->cpuopt_page_tables->setEnabled(runtime_lock); + ui->cpuopt_page_tables->setChecked(Settings::values.cpuopt_page_tables.GetValue()); + ui->cpuopt_block_linking->setEnabled(runtime_lock); + ui->cpuopt_block_linking->setChecked(Settings::values.cpuopt_block_linking.GetValue()); + ui->cpuopt_return_stack_buffer->setEnabled(runtime_lock); + ui->cpuopt_return_stack_buffer->setChecked( + Settings::values.cpuopt_return_stack_buffer.GetValue()); + ui->cpuopt_fast_dispatcher->setEnabled(runtime_lock); + ui->cpuopt_fast_dispatcher->setChecked(Settings::values.cpuopt_fast_dispatcher.GetValue()); + ui->cpuopt_context_elimination->setEnabled(runtime_lock); + ui->cpuopt_context_elimination->setChecked( + Settings::values.cpuopt_context_elimination.GetValue()); + ui->cpuopt_const_prop->setEnabled(runtime_lock); + ui->cpuopt_const_prop->setChecked(Settings::values.cpuopt_const_prop.GetValue()); + ui->cpuopt_misc_ir->setEnabled(runtime_lock); + ui->cpuopt_misc_ir->setChecked(Settings::values.cpuopt_misc_ir.GetValue()); + ui->cpuopt_reduce_misalign_checks->setEnabled(runtime_lock); + ui->cpuopt_reduce_misalign_checks->setChecked( + Settings::values.cpuopt_reduce_misalign_checks.GetValue()); + ui->cpuopt_fastmem->setEnabled(runtime_lock); + ui->cpuopt_fastmem->setChecked(Settings::values.cpuopt_fastmem.GetValue()); + ui->cpuopt_fastmem_exclusives->setEnabled(runtime_lock); + ui->cpuopt_fastmem_exclusives->setChecked( + Settings::values.cpuopt_fastmem_exclusives.GetValue()); + ui->cpuopt_recompile_exclusives->setEnabled(runtime_lock); + ui->cpuopt_recompile_exclusives->setChecked( + Settings::values.cpuopt_recompile_exclusives.GetValue()); + ui->cpuopt_ignore_memory_aborts->setEnabled(runtime_lock); + ui->cpuopt_ignore_memory_aborts->setChecked( + Settings::values.cpuopt_ignore_memory_aborts.GetValue()); +} + +void ConfigureCpuDebug::ApplyConfiguration() { + Settings::values.cpuopt_page_tables = ui->cpuopt_page_tables->isChecked(); + Settings::values.cpuopt_block_linking = ui->cpuopt_block_linking->isChecked(); + Settings::values.cpuopt_return_stack_buffer = ui->cpuopt_return_stack_buffer->isChecked(); + Settings::values.cpuopt_fast_dispatcher = ui->cpuopt_fast_dispatcher->isChecked(); + Settings::values.cpuopt_context_elimination = ui->cpuopt_context_elimination->isChecked(); + Settings::values.cpuopt_const_prop = ui->cpuopt_const_prop->isChecked(); + Settings::values.cpuopt_misc_ir = ui->cpuopt_misc_ir->isChecked(); + Settings::values.cpuopt_reduce_misalign_checks = ui->cpuopt_reduce_misalign_checks->isChecked(); + Settings::values.cpuopt_fastmem = ui->cpuopt_fastmem->isChecked(); + Settings::values.cpuopt_fastmem_exclusives = ui->cpuopt_fastmem_exclusives->isChecked(); + Settings::values.cpuopt_recompile_exclusives = ui->cpuopt_recompile_exclusives->isChecked(); + Settings::values.cpuopt_ignore_memory_aborts = ui->cpuopt_ignore_memory_aborts->isChecked(); +} + +void ConfigureCpuDebug::changeEvent(QEvent* event) { + if (event->type() == QEvent::LanguageChange) { + RetranslateUI(); + } + + QWidget::changeEvent(event); +} + +void ConfigureCpuDebug::RetranslateUI() { + ui->retranslateUi(this); +} diff --git a/src/sudachi/configuration/configure_cpu_debug.h b/src/sudachi/configuration/configure_cpu_debug.h new file mode 100644 index 0000000..362faa7 --- /dev/null +++ b/src/sudachi/configuration/configure_cpu_debug.h @@ -0,0 +1,35 @@ +// SPDX-FileCopyrightText: Copyright 2020 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include +#include + +namespace Core { +class System; +} + +namespace Ui { +class ConfigureCpuDebug; +} + +class ConfigureCpuDebug : public QWidget { + Q_OBJECT + +public: + explicit ConfigureCpuDebug(const Core::System& system_, QWidget* parent = nullptr); + ~ConfigureCpuDebug() override; + + void ApplyConfiguration(); + +private: + void changeEvent(QEvent* event) override; + void RetranslateUI(); + + void SetConfiguration(); + + std::unique_ptr ui; + + const Core::System& system; +}; diff --git a/src/sudachi/configuration/configure_cpu_debug.ui b/src/sudachi/configuration/configure_cpu_debug.ui new file mode 100644 index 0000000..3010f7f --- /dev/null +++ b/src/sudachi/configuration/configure_cpu_debug.ui @@ -0,0 +1,223 @@ + + + ConfigureCpuDebug + + + + 0 + 0 + 592 + 503 + + + + Form + + + CPU + + + + + + + + Toggle CPU Optimizations + + + + + + <html><head/><body><p><span style=" font-weight:600;">For debugging only.</span><br/>If you're not sure what these do, keep all of these enabled. <br/>These settings, when disabled, only take effect when CPU Debugging is enabled. </p></body></html> + + + false + + + + + + + + <div style="white-space: nowrap">This optimization speeds up memory accesses by the guest program.</div> + <div style="white-space: nowrap">Enabling it inlines accesses to PageTable::pointers into emitted code.</div> + <div style="white-space: nowrap">Disabling this forces all memory accesses to go through the Memory::Read/Memory::Write functions.</div> + + + + Enable inline page tables + + + + + + + + <div>This optimization avoids dispatcher lookups by allowing emitted basic blocks to jump directly to other basic blocks if the destination PC is static.</div> + + + + Enable block linking + + + + + + + + <div>This optimization avoids dispatcher lookups by keeping track potential return addresses of BL instructions. This approximates what happens with a return stack buffer on a real CPU.</div> + + + + Enable return stack buffer + + + + + + + + <div>Enable a two-tiered dispatch system. A faster dispatcher written in assembly has a small MRU cache of jump destinations is used first. If that fails, dispatch falls back to the slower C++ dispatcher.</div> + + + + Enable fast dispatcher + + + + + + + + <div>Enables an IR optimization that reduces unnecessary accesses to the CPU context structure.</div> + + + + Enable context elimination + + + + + + + + <div>Enables IR optimizations that involve constant propagation.</div> + + + + Enable constant propagation + + + + + + + + <div>Enables miscellaneous IR optimizations.</div> + + + + Enable miscellaneous optimizations + + + + + + + + <div style="white-space: nowrap">When enabled, a misalignment is only triggered when an access crosses a page boundary.</div> + <div style="white-space: nowrap">When disabled, a misalignment is triggered on all misaligned accesses.</div> + + + + Enable misalignment check reduction + + + + + + + + <div style="white-space: nowrap">This optimization speeds up memory accesses by the guest program.</div> + <div style="white-space: nowrap">Enabling it causes guest memory reads/writes to be done directly into memory and make use of Host's MMU.</div> + <div style="white-space: nowrap">Disabling this forces all memory accesses to use Software MMU Emulation.</div> + + + + Enable Host MMU Emulation (general memory instructions) + + + + + + + + <div style="white-space: nowrap">This optimization speeds up exclusive memory accesses by the guest program.</div> + <div style="white-space: nowrap">Enabling it causes guest exclusive memory reads/writes to be done directly into memory and make use of Host's MMU.</div> + <div style="white-space: nowrap">Disabling this forces all exclusive memory accesses to use Software MMU Emulation.</div> + + + + Enable Host MMU Emulation (exclusive memory instructions) + + + + + + + + <div style="white-space: nowrap">This optimization speeds up exclusive memory accesses by the guest program.</div> + <div style="white-space: nowrap">Enabling it reduces the overhead of fastmem failure of exclusive memory accesses.</div> + + + + Enable recompilation of exclusive memory instructions + + + + + + + + <div style="white-space: nowrap">This optimization speeds up memory accesses by allowing invalid memory accesses to succeed.</div> + <div style="white-space: nowrap">Enabling it reduces the overhead of all memory accesses and has no impact on programs that don't access invalid memory.</div> + + + + Enable fallbacks for invalid memory accesses + + + + + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + CPU settings are available only when game is not running. + + + true + + + + + + + + diff --git a/src/sudachi/configuration/configure_debug.cpp b/src/sudachi/configuration/configure_debug.cpp new file mode 100644 index 0000000..a58ddf7 --- /dev/null +++ b/src/sudachi/configuration/configure_debug.cpp @@ -0,0 +1,130 @@ +// SPDX-FileCopyrightText: 2016 Citra Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include +#include +#include +#include "common/fs/path_util.h" +#include "common/logging/backend.h" +#include "common/logging/filter.h" +#include "common/settings.h" +#include "core/core.h" +#include "ui_configure_debug.h" +#include "sudachi/configuration/configure_debug.h" +#include "sudachi/debugger/console.h" +#include "sudachi/uisettings.h" + +ConfigureDebug::ConfigureDebug(const Core::System& system_, QWidget* parent) + : QScrollArea(parent), ui{std::make_unique()}, system{system_} { + ui->setupUi(this); + SetConfiguration(); + + connect(ui->open_log_button, &QPushButton::clicked, []() { + const auto path = + QString::fromStdString(Common::FS::GetSudachiPathString(Common::FS::SudachiPath::LogDir)); + QDesktopServices::openUrl(QUrl::fromLocalFile(path)); + }); + + connect(ui->toggle_gdbstub, &QCheckBox::toggled, + [&]() { ui->gdbport_spinbox->setEnabled(ui->toggle_gdbstub->isChecked()); }); +} + +ConfigureDebug::~ConfigureDebug() = default; + +void ConfigureDebug::SetConfiguration() { + const bool runtime_lock = !system.IsPoweredOn(); + ui->toggle_gdbstub->setChecked(Settings::values.use_gdbstub.GetValue()); + ui->gdbport_spinbox->setEnabled(Settings::values.use_gdbstub.GetValue()); + ui->gdbport_spinbox->setValue(Settings::values.gdbstub_port.GetValue()); + ui->toggle_console->setEnabled(runtime_lock); + ui->toggle_console->setChecked(UISettings::values.show_console.GetValue()); + ui->log_filter_edit->setText(QString::fromStdString(Settings::values.log_filter.GetValue())); + ui->homebrew_args_edit->setText( + QString::fromStdString(Settings::values.program_args.GetValue())); + ui->fs_access_log->setEnabled(runtime_lock); + ui->fs_access_log->setChecked(Settings::values.enable_fs_access_log.GetValue()); + ui->reporting_services->setChecked(Settings::values.reporting_services.GetValue()); + ui->dump_audio_commands->setChecked(Settings::values.dump_audio_commands.GetValue()); + ui->quest_flag->setChecked(Settings::values.quest_flag.GetValue()); + ui->use_debug_asserts->setChecked(Settings::values.use_debug_asserts.GetValue()); + ui->use_auto_stub->setChecked(Settings::values.use_auto_stub.GetValue()); + ui->enable_all_controllers->setChecked(Settings::values.enable_all_controllers.GetValue()); + ui->enable_renderdoc_hotkey->setEnabled(runtime_lock); + ui->enable_renderdoc_hotkey->setChecked(Settings::values.enable_renderdoc_hotkey.GetValue()); + ui->disable_buffer_reorder->setEnabled(runtime_lock); + ui->disable_buffer_reorder->setChecked(Settings::values.disable_buffer_reorder.GetValue()); + ui->enable_graphics_debugging->setEnabled(runtime_lock); + ui->enable_graphics_debugging->setChecked(Settings::values.renderer_debug.GetValue()); + ui->enable_shader_feedback->setEnabled(runtime_lock); + ui->enable_shader_feedback->setChecked(Settings::values.renderer_shader_feedback.GetValue()); + ui->enable_cpu_debugging->setEnabled(runtime_lock); + ui->enable_cpu_debugging->setChecked(Settings::values.cpu_debug_mode.GetValue()); + ui->enable_nsight_aftermath->setEnabled(runtime_lock); + ui->enable_nsight_aftermath->setChecked(Settings::values.enable_nsight_aftermath.GetValue()); + ui->dump_shaders->setEnabled(runtime_lock); + ui->dump_shaders->setChecked(Settings::values.dump_shaders.GetValue()); + ui->dump_macros->setEnabled(runtime_lock); + ui->dump_macros->setChecked(Settings::values.dump_macros.GetValue()); + ui->disable_macro_jit->setEnabled(runtime_lock); + ui->disable_macro_jit->setChecked(Settings::values.disable_macro_jit.GetValue()); + ui->disable_macro_hle->setEnabled(runtime_lock); + ui->disable_macro_hle->setChecked(Settings::values.disable_macro_hle.GetValue()); + ui->disable_loop_safety_checks->setEnabled(runtime_lock); + ui->disable_loop_safety_checks->setChecked( + Settings::values.disable_shader_loop_safety_checks.GetValue()); + ui->extended_logging->setChecked(Settings::values.extended_logging.GetValue()); + ui->perform_vulkan_check->setChecked(Settings::values.perform_vulkan_check.GetValue()); + +#ifdef SUDACHI_USE_QT_WEB_ENGINE + ui->disable_web_applet->setChecked(UISettings::values.disable_web_applet.GetValue()); +#else + ui->disable_web_applet->setEnabled(false); + ui->disable_web_applet->setText(tr("Web applet not compiled")); +#endif +} + +void ConfigureDebug::ApplyConfiguration() { + Settings::values.use_gdbstub = ui->toggle_gdbstub->isChecked(); + Settings::values.gdbstub_port = ui->gdbport_spinbox->value(); + UISettings::values.show_console = ui->toggle_console->isChecked(); + Settings::values.log_filter = ui->log_filter_edit->text().toStdString(); + Settings::values.program_args = ui->homebrew_args_edit->text().toStdString(); + Settings::values.enable_fs_access_log = ui->fs_access_log->isChecked(); + Settings::values.reporting_services = ui->reporting_services->isChecked(); + Settings::values.dump_audio_commands = ui->dump_audio_commands->isChecked(); + Settings::values.quest_flag = ui->quest_flag->isChecked(); + Settings::values.use_debug_asserts = ui->use_debug_asserts->isChecked(); + Settings::values.use_auto_stub = ui->use_auto_stub->isChecked(); + Settings::values.enable_all_controllers = ui->enable_all_controllers->isChecked(); + Settings::values.renderer_debug = ui->enable_graphics_debugging->isChecked(); + Settings::values.enable_renderdoc_hotkey = ui->enable_renderdoc_hotkey->isChecked(); + Settings::values.disable_buffer_reorder = ui->disable_buffer_reorder->isChecked(); + Settings::values.renderer_shader_feedback = ui->enable_shader_feedback->isChecked(); + Settings::values.cpu_debug_mode = ui->enable_cpu_debugging->isChecked(); + Settings::values.enable_nsight_aftermath = ui->enable_nsight_aftermath->isChecked(); + Settings::values.dump_shaders = ui->dump_shaders->isChecked(); + Settings::values.dump_macros = ui->dump_macros->isChecked(); + Settings::values.disable_shader_loop_safety_checks = + ui->disable_loop_safety_checks->isChecked(); + Settings::values.disable_macro_jit = ui->disable_macro_jit->isChecked(); + Settings::values.disable_macro_hle = ui->disable_macro_hle->isChecked(); + Settings::values.extended_logging = ui->extended_logging->isChecked(); + Settings::values.perform_vulkan_check = ui->perform_vulkan_check->isChecked(); + UISettings::values.disable_web_applet = ui->disable_web_applet->isChecked(); + Debugger::ToggleConsole(); + Common::Log::Filter filter; + filter.ParseFilterString(Settings::values.log_filter.GetValue()); + Common::Log::SetGlobalFilter(filter); +} + +void ConfigureDebug::changeEvent(QEvent* event) { + if (event->type() == QEvent::LanguageChange) { + RetranslateUI(); + } + + QWidget::changeEvent(event); +} + +void ConfigureDebug::RetranslateUI() { + ui->retranslateUi(this); +} diff --git a/src/sudachi/configuration/configure_debug.h b/src/sudachi/configuration/configure_debug.h new file mode 100644 index 0000000..030a0b7 --- /dev/null +++ b/src/sudachi/configuration/configure_debug.h @@ -0,0 +1,37 @@ +// SPDX-FileCopyrightText: 2016 Citra Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include +#include + +namespace Core { +class System; +} + +namespace Ui { +class ConfigureDebug; +} + +class ConfigureDebug : public QScrollArea { + Q_OBJECT + +public: + explicit ConfigureDebug(const Core::System& system_, QWidget* parent = nullptr); + ~ConfigureDebug() override; + + void ApplyConfiguration(); + +private: + void changeEvent(QEvent* event) override; + + void RetranslateUI(); + void SetConfiguration(); + + std::unique_ptr ui; + + const Core::System& system; + + bool crash_dump_warning_shown{false}; +}; diff --git a/src/sudachi/configuration/configure_debug.ui b/src/sudachi/configuration/configure_debug.ui new file mode 100644 index 0000000..4be6d93 --- /dev/null +++ b/src/sudachi/configuration/configure_debug.ui @@ -0,0 +1,576 @@ + + + ConfigureDebug + + + + 0 + 0 + 831 + 760 + + + + true + + + + + 0 + 0 + 842 + 741 + + + + + + + 0 + + + + + + 0 + 0 + + + + Debugger + + + Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop + + + false + + + false + + + + + + + 0 + 0 + + + + + QLayout::SetDefaultConstraint + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + 0 + 0 + + + + Enable GDB Stub + + + + + + + + 0 + 0 + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + 0 + 0 + + + + Port: + + + + + + + + 0 + 0 + + + + 1024 + + + 65535 + + + + + + + + + + + + + + + + + 0 + 0 + + + + Logging + + + + + + Open Log Location + + + + + + + + 0 + 0 + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + 0 + 0 + + + + Global Log Filter + + + + + + + + + + + + + true + + + When checked, the max size of the log increases from 100 MB to 1 GB + + + Enable Extended Logging** + + + + + + + Show Log in Console + + + + + + + + + + + + Homebrew + + + + + + + + Arguments String + + + + + + + + + + + + + + + + + Graphics + + + + + + When checked, it executes shaders without loop logic changes + + + Disable Loop safety checks + + + + + + + true + + + When checked, it will dump all the macro programs of the GPU + + + Dump Maxwell Macros + + + + + + + When checked, it enables Nsight Aftermath crash dumps + + + Enable Nsight Aftermath + + + + + + + true + + + When checked, it will dump all the original assembler shaders from the disk shader cache or game as found + + + Dump Game Shaders + + + + + + + Enable Renderdoc Hotkey + + + + + + + true + + + When checked, it disables the macro Just In Time compiler. Enabling this makes games run slower + + + Disable Macro JIT + + + + + + + true + + + When checked, it disables the macro HLE functions. Enabling this makes games run slower + + + Disable Macro HLE + + + + + + + true + + + When checked, the graphics API enters a slower debugging mode + + + Enable Graphics Debugging + + + + + + + Qt::Vertical + + + QSizePolicy::Preferred + + + + 20 + 0 + + + + + + + + When checked, sudachi will log statistics about the compiled pipeline cache + + + Enable Shader Feedback + + + + + + + <html><head/><body><p>When checked, disables reordering of mapped memory uploads which allows to associate uploads with specific draws. May reduce performance in some cases.</p></body></html> + + + Disable Buffer Reorder + + + + + + + + + + Advanced + + + + + + Enables sudachi to check for a working Vulkan environment when the program starts up. Disable this if this is causing issues with external programs seeing sudachi. + + + Perform Startup Vulkan Check + + + + + + + Disable Web Applet + + + + + + + Enable All Controller Types + + + + + + + Enable Auto-Stub** + + + + + + + Kiosk (Quest) Mode + + + + + + + Enable CPU Debugging + + + + + + + Enable Debug Asserts + + + + + + + Qt::Vertical + + + QSizePolicy::Expanding + + + + 20 + 0 + + + + + + + + + + + Debugging + + + + + + Enable FS Access Log + + + + + + + Enable this to output the latest generated audio command list to the console. Only affects games using the audio renderer. + + + Dump Audio Commands To Console** + + + + + + + Enable Verbose Reporting Services** + + + + + + + Qt::Vertical + + + QSizePolicy::Expanding + + + + 20 + 0 + + + + + + + + + + + + + Qt::Vertical + + + + 20 + 0 + + + + + + + + + 0 + 0 + + + + + true + + + + **This will be reset automatically when sudachi closes. + + + 20 + + + + + + + + log_filter_edit + toggle_console + extended_logging + open_log_button + homebrew_args_edit + enable_graphics_debugging + enable_shader_feedback + enable_nsight_aftermath + fs_access_log + reporting_services + quest_flag + enable_cpu_debugging + use_debug_asserts + + + + diff --git a/src/sudachi/configuration/configure_debug_controller.cpp b/src/sudachi/configuration/configure_debug_controller.cpp new file mode 100644 index 0000000..1da836a --- /dev/null +++ b/src/sudachi/configuration/configure_debug_controller.cpp @@ -0,0 +1,44 @@ +// SPDX-FileCopyrightText: Copyright 2020 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include "hid_core/hid_core.h" +#include "ui_configure_debug_controller.h" +#include "sudachi/configuration/configure_debug_controller.h" +#include "sudachi/configuration/configure_input_player.h" + +ConfigureDebugController::ConfigureDebugController(QWidget* parent, + InputCommon::InputSubsystem* input_subsystem, + InputProfiles* profiles, + Core::HID::HIDCore& hid_core, bool is_powered_on) + : QDialog(parent), ui(std::make_unique()), + debug_controller(new ConfigureInputPlayer(this, 9, nullptr, input_subsystem, profiles, + hid_core, is_powered_on, true)) { + ui->setupUi(this); + + ui->controllerLayout->addWidget(debug_controller); + + connect(ui->clear_all_button, &QPushButton::clicked, this, + [this] { debug_controller->ClearAll(); }); + connect(ui->restore_defaults_button, &QPushButton::clicked, this, + [this] { debug_controller->RestoreDefaults(); }); + + RetranslateUI(); +} + +ConfigureDebugController::~ConfigureDebugController() = default; + +void ConfigureDebugController::ApplyConfiguration() { + debug_controller->ApplyConfiguration(); +} + +void ConfigureDebugController::changeEvent(QEvent* event) { + if (event->type() == QEvent::LanguageChange) { + RetranslateUI(); + } + + QDialog::changeEvent(event); +} + +void ConfigureDebugController::RetranslateUI() { + ui->retranslateUi(this); +} diff --git a/src/sudachi/configuration/configure_debug_controller.h b/src/sudachi/configuration/configure_debug_controller.h new file mode 100644 index 0000000..dc85f6d --- /dev/null +++ b/src/sudachi/configuration/configure_debug_controller.h @@ -0,0 +1,45 @@ +// SPDX-FileCopyrightText: Copyright 2020 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include +#include + +class QPushButton; + +class ConfigureInputPlayer; + +class InputProfiles; + +namespace Core::HID { +class HIDCore; +} + +namespace InputCommon { +class InputSubsystem; +} + +namespace Ui { +class ConfigureDebugController; +} + +class ConfigureDebugController : public QDialog { + Q_OBJECT + +public: + explicit ConfigureDebugController(QWidget* parent, InputCommon::InputSubsystem* input_subsystem, + InputProfiles* profiles, Core::HID::HIDCore& hid_core, + bool is_powered_on); + ~ConfigureDebugController() override; + + void ApplyConfiguration(); + +private: + void changeEvent(QEvent* event) override; + void RetranslateUI(); + + std::unique_ptr ui; + + ConfigureInputPlayer* debug_controller; +}; diff --git a/src/sudachi/configuration/configure_debug_controller.ui b/src/sudachi/configuration/configure_debug_controller.ui new file mode 100644 index 0000000..7b7e658 --- /dev/null +++ b/src/sudachi/configuration/configure_debug_controller.ui @@ -0,0 +1,77 @@ + + + ConfigureDebugController + + + + 0 + 0 + 780 + 500 + + + + Configure Debug Controller + + + + 2 + + + 9 + + + 9 + + + 9 + + + 9 + + + + + + + + + + Clear + + + + + + + Defaults + + + + + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + + + + + + + + + buttonBox + accepted() + ConfigureDebugController + accept() + + + buttonBox + rejected() + ConfigureDebugController + reject() + + + diff --git a/src/sudachi/configuration/configure_debug_tab.cpp b/src/sudachi/configuration/configure_debug_tab.cpp new file mode 100644 index 0000000..962e673 --- /dev/null +++ b/src/sudachi/configuration/configure_debug_tab.cpp @@ -0,0 +1,45 @@ +// SPDX-FileCopyrightText: Copyright 2021 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include +#include "ui_configure_debug_tab.h" +#include "sudachi/configuration/configure_cpu_debug.h" +#include "sudachi/configuration/configure_debug.h" +#include "sudachi/configuration/configure_debug_tab.h" + +ConfigureDebugTab::ConfigureDebugTab(const Core::System& system_, QWidget* parent) + : QWidget(parent), ui{std::make_unique()}, + debug_tab{std::make_unique(system_, this)}, + cpu_debug_tab{std::make_unique(system_, this)} { + ui->setupUi(this); + + ui->tabWidget->addTab(debug_tab.get(), tr("Debug")); + ui->tabWidget->addTab(cpu_debug_tab.get(), tr("CPU")); + + SetConfiguration(); +} + +ConfigureDebugTab::~ConfigureDebugTab() = default; + +void ConfigureDebugTab::ApplyConfiguration() { + debug_tab->ApplyConfiguration(); + cpu_debug_tab->ApplyConfiguration(); +} + +void ConfigureDebugTab::SetCurrentIndex(int index) { + ui->tabWidget->setCurrentIndex(index); +} + +void ConfigureDebugTab::changeEvent(QEvent* event) { + if (event->type() == QEvent::LanguageChange) { + RetranslateUI(); + } + + QWidget::changeEvent(event); +} + +void ConfigureDebugTab::RetranslateUI() { + ui->retranslateUi(this); +} + +void ConfigureDebugTab::SetConfiguration() {} diff --git a/src/sudachi/configuration/configure_debug_tab.h b/src/sudachi/configuration/configure_debug_tab.h new file mode 100644 index 0000000..dc01eb8 --- /dev/null +++ b/src/sudachi/configuration/configure_debug_tab.h @@ -0,0 +1,41 @@ +// SPDX-FileCopyrightText: Copyright 2021 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include +#include + +class ConfigureDebug; +class ConfigureCpuDebug; + +namespace Core { +class System; +} + +namespace Ui { +class ConfigureDebugTab; +} + +class ConfigureDebugTab : public QWidget { + Q_OBJECT + +public: + explicit ConfigureDebugTab(const Core::System& system_, QWidget* parent = nullptr); + ~ConfigureDebugTab() override; + + void ApplyConfiguration(); + + void SetCurrentIndex(int index); + +private: + void changeEvent(QEvent* event) override; + void RetranslateUI(); + + void SetConfiguration(); + + std::unique_ptr ui; + + std::unique_ptr debug_tab; + std::unique_ptr cpu_debug_tab; +}; diff --git a/src/sudachi/configuration/configure_debug_tab.ui b/src/sudachi/configuration/configure_debug_tab.ui new file mode 100644 index 0000000..15ec747 --- /dev/null +++ b/src/sudachi/configuration/configure_debug_tab.ui @@ -0,0 +1,31 @@ + + + ConfigureDebugTab + + + + 0 + 0 + 320 + 240 + + + + Form + + + Debug + + + + + + -1 + + + + + + + + diff --git a/src/sudachi/configuration/configure_dialog.cpp b/src/sudachi/configuration/configure_dialog.cpp new file mode 100644 index 0000000..a8bf0ae --- /dev/null +++ b/src/sudachi/configuration/configure_dialog.cpp @@ -0,0 +1,213 @@ +// SPDX-FileCopyrightText: 2016 Citra Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include +#include "common/logging/log.h" +#include "common/settings.h" +#include "common/settings_enums.h" +#include "core/core.h" +#include "ui_configure.h" +#include "vk_device_info.h" +#include "sudachi/configuration/configure_applets.h" +#include "sudachi/configuration/configure_audio.h" +#include "sudachi/configuration/configure_cpu.h" +#include "sudachi/configuration/configure_debug_tab.h" +#include "sudachi/configuration/configure_dialog.h" +#include "sudachi/configuration/configure_filesystem.h" +#include "sudachi/configuration/configure_general.h" +#include "sudachi/configuration/configure_graphics.h" +#include "sudachi/configuration/configure_graphics_advanced.h" +#include "sudachi/configuration/configure_hotkeys.h" +#include "sudachi/configuration/configure_input.h" +#include "sudachi/configuration/configure_input_player.h" +#include "sudachi/configuration/configure_network.h" +#include "sudachi/configuration/configure_profile_manager.h" +#include "sudachi/configuration/configure_system.h" +#include "sudachi/configuration/configure_ui.h" +#include "sudachi/configuration/configure_web.h" +#include "sudachi/hotkeys.h" +#include "sudachi/uisettings.h" + +ConfigureDialog::ConfigureDialog(QWidget* parent, HotkeyRegistry& registry_, + InputCommon::InputSubsystem* input_subsystem, + std::vector& vk_device_records, + Core::System& system_, bool enable_web_config) + : QDialog(parent), ui{std::make_unique()}, + registry(registry_), system{system_}, builder{std::make_unique( + this, !system_.IsPoweredOn())}, + applets_tab{std::make_unique(system_, nullptr, *builder, this)}, + audio_tab{std::make_unique(system_, nullptr, *builder, this)}, + cpu_tab{std::make_unique(system_, nullptr, *builder, this)}, + debug_tab_tab{std::make_unique(system_, this)}, + filesystem_tab{std::make_unique(this)}, + general_tab{std::make_unique(system_, nullptr, *builder, this)}, + graphics_advanced_tab{ + std::make_unique(system_, nullptr, *builder, this)}, + ui_tab{std::make_unique(system_, this)}, + graphics_tab{std::make_unique( + system_, vk_device_records, [&]() { graphics_advanced_tab->ExposeComputeOption(); }, + [this](Settings::AspectRatio ratio, Settings::ResolutionSetup setup) { + ui_tab->UpdateScreenshotInfo(ratio, setup); + }, + nullptr, *builder, this)}, + hotkeys_tab{std::make_unique(system_.HIDCore(), this)}, + input_tab{std::make_unique(system_, this)}, + network_tab{std::make_unique(system_, this)}, + profile_tab{std::make_unique(system_, this)}, + system_tab{std::make_unique(system_, nullptr, *builder, this)}, + web_tab{std::make_unique(this)} { + Settings::SetConfiguringGlobal(true); + + ui->setupUi(this); + + ui->tabWidget->addTab(applets_tab.get(), tr("Applets")); + ui->tabWidget->addTab(audio_tab.get(), tr("Audio")); + ui->tabWidget->addTab(cpu_tab.get(), tr("CPU")); + ui->tabWidget->addTab(debug_tab_tab.get(), tr("Debug")); + ui->tabWidget->addTab(filesystem_tab.get(), tr("Filesystem")); + ui->tabWidget->addTab(general_tab.get(), tr("General")); + ui->tabWidget->addTab(graphics_tab.get(), tr("Graphics")); + ui->tabWidget->addTab(graphics_advanced_tab.get(), tr("GraphicsAdvanced")); + ui->tabWidget->addTab(hotkeys_tab.get(), tr("Hotkeys")); + ui->tabWidget->addTab(input_tab.get(), tr("Controls")); + ui->tabWidget->addTab(profile_tab.get(), tr("Profiles")); + ui->tabWidget->addTab(network_tab.get(), tr("Network")); + ui->tabWidget->addTab(system_tab.get(), tr("System")); + ui->tabWidget->addTab(ui_tab.get(), tr("Game List")); + ui->tabWidget->addTab(web_tab.get(), tr("Web")); + + web_tab->SetWebServiceConfigEnabled(enable_web_config); + hotkeys_tab->Populate(registry); + + input_tab->Initialize(input_subsystem); + + general_tab->SetResetCallback([&] { this->close(); }); + + SetConfiguration(); + PopulateSelectionList(); + + connect(ui->tabWidget, &QTabWidget::currentChanged, this, [this](int index) { + if (index != -1) { + debug_tab_tab->SetCurrentIndex(0); + } + }); + connect(ui_tab.get(), &ConfigureUi::LanguageChanged, this, &ConfigureDialog::OnLanguageChanged); + connect(ui->selectorList, &QListWidget::itemSelectionChanged, this, + &ConfigureDialog::UpdateVisibleTabs); + + if (system.IsPoweredOn()) { + QPushButton* apply_button = ui->buttonBox->addButton(QDialogButtonBox::Apply); + connect(apply_button, &QAbstractButton::clicked, this, + &ConfigureDialog::HandleApplyButtonClicked); + } + + adjustSize(); + ui->selectorList->setCurrentRow(0); + + // Selects the leftmost button on the bottom bar (Cancel as of writing) + ui->buttonBox->setFocus(); +} + +ConfigureDialog::~ConfigureDialog() = default; + +void ConfigureDialog::SetConfiguration() {} + +void ConfigureDialog::ApplyConfiguration() { + general_tab->ApplyConfiguration(); + ui_tab->ApplyConfiguration(); + system_tab->ApplyConfiguration(); + profile_tab->ApplyConfiguration(); + filesystem_tab->ApplyConfiguration(); + input_tab->ApplyConfiguration(); + hotkeys_tab->ApplyConfiguration(registry); + cpu_tab->ApplyConfiguration(); + graphics_tab->ApplyConfiguration(); + graphics_advanced_tab->ApplyConfiguration(); + audio_tab->ApplyConfiguration(); + debug_tab_tab->ApplyConfiguration(); + web_tab->ApplyConfiguration(); + network_tab->ApplyConfiguration(); + applets_tab->ApplyConfiguration(); + system.ApplySettings(); + Settings::LogSettings(); +} + +void ConfigureDialog::changeEvent(QEvent* event) { + if (event->type() == QEvent::LanguageChange) { + RetranslateUI(); + } + + QDialog::changeEvent(event); +} + +void ConfigureDialog::RetranslateUI() { + const int old_row = ui->selectorList->currentRow(); + const int old_index = ui->tabWidget->currentIndex(); + + ui->retranslateUi(this); + + PopulateSelectionList(); + ui->selectorList->setCurrentRow(old_row); + + UpdateVisibleTabs(); + ui->tabWidget->setCurrentIndex(old_index); +} + +void ConfigureDialog::HandleApplyButtonClicked() { + UISettings::values.configuration_applied = true; + ApplyConfiguration(); +} + +Q_DECLARE_METATYPE(QList); + +void ConfigureDialog::PopulateSelectionList() { + const std::array>, 6> items{ + {{tr("General"), + {general_tab.get(), hotkeys_tab.get(), ui_tab.get(), web_tab.get(), debug_tab_tab.get()}}, + {tr("System"), + {system_tab.get(), profile_tab.get(), network_tab.get(), filesystem_tab.get(), + applets_tab.get()}}, + {tr("CPU"), {cpu_tab.get()}}, + {tr("Graphics"), {graphics_tab.get(), graphics_advanced_tab.get()}}, + {tr("Audio"), {audio_tab.get()}}, + {tr("Controls"), input_tab->GetSubTabs()}}, + }; + + [[maybe_unused]] const QSignalBlocker blocker(ui->selectorList); + + ui->selectorList->clear(); + for (const auto& entry : items) { + auto* const item = new QListWidgetItem(entry.first); + item->setData(Qt::UserRole, QVariant::fromValue(entry.second)); + + ui->selectorList->addItem(item); + } +} + +void ConfigureDialog::OnLanguageChanged(const QString& locale) { + emit LanguageChanged(locale); + // Reloading the game list is needed to force retranslation. + UISettings::values.is_game_list_reload_pending = true; + // first apply the configuration, and then restore the display + ApplyConfiguration(); + RetranslateUI(); + SetConfiguration(); +} + +void ConfigureDialog::UpdateVisibleTabs() { + const auto items = ui->selectorList->selectedItems(); + if (items.isEmpty()) { + return; + } + + [[maybe_unused]] const QSignalBlocker blocker(ui->tabWidget); + + ui->tabWidget->clear(); + + const auto tabs = qvariant_cast>(items[0]->data(Qt::UserRole)); + + for (auto* const tab : tabs) { + LOG_DEBUG(Frontend, "{}", tab->accessibleName().toStdString()); + ui->tabWidget->addTab(tab, tab->accessibleName()); + } +} diff --git a/src/sudachi/configuration/configure_dialog.h b/src/sudachi/configuration/configure_dialog.h new file mode 100644 index 0000000..0ccd831 --- /dev/null +++ b/src/sudachi/configuration/configure_dialog.h @@ -0,0 +1,94 @@ +// SPDX-FileCopyrightText: 2016 Citra Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include +#include +#include +#include "configuration/shared_widget.h" +#include "sudachi/configuration/configuration_shared.h" +#include "sudachi/configuration/shared_translation.h" +#include "sudachi/vk_device_info.h" + +namespace Core { +class System; +} + +class ConfigureApplets; +class ConfigureAudio; +class ConfigureCpu; +class ConfigureDebugTab; +class ConfigureFilesystem; +class ConfigureGeneral; +class ConfigureGraphics; +class ConfigureGraphicsAdvanced; +class ConfigureHotkeys; +class ConfigureInput; +class ConfigureProfileManager; +class ConfigureSystem; +class ConfigureNetwork; +class ConfigureUi; +class ConfigureWeb; + +class HotkeyRegistry; + +namespace InputCommon { +class InputSubsystem; +} + +namespace Ui { +class ConfigureDialog; +} + +class ConfigureDialog : public QDialog { + Q_OBJECT + +public: + explicit ConfigureDialog(QWidget* parent, HotkeyRegistry& registry_, + InputCommon::InputSubsystem* input_subsystem, + std::vector& vk_device_records, + Core::System& system_, bool enable_web_config = true); + ~ConfigureDialog() override; + + void ApplyConfiguration(); + +private slots: + void OnLanguageChanged(const QString& locale); + +signals: + void LanguageChanged(const QString& locale); + +private: + void changeEvent(QEvent* event) override; + void RetranslateUI(); + + void HandleApplyButtonClicked(); + + void SetConfiguration(); + void UpdateVisibleTabs(); + void PopulateSelectionList(); + + std::unique_ptr ui; + HotkeyRegistry& registry; + + Core::System& system; + std::unique_ptr builder; + std::vector tab_group; + + std::unique_ptr applets_tab; + std::unique_ptr audio_tab; + std::unique_ptr cpu_tab; + std::unique_ptr debug_tab_tab; + std::unique_ptr filesystem_tab; + std::unique_ptr general_tab; + std::unique_ptr graphics_advanced_tab; + std::unique_ptr ui_tab; + std::unique_ptr graphics_tab; + std::unique_ptr hotkeys_tab; + std::unique_ptr input_tab; + std::unique_ptr network_tab; + std::unique_ptr profile_tab; + std::unique_ptr system_tab; + std::unique_ptr web_tab; +}; diff --git a/src/sudachi/configuration/configure_filesystem.cpp b/src/sudachi/configuration/configure_filesystem.cpp new file mode 100644 index 0000000..f93e4dc --- /dev/null +++ b/src/sudachi/configuration/configure_filesystem.cpp @@ -0,0 +1,155 @@ +// SPDX-FileCopyrightText: Copyright 2019 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include +#include +#include "common/fs/fs.h" +#include "common/fs/path_util.h" +#include "common/settings.h" +#include "ui_configure_filesystem.h" +#include "sudachi/configuration/configure_filesystem.h" +#include "sudachi/uisettings.h" + +ConfigureFilesystem::ConfigureFilesystem(QWidget* parent) + : QWidget(parent), ui(std::make_unique()) { + ui->setupUi(this); + SetConfiguration(); + + connect(ui->nand_directory_button, &QToolButton::pressed, this, + [this] { SetDirectory(DirectoryTarget::NAND, ui->nand_directory_edit); }); + connect(ui->sdmc_directory_button, &QToolButton::pressed, this, + [this] { SetDirectory(DirectoryTarget::SD, ui->sdmc_directory_edit); }); + connect(ui->gamecard_path_button, &QToolButton::pressed, this, + [this] { SetDirectory(DirectoryTarget::Gamecard, ui->gamecard_path_edit); }); + connect(ui->dump_path_button, &QToolButton::pressed, this, + [this] { SetDirectory(DirectoryTarget::Dump, ui->dump_path_edit); }); + connect(ui->load_path_button, &QToolButton::pressed, this, + [this] { SetDirectory(DirectoryTarget::Load, ui->load_path_edit); }); + + connect(ui->reset_game_list_cache, &QPushButton::pressed, this, + &ConfigureFilesystem::ResetMetadata); + + connect(ui->gamecard_inserted, &QCheckBox::stateChanged, this, + &ConfigureFilesystem::UpdateEnabledControls); + connect(ui->gamecard_current_game, &QCheckBox::stateChanged, this, + &ConfigureFilesystem::UpdateEnabledControls); +} + +ConfigureFilesystem::~ConfigureFilesystem() = default; + +void ConfigureFilesystem::changeEvent(QEvent* event) { + if (event->type() == QEvent::LanguageChange) { + RetranslateUI(); + } + + QWidget::changeEvent(event); +} + +void ConfigureFilesystem::SetConfiguration() { + ui->nand_directory_edit->setText( + QString::fromStdString(Common::FS::GetSudachiPathString(Common::FS::SudachiPath::NANDDir))); + ui->sdmc_directory_edit->setText( + QString::fromStdString(Common::FS::GetSudachiPathString(Common::FS::SudachiPath::SDMCDir))); + ui->gamecard_path_edit->setText( + QString::fromStdString(Settings::values.gamecard_path.GetValue())); + ui->dump_path_edit->setText( + QString::fromStdString(Common::FS::GetSudachiPathString(Common::FS::SudachiPath::DumpDir))); + ui->load_path_edit->setText( + QString::fromStdString(Common::FS::GetSudachiPathString(Common::FS::SudachiPath::LoadDir))); + + ui->gamecard_inserted->setChecked(Settings::values.gamecard_inserted.GetValue()); + ui->gamecard_current_game->setChecked(Settings::values.gamecard_current_game.GetValue()); + ui->dump_exefs->setChecked(Settings::values.dump_exefs.GetValue()); + ui->dump_nso->setChecked(Settings::values.dump_nso.GetValue()); + + ui->cache_game_list->setChecked(UISettings::values.cache_game_list.GetValue()); + + UpdateEnabledControls(); +} + +void ConfigureFilesystem::ApplyConfiguration() { + Common::FS::SetSudachiPath(Common::FS::SudachiPath::NANDDir, + ui->nand_directory_edit->text().toStdString()); + Common::FS::SetSudachiPath(Common::FS::SudachiPath::SDMCDir, + ui->sdmc_directory_edit->text().toStdString()); + Common::FS::SetSudachiPath(Common::FS::SudachiPath::DumpDir, + ui->dump_path_edit->text().toStdString()); + Common::FS::SetSudachiPath(Common::FS::SudachiPath::LoadDir, + ui->load_path_edit->text().toStdString()); + + Settings::values.gamecard_inserted = ui->gamecard_inserted->isChecked(); + Settings::values.gamecard_current_game = ui->gamecard_current_game->isChecked(); + Settings::values.dump_exefs = ui->dump_exefs->isChecked(); + Settings::values.dump_nso = ui->dump_nso->isChecked(); + + UISettings::values.cache_game_list = ui->cache_game_list->isChecked(); +} + +void ConfigureFilesystem::SetDirectory(DirectoryTarget target, QLineEdit* edit) { + QString caption; + + switch (target) { + case DirectoryTarget::NAND: + caption = tr("Select Emulated NAND Directory..."); + break; + case DirectoryTarget::SD: + caption = tr("Select Emulated SD Directory..."); + break; + case DirectoryTarget::Gamecard: + caption = tr("Select Gamecard Path..."); + break; + case DirectoryTarget::Dump: + caption = tr("Select Dump Directory..."); + break; + case DirectoryTarget::Load: + caption = tr("Select Mod Load Directory..."); + break; + } + + QString str; + if (target == DirectoryTarget::Gamecard) { + str = QFileDialog::getOpenFileName(this, caption, QFileInfo(edit->text()).dir().path(), + QStringLiteral("NX Gamecard;*.xci")); + } else { + str = QFileDialog::getExistingDirectory(this, caption, edit->text()); + } + + if (str.isNull() || str.isEmpty()) { + return; + } + + if (str.back() != QChar::fromLatin1('/')) { + str.append(QChar::fromLatin1('/')); + } + + edit->setText(str); +} + +void ConfigureFilesystem::ResetMetadata() { + if (!Common::FS::Exists(Common::FS::GetSudachiPath(Common::FS::SudachiPath::CacheDir) / + "game_list/")) { + QMessageBox::information(this, tr("Reset Metadata Cache"), + tr("The metadata cache is already empty.")); + } else if (Common::FS::RemoveDirRecursively( + Common::FS::GetSudachiPath(Common::FS::SudachiPath::CacheDir) / "game_list")) { + QMessageBox::information(this, tr("Reset Metadata Cache"), + tr("The operation completed successfully.")); + UISettings::values.is_game_list_reload_pending.exchange(true); + } else { + QMessageBox::warning( + this, tr("Reset Metadata Cache"), + tr("The metadata cache couldn't be deleted. It might be in use or non-existent.")); + } +} + +void ConfigureFilesystem::UpdateEnabledControls() { + ui->gamecard_current_game->setEnabled(ui->gamecard_inserted->isChecked()); + ui->gamecard_path_edit->setEnabled(ui->gamecard_inserted->isChecked() && + !ui->gamecard_current_game->isChecked()); + ui->gamecard_path_button->setEnabled(ui->gamecard_inserted->isChecked() && + !ui->gamecard_current_game->isChecked()); +} + +void ConfigureFilesystem::RetranslateUI() { + ui->retranslateUi(this); +} diff --git a/src/sudachi/configuration/configure_filesystem.h b/src/sudachi/configuration/configure_filesystem.h new file mode 100644 index 0000000..8d1f4cf --- /dev/null +++ b/src/sudachi/configuration/configure_filesystem.h @@ -0,0 +1,43 @@ +// SPDX-FileCopyrightText: Copyright 2019 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include +#include + +class QLineEdit; + +namespace Ui { +class ConfigureFilesystem; +} + +class ConfigureFilesystem : public QWidget { + Q_OBJECT + +public: + explicit ConfigureFilesystem(QWidget* parent = nullptr); + ~ConfigureFilesystem() override; + + void ApplyConfiguration(); + +private: + void changeEvent(QEvent* event) override; + + void RetranslateUI(); + void SetConfiguration(); + + enum class DirectoryTarget { + NAND, + SD, + Gamecard, + Dump, + Load, + }; + + void SetDirectory(DirectoryTarget target, QLineEdit* edit); + void ResetMetadata(); + void UpdateEnabledControls(); + + std::unique_ptr ui; +}; diff --git a/src/sudachi/configuration/configure_filesystem.ui b/src/sudachi/configuration/configure_filesystem.ui new file mode 100644 index 0000000..2f6030b --- /dev/null +++ b/src/sudachi/configuration/configure_filesystem.ui @@ -0,0 +1,244 @@ + + + ConfigureFilesystem + + + + 0 + 0 + 453 + 561 + + + + Form + + + Filesystem + + + + + + + + Storage Directories + + + + + + NAND + + + + + + + ... + + + + + + + + + + + + + SD Card + + + + + + + ... + + + + + + + Qt::Horizontal + + + QSizePolicy::Maximum + + + + 60 + 20 + + + + + + + + + + + Gamecard + + + + + + Path + + + + + + + + + + Inserted + + + + + + + Current Game + + + + + + + ... + + + + + + + + + + Patch Manager + + + + + + + + + + + + ... + + + + + + + ... + + + + + + + + + Dump Decompressed NSOs + + + + + + + Dump ExeFS + + + + + + + + + Mod Load Root + + + + + + + Dump Root + + + + + + + Qt::Horizontal + + + QSizePolicy::Fixed + + + + 40 + 20 + + + + + + + + + + + Caching + + + + + + + + Cache Game List Metadata + + + + + + + Reset Metadata Cache + + + + + + + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + diff --git a/src/sudachi/configuration/configure_general.cpp b/src/sudachi/configuration/configure_general.cpp new file mode 100644 index 0000000..a7bc3d7 --- /dev/null +++ b/src/sudachi/configuration/configure_general.cpp @@ -0,0 +1,128 @@ +// SPDX-FileCopyrightText: 2016 Citra Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include +#include +#include +#include +#include "common/settings.h" +#include "core/core.h" +#include "ui_configure_general.h" +#include "sudachi/configuration/configuration_shared.h" +#include "sudachi/configuration/configure_general.h" +#include "sudachi/configuration/shared_widget.h" +#include "sudachi/uisettings.h" + +ConfigureGeneral::ConfigureGeneral(const Core::System& system_, + std::shared_ptr> group_, + const ConfigurationShared::Builder& builder, QWidget* parent) + : Tab(group_, parent), ui{std::make_unique()}, system{system_} { + ui->setupUi(this); + + Setup(builder); + + SetConfiguration(); + + connect(ui->button_reset_defaults, &QPushButton::clicked, this, + &ConfigureGeneral::ResetDefaults); + + if (!Settings::IsConfiguringGlobal()) { + ui->button_reset_defaults->setVisible(false); + } +} + +ConfigureGeneral::~ConfigureGeneral() = default; + +void ConfigureGeneral::SetConfiguration() {} + +void ConfigureGeneral::Setup(const ConfigurationShared::Builder& builder) { + QLayout& general_layout = *ui->general_widget->layout(); + QLayout& linux_layout = *ui->linux_widget->layout(); + + std::map general_hold{}; + std::map linux_hold{}; + + std::vector settings; + + auto push = [&settings](auto& list) { + for (auto setting : list) { + settings.push_back(setting); + } + }; + + push(UISettings::values.linkage.by_category[Settings::Category::UiGeneral]); + push(Settings::values.linkage.by_category[Settings::Category::Linux]); + + // Only show Linux group on Unix +#ifndef __unix__ + ui->LinuxGroupBox->setVisible(false); +#endif + + for (const auto setting : settings) { + auto* widget = builder.BuildWidget(setting, apply_funcs); + + if (widget == nullptr) { + continue; + } + if (!widget->Valid()) { + widget->deleteLater(); + continue; + } + + switch (setting->GetCategory()) { + case Settings::Category::UiGeneral: + general_hold.emplace(setting->Id(), widget); + break; + case Settings::Category::Linux: + linux_hold.emplace(setting->Id(), widget); + break; + default: + widget->deleteLater(); + } + } + + for (const auto& [id, widget] : general_hold) { + general_layout.addWidget(widget); + } + for (const auto& [id, widget] : linux_hold) { + linux_layout.addWidget(widget); + } +} + +// Called to set the callback when resetting settings to defaults +void ConfigureGeneral::SetResetCallback(std::function callback) { + reset_callback = std::move(callback); +} + +void ConfigureGeneral::ResetDefaults() { + QMessageBox::StandardButton answer = QMessageBox::question( + this, tr("sudachi"), + tr("This reset all settings and remove all per-game configurations. This will not delete " + "game directories, profiles, or input profiles. Proceed?"), + QMessageBox::Yes | QMessageBox::No, QMessageBox::No); + if (answer == QMessageBox::No) { + return; + } + UISettings::values.reset_to_defaults = true; + UISettings::values.is_game_list_reload_pending.exchange(true); + reset_callback(); +} + +void ConfigureGeneral::ApplyConfiguration() { + bool powered_on = system.IsPoweredOn(); + for (const auto& func : apply_funcs) { + func(powered_on); + } +} + +void ConfigureGeneral::changeEvent(QEvent* event) { + if (event->type() == QEvent::LanguageChange) { + RetranslateUI(); + } + + QWidget::changeEvent(event); +} + +void ConfigureGeneral::RetranslateUI() { + ui->retranslateUi(this); +} diff --git a/src/sudachi/configuration/configure_general.h b/src/sudachi/configuration/configure_general.h new file mode 100644 index 0000000..7e04cf9 --- /dev/null +++ b/src/sudachi/configuration/configure_general.h @@ -0,0 +1,55 @@ +// SPDX-FileCopyrightText: 2016 Citra Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include +#include +#include +#include +#include "sudachi/configuration/configuration_shared.h" + +namespace Core { +class System; +} + +class ConfigureDialog; +class HotkeyRegistry; + +namespace Ui { +class ConfigureGeneral; +} + +namespace ConfigurationShared { +class Builder; +} + +class ConfigureGeneral : public ConfigurationShared::Tab { + Q_OBJECT + +public: + explicit ConfigureGeneral(const Core::System& system_, + std::shared_ptr> group, + const ConfigurationShared::Builder& builder, + QWidget* parent = nullptr); + ~ConfigureGeneral() override; + + void SetResetCallback(std::function callback); + void ResetDefaults(); + void ApplyConfiguration() override; + void SetConfiguration() override; + +private: + void Setup(const ConfigurationShared::Builder& builder); + + void changeEvent(QEvent* event) override; + void RetranslateUI(); + + std::function reset_callback; + + std::unique_ptr ui; + + std::vector> apply_funcs{}; + + const Core::System& system; +}; diff --git a/src/sudachi/configuration/configure_general.ui b/src/sudachi/configuration/configure_general.ui new file mode 100644 index 0000000..ef20891 --- /dev/null +++ b/src/sudachi/configuration/configure_general.ui @@ -0,0 +1,134 @@ + + + ConfigureGeneral + + + + 0 + 0 + 744 + 568 + + + + Form + + + General + + + + + + + + General + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + + + + + + Linux + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + 6 + + + 5 + + + 5 + + + 5 + + + 5 + + + + + Reset All Settings + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + + + + diff --git a/src/sudachi/configuration/configure_graphics.cpp b/src/sudachi/configuration/configure_graphics.cpp new file mode 100644 index 0000000..bb3eed3 --- /dev/null +++ b/src/sudachi/configuration/configure_graphics.cpp @@ -0,0 +1,552 @@ +// SPDX-FileCopyrightText: 2016 Citra Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "common/common_types.h" +#include "common/dynamic_library.h" +#include "common/logging/log.h" +#include "common/settings.h" +#include "common/settings_enums.h" +#include "core/core.h" +#include "ui_configure_graphics.h" +#include "sudachi/configuration/configuration_shared.h" +#include "sudachi/configuration/configure_graphics.h" +#include "sudachi/configuration/shared_widget.h" +#include "sudachi/qt_common.h" +#include "sudachi/uisettings.h" +#include "sudachi/vk_device_info.h" + +static const std::vector default_present_modes{VK_PRESENT_MODE_IMMEDIATE_KHR, + VK_PRESENT_MODE_FIFO_KHR}; + +// Converts a setting to a present mode (or vice versa) +static constexpr VkPresentModeKHR VSyncSettingToMode(Settings::VSyncMode mode) { + switch (mode) { + case Settings::VSyncMode::Immediate: + return VK_PRESENT_MODE_IMMEDIATE_KHR; + case Settings::VSyncMode::Mailbox: + return VK_PRESENT_MODE_MAILBOX_KHR; + case Settings::VSyncMode::Fifo: + return VK_PRESENT_MODE_FIFO_KHR; + case Settings::VSyncMode::FifoRelaxed: + return VK_PRESENT_MODE_FIFO_RELAXED_KHR; + default: + return VK_PRESENT_MODE_FIFO_KHR; + } +} + +static constexpr Settings::VSyncMode PresentModeToSetting(VkPresentModeKHR mode) { + switch (mode) { + case VK_PRESENT_MODE_IMMEDIATE_KHR: + return Settings::VSyncMode::Immediate; + case VK_PRESENT_MODE_MAILBOX_KHR: + return Settings::VSyncMode::Mailbox; + case VK_PRESENT_MODE_FIFO_KHR: + return Settings::VSyncMode::Fifo; + case VK_PRESENT_MODE_FIFO_RELAXED_KHR: + return Settings::VSyncMode::FifoRelaxed; + default: + return Settings::VSyncMode::Fifo; + } +} + +ConfigureGraphics::ConfigureGraphics( + const Core::System& system_, std::vector& records_, + const std::function& expose_compute_option_, + const std::function& + update_aspect_ratio_, + std::shared_ptr> group_, + const ConfigurationShared::Builder& builder, QWidget* parent) + : ConfigurationShared::Tab(group_, parent), ui{std::make_unique()}, + records{records_}, expose_compute_option{expose_compute_option_}, + update_aspect_ratio{update_aspect_ratio_}, system{system_}, + combobox_translations{builder.ComboboxTranslations()}, + shader_mapping{ + combobox_translations.at(Settings::EnumMetadata::Index())} { + vulkan_device = Settings::values.vulkan_device.GetValue(); + RetrieveVulkanDevices(); + + ui->setupUi(this); + + Setup(builder); + + for (const auto& device : vulkan_devices) { + vulkan_device_combobox->addItem(device); + } + + UpdateBackgroundColorButton(QColor::fromRgb(Settings::values.bg_red.GetValue(), + Settings::values.bg_green.GetValue(), + Settings::values.bg_blue.GetValue())); + UpdateAPILayout(); + PopulateVSyncModeSelection(false); //< must happen after UpdateAPILayout + + // VSync setting needs to be determined after populating the VSync combobox + const auto vsync_mode_setting = Settings::values.vsync_mode.GetValue(); + const auto vsync_mode = VSyncSettingToMode(vsync_mode_setting); + int index{}; + for (const auto mode : vsync_mode_combobox_enum_map) { + if (mode == vsync_mode) { + break; + } + index++; + } + if (static_cast(index) < vsync_mode_combobox_enum_map.size()) { + vsync_mode_combobox->setCurrentIndex(index); + } + + connect(api_combobox, qOverload(&QComboBox::activated), this, [this] { + UpdateAPILayout(); + PopulateVSyncModeSelection(false); + }); + connect(vulkan_device_combobox, qOverload(&QComboBox::activated), this, + [this](int device) { + UpdateDeviceSelection(device); + PopulateVSyncModeSelection(false); + }); + connect(shader_backend_combobox, qOverload(&QComboBox::activated), this, + [this](int backend) { UpdateShaderBackendSelection(backend); }); + + connect(ui->bg_button, &QPushButton::clicked, this, [this] { + const QColor new_bg_color = QColorDialog::getColor(bg_color); + if (!new_bg_color.isValid()) { + return; + } + UpdateBackgroundColorButton(new_bg_color); + }); + + const auto& update_screenshot_info = [this, &builder]() { + const auto& combobox_enumerations = builder.ComboboxTranslations().at( + Settings::EnumMetadata::Index()); + const auto ratio_index = aspect_ratio_combobox->currentIndex(); + const auto ratio = + static_cast(combobox_enumerations[ratio_index].first); + + const auto& combobox_enumerations_resolution = builder.ComboboxTranslations().at( + Settings::EnumMetadata::Index()); + const auto res_index = resolution_combobox->currentIndex(); + const auto setup = static_cast( + combobox_enumerations_resolution[res_index].first); + + update_aspect_ratio(ratio, setup); + }; + + connect(aspect_ratio_combobox, QOverload::of(&QComboBox::currentIndexChanged), + update_screenshot_info); + connect(resolution_combobox, QOverload::of(&QComboBox::currentIndexChanged), + update_screenshot_info); + + api_combobox->setEnabled(!UISettings::values.has_broken_vulkan && api_combobox->isEnabled()); + ui->api_widget->setEnabled( + (!UISettings::values.has_broken_vulkan || Settings::IsConfiguringGlobal()) && + ui->api_widget->isEnabled()); + + if (Settings::IsConfiguringGlobal()) { + ui->bg_widget->setEnabled(Settings::values.bg_red.UsingGlobal()); + } +} + +void ConfigureGraphics::PopulateVSyncModeSelection(bool use_setting) { + const Settings::RendererBackend backend{GetCurrentGraphicsBackend()}; + if (backend == Settings::RendererBackend::Null) { + vsync_mode_combobox->setEnabled(false); + return; + } + vsync_mode_combobox->setEnabled(true); + + const int current_index = //< current selected vsync mode from combobox + vsync_mode_combobox->currentIndex(); + const auto current_mode = //< current selected vsync mode as a VkPresentModeKHR + current_index == -1 || use_setting + ? VSyncSettingToMode(Settings::values.vsync_mode.GetValue()) + : vsync_mode_combobox_enum_map[current_index]; + int index{}; + const int device{vulkan_device_combobox->currentIndex()}; //< current selected Vulkan device + + const auto& present_modes = //< relevant vector of present modes for the selected device or API + backend == Settings::RendererBackend::Vulkan && device > -1 ? device_present_modes[device] + : default_present_modes; + + vsync_mode_combobox->clear(); + vsync_mode_combobox_enum_map.clear(); + vsync_mode_combobox_enum_map.reserve(present_modes.size()); + for (const auto present_mode : present_modes) { + const auto mode_name = TranslateVSyncMode(present_mode, backend); + if (mode_name.isEmpty()) { + continue; + } + + vsync_mode_combobox->insertItem(index, mode_name); + vsync_mode_combobox_enum_map.push_back(present_mode); + if (present_mode == current_mode) { + vsync_mode_combobox->setCurrentIndex(index); + } + index++; + } + + if (!Settings::IsConfiguringGlobal()) { + vsync_restore_global_button->setVisible(!Settings::values.vsync_mode.UsingGlobal()); + + const Settings::VSyncMode global_vsync_mode = Settings::values.vsync_mode.GetValue(true); + vsync_restore_global_button->setEnabled( + (backend == Settings::RendererBackend::OpenGL && + (global_vsync_mode == Settings::VSyncMode::Immediate || + global_vsync_mode == Settings::VSyncMode::Fifo)) || + backend == Settings::RendererBackend::Vulkan); + } +} + +void ConfigureGraphics::UpdateVsyncSetting() const { + const Settings::RendererBackend backend{GetCurrentGraphicsBackend()}; + if (backend == Settings::RendererBackend::Null) { + return; + } + + const auto mode = vsync_mode_combobox_enum_map[vsync_mode_combobox->currentIndex()]; + const auto vsync_mode = PresentModeToSetting(mode); + Settings::values.vsync_mode.SetValue(vsync_mode); +} + +void ConfigureGraphics::UpdateDeviceSelection(int device) { + if (device == -1) { + return; + } + if (GetCurrentGraphicsBackend() == Settings::RendererBackend::Vulkan) { + vulkan_device = device; + } +} + +void ConfigureGraphics::UpdateShaderBackendSelection(int backend) { + if (backend == -1) { + return; + } + if (GetCurrentGraphicsBackend() == Settings::RendererBackend::OpenGL) { + shader_backend = static_cast(backend); + } +} + +ConfigureGraphics::~ConfigureGraphics() = default; + +void ConfigureGraphics::SetConfiguration() {} + +void ConfigureGraphics::Setup(const ConfigurationShared::Builder& builder) { + QLayout* api_layout = ui->api_widget->layout(); + QWidget* api_grid_widget = new QWidget(this); + QVBoxLayout* api_grid_layout = new QVBoxLayout(api_grid_widget); + api_grid_layout->setContentsMargins(0, 0, 0, 0); + api_layout->addWidget(api_grid_widget); + + QLayout& graphics_layout = *ui->graphics_widget->layout(); + + std::map hold_graphics; + std::vector hold_api; + + for (const auto setting : Settings::values.linkage.by_category[Settings::Category::Renderer]) { + ConfigurationShared::Widget* widget = [&]() { + if (setting->Id() == Settings::values.fsr_sharpening_slider.Id()) { + // FSR needs a reversed slider and a 0.5 multiplier + return builder.BuildWidget( + setting, apply_funcs, ConfigurationShared::RequestType::ReverseSlider, true, + 0.5f, nullptr, tr("%", "FSR sharpening percentage (e.g. 50%)")); + } else { + return builder.BuildWidget(setting, apply_funcs); + } + }(); + + if (widget == nullptr) { + continue; + } + if (!widget->Valid()) { + widget->deleteLater(); + continue; + } + + if (setting->Id() == Settings::values.renderer_backend.Id()) { + // Add the renderer combobox now so it's at the top + api_grid_layout->addWidget(widget); + api_combobox = widget->combobox; + api_restore_global_button = widget->restore_button; + + if (!Settings::IsConfiguringGlobal()) { + QObject::connect(api_restore_global_button, &QAbstractButton::clicked, + [this](bool) { UpdateAPILayout(); }); + + // Detach API's restore button and place it where we want + // Lets us put it on the side, and it will automatically scale if there's a + // second combobox (shader_backend, vulkan_device) + widget->layout()->removeWidget(api_restore_global_button); + api_layout->addWidget(api_restore_global_button); + } + } else if (setting->Id() == Settings::values.vulkan_device.Id()) { + // Keep track of vulkan_device's combobox so we can populate it + hold_api.push_back(widget); + vulkan_device_combobox = widget->combobox; + vulkan_device_widget = widget; + } else if (setting->Id() == Settings::values.shader_backend.Id()) { + // Keep track of shader_backend's combobox so we can populate it + hold_api.push_back(widget); + shader_backend_combobox = widget->combobox; + shader_backend_widget = widget; + } else if (setting->Id() == Settings::values.vsync_mode.Id()) { + // Keep track of vsync_mode's combobox so we can populate it + vsync_mode_combobox = widget->combobox; + + // Since vsync is populated at runtime, we have to manually set up the button for + // restoring the global setting. + if (!Settings::IsConfiguringGlobal()) { + QPushButton* restore_button = + ConfigurationShared::Widget::CreateRestoreGlobalButton( + Settings::values.vsync_mode.UsingGlobal(), widget); + restore_button->setEnabled(true); + widget->layout()->addWidget(restore_button); + + QObject::connect(restore_button, &QAbstractButton::clicked, + [restore_button, this](bool) { + Settings::values.vsync_mode.SetGlobal(true); + PopulateVSyncModeSelection(true); + + restore_button->setVisible(false); + }); + + std::function set_non_global = [restore_button, this]() { + Settings::values.vsync_mode.SetGlobal(false); + UpdateVsyncSetting(); + restore_button->setVisible(true); + }; + QObject::connect(widget->combobox, QOverload::of(&QComboBox::activated), + [set_non_global]() { set_non_global(); }); + vsync_restore_global_button = restore_button; + } + hold_graphics.emplace(setting->Id(), widget); + } else if (setting->Id() == Settings::values.aspect_ratio.Id()) { + // Keep track of the aspect ratio combobox to update other UI tabs that need it + aspect_ratio_combobox = widget->combobox; + hold_graphics.emplace(setting->Id(), widget); + } else if (setting->Id() == Settings::values.resolution_setup.Id()) { + // Keep track of the resolution combobox to update other UI tabs that need it + resolution_combobox = widget->combobox; + hold_graphics.emplace(setting->Id(), widget); + } else { + hold_graphics.emplace(setting->Id(), widget); + } + } + + for (const auto& [id, widget] : hold_graphics) { + graphics_layout.addWidget(widget); + } + + for (auto widget : hold_api) { + api_grid_layout->addWidget(widget); + } + + // Background color is too specific to build into the new system, so we manage it here + // (3 settings, all collected into a single widget with a QColor to manage on top) + if (Settings::IsConfiguringGlobal()) { + apply_funcs.push_back([this](bool powered_on) { + Settings::values.bg_red.SetValue(static_cast(bg_color.red())); + Settings::values.bg_green.SetValue(static_cast(bg_color.green())); + Settings::values.bg_blue.SetValue(static_cast(bg_color.blue())); + }); + } else { + QPushButton* bg_restore_button = ConfigurationShared::Widget::CreateRestoreGlobalButton( + Settings::values.bg_red.UsingGlobal(), ui->bg_widget); + ui->bg_widget->layout()->addWidget(bg_restore_button); + + QObject::connect(bg_restore_button, &QAbstractButton::clicked, + [bg_restore_button, this](bool) { + const int r = Settings::values.bg_red.GetValue(true); + const int g = Settings::values.bg_green.GetValue(true); + const int b = Settings::values.bg_blue.GetValue(true); + UpdateBackgroundColorButton(QColor::fromRgb(r, g, b)); + + bg_restore_button->setVisible(false); + bg_restore_button->setEnabled(false); + }); + + QObject::connect(ui->bg_button, &QAbstractButton::clicked, [bg_restore_button](bool) { + bg_restore_button->setVisible(true); + bg_restore_button->setEnabled(true); + }); + + apply_funcs.push_back([bg_restore_button, this](bool powered_on) { + const bool using_global = !bg_restore_button->isEnabled(); + Settings::values.bg_red.SetGlobal(using_global); + Settings::values.bg_green.SetGlobal(using_global); + Settings::values.bg_blue.SetGlobal(using_global); + if (!using_global) { + Settings::values.bg_red.SetValue(static_cast(bg_color.red())); + Settings::values.bg_green.SetValue(static_cast(bg_color.green())); + Settings::values.bg_blue.SetValue(static_cast(bg_color.blue())); + } + }); + } +} + +const QString ConfigureGraphics::TranslateVSyncMode(VkPresentModeKHR mode, + Settings::RendererBackend backend) const { + switch (mode) { + case VK_PRESENT_MODE_IMMEDIATE_KHR: + return backend == Settings::RendererBackend::OpenGL + ? tr("Off") + : QStringLiteral("Immediate (%1)").arg(tr("VSync Off")); + case VK_PRESENT_MODE_MAILBOX_KHR: + return QStringLiteral("Mailbox (%1)").arg(tr("Recommended")); + case VK_PRESENT_MODE_FIFO_KHR: + return backend == Settings::RendererBackend::OpenGL + ? tr("On") + : QStringLiteral("FIFO (%1)").arg(tr("VSync On")); + case VK_PRESENT_MODE_FIFO_RELAXED_KHR: + return QStringLiteral("FIFO Relaxed"); + default: + return {}; + break; + } +} + +int ConfigureGraphics::FindIndex(u32 enumeration, int value) const { + for (u32 i = 0; i < combobox_translations.at(enumeration).size(); i++) { + if (combobox_translations.at(enumeration)[i].first == static_cast(value)) { + return i; + } + } + return -1; +} + +void ConfigureGraphics::ApplyConfiguration() { + const bool powered_on = system.IsPoweredOn(); + for (const auto& func : apply_funcs) { + func(powered_on); + } + + UpdateVsyncSetting(); + + Settings::values.vulkan_device.SetGlobal(true); + Settings::values.shader_backend.SetGlobal(true); + if (Settings::IsConfiguringGlobal() || + (!Settings::IsConfiguringGlobal() && api_restore_global_button->isEnabled())) { + auto backend = static_cast( + combobox_translations + .at(Settings::EnumMetadata< + Settings::RendererBackend>::Index())[api_combobox->currentIndex()] + .first); + switch (backend) { + case Settings::RendererBackend::OpenGL: + Settings::values.shader_backend.SetGlobal(Settings::IsConfiguringGlobal()); + Settings::values.shader_backend.SetValue(static_cast( + shader_mapping[shader_backend_combobox->currentIndex()].first)); + break; + case Settings::RendererBackend::Vulkan: + Settings::values.vulkan_device.SetGlobal(Settings::IsConfiguringGlobal()); + Settings::values.vulkan_device.SetValue(vulkan_device_combobox->currentIndex()); + break; + case Settings::RendererBackend::Null: + break; + } + } +} + +void ConfigureGraphics::changeEvent(QEvent* event) { + if (event->type() == QEvent::LanguageChange) { + RetranslateUI(); + } + + QWidget::changeEvent(event); +} + +void ConfigureGraphics::RetranslateUI() { + ui->retranslateUi(this); +} + +void ConfigureGraphics::UpdateBackgroundColorButton(QColor color) { + bg_color = color; + + QPixmap pixmap(ui->bg_button->size()); + pixmap.fill(bg_color); + + const QIcon color_icon(pixmap); + ui->bg_button->setIcon(color_icon); +} + +void ConfigureGraphics::UpdateAPILayout() { + bool runtime_lock = !system.IsPoweredOn(); + bool need_global = !(Settings::IsConfiguringGlobal() || api_restore_global_button->isEnabled()); + vulkan_device = Settings::values.vulkan_device.GetValue(need_global); + shader_backend = Settings::values.shader_backend.GetValue(need_global); + vulkan_device_widget->setEnabled(!need_global && runtime_lock); + shader_backend_widget->setEnabled(!need_global && runtime_lock); + + const auto current_backend = GetCurrentGraphicsBackend(); + const bool is_opengl = current_backend == Settings::RendererBackend::OpenGL; + const bool is_vulkan = current_backend == Settings::RendererBackend::Vulkan; + + vulkan_device_widget->setVisible(is_vulkan); + shader_backend_widget->setVisible(is_opengl); + + if (is_opengl) { + shader_backend_combobox->setCurrentIndex( + FindIndex(Settings::EnumMetadata::Index(), + static_cast(shader_backend))); + } else if (is_vulkan && static_cast(vulkan_device) < vulkan_device_combobox->count()) { + vulkan_device_combobox->setCurrentIndex(vulkan_device); + } +} + +void ConfigureGraphics::RetrieveVulkanDevices() { + vulkan_devices.clear(); + vulkan_devices.reserve(records.size()); + device_present_modes.clear(); + device_present_modes.reserve(records.size()); + for (const auto& record : records) { + vulkan_devices.push_back(QString::fromStdString(record.name)); + device_present_modes.push_back(record.vsync_support); + + if (record.has_broken_compute) { + expose_compute_option(); + } + } +} + +Settings::RendererBackend ConfigureGraphics::GetCurrentGraphicsBackend() const { + const auto selected_backend = [&]() { + if (!Settings::IsConfiguringGlobal() && !api_restore_global_button->isEnabled()) { + return Settings::values.renderer_backend.GetValue(true); + } + return static_cast( + combobox_translations.at(Settings::EnumMetadata::Index()) + .at(api_combobox->currentIndex()) + .first); + }(); + + if (selected_backend == Settings::RendererBackend::Vulkan && + UISettings::values.has_broken_vulkan) { + return Settings::RendererBackend::OpenGL; + } + return selected_backend; +} diff --git a/src/sudachi/configuration/configure_graphics.h b/src/sudachi/configuration/configure_graphics.h new file mode 100644 index 0000000..ddb7d3a --- /dev/null +++ b/src/sudachi/configuration/configure_graphics.h @@ -0,0 +1,116 @@ +// SPDX-FileCopyrightText: 2016 Citra Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "common/common_types.h" +#include "common/settings_enums.h" +#include "configuration/shared_translation.h" +#include "vk_device_info.h" +#include "sudachi/configuration/configuration_shared.h" + +class QPushButton; +class QEvent; +class QObject; +class QComboBox; + +namespace Settings { +enum class NvdecEmulation : u32; +enum class RendererBackend : u32; +enum class ShaderBackend : u32; +} // namespace Settings + +namespace Core { +class System; +} + +namespace Ui { +class ConfigureGraphics; +} + +namespace ConfigurationShared { +class Builder; +} + +class ConfigureGraphics : public ConfigurationShared::Tab { + Q_OBJECT + +public: + explicit ConfigureGraphics( + const Core::System& system_, std::vector& records, + const std::function& expose_compute_option, + const std::function& + update_aspect_ratio, + std::shared_ptr> group, + const ConfigurationShared::Builder& builder, QWidget* parent = nullptr); + ~ConfigureGraphics() override; + + void ApplyConfiguration() override; + void SetConfiguration() override; + +private: + void changeEvent(QEvent* event) override; + void RetranslateUI(); + + void Setup(const ConfigurationShared::Builder& builder); + + void PopulateVSyncModeSelection(bool use_setting); + void UpdateVsyncSetting() const; + void UpdateBackgroundColorButton(QColor color); + void UpdateAPILayout(); + void UpdateDeviceSelection(int device); + void UpdateShaderBackendSelection(int backend); + + void RetrieveVulkanDevices(); + + /* Turns a Vulkan present mode into a textual string for a UI + * (and eventually for a human to read) */ + const QString TranslateVSyncMode(VkPresentModeKHR mode, + Settings::RendererBackend backend) const; + + Settings::RendererBackend GetCurrentGraphicsBackend() const; + + int FindIndex(u32 enumeration, int value) const; + + std::unique_ptr ui; + QColor bg_color; + + std::vector> apply_funcs{}; + + std::vector& records; + std::vector vulkan_devices; + std::vector> device_present_modes; + std::vector + vsync_mode_combobox_enum_map{}; //< Keeps track of which present mode corresponds to which + // selection in the combobox + u32 vulkan_device{}; + Settings::ShaderBackend shader_backend{}; + const std::function& expose_compute_option; + const std::function update_aspect_ratio; + + const Core::System& system; + const ConfigurationShared::ComboboxTranslationMap& combobox_translations; + const std::vector>& shader_mapping; + + QPushButton* api_restore_global_button; + QComboBox* vulkan_device_combobox; + QComboBox* api_combobox; + QComboBox* shader_backend_combobox; + QComboBox* vsync_mode_combobox; + QPushButton* vsync_restore_global_button; + QWidget* vulkan_device_widget; + QWidget* api_widget; + QWidget* shader_backend_widget; + QComboBox* aspect_ratio_combobox; + QComboBox* resolution_combobox; +}; diff --git a/src/sudachi/configuration/configure_graphics.ui b/src/sudachi/configuration/configure_graphics.ui new file mode 100644 index 0000000..d09415d --- /dev/null +++ b/src/sudachi/configuration/configure_graphics.ui @@ -0,0 +1,151 @@ + + + ConfigureGraphics + + + + 0 + 0 + 541 + 759 + + + + Form + + + Graphics + + + + + + + + API Settings + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + + + + + + + 16777215 + 16777215 + + + + Graphics Settings + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + 0 + 0 + + + + Background Color: + + + + + + + + 0 + 0 + + + + + 40 + 16777215 + + + + + + + + + + + + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + diff --git a/src/sudachi/configuration/configure_graphics_advanced.cpp b/src/sudachi/configuration/configure_graphics_advanced.cpp new file mode 100644 index 0000000..8a54df7 --- /dev/null +++ b/src/sudachi/configuration/configure_graphics_advanced.cpp @@ -0,0 +1,82 @@ +// SPDX-FileCopyrightText: Copyright 2020 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include +#include +#include +#include "common/settings.h" +#include "core/core.h" +#include "ui_configure_graphics_advanced.h" +#include "sudachi/configuration/configuration_shared.h" +#include "sudachi/configuration/configure_graphics_advanced.h" +#include "sudachi/configuration/shared_translation.h" +#include "sudachi/configuration/shared_widget.h" + +ConfigureGraphicsAdvanced::ConfigureGraphicsAdvanced( + const Core::System& system_, std::shared_ptr> group_, + const ConfigurationShared::Builder& builder, QWidget* parent) + : Tab(group_, parent), ui{std::make_unique()}, system{system_} { + + ui->setupUi(this); + + Setup(builder); + + SetConfiguration(); + + checkbox_enable_compute_pipelines->setVisible(false); +} + +ConfigureGraphicsAdvanced::~ConfigureGraphicsAdvanced() = default; + +void ConfigureGraphicsAdvanced::SetConfiguration() {} + +void ConfigureGraphicsAdvanced::Setup(const ConfigurationShared::Builder& builder) { + auto& layout = *ui->populate_target->layout(); + std::map hold{}; // A map will sort the data for us + + for (auto setting : + Settings::values.linkage.by_category[Settings::Category::RendererAdvanced]) { + ConfigurationShared::Widget* widget = builder.BuildWidget(setting, apply_funcs); + + if (widget == nullptr) { + continue; + } + if (!widget->Valid()) { + widget->deleteLater(); + continue; + } + + hold.emplace(setting->Id(), widget); + + // Keep track of enable_compute_pipelines so we can display it when needed + if (setting->Id() == Settings::values.enable_compute_pipelines.Id()) { + checkbox_enable_compute_pipelines = widget; + } + } + for (const auto& [id, widget] : hold) { + layout.addWidget(widget); + } +} + +void ConfigureGraphicsAdvanced::ApplyConfiguration() { + const bool is_powered_on = system.IsPoweredOn(); + for (const auto& func : apply_funcs) { + func(is_powered_on); + } +} + +void ConfigureGraphicsAdvanced::changeEvent(QEvent* event) { + if (event->type() == QEvent::LanguageChange) { + RetranslateUI(); + } + + QWidget::changeEvent(event); +} + +void ConfigureGraphicsAdvanced::RetranslateUI() { + ui->retranslateUi(this); +} + +void ConfigureGraphicsAdvanced::ExposeComputeOption() { + checkbox_enable_compute_pipelines->setVisible(true); +} diff --git a/src/sudachi/configuration/configure_graphics_advanced.h b/src/sudachi/configuration/configure_graphics_advanced.h new file mode 100644 index 0000000..0483b99 --- /dev/null +++ b/src/sudachi/configuration/configure_graphics_advanced.h @@ -0,0 +1,49 @@ +// SPDX-FileCopyrightText: Copyright 2020 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include +#include +#include +#include "sudachi/configuration/configuration_shared.h" + +namespace Core { +class System; +} + +namespace Ui { +class ConfigureGraphicsAdvanced; +} + +namespace ConfigurationShared { +class Builder; +} + +class ConfigureGraphicsAdvanced : public ConfigurationShared::Tab { + Q_OBJECT + +public: + explicit ConfigureGraphicsAdvanced( + const Core::System& system_, std::shared_ptr> group, + const ConfigurationShared::Builder& builder, QWidget* parent = nullptr); + ~ConfigureGraphicsAdvanced() override; + + void ApplyConfiguration() override; + void SetConfiguration() override; + + void ExposeComputeOption(); + +private: + void Setup(const ConfigurationShared::Builder& builder); + void changeEvent(QEvent* event) override; + void RetranslateUI(); + + std::unique_ptr ui; + + const Core::System& system; + + std::vector> apply_funcs; + + QWidget* checkbox_enable_compute_pipelines{}; +}; diff --git a/src/sudachi/configuration/configure_graphics_advanced.ui b/src/sudachi/configuration/configure_graphics_advanced.ui new file mode 100644 index 0000000..37a854c --- /dev/null +++ b/src/sudachi/configuration/configure_graphics_advanced.ui @@ -0,0 +1,68 @@ + + + ConfigureGraphicsAdvanced + + + + 0 + 0 + 404 + 376 + + + + Form + + + Advanced + + + + + + + + Advanced Graphics Settings + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + diff --git a/src/sudachi/configuration/configure_hotkeys.cpp b/src/sudachi/configuration/configure_hotkeys.cpp new file mode 100644 index 0000000..619dc95 --- /dev/null +++ b/src/sudachi/configuration/configure_hotkeys.cpp @@ -0,0 +1,423 @@ +// SPDX-FileCopyrightText: 2017 Citra Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include +#include +#include +#include + +#include "hid_core/frontend/emulated_controller.h" +#include "hid_core/hid_core.h" + +#include "frontend_common/config.h" +#include "ui_configure_hotkeys.h" +#include "sudachi/configuration/configure_hotkeys.h" +#include "sudachi/hotkeys.h" +#include "sudachi/uisettings.h" +#include "sudachi/util/sequence_dialog/sequence_dialog.h" + +constexpr int name_column = 0; +constexpr int hotkey_column = 1; +constexpr int controller_column = 2; + +ConfigureHotkeys::ConfigureHotkeys(Core::HID::HIDCore& hid_core, QWidget* parent) + : QWidget(parent), ui(std::make_unique()), + timeout_timer(std::make_unique()), poll_timer(std::make_unique()) { + ui->setupUi(this); + setFocusPolicy(Qt::ClickFocus); + + model = new QStandardItemModel(this); + model->setColumnCount(3); + + connect(ui->hotkey_list, &QTreeView::doubleClicked, this, &ConfigureHotkeys::Configure); + connect(ui->hotkey_list, &QTreeView::customContextMenuRequested, this, + &ConfigureHotkeys::PopupContextMenu); + ui->hotkey_list->setContextMenuPolicy(Qt::CustomContextMenu); + ui->hotkey_list->setModel(model); + + ui->hotkey_list->header()->setStretchLastSection(false); + ui->hotkey_list->header()->setSectionResizeMode(name_column, QHeaderView::ResizeMode::Stretch); + ui->hotkey_list->header()->setMinimumSectionSize(150); + + connect(ui->button_restore_defaults, &QPushButton::clicked, this, + &ConfigureHotkeys::RestoreDefaults); + connect(ui->button_clear_all, &QPushButton::clicked, this, &ConfigureHotkeys::ClearAll); + + controller = hid_core.GetEmulatedController(Core::HID::NpadIdType::Player1); + + connect(timeout_timer.get(), &QTimer::timeout, [this] { + const bool is_button_pressed = pressed_buttons != Core::HID::NpadButton::None || + pressed_home_button || pressed_capture_button; + SetPollingResult(!is_button_pressed); + }); + + connect(poll_timer.get(), &QTimer::timeout, [this] { + pressed_buttons |= controller->GetNpadButtons().raw; + pressed_home_button |= this->controller->GetHomeButtons().home != 0; + pressed_capture_button |= this->controller->GetCaptureButtons().capture != 0; + if (pressed_buttons != Core::HID::NpadButton::None || pressed_home_button || + pressed_capture_button) { + const QString button_name = + GetButtonCombinationName(pressed_buttons, pressed_home_button, + pressed_capture_button) + + QStringLiteral("..."); + model->setData(button_model_index, button_name); + } + }); + RetranslateUI(); +} + +ConfigureHotkeys::~ConfigureHotkeys() = default; + +void ConfigureHotkeys::Populate(const HotkeyRegistry& registry) { + for (const auto& group : registry.hotkey_groups) { + QString parent_item_data = QString::fromStdString(group.first); + auto* parent_item = + new QStandardItem(QCoreApplication::translate("Hotkeys", qPrintable(parent_item_data))); + parent_item->setEditable(false); + parent_item->setData(parent_item_data); + for (const auto& hotkey : group.second) { + QString hotkey_action_data = QString::fromStdString(hotkey.first); + auto* action = new QStandardItem( + QCoreApplication::translate("Hotkeys", qPrintable(hotkey_action_data))); + auto* keyseq = + new QStandardItem(hotkey.second.keyseq.toString(QKeySequence::NativeText)); + auto* controller_keyseq = + new QStandardItem(QString::fromStdString(hotkey.second.controller_keyseq)); + action->setEditable(false); + action->setData(hotkey_action_data); + keyseq->setEditable(false); + controller_keyseq->setEditable(false); + parent_item->appendRow({action, keyseq, controller_keyseq}); + } + model->appendRow(parent_item); + } + + ui->hotkey_list->expandAll(); + ui->hotkey_list->resizeColumnToContents(hotkey_column); + ui->hotkey_list->resizeColumnToContents(controller_column); +} + +void ConfigureHotkeys::changeEvent(QEvent* event) { + if (event->type() == QEvent::LanguageChange) { + RetranslateUI(); + } + + QWidget::changeEvent(event); +} + +void ConfigureHotkeys::RetranslateUI() { + ui->retranslateUi(this); + + model->setHorizontalHeaderLabels({tr("Action"), tr("Hotkey"), tr("Controller Hotkey")}); + for (int key_id = 0; key_id < model->rowCount(); key_id++) { + QStandardItem* parent = model->item(key_id, 0); + parent->setText( + QCoreApplication::translate("Hotkeys", qPrintable(parent->data().toString()))); + for (int key_column_id = 0; key_column_id < parent->rowCount(); key_column_id++) { + QStandardItem* action = parent->child(key_column_id, name_column); + action->setText( + QCoreApplication::translate("Hotkeys", qPrintable(action->data().toString()))); + } + } +} + +void ConfigureHotkeys::Configure(QModelIndex index) { + if (!index.parent().isValid()) { + return; + } + + // Controller configuration is selected + if (index.column() == controller_column) { + ConfigureController(index); + return; + } + + // Swap to the hotkey column + index = index.sibling(index.row(), hotkey_column); + + const auto previous_key = model->data(index); + + SequenceDialog hotkey_dialog{this}; + + const int return_code = hotkey_dialog.exec(); + const auto key_sequence = hotkey_dialog.GetSequence(); + if (return_code == QDialog::Rejected || key_sequence.isEmpty()) { + return; + } + const auto [key_sequence_used, used_action] = IsUsedKey(key_sequence); + + if (key_sequence_used && key_sequence != QKeySequence(previous_key.toString())) { + QMessageBox::warning( + this, tr("Conflicting Key Sequence"), + tr("The entered key sequence is already assigned to: %1").arg(used_action)); + } else { + model->setData(index, key_sequence.toString(QKeySequence::NativeText)); + } +} +void ConfigureHotkeys::ConfigureController(QModelIndex index) { + if (timeout_timer->isActive()) { + return; + } + + const auto previous_key = model->data(index); + + input_setter = [this, index, previous_key](const bool cancel) { + if (cancel) { + model->setData(index, previous_key); + return; + } + + const QString button_string = + GetButtonCombinationName(pressed_buttons, pressed_home_button, pressed_capture_button); + + const auto [key_sequence_used, used_action] = IsUsedControllerKey(button_string); + + if (key_sequence_used) { + QMessageBox::warning( + this, tr("Conflicting Key Sequence"), + tr("The entered key sequence is already assigned to: %1").arg(used_action)); + model->setData(index, previous_key); + } else { + model->setData(index, button_string); + } + }; + + button_model_index = index; + pressed_buttons = Core::HID::NpadButton::None; + pressed_home_button = false; + pressed_capture_button = false; + + model->setData(index, tr("[waiting]")); + timeout_timer->start(2500); // Cancel after 2.5 seconds + poll_timer->start(100); // Check for new inputs every 100ms + // We need to disable configuration to be able to read npad buttons + controller->DisableConfiguration(); +} + +void ConfigureHotkeys::SetPollingResult(const bool cancel) { + timeout_timer->stop(); + poll_timer->stop(); + (*input_setter)(cancel); + // Re-Enable configuration + controller->EnableConfiguration(); + + input_setter = std::nullopt; +} + +QString ConfigureHotkeys::GetButtonCombinationName(Core::HID::NpadButton button, + const bool home = false, + const bool capture = false) const { + Core::HID::NpadButtonState state{button}; + QString button_combination; + if (home) { + button_combination.append(QStringLiteral("Home+")); + } + if (capture) { + button_combination.append(QStringLiteral("Screenshot+")); + } + if (state.a) { + button_combination.append(QStringLiteral("A+")); + } + if (state.b) { + button_combination.append(QStringLiteral("B+")); + } + if (state.x) { + button_combination.append(QStringLiteral("X+")); + } + if (state.y) { + button_combination.append(QStringLiteral("Y+")); + } + if (state.l || state.right_sl || state.left_sl) { + button_combination.append(QStringLiteral("L+")); + } + if (state.r || state.right_sr || state.left_sr) { + button_combination.append(QStringLiteral("R+")); + } + if (state.zl) { + button_combination.append(QStringLiteral("ZL+")); + } + if (state.zr) { + button_combination.append(QStringLiteral("ZR+")); + } + if (state.left) { + button_combination.append(QStringLiteral("Dpad_Left+")); + } + if (state.right) { + button_combination.append(QStringLiteral("Dpad_Right+")); + } + if (state.up) { + button_combination.append(QStringLiteral("Dpad_Up+")); + } + if (state.down) { + button_combination.append(QStringLiteral("Dpad_Down+")); + } + if (state.stick_l) { + button_combination.append(QStringLiteral("Left_Stick+")); + } + if (state.stick_r) { + button_combination.append(QStringLiteral("Right_Stick+")); + } + if (state.minus) { + button_combination.append(QStringLiteral("Minus+")); + } + if (state.plus) { + button_combination.append(QStringLiteral("Plus+")); + } + if (button_combination.isEmpty()) { + return tr("Invalid"); + } else { + button_combination.chop(1); + return button_combination; + } +} + +std::pair ConfigureHotkeys::IsUsedKey(QKeySequence key_sequence) const { + for (int r = 0; r < model->rowCount(); ++r) { + const QStandardItem* const parent = model->item(r, 0); + + for (int r2 = 0; r2 < parent->rowCount(); ++r2) { + const QStandardItem* const key_seq_item = parent->child(r2, hotkey_column); + const auto key_seq_str = key_seq_item->text(); + const auto key_seq = QKeySequence::fromString(key_seq_str, QKeySequence::NativeText); + + if (key_sequence == key_seq) { + return std::make_pair(true, parent->child(r2, 0)->text()); + } + } + } + + return std::make_pair(false, QString()); +} + +std::pair ConfigureHotkeys::IsUsedControllerKey(const QString& key_sequence) const { + for (int r = 0; r < model->rowCount(); ++r) { + const QStandardItem* const parent = model->item(r, 0); + + for (int r2 = 0; r2 < parent->rowCount(); ++r2) { + const QStandardItem* const key_seq_item = parent->child(r2, controller_column); + const auto key_seq_str = key_seq_item->text(); + + if (key_sequence == key_seq_str) { + return std::make_pair(true, parent->child(r2, 0)->text()); + } + } + } + + return std::make_pair(false, QString()); +} + +void ConfigureHotkeys::ApplyConfiguration(HotkeyRegistry& registry) { + for (int key_id = 0; key_id < model->rowCount(); key_id++) { + const QStandardItem* parent = model->item(key_id, 0); + for (int key_column_id = 0; key_column_id < parent->rowCount(); key_column_id++) { + const QStandardItem* action = parent->child(key_column_id, name_column); + const QStandardItem* keyseq = parent->child(key_column_id, hotkey_column); + const QStandardItem* controller_keyseq = + parent->child(key_column_id, controller_column); + for (auto& [group, sub_actions] : registry.hotkey_groups) { + if (group != parent->data().toString().toStdString()) + continue; + for (auto& [action_name, hotkey] : sub_actions) { + if (action_name != action->data().toString().toStdString()) + continue; + hotkey.keyseq = QKeySequence(keyseq->text()); + hotkey.controller_keyseq = controller_keyseq->text().toStdString(); + } + } + } + } + + registry.SaveHotkeys(); +} + +void ConfigureHotkeys::RestoreDefaults() { + for (int r = 0; r < model->rowCount(); ++r) { + const QStandardItem* parent = model->item(r, 0); + const int hotkey_size = static_cast(UISettings::default_hotkeys.size()); + + if (hotkey_size != parent->rowCount()) { + QMessageBox::warning(this, tr("Invalid hotkey settings"), + tr("An error occurred. Please report this issue on github.")); + return; + } + + for (int r2 = 0; r2 < parent->rowCount(); ++r2) { + model->item(r, 0) + ->child(r2, hotkey_column) + ->setText(QString::fromStdString(UISettings::default_hotkeys[r2].shortcut.keyseq)); + model->item(r, 0) + ->child(r2, controller_column) + ->setText(QString::fromStdString( + UISettings::default_hotkeys[r2].shortcut.controller_keyseq)); + } + } +} + +void ConfigureHotkeys::ClearAll() { + for (int r = 0; r < model->rowCount(); ++r) { + const QStandardItem* parent = model->item(r, 0); + + for (int r2 = 0; r2 < parent->rowCount(); ++r2) { + model->item(r, 0)->child(r2, hotkey_column)->setText(QString{}); + model->item(r, 0)->child(r2, controller_column)->setText(QString{}); + } + } +} + +void ConfigureHotkeys::PopupContextMenu(const QPoint& menu_location) { + QModelIndex index = ui->hotkey_list->indexAt(menu_location); + if (!index.parent().isValid()) { + return; + } + + // Swap to the hotkey column if the controller hotkey column is not selected + if (index.column() != controller_column) { + index = index.sibling(index.row(), hotkey_column); + } + + QMenu context_menu; + + QAction* restore_default = context_menu.addAction(tr("Restore Default")); + QAction* clear = context_menu.addAction(tr("Clear")); + + connect(restore_default, &QAction::triggered, [this, index] { + if (index.column() == controller_column) { + RestoreControllerHotkey(index); + return; + } + RestoreHotkey(index); + }); + connect(clear, &QAction::triggered, [this, index] { model->setData(index, QString{}); }); + + context_menu.exec(ui->hotkey_list->viewport()->mapToGlobal(menu_location)); +} + +void ConfigureHotkeys::RestoreControllerHotkey(QModelIndex index) { + const QString& default_key_sequence = + QString::fromStdString(UISettings::default_hotkeys[index.row()].shortcut.controller_keyseq); + const auto [key_sequence_used, used_action] = IsUsedControllerKey(default_key_sequence); + + if (key_sequence_used && default_key_sequence != model->data(index).toString()) { + QMessageBox::warning( + this, tr("Conflicting Button Sequence"), + tr("The default button sequence is already assigned to: %1").arg(used_action)); + } else { + model->setData(index, default_key_sequence); + } +} + +void ConfigureHotkeys::RestoreHotkey(QModelIndex index) { + const QKeySequence& default_key_sequence = QKeySequence::fromString( + QString::fromStdString(UISettings::default_hotkeys[index.row()].shortcut.keyseq), + QKeySequence::NativeText); + const auto [key_sequence_used, used_action] = IsUsedKey(default_key_sequence); + + if (key_sequence_used && default_key_sequence != QKeySequence(model->data(index).toString())) { + QMessageBox::warning( + this, tr("Conflicting Key Sequence"), + tr("The default key sequence is already assigned to: %1").arg(used_action)); + } else { + model->setData(index, default_key_sequence.toString(QKeySequence::NativeText)); + } +} diff --git a/src/sudachi/configuration/configure_hotkeys.h b/src/sudachi/configuration/configure_hotkeys.h new file mode 100644 index 0000000..20ea3b5 --- /dev/null +++ b/src/sudachi/configuration/configure_hotkeys.h @@ -0,0 +1,74 @@ +// SPDX-FileCopyrightText: 2017 Citra Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include +#include +#include + +namespace Common { +class ParamPackage; +} + +namespace Core::HID { +class HIDCore; +class EmulatedController; +enum class NpadButton : u64; +} // namespace Core::HID + +namespace Ui { +class ConfigureHotkeys; +} + +class HotkeyRegistry; +class QStandardItemModel; + +class ConfigureHotkeys : public QWidget { + Q_OBJECT + +public: + explicit ConfigureHotkeys(Core::HID::HIDCore& hid_core_, QWidget* parent = nullptr); + ~ConfigureHotkeys() override; + + void ApplyConfiguration(HotkeyRegistry& registry); + + /** + * Populates the hotkey list widget using data from the provided registry. + * Called every time the Configure dialog is opened. + * @param registry The HotkeyRegistry whose data is used to populate the list. + */ + void Populate(const HotkeyRegistry& registry); + +private: + void changeEvent(QEvent* event) override; + void RetranslateUI(); + + void Configure(QModelIndex index); + void ConfigureController(QModelIndex index); + std::pair IsUsedKey(QKeySequence key_sequence) const; + std::pair IsUsedControllerKey(const QString& key_sequence) const; + + void RestoreDefaults(); + void ClearAll(); + void PopupContextMenu(const QPoint& menu_location); + void RestoreControllerHotkey(QModelIndex index); + void RestoreHotkey(QModelIndex index); + + void SetPollingResult(bool cancel); + QString GetButtonCombinationName(Core::HID::NpadButton button, bool home, bool capture) const; + + std::unique_ptr ui; + + QStandardItemModel* model; + + bool pressed_home_button; + bool pressed_capture_button; + QModelIndex button_model_index; + Core::HID::NpadButton pressed_buttons; + + Core::HID::EmulatedController* controller; + std::unique_ptr timeout_timer; + std::unique_ptr poll_timer; + std::optional> input_setter; +}; diff --git a/src/sudachi/configuration/configure_hotkeys.ui b/src/sudachi/configuration/configure_hotkeys.ui new file mode 100644 index 0000000..a6902a5 --- /dev/null +++ b/src/sudachi/configuration/configure_hotkeys.ui @@ -0,0 +1,76 @@ + + + ConfigureHotkeys + + + + 0 + 0 + 439 + 510 + + + + Hotkey Settings + + + Hotkeys + + + + + + + + Double-click on a binding to change it. + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Clear All + + + + + + + Restore Defaults + + + + + + + + + + + QAbstractItemView::NoEditTriggers + + + false + + + + + + + + + + diff --git a/src/sudachi/configuration/configure_input.cpp b/src/sudachi/configuration/configure_input.cpp new file mode 100644 index 0000000..d3a0a06 --- /dev/null +++ b/src/sudachi/configuration/configure_input.cpp @@ -0,0 +1,309 @@ +// SPDX-FileCopyrightText: 2016 Citra Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include +#include + +#include "common/settings.h" +#include "common/settings_enums.h" +#include "core/core.h" +#include "core/hle/service/am/am.h" +#include "core/hle/service/am/applet_manager.h" +#include "core/hle/service/sm/sm.h" +#include "hid_core/frontend/emulated_controller.h" +#include "hid_core/hid_core.h" +#include "ui_configure_input.h" +#include "ui_configure_input_advanced.h" +#include "ui_configure_input_player.h" +#include "sudachi/configuration/configure_camera.h" +#include "sudachi/configuration/configure_debug_controller.h" +#include "sudachi/configuration/configure_input.h" +#include "sudachi/configuration/configure_input_advanced.h" +#include "sudachi/configuration/configure_input_player.h" +#include "sudachi/configuration/configure_motion_touch.h" +#include "sudachi/configuration/configure_ringcon.h" +#include "sudachi/configuration/configure_touchscreen_advanced.h" +#include "sudachi/configuration/configure_vibration.h" +#include "sudachi/configuration/input_profiles.h" + +namespace { +template +void CallConfigureDialog(ConfigureInput& parent, Args&&... args) { + Dialog dialog(&parent, std::forward(args)...); + + const auto res = dialog.exec(); + if (res == QDialog::Accepted) { + dialog.ApplyConfiguration(); + } +} +} // Anonymous namespace + +void OnDockedModeChanged(bool last_state, bool new_state, Core::System& system) { + if (last_state == new_state) { + return; + } + + if (!system.IsPoweredOn()) { + return; + } + + system.GetAppletManager().OperationModeChanged(); +} + +ConfigureInput::ConfigureInput(Core::System& system_, QWidget* parent) + : QWidget(parent), ui(std::make_unique()), + profiles(std::make_unique()), system{system_} { + ui->setupUi(this); +} + +ConfigureInput::~ConfigureInput() = default; + +void ConfigureInput::Initialize(InputCommon::InputSubsystem* input_subsystem, + std::size_t max_players) { + const bool is_powered_on = system.IsPoweredOn(); + auto& hid_core = system.HIDCore(); + player_controllers = { + new ConfigureInputPlayer(this, 0, ui->consoleInputSettings, input_subsystem, profiles.get(), + hid_core, is_powered_on), + new ConfigureInputPlayer(this, 1, ui->consoleInputSettings, input_subsystem, profiles.get(), + hid_core, is_powered_on), + new ConfigureInputPlayer(this, 2, ui->consoleInputSettings, input_subsystem, profiles.get(), + hid_core, is_powered_on), + new ConfigureInputPlayer(this, 3, ui->consoleInputSettings, input_subsystem, profiles.get(), + hid_core, is_powered_on), + new ConfigureInputPlayer(this, 4, ui->consoleInputSettings, input_subsystem, profiles.get(), + hid_core, is_powered_on), + new ConfigureInputPlayer(this, 5, ui->consoleInputSettings, input_subsystem, profiles.get(), + hid_core, is_powered_on), + new ConfigureInputPlayer(this, 6, ui->consoleInputSettings, input_subsystem, profiles.get(), + hid_core, is_powered_on), + new ConfigureInputPlayer(this, 7, ui->consoleInputSettings, input_subsystem, profiles.get(), + hid_core, is_powered_on), + }; + + player_tabs = { + ui->tabPlayer1, ui->tabPlayer2, ui->tabPlayer3, ui->tabPlayer4, + ui->tabPlayer5, ui->tabPlayer6, ui->tabPlayer7, ui->tabPlayer8, + }; + + connected_controller_checkboxes = { + ui->checkboxPlayer1Connected, ui->checkboxPlayer2Connected, ui->checkboxPlayer3Connected, + ui->checkboxPlayer4Connected, ui->checkboxPlayer5Connected, ui->checkboxPlayer6Connected, + ui->checkboxPlayer7Connected, ui->checkboxPlayer8Connected, + }; + + std::array connected_controller_labels = { + ui->label, ui->label_3, ui->label_4, ui->label_5, + ui->label_6, ui->label_7, ui->label_8, ui->label_9, + }; + + for (std::size_t i = 0; i < player_tabs.size(); ++i) { + player_tabs[i]->setLayout(new QHBoxLayout(player_tabs[i])); + player_tabs[i]->layout()->addWidget(player_controllers[i]); + connect(player_controllers[i], &ConfigureInputPlayer::Connected, [this, i](bool checked) { + // Ensures that connecting a controller changes the number of players + if (connected_controller_checkboxes[i]->isChecked() != checked) { + // Ensures that the players are always connected in sequential order + PropagatePlayerNumberChanged(i, checked); + } + }); + connect(connected_controller_checkboxes[i], &QCheckBox::clicked, [this, i](bool checked) { + // Reconnect current controller if it was the last one checked + // (player number was reduced by more than one) + const bool reconnect_first = !checked && + i < connected_controller_checkboxes.size() - 1 && + connected_controller_checkboxes[i + 1]->isChecked(); + + // Ensures that the players are always connected in sequential order + PropagatePlayerNumberChanged(i, checked, reconnect_first); + }); + connect(player_controllers[i], &ConfigureInputPlayer::RefreshInputDevices, this, + &ConfigureInput::UpdateAllInputDevices); + connect(player_controllers[i], &ConfigureInputPlayer::RefreshInputProfiles, this, + &ConfigureInput::UpdateAllInputProfiles, Qt::QueuedConnection); + connect(connected_controller_checkboxes[i], &QCheckBox::stateChanged, [this, i](int state) { + // Keep activated controllers synced with the "Connected Controllers" checkboxes + player_controllers[i]->ConnectPlayer(state == Qt::Checked); + }); + + // Remove/hide all the elements that exceed max_players, if applicable. + if (i >= max_players) { + ui->tabWidget->removeTab(static_cast(max_players)); + connected_controller_checkboxes[i]->hide(); + connected_controller_labels[i]->hide(); + } + } + // Only the first player can choose handheld mode so connect the signal just to player 1 + connect(player_controllers[0], &ConfigureInputPlayer::HandheldStateChanged, + [this](bool is_handheld) { UpdateDockedState(is_handheld); }); + + advanced = new ConfigureInputAdvanced(hid_core, this); + ui->tabAdvanced->setLayout(new QHBoxLayout(ui->tabAdvanced)); + ui->tabAdvanced->layout()->addWidget(advanced); + + connect(advanced, &ConfigureInputAdvanced::CallDebugControllerDialog, + [this, input_subsystem, &hid_core, is_powered_on] { + CallConfigureDialog( + *this, input_subsystem, profiles.get(), hid_core, is_powered_on); + }); + connect(advanced, &ConfigureInputAdvanced::CallTouchscreenConfigDialog, + [this] { CallConfigureDialog(*this); }); + connect(advanced, &ConfigureInputAdvanced::CallMotionTouchConfigDialog, + [this, input_subsystem] { + CallConfigureDialog(*this, input_subsystem); + }); + connect(advanced, &ConfigureInputAdvanced::CallRingControllerDialog, + [this, input_subsystem, &hid_core] { + CallConfigureDialog(*this, input_subsystem, hid_core); + }); + connect(advanced, &ConfigureInputAdvanced::CallCameraDialog, [this, input_subsystem] { + CallConfigureDialog(*this, input_subsystem); + }); + + connect(ui->vibrationButton, &QPushButton::clicked, + [this, &hid_core] { CallConfigureDialog(*this, hid_core); }); + + connect(ui->motionButton, &QPushButton::clicked, [this, input_subsystem] { + CallConfigureDialog(*this, input_subsystem); + }); + + connect(ui->buttonClearAll, &QPushButton::clicked, [this] { ClearAll(); }); + connect(ui->buttonRestoreDefaults, &QPushButton::clicked, [this] { RestoreDefaults(); }); + + RetranslateUI(); + LoadConfiguration(); +} + +void ConfigureInput::PropagatePlayerNumberChanged(size_t player_index, bool checked, + bool reconnect_current) { + connected_controller_checkboxes[player_index]->setChecked(checked); + + if (checked) { + // Check all previous buttons when checked + if (player_index > 0) { + PropagatePlayerNumberChanged(player_index - 1, checked); + } + } else { + // Unchecked all following buttons when unchecked + if (player_index < connected_controller_checkboxes.size() - 1) { + PropagatePlayerNumberChanged(player_index + 1, checked); + } + } + + if (reconnect_current) { + connected_controller_checkboxes[player_index]->setCheckState(Qt::Checked); + } +} + +QList ConfigureInput::GetSubTabs() const { + return { + ui->tabPlayer1, ui->tabPlayer2, ui->tabPlayer3, ui->tabPlayer4, ui->tabPlayer5, + ui->tabPlayer6, ui->tabPlayer7, ui->tabPlayer8, ui->tabAdvanced, + }; +} + +void ConfigureInput::ApplyConfiguration() { + const bool was_global = Settings::values.players.UsingGlobal(); + Settings::values.players.SetGlobal(true); + for (auto* controller : player_controllers) { + controller->ApplyConfiguration(); + } + + advanced->ApplyConfiguration(); + + const bool pre_docked_mode = Settings::IsDockedMode(); + const bool docked_mode_selected = ui->radioDocked->isChecked(); + Settings::values.use_docked_mode.SetValue( + docked_mode_selected ? Settings::ConsoleMode::Docked : Settings::ConsoleMode::Handheld); + OnDockedModeChanged(pre_docked_mode, docked_mode_selected, system); + + Settings::values.vibration_enabled.SetValue(ui->vibrationGroup->isChecked()); + Settings::values.motion_enabled.SetValue(ui->motionGroup->isChecked()); + Settings::values.players.SetGlobal(was_global); +} + +void ConfigureInput::changeEvent(QEvent* event) { + if (event->type() == QEvent::LanguageChange) { + RetranslateUI(); + } + + QWidget::changeEvent(event); +} + +void ConfigureInput::RetranslateUI() { + ui->retranslateUi(this); +} + +void ConfigureInput::LoadConfiguration() { + const auto* handheld = system.HIDCore().GetEmulatedController(Core::HID::NpadIdType::Handheld); + + LoadPlayerControllerIndices(); + UpdateDockedState(handheld->IsConnected()); + + ui->vibrationGroup->setChecked(Settings::values.vibration_enabled.GetValue()); + ui->motionGroup->setChecked(Settings::values.motion_enabled.GetValue()); +} + +void ConfigureInput::LoadPlayerControllerIndices() { + for (std::size_t i = 0; i < connected_controller_checkboxes.size(); ++i) { + if (i == 0) { + auto* handheld = + system.HIDCore().GetEmulatedController(Core::HID::NpadIdType::Handheld); + if (handheld->IsConnected()) { + connected_controller_checkboxes[i]->setChecked(true); + continue; + } + } + const auto* controller = system.HIDCore().GetEmulatedControllerByIndex(i); + connected_controller_checkboxes[i]->setChecked(controller->IsConnected()); + } +} + +void ConfigureInput::ClearAll() { + // We don't have a good way to know what tab is active, but we can find out by getting the + // parent of the consoleInputSettings + auto* player_tab = static_cast(ui->consoleInputSettings->parent()); + player_tab->ClearAll(); +} + +void ConfigureInput::RestoreDefaults() { + // We don't have a good way to know what tab is active, but we can find out by getting the + // parent of the consoleInputSettings + auto* player_tab = static_cast(ui->consoleInputSettings->parent()); + player_tab->RestoreDefaults(); + + ui->radioDocked->setChecked(true); + ui->radioUndocked->setChecked(false); + ui->vibrationGroup->setChecked(true); + ui->motionGroup->setChecked(true); +} + +void ConfigureInput::UpdateDockedState(bool is_handheld) { + // Disallow changing the console mode if the controller type is handheld. + ui->radioDocked->setEnabled(!is_handheld); + ui->radioUndocked->setEnabled(!is_handheld); + + ui->radioDocked->setChecked(Settings::IsDockedMode()); + ui->radioUndocked->setChecked(!Settings::IsDockedMode()); + + // Also force into undocked mode if the controller type is handheld. + if (is_handheld) { + ui->radioUndocked->setChecked(true); + } +} + +void ConfigureInput::UpdateAllInputDevices() { + for (const auto& player : player_controllers) { + player->UpdateInputDeviceCombobox(); + } +} + +void ConfigureInput::UpdateAllInputProfiles(std::size_t player_index) { + for (std::size_t i = 0; i < player_controllers.size(); ++i) { + if (i == player_index) { + continue; + } + + player_controllers[i]->UpdateInputProfiles(); + } +} diff --git a/src/sudachi/configuration/configure_input.h b/src/sudachi/configuration/configure_input.h new file mode 100644 index 0000000..beb503d --- /dev/null +++ b/src/sudachi/configuration/configure_input.h @@ -0,0 +1,81 @@ +// SPDX-FileCopyrightText: 2016 Citra Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include +#include + +#include +#include +#include + +namespace Core { +class System; +} + +class QCheckBox; +class QString; +class QTimer; + +class ConfigureInputAdvanced; +class ConfigureInputPlayer; + +class InputProfiles; + +namespace InputCommon { +class InputSubsystem; +} + +namespace Ui { +class ConfigureInput; +} + +void OnDockedModeChanged(bool last_state, bool new_state, Core::System& system); + +class ConfigureInput : public QWidget { + Q_OBJECT + +public: + explicit ConfigureInput(Core::System& system_, QWidget* parent = nullptr); + ~ConfigureInput() override; + + /// Initializes the input dialog with the given input subsystem. + void Initialize(InputCommon::InputSubsystem* input_subsystem_, std::size_t max_players = 8); + + /// Save all button configurations to settings file. + void ApplyConfiguration(); + + QList GetSubTabs() const; + +private: + void changeEvent(QEvent* event) override; + void RetranslateUI(); + void ClearAll(); + + void UpdateDockedState(bool is_handheld); + void UpdateAllInputDevices(); + void UpdateAllInputProfiles(std::size_t player_index); + // Enable preceding controllers or disable following ones + void PropagatePlayerNumberChanged(size_t player_index, bool checked, + bool reconnect_current = false); + + /// Load configuration settings. + void LoadConfiguration(); + void LoadPlayerControllerIndices(); + + /// Restore all buttons to their default values. + void RestoreDefaults(); + + std::unique_ptr ui; + + std::unique_ptr profiles; + + std::array player_controllers; + std::array player_tabs; + // Checkboxes representing the "Connected Controllers". + std::array connected_controller_checkboxes; + ConfigureInputAdvanced* advanced; + + Core::System& system; +}; diff --git a/src/sudachi/configuration/configure_input.ui b/src/sudachi/configuration/configure_input.ui new file mode 100644 index 0000000..d517740 --- /dev/null +++ b/src/sudachi/configuration/configure_input.ui @@ -0,0 +1,548 @@ + + + ConfigureInput + + + + 0 + 0 + 680 + 540 + + + + ConfigureInput + + + + 2 + + + 3 + + + 3 + + + 3 + + + 3 + + + + + 0 + + + + Player 1 + + + Player 1 + + + + + Player 2 + + + Player 2 + + + + + Player 3 + + + Player 3 + + + + + Player 4 + + + Player 4 + + + + + Player 5 + + + Player 5 + + + + + Player 6 + + + Player 6 + + + + + Player 7 + + + Player 7 + + + + + Player 8 + + + Player 8 + + + + + Advanced + + + Advanced + + + + + + + + + 3 + + + 0 + + + 3 + + + 0 + + + 0 + + + + + + 16777215 + 16777215 + + + + Console Mode + + + + 6 + + + 8 + + + 6 + + + 3 + + + 6 + + + + + Docked + + + true + + + + + + + Handheld + + + + + + + + + + Vibration + + + true + + + + 3 + + + 3 + + + 3 + + + 3 + + + + + + 68 + 0 + + + + + 68 + 16777215 + + + + min-width: 68px; + + + Configure + + + + + + + + + + Motion + + + true + + + + 3 + + + 3 + + + 3 + + + 3 + + + + + + 68 + 0 + + + + + 68 + 16777215 + + + + min-width: 68px; + + + Configure + + + + + + + + + + + 5 + + + 0 + + + 0 + + + 0 + + + 3 + + + + + + + + + + + + Controllers + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1 + + + Qt::AlignCenter + + + + + + + + + + + + + + + + + + + + + Qt::LeftToRight + + + true + + + + + + + + + + + + + + 2 + + + Qt::AlignCenter + + + + + + + 3 + + + Qt::AlignCenter + + + + + + + 4 + + + Qt::AlignCenter + + + + + + + 5 + + + Qt::AlignCenter + + + + + + + 6 + + + Qt::AlignCenter + + + + + + + 7 + + + Qt::AlignCenter + + + + + + + 8 + + + Qt::AlignCenter + + + + + + + Connected + + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + 0 + 0 + + + + + 68 + 0 + + + + + 68 + 16777215 + + + + + 0 + 0 + + + + + 0 + 0 + + + + Qt::LeftToRight + + + min-width: 68px; + + + Defaults + + + + + + + + 0 + 0 + + + + + 68 + 0 + + + + + 68 + 16777215 + + + + + 0 + 0 + + + + + 0 + 0 + + + + Qt::LeftToRight + + + min-width: 68px; + + + Clear + + + + + + + + + + + diff --git a/src/sudachi/configuration/configure_input_advanced.cpp b/src/sudachi/configuration/configure_input_advanced.cpp new file mode 100644 index 0000000..7148c4e --- /dev/null +++ b/src/sudachi/configuration/configure_input_advanced.cpp @@ -0,0 +1,204 @@ +// SPDX-FileCopyrightText: Copyright 2020 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include +#include "common/settings.h" +#include "core/core.h" +#include "hid_core/frontend/emulated_controller.h" +#include "hid_core/hid_core.h" +#include "ui_configure_input_advanced.h" +#include "sudachi/configuration/configure_input_advanced.h" + +ConfigureInputAdvanced::ConfigureInputAdvanced(Core::HID::HIDCore& hid_core_, QWidget* parent) + : QWidget(parent), ui(std::make_unique()), hid_core{hid_core_} { + ui->setupUi(this); + + controllers_color_buttons = {{ + { + ui->player1_left_body_button, + ui->player1_left_buttons_button, + ui->player1_right_body_button, + ui->player1_right_buttons_button, + }, + { + ui->player2_left_body_button, + ui->player2_left_buttons_button, + ui->player2_right_body_button, + ui->player2_right_buttons_button, + }, + { + ui->player3_left_body_button, + ui->player3_left_buttons_button, + ui->player3_right_body_button, + ui->player3_right_buttons_button, + }, + { + ui->player4_left_body_button, + ui->player4_left_buttons_button, + ui->player4_right_body_button, + ui->player4_right_buttons_button, + }, + { + ui->player5_left_body_button, + ui->player5_left_buttons_button, + ui->player5_right_body_button, + ui->player5_right_buttons_button, + }, + { + ui->player6_left_body_button, + ui->player6_left_buttons_button, + ui->player6_right_body_button, + ui->player6_right_buttons_button, + }, + { + ui->player7_left_body_button, + ui->player7_left_buttons_button, + ui->player7_right_body_button, + ui->player7_right_buttons_button, + }, + { + ui->player8_left_body_button, + ui->player8_left_buttons_button, + ui->player8_right_body_button, + ui->player8_right_buttons_button, + }, + }}; + + for (std::size_t player_idx = 0; player_idx < controllers_color_buttons.size(); ++player_idx) { + auto& color_buttons = controllers_color_buttons[player_idx]; + for (std::size_t button_idx = 0; button_idx < color_buttons.size(); ++button_idx) { + connect(color_buttons[button_idx], &QPushButton::clicked, this, + [this, player_idx, button_idx] { + OnControllerButtonClick(player_idx, button_idx); + }); + } + } + + connect(ui->mouse_enabled, &QCheckBox::stateChanged, this, + &ConfigureInputAdvanced::UpdateUIEnabled); + connect(ui->debug_enabled, &QCheckBox::stateChanged, this, + &ConfigureInputAdvanced::UpdateUIEnabled); + connect(ui->touchscreen_enabled, &QCheckBox::stateChanged, this, + &ConfigureInputAdvanced::UpdateUIEnabled); + connect(ui->enable_ring_controller, &QCheckBox::stateChanged, this, + &ConfigureInputAdvanced::UpdateUIEnabled); + + connect(ui->debug_configure, &QPushButton::clicked, this, + [this] { CallDebugControllerDialog(); }); + connect(ui->touchscreen_advanced, &QPushButton::clicked, this, + [this] { CallTouchscreenConfigDialog(); }); + connect(ui->buttonMotionTouch, &QPushButton::clicked, this, + [this] { CallMotionTouchConfigDialog(); }); + connect(ui->ring_controller_configure, &QPushButton::clicked, this, + [this] { CallRingControllerDialog(); }); + connect(ui->camera_configure, &QPushButton::clicked, this, [this] { CallCameraDialog(); }); + +#ifndef _WIN32 + ui->enable_raw_input->setVisible(false); +#endif + + LoadConfiguration(); +} + +ConfigureInputAdvanced::~ConfigureInputAdvanced() = default; + +void ConfigureInputAdvanced::OnControllerButtonClick(std::size_t player_idx, + std::size_t button_idx) { + const QColor new_bg_color = QColorDialog::getColor(controllers_colors[player_idx][button_idx]); + if (!new_bg_color.isValid()) { + return; + } + controllers_colors[player_idx][button_idx] = new_bg_color; + controllers_color_buttons[player_idx][button_idx]->setStyleSheet( + QStringLiteral("background-color: %1; min-width: 60px;") + .arg(controllers_colors[player_idx][button_idx].name())); +} + +void ConfigureInputAdvanced::ApplyConfiguration() { + for (std::size_t player_idx = 0; player_idx < controllers_color_buttons.size(); ++player_idx) { + auto& player = Settings::values.players.GetValue()[player_idx]; + std::array colors{}; + std::transform(controllers_colors[player_idx].begin(), controllers_colors[player_idx].end(), + colors.begin(), [](QColor color) { return color.rgb(); }); + + player.body_color_left = colors[0]; + player.button_color_left = colors[1]; + player.body_color_right = colors[2]; + player.button_color_right = colors[3]; + + hid_core.GetEmulatedControllerByIndex(player_idx)->ReloadColorsFromSettings(); + } + + Settings::values.debug_pad_enabled = ui->debug_enabled->isChecked(); + Settings::values.mouse_enabled = ui->mouse_enabled->isChecked(); + Settings::values.keyboard_enabled = ui->keyboard_enabled->isChecked(); + Settings::values.emulate_analog_keyboard = ui->emulate_analog_keyboard->isChecked(); + Settings::values.touchscreen.enabled = ui->touchscreen_enabled->isChecked(); + Settings::values.enable_raw_input = ui->enable_raw_input->isChecked(); + Settings::values.enable_udp_controller = ui->enable_udp_controller->isChecked(); + Settings::values.controller_navigation = ui->controller_navigation->isChecked(); + Settings::values.enable_ring_controller = ui->enable_ring_controller->isChecked(); + Settings::values.enable_ir_sensor = ui->enable_ir_sensor->isChecked(); + Settings::values.enable_joycon_driver = ui->enable_joycon_driver->isChecked(); + Settings::values.enable_procon_driver = ui->enable_procon_driver->isChecked(); + Settings::values.random_amiibo_id = ui->random_amiibo_id->isChecked(); +} + +void ConfigureInputAdvanced::LoadConfiguration() { + for (std::size_t player_idx = 0; player_idx < controllers_color_buttons.size(); ++player_idx) { + auto& player = Settings::values.players.GetValue()[player_idx]; + std::array colors = { + player.body_color_left, + player.button_color_left, + player.body_color_right, + player.button_color_right, + }; + + std::transform(colors.begin(), colors.end(), controllers_colors[player_idx].begin(), + [](u32 rgb) { return QColor::fromRgb(rgb); }); + + for (std::size_t button_idx = 0; button_idx < colors.size(); ++button_idx) { + controllers_color_buttons[player_idx][button_idx]->setStyleSheet( + QStringLiteral("background-color: %1; min-width: 60px;") + .arg(controllers_colors[player_idx][button_idx].name())); + } + } + + ui->debug_enabled->setChecked(Settings::values.debug_pad_enabled.GetValue()); + ui->mouse_enabled->setChecked(Settings::values.mouse_enabled.GetValue()); + ui->keyboard_enabled->setChecked(Settings::values.keyboard_enabled.GetValue()); + ui->emulate_analog_keyboard->setChecked(Settings::values.emulate_analog_keyboard.GetValue()); + ui->touchscreen_enabled->setChecked(Settings::values.touchscreen.enabled); + ui->enable_raw_input->setChecked(Settings::values.enable_raw_input.GetValue()); + ui->enable_udp_controller->setChecked(Settings::values.enable_udp_controller.GetValue()); + ui->controller_navigation->setChecked(Settings::values.controller_navigation.GetValue()); + ui->enable_ring_controller->setChecked(Settings::values.enable_ring_controller.GetValue()); + ui->enable_ir_sensor->setChecked(Settings::values.enable_ir_sensor.GetValue()); + ui->enable_joycon_driver->setChecked(Settings::values.enable_joycon_driver.GetValue()); + ui->enable_procon_driver->setChecked(Settings::values.enable_procon_driver.GetValue()); + ui->random_amiibo_id->setChecked(Settings::values.random_amiibo_id.GetValue()); + + UpdateUIEnabled(); +} + +void ConfigureInputAdvanced::changeEvent(QEvent* event) { + if (event->type() == QEvent::LanguageChange) { + RetranslateUI(); + } + + QWidget::changeEvent(event); +} + +void ConfigureInputAdvanced::RetranslateUI() { + ui->retranslateUi(this); +} + +void ConfigureInputAdvanced::UpdateUIEnabled() { + ui->debug_configure->setEnabled(ui->debug_enabled->isChecked()); + ui->touchscreen_advanced->setEnabled(ui->touchscreen_enabled->isChecked()); + ui->ring_controller_configure->setEnabled(ui->enable_ring_controller->isChecked()); +#if QT_VERSION > QT_VERSION_CHECK(6, 0, 0) || !defined(SUDACHI_USE_QT_MULTIMEDIA) + ui->enable_ir_sensor->setEnabled(false); + ui->camera_configure->setEnabled(false); +#endif +} diff --git a/src/sudachi/configuration/configure_input_advanced.h b/src/sudachi/configuration/configure_input_advanced.h new file mode 100644 index 0000000..4f00c30 --- /dev/null +++ b/src/sudachi/configuration/configure_input_advanced.h @@ -0,0 +1,53 @@ +// SPDX-FileCopyrightText: Copyright 2020 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include +#include +#include + +class QColor; +class QPushButton; + +namespace Ui { +class ConfigureInputAdvanced; +} + +namespace Core::HID { +class HIDCore; +} // namespace Core::HID + +class ConfigureInputAdvanced : public QWidget { + Q_OBJECT + +public: + explicit ConfigureInputAdvanced(Core::HID::HIDCore& hid_core_, QWidget* parent = nullptr); + ~ConfigureInputAdvanced() override; + + void ApplyConfiguration(); + +signals: + void CallDebugControllerDialog(); + void CallMouseConfigDialog(); + void CallTouchscreenConfigDialog(); + void CallMotionTouchConfigDialog(); + void CallRingControllerDialog(); + void CallCameraDialog(); + +private: + void changeEvent(QEvent* event) override; + void RetranslateUI(); + void UpdateUIEnabled(); + + void OnControllerButtonClick(std::size_t player_idx, std::size_t button_idx); + + void LoadConfiguration(); + + std::unique_ptr ui; + + std::array, 8> controllers_colors; + std::array, 8> controllers_color_buttons; + + Core::HID::HIDCore& hid_core; +}; diff --git a/src/sudachi/configuration/configure_input_advanced.ui b/src/sudachi/configuration/configure_input_advanced.ui new file mode 100644 index 0000000..6c74afc --- /dev/null +++ b/src/sudachi/configuration/configure_input_advanced.ui @@ -0,0 +1,2821 @@ + + + ConfigureInputAdvanced + + + + 0 + 0 + 710 + 580 + + + + Configure Input + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + 9 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + 3 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Joycon Colors + + + + 9 + + + 9 + + + 9 + + + 9 + + + + + + 6 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Player 1 + + + + 6 + + + 6 + + + 0 + + + 6 + + + 6 + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + L Body + + + Qt::AlignCenter + + + + 3 + + + 3 + + + 3 + + + 3 + + + 3 + + + + + + 0 + 0 + + + + + 68 + 0 + + + + + 68 + 16777215 + + + + min-width: 68px; + + + + + + + + + + + + + L Button + + + Qt::AlignCenter + + + + 3 + + + 3 + + + 3 + + + 3 + + + 3 + + + + + + 0 + 0 + + + + + 68 + 0 + + + + + 68 + 16777215 + + + + min-width: 68px; + + + + + + + + + + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + R Body + + + Qt::AlignCenter + + + + 3 + + + 3 + + + 3 + + + 3 + + + 3 + + + + + + 0 + 0 + + + + + 68 + 0 + + + + + 68 + 16777215 + + + + min-width: 68px; + + + + + + + + + + + + + R Button + + + Qt::AlignCenter + + + + 3 + + + 3 + + + 3 + + + 3 + + + 3 + + + + + + 0 + 0 + + + + + 68 + 0 + + + + + 68 + 16777215 + + + + min-width: 68px; + + + + + + + + + + + + + + + + + + + Player 2 + + + + 6 + + + 6 + + + 0 + + + 6 + + + 6 + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + L Body + + + Qt::AlignCenter + + + + 3 + + + 3 + + + 3 + + + 3 + + + 3 + + + + + + 0 + 0 + + + + + 68 + 0 + + + + + 68 + 16777215 + + + + min-width: 68px; + + + + + + + + + + + + + L Button + + + Qt::AlignCenter + + + + 3 + + + 3 + + + 3 + + + 3 + + + 3 + + + + + + 0 + 0 + + + + + 68 + 0 + + + + + 68 + 16777215 + + + + min-width: 68px; + + + + + + + + + + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + R Body + + + Qt::AlignCenter + + + + 3 + + + 3 + + + 3 + + + 3 + + + 3 + + + + + + 0 + 0 + + + + + 68 + 0 + + + + + 68 + 16777215 + + + + min-width: 68px; + + + + + + + + + + + + + R Button + + + Qt::AlignCenter + + + + 3 + + + 3 + + + 3 + + + 3 + + + 3 + + + + + + 0 + 0 + + + + + 68 + 0 + + + + + 68 + 16777215 + + + + min-width: 68px; + + + + + + + + + + + + + + + + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Player 3 + + + + 6 + + + 6 + + + 0 + + + 6 + + + 6 + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + L Body + + + Qt::AlignCenter + + + + 3 + + + 3 + + + 3 + + + 3 + + + 3 + + + + + + 0 + 0 + + + + + 68 + 0 + + + + + 68 + 16777215 + + + + min-width: 68px; + + + + + + + + + + + + + L Button + + + Qt::AlignCenter + + + + 3 + + + 3 + + + 3 + + + 3 + + + 3 + + + + + + 0 + 0 + + + + + 68 + 0 + + + + + 68 + 16777215 + + + + min-width: 68px; + + + + + + + + + + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + R Body + + + Qt::AlignCenter + + + + 3 + + + 3 + + + 3 + + + 3 + + + 3 + + + + + + 0 + 0 + + + + + 68 + 0 + + + + + 68 + 16777215 + + + + min-width: 68px; + + + + + + + + + + + + + R Button + + + Qt::AlignCenter + + + + 3 + + + 3 + + + 3 + + + 3 + + + 3 + + + + + + 0 + 0 + + + + + 68 + 0 + + + + + 68 + 16777215 + + + + min-width: 68px; + + + + + + + + + + + + + + + + + + + Player 4 + + + + 6 + + + 6 + + + 0 + + + 6 + + + 6 + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + L Body + + + Qt::AlignCenter + + + + 3 + + + 3 + + + 3 + + + 3 + + + 3 + + + + + + 0 + 0 + + + + + 68 + 0 + + + + + 68 + 16777215 + + + + min-width: 68px; + + + + + + + + + + + + + L Button + + + Qt::AlignCenter + + + + 3 + + + 3 + + + 3 + + + 3 + + + 3 + + + + + + 0 + 0 + + + + + 68 + 0 + + + + + 68 + 16777215 + + + + min-width: 68px; + + + + + + + + + + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + R Body + + + Qt::AlignCenter + + + + 3 + + + 3 + + + 3 + + + 3 + + + 3 + + + + + + 0 + 0 + + + + + 68 + 0 + + + + + 68 + 16777215 + + + + min-width: 68px; + + + + + + + + + + + + + R Button + + + Qt::AlignCenter + + + + 3 + + + 3 + + + 3 + + + 3 + + + 3 + + + + + + 0 + 0 + + + + + 68 + 0 + + + + + 68 + 16777215 + + + + min-width: 68px; + + + + + + + + + + + + + + + + + + + + + + + + + + 6 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Player 5 + + + + 6 + + + 6 + + + 0 + + + 6 + + + 6 + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + L Body + + + Qt::AlignCenter + + + + 3 + + + 3 + + + 3 + + + 3 + + + 3 + + + + + + 0 + 0 + + + + + 68 + 0 + + + + + 68 + 16777215 + + + + min-width: 68px; + + + + + + + + + + + + + L Button + + + Qt::AlignCenter + + + + 3 + + + 3 + + + 3 + + + 3 + + + 3 + + + + + + 0 + 0 + + + + + 68 + 0 + + + + + 68 + 16777215 + + + + min-width: 68px; + + + + + + + + + + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + R Body + + + Qt::AlignCenter + + + + 3 + + + 3 + + + 3 + + + 3 + + + 3 + + + + + + 0 + 0 + + + + + 68 + 0 + + + + + 68 + 16777215 + + + + min-width: 68px; + + + + + + + + + + + + + R Button + + + Qt::AlignCenter + + + + 3 + + + 3 + + + 3 + + + 3 + + + 3 + + + + + + 0 + 0 + + + + + 68 + 0 + + + + + 68 + 16777215 + + + + min-width: 68px; + + + + + + + + + + + + + + + + + + + Player 6 + + + + 6 + + + 6 + + + 0 + + + 6 + + + 6 + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + L Body + + + Qt::AlignCenter + + + + 3 + + + 3 + + + 3 + + + 3 + + + 3 + + + + + + 0 + 0 + + + + + 68 + 0 + + + + + 68 + 16777215 + + + + min-width: 68px; + + + + + + + + + + + + + L Button + + + Qt::AlignCenter + + + + 3 + + + 3 + + + 3 + + + 3 + + + 3 + + + + + + 0 + 0 + + + + + 68 + 0 + + + + + 68 + 16777215 + + + + min-width: 68px; + + + + + + + + + + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + R Body + + + Qt::AlignCenter + + + + 3 + + + 3 + + + 3 + + + 3 + + + 3 + + + + + + 0 + 0 + + + + + 68 + 0 + + + + + 68 + 16777215 + + + + min-width: 68px; + + + + + + + + + + + + + R Button + + + Qt::AlignCenter + + + + 3 + + + 3 + + + 3 + + + 3 + + + 3 + + + + + + 0 + 0 + + + + + 68 + 0 + + + + + 68 + 16777215 + + + + min-width: 68px; + + + + + + + + + + + + + + + + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Player 7 + + + + 6 + + + 6 + + + 0 + + + 6 + + + 6 + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + L Body + + + Qt::AlignCenter + + + + 3 + + + 3 + + + 3 + + + 3 + + + 3 + + + + + + 0 + 0 + + + + + 68 + 0 + + + + + 68 + 16777215 + + + + min-width: 68px; + + + + + + + + + + + + + L Button + + + Qt::AlignCenter + + + + 3 + + + 3 + + + 3 + + + 3 + + + 3 + + + + + + 0 + 0 + + + + + 68 + 0 + + + + + 68 + 16777215 + + + + min-width: 68px; + + + + + + + + + + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + R Body + + + Qt::AlignCenter + + + + 3 + + + 3 + + + 3 + + + 3 + + + 3 + + + + + + 0 + 0 + + + + + 68 + 0 + + + + + 68 + 16777215 + + + + min-width: 68px; + + + + + + + + + + + + + R Button + + + Qt::AlignCenter + + + + 3 + + + 3 + + + 3 + + + 3 + + + 3 + + + + + + 0 + 0 + + + + + 68 + 0 + + + + + 68 + 16777215 + + + + min-width: 68px; + + + + + + + + + + + + + + + + + + + Player 8 + + + + 6 + + + 6 + + + 0 + + + 6 + + + 6 + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + L Body + + + Qt::AlignCenter + + + + 3 + + + 3 + + + 3 + + + 3 + + + 3 + + + + + + 0 + 0 + + + + + 68 + 0 + + + + + 68 + 16777215 + + + + min-width: 68px; + + + + + + + + + + + + + L Button + + + Qt::AlignCenter + + + + 3 + + + 3 + + + 3 + + + 3 + + + 3 + + + + + + 0 + 0 + + + + + 68 + 0 + + + + + 68 + 16777215 + + + + min-width: 68px; + + + + + + + + + + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + R Body + + + Qt::AlignCenter + + + + 3 + + + 3 + + + 3 + + + 3 + + + 3 + + + + + + 0 + 0 + + + + + 68 + 0 + + + + + 68 + 16777215 + + + + min-width: 68px; + + + + + + + + + + + + + R Button + + + Qt::AlignCenter + + + + 3 + + + 3 + + + 3 + + + 3 + + + 3 + + + + + + 0 + 0 + + + + + 68 + 0 + + + + + 68 + 16777215 + + + + min-width: 68px; + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 3 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Emulated Devices + + + + + + + 0 + 23 + + + + Keyboard + + + + + + + + 0 + 23 + + + + Mouse + + + + + + + Touchscreen + + + + + + + Qt::Horizontal + + + QSizePolicy::Fixed + + + + 76 + 20 + + + + + + + + Advanced + + + + + + + Debug Controller + + + + + + + Configure + + + + + + + Ring Controller + + + + + + + Configure + + + + + + + Infrared Camera + + + + + + + Configure + + + + + + + + + + Other + + + + + + + 0 + 23 + + + + Emulate Analog with Keyboard Input + + + + + + + Requires restarting sudachi + + + + 0 + 23 + + + + Enable XInput 8 player support (disables web applet) + + + + + + + + 0 + 23 + + + + Enable UDP controllers (not needed for motion) + + + + + + + + 0 + 23 + + + + Controller navigation + + + + + + + Requires restarting sudachi + + + + 0 + 23 + + + + Enable direct JoyCon driver + + + + + + + Requires restarting sudachi + + + + 0 + 23 + + + + Enable direct Pro Controller driver [EXPERIMENTAL] + + + + + + + Allows unlimited uses of the same Amiibo in games that would otherwise limit you to one use. + + + + 0 + 23 + + + + Use random Amiibo ID + + + + + + + Motion / Touch + + + + + + + Configure + + + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + + + + + + + + + + + diff --git a/src/sudachi/configuration/configure_input_per_game.cpp b/src/sudachi/configuration/configure_input_per_game.cpp new file mode 100644 index 0000000..55780a9 --- /dev/null +++ b/src/sudachi/configuration/configure_input_per_game.cpp @@ -0,0 +1,115 @@ +// SPDX-FileCopyrightText: 2022 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include "common/settings.h" +#include "core/core.h" +#include "frontend_common/config.h" +#include "hid_core/frontend/emulated_controller.h" +#include "hid_core/hid_core.h" +#include "ui_configure_input_per_game.h" +#include "sudachi/configuration/configure_input_per_game.h" +#include "sudachi/configuration/input_profiles.h" + +ConfigureInputPerGame::ConfigureInputPerGame(Core::System& system_, QtConfig* config_, + QWidget* parent) + : QWidget(parent), ui(std::make_unique()), + profiles(std::make_unique()), system{system_}, config{config_} { + ui->setupUi(this); + const std::array labels = { + ui->label_player_1, ui->label_player_2, ui->label_player_3, ui->label_player_4, + ui->label_player_5, ui->label_player_6, ui->label_player_7, ui->label_player_8, + }; + profile_comboboxes = { + ui->profile_player_1, ui->profile_player_2, ui->profile_player_3, ui->profile_player_4, + ui->profile_player_5, ui->profile_player_6, ui->profile_player_7, ui->profile_player_8, + }; + + Settings::values.players.SetGlobal(false); + + const auto& profile_names = profiles->GetInputProfileNames(); + const auto populate_profiles = [this, &profile_names](size_t player_index) { + const auto previous_profile = + Settings::values.players.GetValue()[player_index].profile_name; + + auto* const player_combobox = profile_comboboxes[player_index]; + player_combobox->addItem(tr("Use global input configuration")); + + for (size_t index = 0; index < profile_names.size(); ++index) { + const auto& profile_name = profile_names[index]; + player_combobox->addItem(QString::fromStdString(profile_name)); + if (profile_name == previous_profile) { + // offset by 1 since the first element is the global config + player_combobox->setCurrentIndex(static_cast(index + 1)); + } + } + }; + for (size_t index = 0; index < profile_comboboxes.size(); ++index) { + labels[index]->setText(tr("Player %1 profile").arg(index + 1)); + populate_profiles(index); + } + + LoadConfiguration(); +} + +void ConfigureInputPerGame::ApplyConfiguration() { + LoadConfiguration(); + SaveConfiguration(); +} + +void ConfigureInputPerGame::LoadConfiguration() { + static constexpr size_t HANDHELD_INDEX = 8; + + auto& hid_core = system.HIDCore(); + for (size_t player_index = 0; player_index < profile_comboboxes.size(); ++player_index) { + Settings::values.players.SetGlobal(false); + + auto* emulated_controller = hid_core.GetEmulatedControllerByIndex(player_index); + auto* const player_combobox = profile_comboboxes[player_index]; + + const auto selection_index = player_combobox->currentIndex(); + if (selection_index == 0) { + Settings::values.players.GetValue()[player_index].profile_name = ""; + if (player_index == 0) { + Settings::values.players.GetValue()[HANDHELD_INDEX] = {}; + } + Settings::values.players.SetGlobal(true); + emulated_controller->ReloadFromSettings(); + continue; + } + const auto profile_name = player_combobox->itemText(selection_index).toStdString(); + if (profile_name.empty()) { + continue; + } + auto& player = Settings::values.players.GetValue()[player_index]; + player.profile_name = profile_name; + // Read from the profile into the custom player settings + profiles->LoadProfile(profile_name, player_index); + // Make sure the controller is connected + player.connected = true; + + emulated_controller->ReloadFromSettings(); + + if (player_index > 0) { + continue; + } + // Handle Handheld cases + auto& handheld_player = Settings::values.players.GetValue()[HANDHELD_INDEX]; + auto* handheld_controller = hid_core.GetEmulatedController(Core::HID::NpadIdType::Handheld); + if (player.controller_type == Settings::ControllerType::Handheld) { + handheld_player = player; + } else { + handheld_player = {}; + } + handheld_controller->ReloadFromSettings(); + } +} + +void ConfigureInputPerGame::SaveConfiguration() { + Settings::values.players.SetGlobal(false); + + // Clear all controls from the config in case the user reverted back to globals + config->ClearControlPlayerValues(); + for (size_t index = 0; index < Settings::values.players.GetValue().size(); ++index) { + config->SaveQtControlPlayerValues(index); + } +} diff --git a/src/sudachi/configuration/configure_input_per_game.h b/src/sudachi/configuration/configure_input_per_game.h new file mode 100644 index 0000000..4345daa --- /dev/null +++ b/src/sudachi/configuration/configure_input_per_game.h @@ -0,0 +1,46 @@ +// SPDX-FileCopyrightText: 2022 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include + +#include + +#include "ui_configure_input_per_game.h" +#include "sudachi/configuration/input_profiles.h" +#include "sudachi/configuration/qt_config.h" + +class QComboBox; + +namespace Core { +class System; +} // namespace Core + +class Config; + +class ConfigureInputPerGame : public QWidget { + Q_OBJECT + +public: + explicit ConfigureInputPerGame(Core::System& system_, QtConfig* config_, + QWidget* parent = nullptr); + + /// Load and Save configurations to settings file. + void ApplyConfiguration(); + +private: + /// Load configuration from settings file. + void LoadConfiguration(); + + /// Save configuration to settings file. + void SaveConfiguration(); + + std::unique_ptr ui; + std::unique_ptr profiles; + + std::array profile_comboboxes; + + Core::System& system; + QtConfig* config; +}; diff --git a/src/sudachi/configuration/configure_input_per_game.ui b/src/sudachi/configuration/configure_input_per_game.ui new file mode 100644 index 0000000..fbd8eab --- /dev/null +++ b/src/sudachi/configuration/configure_input_per_game.ui @@ -0,0 +1,333 @@ + + + ConfigureInputPerGame + + + + 0 + 0 + 541 + 759 + + + + Form + + + Graphics + + + + + + 0 + + + + + Input Profiles + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Player 1 Profile + + + + + + + + 0 + 0 + + + + + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Player 2 Profile + + + + + + + + 0 + 0 + + + + + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Player 3 Profile + + + + + + + + 0 + 0 + + + + + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Player 4 Profile + + + + + + + + 0 + 0 + + + + + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Player 5 Profile + + + + + + + + 0 + 0 + + + + + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Player 6 Profile + + + + + + + + 0 + 0 + + + + + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Player 7 Profile + + + + + + + + 0 + 0 + + + + + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Player 8 Profile + + + + + + + + 0 + 0 + + + + + + + + + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + diff --git a/src/sudachi/configuration/configure_input_player.cpp b/src/sudachi/configuration/configure_input_player.cpp new file mode 100644 index 0000000..a189a7f --- /dev/null +++ b/src/sudachi/configuration/configure_input_player.cpp @@ -0,0 +1,1670 @@ +// SPDX-FileCopyrightText: 2016 Citra Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "common/assert.h" +#include "common/param_package.h" +#include "configuration/qt_config.h" +#include "frontend_common/config.h" +#include "hid_core/frontend/emulated_controller.h" +#include "hid_core/hid_core.h" +#include "hid_core/hid_types.h" +#include "input_common/drivers/keyboard.h" +#include "input_common/drivers/mouse.h" +#include "input_common/main.h" +#include "ui_configure_input_player.h" +#include "sudachi/bootmanager.h" +#include "sudachi/configuration/configure_input_player.h" +#include "sudachi/configuration/configure_input_player_widget.h" +#include "sudachi/configuration/configure_mouse_panning.h" +#include "sudachi/configuration/input_profiles.h" +#include "sudachi/util/limitable_input_dialog.h" + +const std::array + ConfigureInputPlayer::analog_sub_buttons{{ + "up", + "down", + "left", + "right", + }}; + +namespace { + +QString GetKeyName(int key_code) { + switch (key_code) { + case Qt::Key_Shift: + return QObject::tr("Shift"); + case Qt::Key_Control: + return QObject::tr("Ctrl"); + case Qt::Key_Alt: + return QObject::tr("Alt"); + case Qt::Key_Meta: + return {}; + default: + return QKeySequence(key_code).toString(); + } +} + +QString GetButtonName(Common::Input::ButtonNames button_name) { + switch (button_name) { + case Common::Input::ButtonNames::ButtonLeft: + return QObject::tr("Left"); + case Common::Input::ButtonNames::ButtonRight: + return QObject::tr("Right"); + case Common::Input::ButtonNames::ButtonDown: + return QObject::tr("Down"); + case Common::Input::ButtonNames::ButtonUp: + return QObject::tr("Up"); + case Common::Input::ButtonNames::TriggerZ: + return QObject::tr("Z"); + case Common::Input::ButtonNames::TriggerR: + return QObject::tr("R"); + case Common::Input::ButtonNames::TriggerL: + return QObject::tr("L"); + case Common::Input::ButtonNames::TriggerZR: + return QObject::tr("ZR"); + case Common::Input::ButtonNames::TriggerZL: + return QObject::tr("ZL"); + case Common::Input::ButtonNames::TriggerSR: + return QObject::tr("SR"); + case Common::Input::ButtonNames::TriggerSL: + return QObject::tr("SL"); + case Common::Input::ButtonNames::ButtonStickL: + return QObject::tr("Stick L"); + case Common::Input::ButtonNames::ButtonStickR: + return QObject::tr("Stick R"); + case Common::Input::ButtonNames::ButtonA: + return QObject::tr("A"); + case Common::Input::ButtonNames::ButtonB: + return QObject::tr("B"); + case Common::Input::ButtonNames::ButtonX: + return QObject::tr("X"); + case Common::Input::ButtonNames::ButtonY: + return QObject::tr("Y"); + case Common::Input::ButtonNames::ButtonStart: + return QObject::tr("Start"); + case Common::Input::ButtonNames::ButtonPlus: + return QObject::tr("Plus"); + case Common::Input::ButtonNames::ButtonMinus: + return QObject::tr("Minus"); + case Common::Input::ButtonNames::ButtonHome: + return QObject::tr("Home"); + case Common::Input::ButtonNames::ButtonCapture: + return QObject::tr("Capture"); + case Common::Input::ButtonNames::L1: + return QObject::tr("L1"); + case Common::Input::ButtonNames::L2: + return QObject::tr("L2"); + case Common::Input::ButtonNames::L3: + return QObject::tr("L3"); + case Common::Input::ButtonNames::R1: + return QObject::tr("R1"); + case Common::Input::ButtonNames::R2: + return QObject::tr("R2"); + case Common::Input::ButtonNames::R3: + return QObject::tr("R3"); + case Common::Input::ButtonNames::Circle: + return QObject::tr("Circle"); + case Common::Input::ButtonNames::Cross: + return QObject::tr("Cross"); + case Common::Input::ButtonNames::Square: + return QObject::tr("Square"); + case Common::Input::ButtonNames::Triangle: + return QObject::tr("Triangle"); + case Common::Input::ButtonNames::Share: + return QObject::tr("Share"); + case Common::Input::ButtonNames::Options: + return QObject::tr("Options"); + case Common::Input::ButtonNames::Home: + return QObject::tr("Home"); + case Common::Input::ButtonNames::Touch: + return QObject::tr("Touch"); + case Common::Input::ButtonNames::ButtonMouseWheel: + return QObject::tr("Wheel", "Indicates the mouse wheel"); + case Common::Input::ButtonNames::ButtonBackward: + return QObject::tr("Backward"); + case Common::Input::ButtonNames::ButtonForward: + return QObject::tr("Forward"); + case Common::Input::ButtonNames::ButtonTask: + return QObject::tr("Task"); + case Common::Input::ButtonNames::ButtonExtra: + return QObject::tr("Extra"); + default: + return QObject::tr("[undefined]"); + } +} + +QString GetDirectionName(const std::string& direction) { + if (direction == "left") { + return QObject::tr("Left"); + } + if (direction == "right") { + return QObject::tr("Right"); + } + if (direction == "up") { + return QObject::tr("Up"); + } + if (direction == "down") { + return QObject::tr("Down"); + } + UNIMPLEMENTED_MSG("Unimplemented direction name={}", direction); + return QString::fromStdString(direction); +} + +void SetAnalogParam(const Common::ParamPackage& input_param, Common::ParamPackage& analog_param, + const std::string& button_name) { + // The poller returned a complete axis, so set all the buttons + if (input_param.Has("axis_x") && input_param.Has("axis_y")) { + analog_param = input_param; + return; + } + // Check if the current configuration has either no engine or an axis binding. + // Clears out the old binding and adds one with analog_from_button. + if (!analog_param.Has("engine") || analog_param.Has("axis_x") || analog_param.Has("axis_y")) { + analog_param = { + {"engine", "analog_from_button"}, + }; + } + analog_param.Set(button_name, input_param.Serialize()); +} +} // namespace + +QString ConfigureInputPlayer::ButtonToText(const Common::ParamPackage& param) { + if (!param.Has("engine")) { + return QObject::tr("[not set]"); + } + + const QString toggle = QString::fromStdString(param.Get("toggle", false) ? "~" : ""); + const QString inverted = QString::fromStdString(param.Get("inverted", false) ? "!" : ""); + const QString invert = QString::fromStdString(param.Get("invert", "+") == "-" ? "-" : ""); + const QString turbo = QString::fromStdString(param.Get("turbo", false) ? "$" : ""); + const auto common_button_name = input_subsystem->GetButtonName(param); + + // Retrieve the names from Qt + if (param.Get("engine", "") == "keyboard") { + const QString button_str = GetKeyName(param.Get("code", 0)); + return QObject::tr("%1%2%3%4").arg(turbo, toggle, inverted, button_str); + } + + if (common_button_name == Common::Input::ButtonNames::Invalid) { + return QObject::tr("[invalid]"); + } + + if (common_button_name == Common::Input::ButtonNames::Engine) { + return QString::fromStdString(param.Get("engine", "")); + } + + if (common_button_name == Common::Input::ButtonNames::Value) { + if (param.Has("hat")) { + const QString hat = GetDirectionName(param.Get("direction", "")); + return QObject::tr("%1%2%3Hat %4").arg(turbo, toggle, inverted, hat); + } + if (param.Has("axis")) { + const QString axis = QString::fromStdString(param.Get("axis", "")); + return QObject::tr("%1%2%3Axis %4").arg(toggle, inverted, invert, axis); + } + if (param.Has("axis_x") && param.Has("axis_y") && param.Has("axis_z")) { + const QString axis_x = QString::fromStdString(param.Get("axis_x", "")); + const QString axis_y = QString::fromStdString(param.Get("axis_y", "")); + const QString axis_z = QString::fromStdString(param.Get("axis_z", "")); + return QObject::tr("%1%2Axis %3,%4,%5").arg(toggle, inverted, axis_x, axis_y, axis_z); + } + if (param.Has("motion")) { + const QString motion = QString::fromStdString(param.Get("motion", "")); + return QObject::tr("%1%2Motion %3").arg(toggle, inverted, motion); + } + if (param.Has("button")) { + const QString button = QString::fromStdString(param.Get("button", "")); + return QObject::tr("%1%2%3Button %4").arg(turbo, toggle, inverted, button); + } + } + + QString button_name = GetButtonName(common_button_name); + if (param.Has("hat")) { + return QObject::tr("%1%2%3Hat %4").arg(turbo, toggle, inverted, button_name); + } + if (param.Has("axis")) { + return QObject::tr("%1%2%3Axis %4").arg(toggle, inverted, invert, button_name); + } + if (param.Has("motion")) { + return QObject::tr("%1%2Axis %3").arg(toggle, inverted, button_name); + } + if (param.Has("button")) { + return QObject::tr("%1%2%3Button %4").arg(turbo, toggle, inverted, button_name); + } + + return QObject::tr("[unknown]"); +} + +QString ConfigureInputPlayer::AnalogToText(const Common::ParamPackage& param, + const std::string& dir) { + if (!param.Has("engine")) { + return QObject::tr("[not set]"); + } + + if (param.Get("engine", "") == "analog_from_button") { + return ButtonToText(Common::ParamPackage{param.Get(dir, "")}); + } + + if (!param.Has("axis_x") || !param.Has("axis_y")) { + return QObject::tr("[unknown]"); + } + + const auto engine_str = param.Get("engine", ""); + const QString axis_x_str = QString::fromStdString(param.Get("axis_x", "")); + const QString axis_y_str = QString::fromStdString(param.Get("axis_y", "")); + const bool invert_x = param.Get("invert_x", "+") == "-"; + const bool invert_y = param.Get("invert_y", "+") == "-"; + + if (dir == "modifier") { + return QObject::tr("[unused]"); + } + + if (dir == "left") { + const QString invert_x_str = QString::fromStdString(invert_x ? "+" : "-"); + return QObject::tr("Axis %1%2").arg(axis_x_str, invert_x_str); + } + if (dir == "right") { + const QString invert_x_str = QString::fromStdString(invert_x ? "-" : "+"); + return QObject::tr("Axis %1%2").arg(axis_x_str, invert_x_str); + } + if (dir == "up") { + const QString invert_y_str = QString::fromStdString(invert_y ? "-" : "+"); + return QObject::tr("Axis %1%2").arg(axis_y_str, invert_y_str); + } + if (dir == "down") { + const QString invert_y_str = QString::fromStdString(invert_y ? "+" : "-"); + return QObject::tr("Axis %1%2").arg(axis_y_str, invert_y_str); + } + + return QObject::tr("[unknown]"); +} + +ConfigureInputPlayer::ConfigureInputPlayer(QWidget* parent, std::size_t player_index_, + QWidget* bottom_row_, + InputCommon::InputSubsystem* input_subsystem_, + InputProfiles* profiles_, Core::HID::HIDCore& hid_core_, + bool is_powered_on_, bool debug_) + : QWidget(parent), + ui(std::make_unique()), player_index{player_index_}, debug{debug_}, + is_powered_on{is_powered_on_}, input_subsystem{input_subsystem_}, profiles(profiles_), + timeout_timer(std::make_unique()), + poll_timer(std::make_unique()), bottom_row{bottom_row_}, hid_core{hid_core_} { + if (player_index == 0) { + auto* emulated_controller_p1 = + hid_core.GetEmulatedController(Core::HID::NpadIdType::Player1); + auto* emulated_controller_handheld = + hid_core.GetEmulatedController(Core::HID::NpadIdType::Handheld); + emulated_controller_p1->SaveCurrentConfig(); + emulated_controller_p1->EnableConfiguration(); + emulated_controller_handheld->SaveCurrentConfig(); + emulated_controller_handheld->EnableConfiguration(); + if (emulated_controller_handheld->IsConnected(true)) { + emulated_controller_p1->Disconnect(); + emulated_controller = emulated_controller_handheld; + } else { + emulated_controller = emulated_controller_p1; + } + } else { + emulated_controller = hid_core.GetEmulatedControllerByIndex(player_index); + emulated_controller->SaveCurrentConfig(); + emulated_controller->EnableConfiguration(); + } + ui->setupUi(this); + + setFocusPolicy(Qt::ClickFocus); + + button_map = { + ui->buttonA, ui->buttonB, ui->buttonX, ui->buttonY, + ui->buttonLStick, ui->buttonRStick, ui->buttonL, ui->buttonR, + ui->buttonZL, ui->buttonZR, ui->buttonPlus, ui->buttonMinus, + ui->buttonDpadLeft, ui->buttonDpadUp, ui->buttonDpadRight, ui->buttonDpadDown, + ui->buttonSLLeft, ui->buttonSRLeft, ui->buttonHome, ui->buttonScreenshot, + ui->buttonSLRight, ui->buttonSRRight, + }; + + analog_map_buttons = {{ + { + ui->buttonLStickUp, + ui->buttonLStickDown, + ui->buttonLStickLeft, + ui->buttonLStickRight, + }, + { + ui->buttonRStickUp, + ui->buttonRStickDown, + ui->buttonRStickLeft, + ui->buttonRStickRight, + }, + }}; + + motion_map = { + ui->buttonMotionLeft, + ui->buttonMotionRight, + }; + + analog_map_deadzone_label = {ui->labelLStickDeadzone, ui->labelRStickDeadzone}; + analog_map_deadzone_slider = {ui->sliderLStickDeadzone, ui->sliderRStickDeadzone}; + analog_map_modifier_groupbox = {ui->buttonLStickModGroup, ui->buttonRStickModGroup}; + analog_map_modifier_button = {ui->buttonLStickMod, ui->buttonRStickMod}; + analog_map_modifier_label = {ui->labelLStickModifierRange, ui->labelRStickModifierRange}; + analog_map_modifier_slider = {ui->sliderLStickModifierRange, ui->sliderRStickModifierRange}; + analog_map_range_groupbox = {ui->buttonLStickRangeGroup, ui->buttonRStickRangeGroup}; + analog_map_range_spinbox = {ui->spinboxLStickRange, ui->spinboxRStickRange}; + + ui->controllerFrame->SetController(emulated_controller); + + for (int button_id = 0; button_id < Settings::NativeButton::NumButtons; ++button_id) { + auto* const button = button_map[button_id]; + + if (button == nullptr) { + continue; + } + + connect(button, &QPushButton::clicked, [=, this] { + HandleClick( + button, button_id, + [=, this](const Common::ParamPackage& params) { + emulated_controller->SetButtonParam(button_id, params); + }, + InputCommon::Polling::InputType::Button); + }); + + button->setContextMenuPolicy(Qt::CustomContextMenu); + connect(button, &QPushButton::customContextMenuRequested, + [=, this](const QPoint& menu_location) { + QMenu context_menu; + Common::ParamPackage param = emulated_controller->GetButtonParam(button_id); + context_menu.addAction(tr("Clear"), [&] { + emulated_controller->SetButtonParam(button_id, {}); + button_map[button_id]->setText(tr("[not set]")); + }); + if (param.Has("code") || param.Has("button") || param.Has("hat")) { + context_menu.addAction(tr("Invert button"), [&] { + const bool invert_value = !param.Get("inverted", false); + param.Set("inverted", invert_value); + button_map[button_id]->setText(ButtonToText(param)); + emulated_controller->SetButtonParam(button_id, param); + }); + context_menu.addAction(tr("Toggle button"), [&] { + const bool toggle_value = !param.Get("toggle", false); + param.Set("toggle", toggle_value); + button_map[button_id]->setText(ButtonToText(param)); + emulated_controller->SetButtonParam(button_id, param); + }); + context_menu.addAction(tr("Turbo button"), [&] { + const bool turbo_value = !param.Get("turbo", false); + param.Set("turbo", turbo_value); + button_map[button_id]->setText(ButtonToText(param)); + emulated_controller->SetButtonParam(button_id, param); + }); + } + if (param.Has("axis")) { + context_menu.addAction(tr("Invert axis"), [&] { + const bool toggle_value = !(param.Get("invert", "+") == "-"); + param.Set("invert", toggle_value ? "-" : "+"); + button_map[button_id]->setText(ButtonToText(param)); + emulated_controller->SetButtonParam(button_id, param); + }); + context_menu.addAction(tr("Invert button"), [&] { + const bool invert_value = !param.Get("inverted", false); + param.Set("inverted", invert_value); + button_map[button_id]->setText(ButtonToText(param)); + emulated_controller->SetButtonParam(button_id, param); + }); + context_menu.addAction(tr("Set threshold"), [&] { + const int button_threshold = + static_cast(param.Get("threshold", 0.5f) * 100.0f); + const int new_threshold = QInputDialog::getInt( + this, tr("Set threshold"), tr("Choose a value between 0% and 100%"), + button_threshold, 0, 100); + param.Set("threshold", new_threshold / 100.0f); + + if (button_id == Settings::NativeButton::ZL) { + ui->sliderZLThreshold->setValue(new_threshold); + } + if (button_id == Settings::NativeButton::ZR) { + ui->sliderZRThreshold->setValue(new_threshold); + } + emulated_controller->SetButtonParam(button_id, param); + }); + context_menu.addAction(tr("Toggle axis"), [&] { + const bool toggle_value = !param.Get("toggle", false); + param.Set("toggle", toggle_value); + button_map[button_id]->setText(ButtonToText(param)); + emulated_controller->SetButtonParam(button_id, param); + }); + } + context_menu.exec(button_map[button_id]->mapToGlobal(menu_location)); + }); + } + + for (int motion_id = 0; motion_id < Settings::NativeMotion::NumMotions; ++motion_id) { + auto* const button = motion_map[motion_id]; + if (button == nullptr) { + continue; + } + + connect(button, &QPushButton::clicked, [=, this] { + HandleClick( + button, motion_id, + [=, this](const Common::ParamPackage& params) { + emulated_controller->SetMotionParam(motion_id, params); + }, + InputCommon::Polling::InputType::Motion); + }); + + button->setContextMenuPolicy(Qt::CustomContextMenu); + + connect(button, &QPushButton::customContextMenuRequested, + [=, this](const QPoint& menu_location) { + QMenu context_menu; + Common::ParamPackage param = emulated_controller->GetMotionParam(motion_id); + context_menu.addAction(tr("Clear"), [&] { + emulated_controller->SetMotionParam(motion_id, {}); + motion_map[motion_id]->setText(tr("[not set]")); + }); + if (param.Has("motion")) { + context_menu.addAction(tr("Set gyro threshold"), [&] { + const int gyro_threshold = + static_cast(param.Get("threshold", 0.007f) * 1000.0f); + const int new_threshold = QInputDialog::getInt( + this, tr("Set threshold"), tr("Choose a value between 0% and 100%"), + gyro_threshold, 0, 100); + param.Set("threshold", new_threshold / 1000.0f); + emulated_controller->SetMotionParam(motion_id, param); + }); + context_menu.addAction(tr("Calibrate sensor"), [&] { + emulated_controller->StartMotionCalibration(); + }); + } + context_menu.exec(motion_map[motion_id]->mapToGlobal(menu_location)); + }); + } + + connect(ui->sliderZLThreshold, &QSlider::valueChanged, [=, this] { + Common::ParamPackage param = + emulated_controller->GetButtonParam(Settings::NativeButton::ZL); + if (param.Has("threshold")) { + const auto slider_value = ui->sliderZLThreshold->value(); + param.Set("threshold", slider_value / 100.0f); + emulated_controller->SetButtonParam(Settings::NativeButton::ZL, param); + } + }); + + connect(ui->sliderZRThreshold, &QSlider::valueChanged, [=, this] { + Common::ParamPackage param = + emulated_controller->GetButtonParam(Settings::NativeButton::ZR); + if (param.Has("threshold")) { + const auto slider_value = ui->sliderZRThreshold->value(); + param.Set("threshold", slider_value / 100.0f); + emulated_controller->SetButtonParam(Settings::NativeButton::ZR, param); + } + }); + + for (int analog_id = 0; analog_id < Settings::NativeAnalog::NumAnalogs; ++analog_id) { + for (int sub_button_id = 0; sub_button_id < ANALOG_SUB_BUTTONS_NUM; ++sub_button_id) { + auto* const analog_button = analog_map_buttons[analog_id][sub_button_id]; + + if (analog_button == nullptr) { + continue; + } + + connect(analog_button, &QPushButton::clicked, [=, this] { + if (!map_analog_stick_accepted) { + map_analog_stick_accepted = + QMessageBox::information( + this, tr("Map Analog Stick"), + tr("After pressing OK, first move your joystick horizontally, and then " + "vertically.\nTo invert the axes, first move your joystick " + "vertically, and then horizontally."), + QMessageBox::Ok | QMessageBox::Cancel) == QMessageBox::Ok; + if (!map_analog_stick_accepted) { + return; + } + } + HandleClick( + analog_map_buttons[analog_id][sub_button_id], analog_id, + [=, this](const Common::ParamPackage& params) { + Common::ParamPackage param = emulated_controller->GetStickParam(analog_id); + SetAnalogParam(params, param, analog_sub_buttons[sub_button_id]); + // Correct axis direction for inverted sticks + if (input_subsystem->IsStickInverted(param)) { + switch (analog_id) { + case Settings::NativeAnalog::LStick: { + const bool invert_value = param.Get("invert_x", "+") == "-"; + const std::string invert_str = invert_value ? "+" : "-"; + param.Set("invert_x", invert_str); + break; + } + case Settings::NativeAnalog::RStick: { + const bool invert_value = param.Get("invert_y", "+") == "-"; + const std::string invert_str = invert_value ? "+" : "-"; + param.Set("invert_y", invert_str); + break; + } + default: + break; + } + } + emulated_controller->SetStickParam(analog_id, param); + }, + InputCommon::Polling::InputType::Stick); + }); + + analog_button->setContextMenuPolicy(Qt::CustomContextMenu); + + connect(analog_button, &QPushButton::customContextMenuRequested, + [=, this](const QPoint& menu_location) { + QMenu context_menu; + Common::ParamPackage param = emulated_controller->GetStickParam(analog_id); + context_menu.addAction(tr("Clear"), [&] { + if (param.Get("engine", "") != "analog_from_button") { + emulated_controller->SetStickParam(analog_id, {}); + for (auto button : analog_map_buttons[analog_id]) { + button->setText(tr("[not set]")); + } + return; + } + switch (sub_button_id) { + case 0: + param.Erase("up"); + break; + case 1: + param.Erase("down"); + break; + case 2: + param.Erase("left"); + break; + case 3: + param.Erase("right"); + break; + } + emulated_controller->SetStickParam(analog_id, param); + analog_map_buttons[analog_id][sub_button_id]->setText(tr("[not set]")); + }); + context_menu.addAction(tr("Center axis"), [&] { + const auto stick_value = + emulated_controller->GetSticksValues()[analog_id]; + const float offset_x = stick_value.x.properties.offset; + const float offset_y = stick_value.y.properties.offset; + float raw_value_x = stick_value.x.raw_value; + float raw_value_y = stick_value.y.raw_value; + // See Core::HID::SanitizeStick() to obtain the original raw axis value + if (std::abs(offset_x) < 0.5f) { + if (raw_value_x > 0) { + raw_value_x *= 1 + offset_x; + } else { + raw_value_x *= 1 - offset_x; + } + } + if (std::abs(offset_x) < 0.5f) { + if (raw_value_y > 0) { + raw_value_y *= 1 + offset_y; + } else { + raw_value_y *= 1 - offset_y; + } + } + param.Set("offset_x", -raw_value_x + offset_x); + param.Set("offset_y", -raw_value_y + offset_y); + emulated_controller->SetStickParam(analog_id, param); + }); + context_menu.addAction(tr("Invert axis"), [&] { + if (sub_button_id == 2 || sub_button_id == 3) { + const bool invert_value = param.Get("invert_x", "+") == "-"; + const std::string invert_str = invert_value ? "+" : "-"; + param.Set("invert_x", invert_str); + emulated_controller->SetStickParam(analog_id, param); + } + if (sub_button_id == 0 || sub_button_id == 1) { + const bool invert_value = param.Get("invert_y", "+") == "-"; + const std::string invert_str = invert_value ? "+" : "-"; + param.Set("invert_y", invert_str); + emulated_controller->SetStickParam(analog_id, param); + } + for (int analog_sub_button_id = 0; + analog_sub_button_id < ANALOG_SUB_BUTTONS_NUM; + ++analog_sub_button_id) { + analog_map_buttons[analog_id][analog_sub_button_id]->setText( + AnalogToText(param, analog_sub_buttons[analog_sub_button_id])); + } + }); + context_menu.exec(analog_map_buttons[analog_id][sub_button_id]->mapToGlobal( + menu_location)); + }); + } + + // Handle clicks for the modifier buttons as well. + connect(analog_map_modifier_button[analog_id], &QPushButton::clicked, [=, this] { + HandleClick( + analog_map_modifier_button[analog_id], analog_id, + [=, this](const Common::ParamPackage& params) { + Common::ParamPackage param = emulated_controller->GetStickParam(analog_id); + param.Set("modifier", params.Serialize()); + emulated_controller->SetStickParam(analog_id, param); + }, + InputCommon::Polling::InputType::Button); + }); + + analog_map_modifier_button[analog_id]->setContextMenuPolicy(Qt::CustomContextMenu); + + connect( + analog_map_modifier_button[analog_id], &QPushButton::customContextMenuRequested, + [=, this](const QPoint& menu_location) { + QMenu context_menu; + Common::ParamPackage param = emulated_controller->GetStickParam(analog_id); + context_menu.addAction(tr("Clear"), [&] { + param.Set("modifier", ""); + analog_map_modifier_button[analog_id]->setText(tr("[not set]")); + emulated_controller->SetStickParam(analog_id, param); + }); + context_menu.addAction(tr("Toggle button"), [&] { + Common::ParamPackage modifier_param = + Common::ParamPackage{param.Get("modifier", "")}; + const bool toggle_value = !modifier_param.Get("toggle", false); + modifier_param.Set("toggle", toggle_value); + param.Set("modifier", modifier_param.Serialize()); + analog_map_modifier_button[analog_id]->setText(ButtonToText(modifier_param)); + emulated_controller->SetStickParam(analog_id, param); + }); + context_menu.addAction(tr("Invert button"), [&] { + Common::ParamPackage modifier_param = + Common::ParamPackage{param.Get("modifier", "")}; + const bool invert_value = !modifier_param.Get("inverted", false); + modifier_param.Set("inverted", invert_value); + param.Set("modifier", modifier_param.Serialize()); + analog_map_modifier_button[analog_id]->setText(ButtonToText(modifier_param)); + emulated_controller->SetStickParam(analog_id, param); + }); + context_menu.exec( + analog_map_modifier_button[analog_id]->mapToGlobal(menu_location)); + }); + + connect(analog_map_range_spinbox[analog_id], qOverload(&QSpinBox::valueChanged), + [=, this] { + Common::ParamPackage param = emulated_controller->GetStickParam(analog_id); + const auto spinbox_value = analog_map_range_spinbox[analog_id]->value(); + param.Set("range", spinbox_value / 100.0f); + emulated_controller->SetStickParam(analog_id, param); + }); + + connect(analog_map_deadzone_slider[analog_id], &QSlider::valueChanged, [=, this] { + Common::ParamPackage param = emulated_controller->GetStickParam(analog_id); + const auto slider_value = analog_map_deadzone_slider[analog_id]->value(); + analog_map_deadzone_label[analog_id]->setText(tr("Deadzone: %1%").arg(slider_value)); + param.Set("deadzone", slider_value / 100.0f); + emulated_controller->SetStickParam(analog_id, param); + }); + + connect(analog_map_modifier_slider[analog_id], &QSlider::valueChanged, [=, this] { + Common::ParamPackage param = emulated_controller->GetStickParam(analog_id); + const auto slider_value = analog_map_modifier_slider[analog_id]->value(); + analog_map_modifier_label[analog_id]->setText( + tr("Modifier Range: %1%").arg(slider_value)); + param.Set("modifier_scale", slider_value / 100.0f); + emulated_controller->SetStickParam(analog_id, param); + }); + } + + if (player_index_ == 0) { + connect(ui->mousePanningButton, &QPushButton::clicked, [this, input_subsystem_] { + const auto right_stick_param = + emulated_controller->GetStickParam(Settings::NativeAnalog::RStick); + ConfigureMousePanning dialog(this, input_subsystem_, + right_stick_param.Get("deadzone", 0.0f), + right_stick_param.Get("range", 1.0f)); + if (dialog.exec() == QDialog::Accepted) { + dialog.ApplyConfiguration(); + } + }); + } else { + ui->mousePanningWidget->hide(); + } + + // Player Connected checkbox + connect(ui->groupConnectedController, &QGroupBox::toggled, + [this](bool checked) { emit Connected(checked); }); + + if (player_index == 0) { + connect(ui->comboControllerType, qOverload(&QComboBox::currentIndexChanged), + [this](int index) { + emit HandheldStateChanged(GetControllerTypeFromIndex(index) == + Core::HID::NpadStyleIndex::Handheld); + }); + } + + if (debug || player_index == 9) { + ui->groupConnectedController->setCheckable(false); + } + + // The Debug Controller can only choose the Pro Controller. + if (debug) { + ui->buttonScreenshot->setEnabled(false); + ui->buttonHome->setEnabled(false); + ui->comboControllerType->addItem(tr("Pro Controller")); + } else { + SetConnectableControllers(); + } + + UpdateControllerAvailableButtons(); + UpdateControllerEnabledButtons(); + UpdateControllerButtonNames(); + UpdateMotionButtons(); + connect(ui->comboControllerType, qOverload(&QComboBox::currentIndexChanged), [this](int) { + UpdateControllerAvailableButtons(); + UpdateControllerEnabledButtons(); + UpdateControllerButtonNames(); + UpdateMotionButtons(); + const Core::HID::NpadStyleIndex type = + GetControllerTypeFromIndex(ui->comboControllerType->currentIndex()); + + if (player_index == 0) { + auto* emulated_controller_p1 = + hid_core.GetEmulatedController(Core::HID::NpadIdType::Player1); + auto* emulated_controller_handheld = + hid_core.GetEmulatedController(Core::HID::NpadIdType::Handheld); + bool is_connected = emulated_controller->IsConnected(true); + + emulated_controller_p1->SetNpadStyleIndex(type); + emulated_controller_handheld->SetNpadStyleIndex(type); + if (is_connected) { + if (type == Core::HID::NpadStyleIndex::Handheld) { + emulated_controller_p1->Disconnect(); + emulated_controller_handheld->Connect(true); + emulated_controller = emulated_controller_handheld; + } else { + emulated_controller_handheld->Disconnect(); + emulated_controller_p1->Connect(true); + emulated_controller = emulated_controller_p1; + } + } + ui->controllerFrame->SetController(emulated_controller); + } + emulated_controller->SetNpadStyleIndex(type); + }); + + connect(ui->comboDevices, qOverload(&QComboBox::activated), this, + &ConfigureInputPlayer::UpdateMappingWithDefaults); + ui->comboDevices->installEventFilter(this); + + ui->comboDevices->setCurrentIndex(-1); + + timeout_timer->setSingleShot(true); + connect(timeout_timer.get(), &QTimer::timeout, [this] { SetPollingResult({}, true); }); + + connect(poll_timer.get(), &QTimer::timeout, [this] { + const auto& params = input_subsystem->GetNextInput(); + if (params.Has("engine") && IsInputAcceptable(params)) { + SetPollingResult(params, false); + return; + } + }); + + UpdateInputProfiles(); + + connect(ui->buttonProfilesNew, &QPushButton::clicked, this, + &ConfigureInputPlayer::CreateProfile); + connect(ui->buttonProfilesDelete, &QPushButton::clicked, this, + &ConfigureInputPlayer::DeleteProfile); + connect(ui->comboProfiles, qOverload(&QComboBox::activated), this, + &ConfigureInputPlayer::LoadProfile); + connect(ui->buttonProfilesSave, &QPushButton::clicked, this, + &ConfigureInputPlayer::SaveProfile); + + LoadConfiguration(); +} + +ConfigureInputPlayer::~ConfigureInputPlayer() { + if (player_index == 0) { + auto* emulated_controller_p1 = + hid_core.GetEmulatedController(Core::HID::NpadIdType::Player1); + auto* emulated_controller_handheld = + hid_core.GetEmulatedController(Core::HID::NpadIdType::Handheld); + emulated_controller_p1->DisableConfiguration(); + emulated_controller_handheld->DisableConfiguration(); + } else { + emulated_controller->DisableConfiguration(); + } +} + +void ConfigureInputPlayer::ApplyConfiguration() { + if (player_index == 0) { + auto* emulated_controller_p1 = + hid_core.GetEmulatedController(Core::HID::NpadIdType::Player1); + auto* emulated_controller_handheld = + hid_core.GetEmulatedController(Core::HID::NpadIdType::Handheld); + emulated_controller_p1->DisableConfiguration(); + emulated_controller_p1->SaveCurrentConfig(); + emulated_controller_p1->EnableConfiguration(); + emulated_controller_handheld->DisableConfiguration(); + emulated_controller_handheld->SaveCurrentConfig(); + emulated_controller_handheld->EnableConfiguration(); + return; + } + emulated_controller->DisableConfiguration(); + emulated_controller->SaveCurrentConfig(); + emulated_controller->EnableConfiguration(); +} + +void ConfigureInputPlayer::showEvent(QShowEvent* event) { + if (bottom_row == nullptr) { + return; + } + QWidget::showEvent(event); + ui->main->addWidget(bottom_row); +} + +void ConfigureInputPlayer::changeEvent(QEvent* event) { + if (event->type() == QEvent::LanguageChange) { + RetranslateUI(); + } + + QWidget::changeEvent(event); +} + +void ConfigureInputPlayer::RetranslateUI() { + ui->retranslateUi(this); + UpdateUI(); +} + +void ConfigureInputPlayer::LoadConfiguration() { + emulated_controller->ReloadFromSettings(); + + UpdateUI(); + UpdateInputDeviceCombobox(); + + if (debug) { + return; + } + + const int comboBoxIndex = + GetIndexFromControllerType(emulated_controller->GetNpadStyleIndex(true)); + ui->comboControllerType->setCurrentIndex(comboBoxIndex); + ui->groupConnectedController->setChecked(emulated_controller->IsConnected(true)); +} + +void ConfigureInputPlayer::ConnectPlayer(bool connected) { + ui->groupConnectedController->setChecked(connected); + if (connected) { + emulated_controller->Connect(true); + } else { + emulated_controller->Disconnect(); + } +} + +void ConfigureInputPlayer::UpdateInputDeviceCombobox() { + // Skip input device persistence if "Input Devices" is set to "Any". + if (ui->comboDevices->currentIndex() == 0) { + UpdateInputDevices(); + return; + } + + const auto devices = emulated_controller->GetMappedDevices(); + UpdateInputDevices(); + + if (devices.empty()) { + return; + } + + if (devices.size() > 2) { + ui->comboDevices->setCurrentIndex(0); + return; + } + + const auto first_engine = devices[0].Get("engine", ""); + const auto first_guid = devices[0].Get("guid", ""); + const auto first_port = devices[0].Get("port", 0); + const auto first_pad = devices[0].Get("pad", 0); + + if (devices.size() == 1) { + const auto devices_it = std::find_if( + input_devices.begin(), input_devices.end(), + [first_engine, first_guid, first_port, first_pad](const Common::ParamPackage& param) { + return param.Get("engine", "") == first_engine && + param.Get("guid", "") == first_guid && param.Get("port", 0) == first_port && + param.Get("pad", 0) == first_pad; + }); + const int device_index = + devices_it != input_devices.end() + ? static_cast(std::distance(input_devices.begin(), devices_it)) + : 0; + ui->comboDevices->setCurrentIndex(device_index); + return; + } + + const auto second_engine = devices[1].Get("engine", ""); + const auto second_guid = devices[1].Get("guid", ""); + const auto second_port = devices[1].Get("port", 0); + + const bool is_keyboard_mouse = (first_engine == "keyboard" || first_engine == "mouse") && + (second_engine == "keyboard" || second_engine == "mouse"); + + if (is_keyboard_mouse) { + ui->comboDevices->setCurrentIndex(2); + return; + } + + const bool is_engine_equal = first_engine == second_engine; + const bool is_port_equal = first_port == second_port; + + if (is_engine_equal && is_port_equal) { + const auto devices_it = std::find_if( + input_devices.begin(), input_devices.end(), + [first_engine, first_guid, second_guid, first_port](const Common::ParamPackage& param) { + const bool is_guid_valid = + (param.Get("guid", "") == first_guid && + param.Get("guid2", "") == second_guid) || + (param.Get("guid", "") == second_guid && param.Get("guid2", "") == first_guid); + return param.Get("engine", "") == first_engine && is_guid_valid && + param.Get("port", 0) == first_port; + }); + const int device_index = + devices_it != input_devices.end() + ? static_cast(std::distance(input_devices.begin(), devices_it)) + : 0; + ui->comboDevices->setCurrentIndex(device_index); + } else { + ui->comboDevices->setCurrentIndex(0); + } +} + +void ConfigureInputPlayer::RestoreDefaults() { + UpdateMappingWithDefaults(); +} + +void ConfigureInputPlayer::ClearAll() { + for (int button_id = 0; button_id < Settings::NativeButton::NumButtons; ++button_id) { + const auto* const button = button_map[button_id]; + if (button == nullptr) { + continue; + } + emulated_controller->SetButtonParam(button_id, {}); + } + + for (int analog_id = 0; analog_id < Settings::NativeAnalog::NumAnalogs; ++analog_id) { + for (int sub_button_id = 0; sub_button_id < ANALOG_SUB_BUTTONS_NUM; ++sub_button_id) { + const auto* const analog_button = analog_map_buttons[analog_id][sub_button_id]; + if (analog_button == nullptr) { + continue; + } + emulated_controller->SetStickParam(analog_id, {}); + } + } + + for (int motion_id = 0; motion_id < Settings::NativeMotion::NumMotions; ++motion_id) { + const auto* const motion_button = motion_map[motion_id]; + if (motion_button == nullptr) { + continue; + } + emulated_controller->SetMotionParam(motion_id, {}); + } + + UpdateUI(); + UpdateInputDevices(); +} + +void ConfigureInputPlayer::UpdateUI() { + for (int button = 0; button < Settings::NativeButton::NumButtons; ++button) { + const Common::ParamPackage param = emulated_controller->GetButtonParam(button); + button_map[button]->setText(ButtonToText(param)); + } + + const Common::ParamPackage ZL_param = + emulated_controller->GetButtonParam(Settings::NativeButton::ZL); + if (ZL_param.Has("threshold")) { + const int button_threshold = static_cast(ZL_param.Get("threshold", 0.5f) * 100.0f); + ui->sliderZLThreshold->setValue(button_threshold); + } + + const Common::ParamPackage ZR_param = + emulated_controller->GetButtonParam(Settings::NativeButton::ZR); + if (ZR_param.Has("threshold")) { + const int button_threshold = static_cast(ZR_param.Get("threshold", 0.5f) * 100.0f); + ui->sliderZRThreshold->setValue(button_threshold); + } + + for (int motion_id = 0; motion_id < Settings::NativeMotion::NumMotions; ++motion_id) { + const Common::ParamPackage param = emulated_controller->GetMotionParam(motion_id); + motion_map[motion_id]->setText(ButtonToText(param)); + } + + for (int analog_id = 0; analog_id < Settings::NativeAnalog::NumAnalogs; ++analog_id) { + const Common::ParamPackage param = emulated_controller->GetStickParam(analog_id); + for (int sub_button_id = 0; sub_button_id < ANALOG_SUB_BUTTONS_NUM; ++sub_button_id) { + auto* const analog_button = analog_map_buttons[analog_id][sub_button_id]; + + if (analog_button == nullptr) { + continue; + } + + analog_button->setText(AnalogToText(param, analog_sub_buttons[sub_button_id])); + } + + analog_map_modifier_button[analog_id]->setText( + ButtonToText(Common::ParamPackage{param.Get("modifier", "")})); + + const auto deadzone_label = analog_map_deadzone_label[analog_id]; + const auto deadzone_slider = analog_map_deadzone_slider[analog_id]; + const auto modifier_groupbox = analog_map_modifier_groupbox[analog_id]; + const auto modifier_label = analog_map_modifier_label[analog_id]; + const auto modifier_slider = analog_map_modifier_slider[analog_id]; + const auto range_groupbox = analog_map_range_groupbox[analog_id]; + const auto range_spinbox = analog_map_range_spinbox[analog_id]; + + int slider_value; + const bool is_controller = input_subsystem->IsController(param); + + if (is_controller) { + slider_value = static_cast(param.Get("deadzone", 0.15f) * 100); + deadzone_label->setText(tr("Deadzone: %1%").arg(slider_value)); + deadzone_slider->setValue(slider_value); + range_spinbox->setValue(static_cast(param.Get("range", 0.95f) * 100)); + } else { + slider_value = static_cast(param.Get("modifier_scale", 0.5f) * 100); + modifier_label->setText(tr("Modifier Range: %1%").arg(slider_value)); + modifier_slider->setValue(slider_value); + } + + deadzone_label->setVisible(is_controller); + deadzone_slider->setVisible(is_controller); + modifier_groupbox->setVisible(!is_controller); + modifier_label->setVisible(!is_controller); + modifier_slider->setVisible(!is_controller); + range_groupbox->setVisible(is_controller); + } +} + +void ConfigureInputPlayer::SetConnectableControllers() { + const auto npad_style_set = hid_core.GetSupportedStyleTag(); + index_controller_type_pairs.clear(); + ui->comboControllerType->clear(); + + const auto add_item = [&](Core::HID::NpadStyleIndex controller_type, + const QString& controller_name) { + index_controller_type_pairs.emplace_back(ui->comboControllerType->count(), controller_type); + ui->comboControllerType->addItem(controller_name); + }; + + if (npad_style_set.fullkey == 1) { + add_item(Core::HID::NpadStyleIndex::Fullkey, tr("Pro Controller")); + } + + if (npad_style_set.joycon_dual == 1) { + add_item(Core::HID::NpadStyleIndex::JoyconDual, tr("Dual Joycons")); + } + + if (npad_style_set.joycon_left == 1) { + add_item(Core::HID::NpadStyleIndex::JoyconLeft, tr("Left Joycon")); + } + + if (npad_style_set.joycon_right == 1) { + add_item(Core::HID::NpadStyleIndex::JoyconRight, tr("Right Joycon")); + } + + if (player_index == 0 && npad_style_set.handheld == 1) { + add_item(Core::HID::NpadStyleIndex::Handheld, tr("Handheld")); + } + + if (npad_style_set.gamecube == 1) { + add_item(Core::HID::NpadStyleIndex::GameCube, tr("GameCube Controller")); + } + + // Disable all unsupported controllers + if (!Settings::values.enable_all_controllers) { + return; + } + + if (npad_style_set.palma == 1) { + add_item(Core::HID::NpadStyleIndex::Pokeball, tr("Poke Ball Plus")); + } + + if (npad_style_set.lark == 1) { + add_item(Core::HID::NpadStyleIndex::NES, tr("NES Controller")); + } + + if (npad_style_set.lucia == 1) { + add_item(Core::HID::NpadStyleIndex::SNES, tr("SNES Controller")); + } + + if (npad_style_set.lagoon == 1) { + add_item(Core::HID::NpadStyleIndex::N64, tr("N64 Controller")); + } + + if (npad_style_set.lager == 1) { + add_item(Core::HID::NpadStyleIndex::SegaGenesis, tr("Sega Genesis")); + } +} + +Core::HID::NpadStyleIndex ConfigureInputPlayer::GetControllerTypeFromIndex(int index) const { + const auto it = + std::find_if(index_controller_type_pairs.begin(), index_controller_type_pairs.end(), + [index](const auto& pair) { return pair.first == index; }); + + if (it == index_controller_type_pairs.end()) { + return Core::HID::NpadStyleIndex::Fullkey; + } + + return it->second; +} + +int ConfigureInputPlayer::GetIndexFromControllerType(Core::HID::NpadStyleIndex type) const { + const auto it = + std::find_if(index_controller_type_pairs.begin(), index_controller_type_pairs.end(), + [type](const auto& pair) { return pair.second == type; }); + + if (it == index_controller_type_pairs.end()) { + return -1; + } + + return it->first; +} + +void ConfigureInputPlayer::UpdateInputDevices() { + input_devices = input_subsystem->GetInputDevices(); + ui->comboDevices->clear(); + for (const auto& device : input_devices) { + ui->comboDevices->addItem(QString::fromStdString(device.Get("display", "Unknown")), {}); + } +} + +void ConfigureInputPlayer::UpdateControllerAvailableButtons() { + auto layout = GetControllerTypeFromIndex(ui->comboControllerType->currentIndex()); + if (debug) { + layout = Core::HID::NpadStyleIndex::Fullkey; + } + + // List of all the widgets that will be hidden by any of the following layouts that need + // "unhidden" after the controller type changes + const std::array layout_show = { + ui->buttonShoulderButtonsSLSRLeft, + ui->buttonShoulderButtonsSLSRRight, + ui->horizontalSpacerShoulderButtonsWidget, + ui->horizontalSpacerShoulderButtonsWidget2, + ui->horizontalSpacerShoulderButtonsWidget3, + ui->horizontalSpacerShoulderButtonsWidget4, + ui->buttonShoulderButtonsLeft, + ui->buttonMiscButtonsMinusScreenshot, + ui->bottomLeft, + ui->buttonShoulderButtonsRight, + ui->buttonMiscButtonsPlusHome, + ui->bottomRight, + ui->buttonMiscButtonsMinusGroup, + ui->buttonMiscButtonsScreenshotGroup, + }; + + for (auto* widget : layout_show) { + widget->show(); + } + + std::vector layout_hidden; + switch (layout) { + case Core::HID::NpadStyleIndex::Fullkey: + case Core::HID::NpadStyleIndex::Handheld: + layout_hidden = { + ui->buttonShoulderButtonsSLSRLeft, + ui->buttonShoulderButtonsSLSRRight, + ui->horizontalSpacerShoulderButtonsWidget2, + ui->horizontalSpacerShoulderButtonsWidget4, + }; + break; + case Core::HID::NpadStyleIndex::JoyconLeft: + layout_hidden = { + ui->buttonShoulderButtonsSLSRRight, + ui->horizontalSpacerShoulderButtonsWidget2, + ui->horizontalSpacerShoulderButtonsWidget3, + ui->buttonShoulderButtonsRight, + ui->buttonMiscButtonsPlusHome, + ui->bottomRight, + }; + break; + case Core::HID::NpadStyleIndex::JoyconRight: + layout_hidden = { + ui->buttonShoulderButtonsSLSRLeft, ui->horizontalSpacerShoulderButtonsWidget, + ui->horizontalSpacerShoulderButtonsWidget4, ui->buttonShoulderButtonsLeft, + ui->buttonMiscButtonsMinusScreenshot, ui->bottomLeft, + }; + break; + case Core::HID::NpadStyleIndex::GameCube: + layout_hidden = { + ui->buttonShoulderButtonsSLSRLeft, + ui->buttonShoulderButtonsSLSRRight, + ui->horizontalSpacerShoulderButtonsWidget2, + ui->horizontalSpacerShoulderButtonsWidget4, + ui->buttonMiscButtonsMinusGroup, + ui->buttonMiscButtonsScreenshotGroup, + }; + break; + default: + break; + } + + for (auto* widget : layout_hidden) { + widget->hide(); + } +} + +void ConfigureInputPlayer::UpdateControllerEnabledButtons() { + auto layout = GetControllerTypeFromIndex(ui->comboControllerType->currentIndex()); + if (debug) { + layout = Core::HID::NpadStyleIndex::Fullkey; + } + + // List of all the widgets that will be disabled by any of the following layouts that need + // "enabled" after the controller type changes + const std::array layout_enable = { + ui->buttonLStickPressedGroup, + ui->groupRStickPressed, + ui->buttonShoulderButtonsButtonLGroup, + }; + + for (auto* widget : layout_enable) { + widget->setEnabled(true); + } + + std::vector layout_disable; + switch (layout) { + case Core::HID::NpadStyleIndex::Fullkey: + case Core::HID::NpadStyleIndex::JoyconDual: + case Core::HID::NpadStyleIndex::Handheld: + case Core::HID::NpadStyleIndex::JoyconLeft: + case Core::HID::NpadStyleIndex::JoyconRight: + break; + case Core::HID::NpadStyleIndex::GameCube: + layout_disable = { + ui->buttonHome, + ui->buttonLStickPressedGroup, + ui->groupRStickPressed, + ui->buttonShoulderButtonsButtonLGroup, + }; + break; + default: + break; + } + + for (auto* widget : layout_disable) { + widget->setEnabled(false); + } +} + +void ConfigureInputPlayer::UpdateMotionButtons() { + if (debug) { + // Motion isn't used with the debug controller, hide both groupboxes. + ui->buttonMotionLeftGroup->hide(); + ui->buttonMotionRightGroup->hide(); + return; + } + + // Show/hide the "Motion 1/2" groupboxes depending on the currently selected controller. + switch (GetControllerTypeFromIndex(ui->comboControllerType->currentIndex())) { + case Core::HID::NpadStyleIndex::Fullkey: + case Core::HID::NpadStyleIndex::JoyconLeft: + case Core::HID::NpadStyleIndex::Handheld: + // Show "Motion 1" and hide "Motion 2". + ui->buttonMotionLeftGroup->show(); + ui->buttonMotionRightGroup->hide(); + break; + case Core::HID::NpadStyleIndex::JoyconRight: + // Show "Motion 2" and hide "Motion 1". + ui->buttonMotionLeftGroup->hide(); + ui->buttonMotionRightGroup->show(); + break; + case Core::HID::NpadStyleIndex::GameCube: + // Hide both "Motion 1/2". + ui->buttonMotionLeftGroup->hide(); + ui->buttonMotionRightGroup->hide(); + break; + case Core::HID::NpadStyleIndex::JoyconDual: + default: + // Show both "Motion 1/2". + ui->buttonMotionLeftGroup->show(); + ui->buttonMotionRightGroup->show(); + break; + } +} + +void ConfigureInputPlayer::UpdateControllerButtonNames() { + auto layout = GetControllerTypeFromIndex(ui->comboControllerType->currentIndex()); + if (debug) { + layout = Core::HID::NpadStyleIndex::Fullkey; + } + + switch (layout) { + case Core::HID::NpadStyleIndex::Fullkey: + case Core::HID::NpadStyleIndex::JoyconDual: + case Core::HID::NpadStyleIndex::Handheld: + case Core::HID::NpadStyleIndex::JoyconLeft: + case Core::HID::NpadStyleIndex::JoyconRight: + ui->buttonMiscButtonsPlusGroup->setTitle(tr("Plus")); + ui->buttonShoulderButtonsButtonZLGroup->setTitle(tr("ZL")); + ui->buttonShoulderButtonsZRGroup->setTitle(tr("ZR")); + ui->buttonShoulderButtonsRGroup->setTitle(tr("R")); + ui->LStick->setTitle(tr("Left Stick")); + ui->RStick->setTitle(tr("Right Stick")); + break; + case Core::HID::NpadStyleIndex::GameCube: + ui->buttonMiscButtonsPlusGroup->setTitle(tr("Start / Pause")); + ui->buttonShoulderButtonsButtonZLGroup->setTitle(tr("L")); + ui->buttonShoulderButtonsZRGroup->setTitle(tr("R")); + ui->buttonShoulderButtonsRGroup->setTitle(tr("Z")); + ui->LStick->setTitle(tr("Control Stick")); + ui->RStick->setTitle(tr("C-Stick")); + break; + default: + break; + } +} + +void ConfigureInputPlayer::UpdateMappingWithDefaults() { + if (ui->comboDevices->currentIndex() == 0) { + return; + } + + for (int button_id = 0; button_id < Settings::NativeButton::NumButtons; ++button_id) { + const auto* const button = button_map[button_id]; + if (button == nullptr) { + continue; + } + emulated_controller->SetButtonParam(button_id, {}); + } + + for (int analog_id = 0; analog_id < Settings::NativeAnalog::NumAnalogs; ++analog_id) { + for (int sub_button_id = 0; sub_button_id < ANALOG_SUB_BUTTONS_NUM; ++sub_button_id) { + const auto* const analog_button = analog_map_buttons[analog_id][sub_button_id]; + if (analog_button == nullptr) { + continue; + } + emulated_controller->SetStickParam(analog_id, {}); + } + } + + for (int motion_id = 0; motion_id < Settings::NativeMotion::NumMotions; ++motion_id) { + const auto* const motion_button = motion_map[motion_id]; + if (motion_button == nullptr) { + continue; + } + emulated_controller->SetMotionParam(motion_id, {}); + } + + // Reset keyboard or mouse bindings + if (ui->comboDevices->currentIndex() == 1 || ui->comboDevices->currentIndex() == 2) { + for (int button_id = 0; button_id < Settings::NativeButton::NumButtons; ++button_id) { + emulated_controller->SetButtonParam( + button_id, Common::ParamPackage{InputCommon::GenerateKeyboardParam( + QtConfig::default_buttons[button_id])}); + } + for (int analog_id = 0; analog_id < Settings::NativeAnalog::NumAnalogs; ++analog_id) { + Common::ParamPackage analog_param{}; + for (int sub_button_id = 0; sub_button_id < ANALOG_SUB_BUTTONS_NUM; ++sub_button_id) { + Common::ParamPackage params{InputCommon::GenerateKeyboardParam( + QtConfig::default_analogs[analog_id][sub_button_id])}; + SetAnalogParam(params, analog_param, analog_sub_buttons[sub_button_id]); + } + + analog_param.Set("modifier", InputCommon::GenerateKeyboardParam( + QtConfig::default_stick_mod[analog_id])); + emulated_controller->SetStickParam(analog_id, analog_param); + } + + for (int motion_id = 0; motion_id < Settings::NativeMotion::NumMotions; ++motion_id) { + emulated_controller->SetMotionParam( + motion_id, Common::ParamPackage{InputCommon::GenerateKeyboardParam( + QtConfig::default_motions[motion_id])}); + } + + // If mouse is selected we want to override with mappings from the driver + if (ui->comboDevices->currentIndex() == 1) { + UpdateUI(); + return; + } + } + + // Reset controller bindings + const auto& device = input_devices[ui->comboDevices->currentIndex()]; + auto button_mappings = input_subsystem->GetButtonMappingForDevice(device); + auto analog_mappings = input_subsystem->GetAnalogMappingForDevice(device); + auto motion_mappings = input_subsystem->GetMotionMappingForDevice(device); + + for (const auto& button_mapping : button_mappings) { + const std::size_t index = button_mapping.first; + emulated_controller->SetButtonParam(index, button_mapping.second); + } + for (const auto& analog_mapping : analog_mappings) { + const std::size_t index = analog_mapping.first; + emulated_controller->SetStickParam(index, analog_mapping.second); + } + for (const auto& motion_mapping : motion_mappings) { + const std::size_t index = motion_mapping.first; + emulated_controller->SetMotionParam(index, motion_mapping.second); + } + + UpdateUI(); +} + +void ConfigureInputPlayer::HandleClick( + QPushButton* button, std::size_t button_id, + std::function new_input_setter, + InputCommon::Polling::InputType type) { + if (timeout_timer->isActive()) { + return; + } + if (button == ui->buttonMotionLeft || button == ui->buttonMotionRight) { + button->setText(tr("Shake!")); + } else { + button->setText(tr("[waiting]")); + } + button->setFocus(); + + input_setter = std::move(new_input_setter); + + input_subsystem->BeginMapping(type); + + QWidget::grabMouse(); + QWidget::grabKeyboard(); + + if (type == InputCommon::Polling::InputType::Button) { + ui->controllerFrame->BeginMappingButton(button_id); + } else if (type == InputCommon::Polling::InputType::Stick) { + ui->controllerFrame->BeginMappingAnalog(button_id); + } + + timeout_timer->start(4000); // Cancel after 4 seconds + poll_timer->start(25); // Check for new inputs every 25ms +} + +void ConfigureInputPlayer::SetPollingResult(const Common::ParamPackage& params, bool abort) { + timeout_timer->stop(); + poll_timer->stop(); + input_subsystem->StopMapping(); + + QWidget::releaseMouse(); + QWidget::releaseKeyboard(); + + if (!abort) { + (*input_setter)(params); + } + + UpdateUI(); + UpdateInputDeviceCombobox(); + ui->controllerFrame->EndMapping(); + + input_setter = std::nullopt; +} + +bool ConfigureInputPlayer::IsInputAcceptable(const Common::ParamPackage& params) const { + if (ui->comboDevices->currentIndex() == 0) { + return true; + } + + if (params.Has("motion")) { + return true; + } + + // Keyboard/Mouse + if (ui->comboDevices->currentIndex() == 1 || ui->comboDevices->currentIndex() == 2) { + return params.Get("engine", "") == "keyboard" || params.Get("engine", "") == "mouse"; + } + + const auto& current_input_device = input_devices[ui->comboDevices->currentIndex()]; + return params.Get("engine", "") == current_input_device.Get("engine", "") && + (params.Get("guid", "") == current_input_device.Get("guid", "") || + params.Get("guid", "") == current_input_device.Get("guid2", "")) && + params.Get("port", 0) == current_input_device.Get("port", 0); +} + +void ConfigureInputPlayer::mousePressEvent(QMouseEvent* event) { + if (!input_setter || !event) { + return; + } + + const auto button = GRenderWindow::QtButtonToMouseButton(event->button()); + input_subsystem->GetMouse()->PressButton(0, 0, button); +} + +void ConfigureInputPlayer::wheelEvent(QWheelEvent* event) { + const int x = event->angleDelta().x(); + const int y = event->angleDelta().y(); + input_subsystem->GetMouse()->MouseWheelChange(x, y); +} + +void ConfigureInputPlayer::keyPressEvent(QKeyEvent* event) { + if (!input_setter || !event) { + return; + } + event->ignore(); + if (event->key() != Qt::Key_Escape) { + input_subsystem->GetKeyboard()->PressKey(event->key()); + } +} + +bool ConfigureInputPlayer::eventFilter(QObject* object, QEvent* event) { + if (object == ui->comboDevices && event->type() == QEvent::MouseButtonPress) { + RefreshInputDevices(); + } + return object->eventFilter(object, event); +} + +void ConfigureInputPlayer::CreateProfile() { + const auto profile_name = + LimitableInputDialog::GetText(this, tr("New Profile"), tr("Enter a profile name:"), 1, 30, + LimitableInputDialog::InputLimiter::Filesystem); + + if (profile_name.isEmpty()) { + return; + } + + if (!InputProfiles::IsProfileNameValid(profile_name.toStdString())) { + QMessageBox::critical(this, tr("Create Input Profile"), + tr("The given profile name is not valid!")); + return; + } + + ApplyConfiguration(); + + if (!profiles->CreateProfile(profile_name.toStdString(), player_index)) { + QMessageBox::critical(this, tr("Create Input Profile"), + tr("Failed to create the input profile \"%1\"").arg(profile_name)); + UpdateInputProfiles(); + emit RefreshInputProfiles(player_index); + return; + } + + emit RefreshInputProfiles(player_index); + + ui->comboProfiles->addItem(profile_name); + ui->comboProfiles->setCurrentIndex(ui->comboProfiles->count() - 1); +} + +void ConfigureInputPlayer::DeleteProfile() { + const QString profile_name = ui->comboProfiles->itemText(ui->comboProfiles->currentIndex()); + + if (profile_name.isEmpty()) { + return; + } + + if (!profiles->DeleteProfile(profile_name.toStdString())) { + QMessageBox::critical(this, tr("Delete Input Profile"), + tr("Failed to delete the input profile \"%1\"").arg(profile_name)); + UpdateInputProfiles(); + emit RefreshInputProfiles(player_index); + return; + } + + emit RefreshInputProfiles(player_index); + + ui->comboProfiles->removeItem(ui->comboProfiles->currentIndex()); + ui->comboProfiles->setCurrentIndex(-1); +} + +void ConfigureInputPlayer::LoadProfile() { + const QString profile_name = ui->comboProfiles->itemText(ui->comboProfiles->currentIndex()); + + if (profile_name.isEmpty()) { + return; + } + + ApplyConfiguration(); + + if (!profiles->LoadProfile(profile_name.toStdString(), player_index)) { + QMessageBox::critical(this, tr("Load Input Profile"), + tr("Failed to load the input profile \"%1\"").arg(profile_name)); + UpdateInputProfiles(); + emit RefreshInputProfiles(player_index); + return; + } + + LoadConfiguration(); +} + +void ConfigureInputPlayer::SaveProfile() { + static constexpr size_t HANDHELD_INDEX = 8; + const QString profile_name = ui->comboProfiles->itemText(ui->comboProfiles->currentIndex()); + + if (profile_name.isEmpty()) { + return; + } + + ApplyConfiguration(); + + // When we're in handheld mode, only the handheld emulated controller bindings are updated + const bool is_handheld = player_index == 0 && emulated_controller->GetNpadIdType() == + Core::HID::NpadIdType::Handheld; + const auto profile_player_index = is_handheld ? HANDHELD_INDEX : player_index; + + if (!profiles->SaveProfile(profile_name.toStdString(), profile_player_index)) { + QMessageBox::critical(this, tr("Save Input Profile"), + tr("Failed to save the input profile \"%1\"").arg(profile_name)); + UpdateInputProfiles(); + emit RefreshInputProfiles(player_index); + return; + } +} + +void ConfigureInputPlayer::UpdateInputProfiles() { + ui->comboProfiles->clear(); + + // Set current profile as empty by default + int profile_index = -1; + + // Add every available profile and search the player profile to set it as current one + auto& current_profile = Settings::values.players.GetValue()[player_index].profile_name; + std::vector profile_names = profiles->GetInputProfileNames(); + std::string profile_name; + for (size_t i = 0; i < profile_names.size(); i++) { + profile_name = profile_names[i]; + ui->comboProfiles->addItem(QString::fromStdString(profile_name)); + if (current_profile == profile_name) { + profile_index = (int)i; + } + } + + LOG_DEBUG(Frontend, "Setting the current input profile to index {}", profile_index); + ui->comboProfiles->setCurrentIndex(profile_index); +} diff --git a/src/sudachi/configuration/configure_input_player.h b/src/sudachi/configuration/configure_input_player.h new file mode 100644 index 0000000..fda09e9 --- /dev/null +++ b/src/sudachi/configuration/configure_input_player.h @@ -0,0 +1,228 @@ +// SPDX-FileCopyrightText: 2016 Citra Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include +#include +#include +#include +#include +#include + +#include + +#include "common/param_package.h" +#include "common/settings.h" +#include "ui_configure_input.h" + +class QCheckBox; +class QKeyEvent; +class QLabel; +class QPushButton; +class QSlider; +class QSpinBox; +class QString; +class QTimer; +class QWidget; + +class InputProfiles; + +namespace InputCommon { +class InputSubsystem; +} + +namespace InputCommon::Polling { +enum class InputType; +} // namespace InputCommon::Polling + +namespace Ui { +class ConfigureInputPlayer; +} // namespace Ui + +namespace Core::HID { +class HIDCore; +class EmulatedController; +enum class NpadStyleIndex : u8; +} // namespace Core::HID + +class ConfigureInputPlayer : public QWidget { + Q_OBJECT + +public: + explicit ConfigureInputPlayer(QWidget* parent, std::size_t player_index, QWidget* bottom_row, + InputCommon::InputSubsystem* input_subsystem_, + InputProfiles* profiles_, Core::HID::HIDCore& hid_core_, + bool is_powered_on_, bool debug = false); + ~ConfigureInputPlayer() override; + + /// Save all button configurations to settings file. + void ApplyConfiguration(); + + /// Set the connection state checkbox (used to sync state). + void ConnectPlayer(bool connected); + + /// Update the input devices combobox. + void UpdateInputDeviceCombobox(); + + /// Updates the list of controller profiles. + void UpdateInputProfiles(); + + /// Restore all buttons to their default values. + void RestoreDefaults(); + + /// Clear all input configuration. + void ClearAll(); + +signals: + /// Emitted when this controller is (dis)connected by the user. + void Connected(bool connected); + /// Emitted when the Handheld mode is selected (undocked with dual joycons attached). + void HandheldStateChanged(bool is_handheld); + /// Emitted when the input devices combobox is being refreshed. + void RefreshInputDevices(); + /** + * Emitted when the input profiles combobox is being refreshed. + * The player_index represents the current player's index, and the profile combobox + * will not be updated for this index as they are already updated by other mechanisms. + */ + void RefreshInputProfiles(std::size_t player_index); + +protected: + void showEvent(QShowEvent* event) override; + +private: + QString ButtonToText(const Common::ParamPackage& param); + + QString AnalogToText(const Common::ParamPackage& param, const std::string& dir); + + void changeEvent(QEvent* event) override; + void RetranslateUI(); + + /// Load configuration settings. + void LoadConfiguration(); + + /// Called when the button was pressed. + void HandleClick(QPushButton* button, std::size_t button_id, + std::function new_input_setter, + InputCommon::Polling::InputType type); + + /// Finish polling and configure input using the input_setter. + void SetPollingResult(const Common::ParamPackage& params, bool abort); + + /// Checks whether a given input can be accepted. + bool IsInputAcceptable(const Common::ParamPackage& params) const; + + /// Handle mouse button press events. + void mousePressEvent(QMouseEvent* event) override; + + /// Handle mouse wheel move events. + void wheelEvent(QWheelEvent* event) override; + + /// Handle key press events. + void keyPressEvent(QKeyEvent* event) override; + + /// Handle combobox list refresh + bool eventFilter(QObject* object, QEvent* event) override; + + /// Update UI to reflect current configuration. + void UpdateUI(); + + /// Sets the available controllers. + void SetConnectableControllers(); + + /// Gets the Controller Type for a given controller combobox index. + Core::HID::NpadStyleIndex GetControllerTypeFromIndex(int index) const; + + /// Gets the controller combobox index for a given Controller Type. + int GetIndexFromControllerType(Core::HID::NpadStyleIndex type) const; + + /// Update the available input devices. + void UpdateInputDevices(); + + /// Hides and disables controller settings based on the current controller type. + void UpdateControllerAvailableButtons(); + + /// Disables controller settings based on the current controller type. + void UpdateControllerEnabledButtons(); + + /// Shows or hides motion groupboxes based on the current controller type. + void UpdateMotionButtons(); + + /// Alters the button names based on the current controller type. + void UpdateControllerButtonNames(); + + /// Gets the default controller mapping for this device and auto configures the input to match. + void UpdateMappingWithDefaults(); + + /// Creates a controller profile. + void CreateProfile(); + + /// Deletes the selected controller profile. + void DeleteProfile(); + + /// Loads the selected controller profile. + void LoadProfile(); + + /// Saves the current controller configuration into a selected controller profile. + void SaveProfile(); + + std::unique_ptr ui; + + std::size_t player_index; + bool debug; + bool is_powered_on; + + InputCommon::InputSubsystem* input_subsystem; + + InputProfiles* profiles; + + std::unique_ptr timeout_timer; + std::unique_ptr poll_timer; + + /// Stores a pair of "Connected Controllers" combobox index and Controller Type enum. + std::vector> index_controller_type_pairs; + + /// This will be the the setting function when an input is awaiting configuration. + std::optional> input_setter; + + Core::HID::EmulatedController* emulated_controller; + + static constexpr int ANALOG_SUB_BUTTONS_NUM = 4; + + /// Each button input is represented by a QPushButton. + std::array button_map; + + /// A group of four QPushButtons represent one analog input. The buttons each represent up, + /// down, left, right, respectively. + std::array, Settings::NativeAnalog::NumAnalogs> + analog_map_buttons; + + /// Each motion input is represented by a QPushButton. + std::array motion_map; + + std::array analog_map_deadzone_label; + std::array analog_map_deadzone_slider; + std::array analog_map_modifier_groupbox; + std::array analog_map_modifier_button; + std::array analog_map_modifier_label; + std::array analog_map_modifier_slider; + std::array analog_map_range_groupbox; + std::array analog_map_range_spinbox; + + static const std::array analog_sub_buttons; + + /// A flag to indicate that the "Map Analog Stick" pop-up has been shown and accepted once. + bool map_analog_stick_accepted{}; + + /// List of physical devices users can map with. If a SDL backed device is selected, then you + /// can use this device to get a default mapping. + std::vector input_devices; + + /// Bottom row is where console wide settings are held, and its "owned" by the parent + /// ConfigureInput widget. On show, add this widget to the main layout. This will change the + /// parent of the widget to this widget (but that's fine). + QWidget* bottom_row; + + Core::HID::HIDCore& hid_core; +}; diff --git a/src/sudachi/configuration/configure_input_player.ui b/src/sudachi/configuration/configure_input_player.ui new file mode 100644 index 0000000..1eec462 --- /dev/null +++ b/src/sudachi/configuration/configure_input_player.ui @@ -0,0 +1,3323 @@ + + + ConfigureInputPlayer + + + + 0 + 0 + 780 + 487 + + + + Configure Input + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + 3 + + + 0 + + + + + Qt::LeftToRight + + + Connect Controller + + + false + + + true + + + + 5 + + + 5 + + + 5 + + + 5 + + + + + + 0 + 21 + + + + + + + + + + + Input Device + + + + 3 + + + 5 + + + 5 + + + 5 + + + 5 + + + + + 60 + + + + + + + + + + + 0 + 0 + + + + Profile + + + + 3 + + + 5 + + + 5 + + + 5 + + + 5 + + + + + + 0 + 21 + + + + + + + + + 68 + 16777215 + + + + min-width: 68px; + + + Save + + + + + + + + 68 + 16777215 + + + + min-width: 68px; + + + New + + + + + + + + 68 + 16777215 + + + + min-width: 68px; + + + Delete + + + + + + + + + + + + + 0 + 0 + + + + + QLayout::SetMinimumSize + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + 0 + + + QLayout::SetDefaultConstraint + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + 0 + 0 + + + + Left Stick + + + Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop + + + + 0 + + + QLayout::SetDefaultConstraint + + + 3 + + + 6 + + + 3 + + + 0 + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Qt::Horizontal + + + + 20 + 20 + + + + + + + + Up + + + Qt::AlignCenter + + + false + + + + 3 + + + 3 + + + 3 + + + 3 + + + 3 + + + + + + 68 + 0 + + + + + 68 + 16777215 + + + + min-width: 68px; + + + Up + + + + + + + + + + Qt::Horizontal + + + + 20 + 20 + + + + + + + + + + + 3 + + + + + Left + + + Qt::AlignCenter + + + + 3 + + + 3 + + + 3 + + + 3 + + + 3 + + + + + + 68 + 0 + + + + + 68 + 16777215 + + + + min-width: 68px; + + + Left + + + + + + + + + + Right + + + Qt::AlignCenter + + + + 3 + + + 3 + + + 3 + + + 3 + + + 3 + + + + + + 68 + 0 + + + + + 68 + 16777215 + + + + min-width: 68px; + + + Right + + + + + + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Qt::Horizontal + + + + 20 + 20 + + + + + + + + Down + + + Qt::AlignCenter + + + + 3 + + + 3 + + + 3 + + + 3 + + + 3 + + + + + + 68 + 0 + + + + + 68 + 16777215 + + + + min-width: 68px; + + + Down + + + + + + + + + + Qt::Horizontal + + + + 20 + 20 + + + + + + + + + + + 3 + + + + + Pressed + + + Qt::AlignCenter + + + + 3 + + + 3 + + + 3 + + + 3 + + + 3 + + + + + + 68 + 0 + + + + + 68 + 16777215 + + + + min-width: 68px; + + + Pressed + + + + + + + + + + Modifier + + + Qt::AlignCenter + + + + 3 + + + 3 + + + 3 + + + 3 + + + 3 + + + + + + 68 + 0 + + + + + 68 + 16777215 + + + + min-width: 68px; + + + Modifier + + + + + + + + + + Range + + + + 3 + + + 3 + + + 3 + + + 3 + + + 3 + + + + + + 68 + 21 + + + + + 68 + 16777215 + + + + % + + + 25 + + + 150 + + + 95 + + + + + + + + + + + + 3 + + + QLayout::SetDefaultConstraint + + + 0 + + + 2 + + + 0 + + + 3 + + + + + + + Deadzone: 0% + + + Qt::AlignHCenter + + + + + + + + + 100 + + + Qt::Horizontal + + + + + + + + + Modifier Range: 0% + + + Qt::AlignHCenter + + + + + + + + + 100 + + + Qt::Horizontal + + + + + + + + + + + + Qt::Vertical + + + + 20 + 0 + + + + + + + + + 0 + 0 + + + + D-Pad + + + false + + + false + + + + 0 + + + 3 + + + 6 + + + 3 + + + 3 + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Qt::Horizontal + + + + 20 + 20 + + + + + + + + Up + + + Qt::AlignCenter + + + + 3 + + + 3 + + + 3 + + + 3 + + + 3 + + + + + + 68 + 0 + + + + + 68 + 16777215 + + + + min-width: 68px; + + + Up + + + + + + + + + + Qt::Horizontal + + + + 20 + 20 + + + + + + + + + + + 3 + + + + + Left + + + Qt::AlignCenter + + + + 3 + + + 3 + + + 3 + + + 3 + + + 3 + + + + + + 68 + 0 + + + + + 68 + 16777215 + + + + min-width: 68px; + + + Left + + + + + + + + + + Right + + + Qt::AlignCenter + + + + 3 + + + 3 + + + 3 + + + 3 + + + 3 + + + + + + 68 + 0 + + + + + 68 + 16777215 + + + + min-width: 68px; + + + Right + + + + + + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Qt::Horizontal + + + + 20 + 20 + + + + + + + + Down + + + Qt::AlignCenter + + + + 3 + + + 3 + + + 3 + + + 3 + + + 3 + + + + + + 68 + 0 + + + + + 68 + 16777215 + + + + min-width: 68px; + + + Down + + + + + + + + + + Qt::Horizontal + + + + 20 + 20 + + + + + + + + + + + + + + Qt::Vertical + + + + 20 + 0 + + + + + + + + + + + + 6 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + 3 + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + SL + + + Qt::AlignCenter + + + + 3 + + + 3 + + + 3 + + + 3 + + + 3 + + + + + + 68 + 0 + + + + + 68 + 16777215 + + + + min-width: 68px; + + + SL + + + + + + + + + + SR + + + Qt::AlignCenter + + + + 3 + + + 3 + + + 3 + + + 3 + + + 3 + + + + + + 68 + 0 + + + + + 68 + 16777215 + + + + min-width: 68px; + + + SR + + + + + + + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Qt::Horizontal + + + + 0 + 20 + + + + + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + L + + + Qt::AlignCenter + + + + 3 + + + 3 + + + 3 + + + 3 + + + 3 + + + + + + 68 + 0 + + + + + 68 + 16777215 + + + + min-width: 68px; + + + L + + + + + + + + + + + 0 + 0 + + + + ZL + + + Qt::AlignCenter + + + + 3 + + + 3 + + + 3 + + + 3 + + + 3 + + + + + + 68 + 0 + + + + + 68 + 16777215 + + + + min-width: 68px; + + + ZL + + + + + + + + 70 + 15 + + + + 100 + + + Qt::Horizontal + + + + + + + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Qt::Horizontal + + + + 0 + 20 + + + + + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Minus + + + Qt::AlignCenter + + + + 3 + + + 3 + + + 3 + + + 3 + + + 3 + + + + + + 68 + 0 + + + + + 68 + 16777215 + + + + min-width: 68px; + + + Minus + + + + + + + + + + Capture + + + Qt::AlignCenter + + + + 3 + + + 3 + + + 3 + + + 3 + + + 3 + + + + + + 68 + 0 + + + + + 68 + 16777215 + + + + min-width: 68px; + + + Capture + + + + + + + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Plus + + + Qt::AlignCenter + + + + 3 + + + 3 + + + 3 + + + 3 + + + 3 + + + + + + 68 + 0 + + + + + 68 + 16777215 + + + + min-width: 68px; + + + Plus + + + + + + + + + + Home + + + Qt::AlignCenter + + + + 3 + + + 3 + + + 3 + + + 3 + + + 3 + + + + + + 68 + 0 + + + + + 68 + 16777215 + + + + min-width: 68px; + + + Home + + + + + + + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Qt::Horizontal + + + + 0 + 20 + + + + + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + R + + + Qt::AlignCenter + + + + 3 + + + 3 + + + 3 + + + 3 + + + 3 + + + + + + 68 + 0 + + + + + 68 + 16777215 + + + + min-width: 68px; + + + R + + + + + + + + + + + 0 + 0 + + + + ZR + + + Qt::AlignCenter + + + + 3 + + + 3 + + + 3 + + + 3 + + + 3 + + + + + + 68 + 0 + + + + + 68 + 16777215 + + + + min-width: 68px; + + + ZR + + + + + + + + 70 + 15 + + + + 100 + + + Qt::Horizontal + + + + + + + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Qt::Horizontal + + + + 0 + 20 + + + + + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + SL + + + Qt::AlignCenter + + + + 3 + + + 3 + + + 3 + + + 3 + + + 3 + + + + + + 68 + 0 + + + + + 68 + 16777215 + + + + min-width: 68px; + + + SL + + + + + + + + + + SR + + + Qt::AlignCenter + + + + 3 + + + 3 + + + 3 + + + 3 + + + 3 + + + + + + 68 + 0 + + + + + 68 + 16777215 + + + + min-width: 68px; + + + SR + + + + + + + + + + + + + + + + 0 + 0 + + + + + 75 + true + + + + image: url(:/controller/pro); + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + + + 3 + + + 0 + + + + + Qt::Horizontal + + + + 20 + 20 + + + + + + + + Motion 1 + + + Qt::AlignCenter + + + + 3 + + + 3 + + + 3 + + + 3 + + + 3 + + + + + + 68 + 0 + + + + + 68 + 16777215 + + + + min-width: 68px; + + + Left + + + + + + + + + + Motion 2 + + + Qt::AlignCenter + + + + 3 + + + 3 + + + 3 + + + 3 + + + 3 + + + + + + 68 + 0 + + + + + 68 + 16777215 + + + + min-width: 68px; + + + Right + + + + + + + + + + Qt::Horizontal + + + + 20 + 20 + + + + + + + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + 0 + 0 + + + + Face Buttons + + + false + + + false + + + + 0 + + + 3 + + + 6 + + + 3 + + + 3 + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Qt::Horizontal + + + + 20 + 20 + + + + + + + + X + + + Qt::AlignCenter + + + + 3 + + + 3 + + + 3 + + + 3 + + + 3 + + + + + + 68 + 0 + + + + + 68 + 16777215 + + + + min-width: 68px; + + + X + + + + + + + + + + Qt::Horizontal + + + + 20 + 20 + + + + + + + + + + + 3 + + + + + Y + + + Qt::AlignCenter + + + + 3 + + + 3 + + + 3 + + + 3 + + + 3 + + + + + + 68 + 0 + + + + + 68 + 16777215 + + + + min-width: 68px; + + + Y + + + + + + + + + + A + + + Qt::AlignCenter + + + + 3 + + + 3 + + + 3 + + + 3 + + + 3 + + + + + + 68 + 0 + + + + + 68 + 16777215 + + + + min-width: 68px; + + + A + + + + + + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Qt::Horizontal + + + + 20 + 20 + + + + + + + + B + + + Qt::AlignCenter + + + + 3 + + + 3 + + + 3 + + + 3 + + + 3 + + + + + + 68 + 0 + + + + + 68 + 16777215 + + + + min-width: 68px; + + + B + + + + + + + + + + Qt::Horizontal + + + + 20 + 20 + + + + + + + + + + + + + + Qt::Vertical + + + + 20 + 0 + + + + + + + + + 0 + 0 + + + + Right Stick + + + Qt::AlignLeading|Qt::AlignLeft|Qt::AlignVCenter + + + false + + + false + + + + 0 + + + 3 + + + 6 + + + 3 + + + 0 + + + + + + 0 + 0 + + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Qt::Horizontal + + + + 20 + 20 + + + + + + + + Up + + + Qt::AlignCenter + + + + 3 + + + 3 + + + 3 + + + 3 + + + 3 + + + + + + 68 + 0 + + + + + 68 + 16777215 + + + + min-width: 68px; + + + Up + + + + + + + + + + Qt::Horizontal + + + + 20 + 20 + + + + + + + + + + + 3 + + + + + Left + + + Qt::AlignCenter + + + + 3 + + + 3 + + + 3 + + + 3 + + + 3 + + + + + + 68 + 0 + + + + + 68 + 16777215 + + + + min-width: 68px; + + + Left + + + + + + + + + + Right + + + Qt::AlignCenter + + + + 3 + + + 3 + + + 3 + + + 3 + + + 3 + + + + + + 68 + 0 + + + + + 68 + 16777215 + + + + min-width: 68px; + + + Right + + + + + + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Qt::Horizontal + + + + 20 + 20 + + + + + + + + Down + + + Qt::AlignCenter + + + + 3 + + + 3 + + + 3 + + + 3 + + + 3 + + + + + + 68 + 0 + + + + + 68 + 16777215 + + + + min-width: 68px; + + + Down + + + + + + + + + + Qt::Horizontal + + + + 20 + 20 + + + + + + + + + + + 3 + + + + + Pressed + + + Qt::AlignCenter + + + + 3 + + + 3 + + + 3 + + + 3 + + + 3 + + + + + + 68 + 0 + + + + + 68 + 16777215 + + + + min-width: 68px; + + + Pressed + + + + + + + + + + Modifier + + + Qt::AlignCenter + + + + 3 + + + 3 + + + 3 + + + 3 + + + 3 + + + + + + 68 + 0 + + + + + 68 + 16777215 + + + + min-width: 68px; + + + Modifier + + + + + + + + + + Range + + + + 3 + + + 3 + + + 3 + + + 3 + + + 3 + + + + + + 68 + 21 + + + + + 68 + 16777215 + + + + % + + + 25 + + + 150 + + + 95 + + + + + + + + + + + + 3 + + + 0 + + + 2 + + + 0 + + + 3 + + + + + + + Deadzone: 0% + + + Qt::AlignHCenter + + + + + + + + + 100 + + + Qt::Horizontal + + + + + + + + + Modifier Range: 0% + + + Qt::AlignHCenter + + + + + + + + + 100 + + + Qt::Horizontal + + + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + 3 + + + + + Qt::Horizontal + + + + 20 + 20 + + + + + + + + Mouse panning + + + Qt::AlignCenter + + + + 3 + + + 3 + + + 3 + + + 3 + + + 3 + + + + + Configure + + + + + + + + + + Qt::Horizontal + + + + 20 + 20 + + + + + + + + + + + + + + Qt::Vertical + + + + 20 + 0 + + + + + + + + + + + + + + + + + PlayerControlPreview + QFrame +
sudachi/configuration/configure_input_player_widget.h
+ 1 +
+
+ + + + +
diff --git a/src/sudachi/configuration/configure_input_player_widget.cpp b/src/sudachi/configuration/configure_input_player_widget.cpp new file mode 100644 index 0000000..dca86d9 --- /dev/null +++ b/src/sudachi/configuration/configure_input_player_widget.cpp @@ -0,0 +1,3007 @@ +// SPDX-FileCopyrightText: Copyright 2020 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include +#include +#include +#include + +#include "hid_core/frontend/emulated_controller.h" +#include "sudachi/configuration/configure_input_player_widget.h" + +PlayerControlPreview::PlayerControlPreview(QWidget* parent) : QFrame(parent) { + is_controller_set = false; + QTimer* timer = new QTimer(this); + connect(timer, &QTimer::timeout, this, QOverload<>::of(&PlayerControlPreview::UpdateInput)); + + // refresh at 60hz + timer->start(16); +} + +PlayerControlPreview::~PlayerControlPreview() { + UnloadController(); +}; + +void PlayerControlPreview::SetController(Core::HID::EmulatedController* controller_) { + UnloadController(); + is_controller_set = true; + controller = controller_; + Core::HID::ControllerUpdateCallback engine_callback{ + .on_change = [this](Core::HID::ControllerTriggerType type) { ControllerUpdate(type); }, + .is_npad_service = false, + }; + callback_key = controller->SetCallback(engine_callback); + ControllerUpdate(Core::HID::ControllerTriggerType::All); +} + +void PlayerControlPreview::UnloadController() { + if (is_controller_set) { + controller->DeleteCallback(callback_key); + is_controller_set = false; + } +} + +void PlayerControlPreview::BeginMappingButton(std::size_t button_id) { + button_mapping_index = button_id; + mapping_active = true; +} + +void PlayerControlPreview::BeginMappingAnalog(std::size_t stick_id) { + button_mapping_index = Settings::NativeButton::LStick + stick_id; + analog_mapping_index = stick_id; + mapping_active = true; +} + +void PlayerControlPreview::EndMapping() { + button_mapping_index = Settings::NativeButton::BUTTON_NS_END; + analog_mapping_index = Settings::NativeAnalog::NumAnalogs; + mapping_active = false; + blink_counter = 0; + ResetInputs(); +} + +void PlayerControlPreview::UpdateColors() { + if (QIcon::themeName().contains(QStringLiteral("dark")) || + QIcon::themeName().contains(QStringLiteral("midnight"))) { + colors.primary = QColor(204, 204, 204); + colors.button = QColor(35, 38, 41); + colors.button2 = QColor(26, 27, 30); + colors.slider_arrow = QColor(14, 15, 18); + colors.font2 = QColor(255, 255, 255); + colors.indicator = QColor(170, 238, 255); + colors.deadzone = QColor(204, 136, 136); + colors.slider_button = colors.button; + } + + if (QIcon::themeName().contains(QStringLiteral("dark"))) { + colors.outline = QColor(160, 160, 160); + } else if (QIcon::themeName().contains(QStringLiteral("midnight"))) { + colors.outline = QColor(145, 145, 145); + } else { + colors.outline = QColor(0, 0, 0); + colors.primary = QColor(225, 225, 225); + colors.button = QColor(109, 111, 114); + colors.button2 = QColor(77, 80, 84); + colors.slider_arrow = QColor(65, 68, 73); + colors.font2 = QColor(0, 0, 0); + colors.indicator = QColor(0, 0, 200); + colors.deadzone = QColor(170, 0, 0); + colors.slider_button = QColor(153, 149, 149); + } + + // Constant colors + colors.highlight = QColor(170, 0, 0); + colors.highlight2 = QColor(119, 0, 0); + colors.slider = QColor(103, 106, 110); + colors.transparent = QColor(0, 0, 0, 0); + colors.font = QColor(255, 255, 255); + colors.led_on = QColor(255, 255, 0); + colors.led_off = QColor(170, 238, 255); + colors.indicator2 = QColor(59, 165, 93); + colors.charging = QColor(250, 168, 26); + colors.button_turbo = QColor(217, 158, 4); + + colors.left = colors.primary; + colors.right = colors.primary; + + const auto color_left = controller->GetColorsValues()[0].body; + const auto color_right = controller->GetColorsValues()[1].body; + if (color_left != 0 && color_right != 0) { + colors.left = QColor(color_left); + colors.right = QColor(color_right); + } +} + +void PlayerControlPreview::ResetInputs() { + button_values.fill({ + .value = false, + }); + stick_values.fill({ + .x = {.value = 0, .properties = {0, 1, 0}}, + .y = {.value = 0, .properties = {0, 1, 0}}, + }); + trigger_values.fill({ + .analog = {.value = 0, .properties = {0, 1, 0}}, + .pressed = {.value = false}, + }); + update(); +} + +void PlayerControlPreview::ControllerUpdate(Core::HID::ControllerTriggerType type) { + if (type == Core::HID::ControllerTriggerType::All) { + ControllerUpdate(Core::HID::ControllerTriggerType::Color); + ControllerUpdate(Core::HID::ControllerTriggerType::Type); + ControllerUpdate(Core::HID::ControllerTriggerType::Connected); + ControllerUpdate(Core::HID::ControllerTriggerType::Button); + ControllerUpdate(Core::HID::ControllerTriggerType::Stick); + ControllerUpdate(Core::HID::ControllerTriggerType::Trigger); + ControllerUpdate(Core::HID::ControllerTriggerType::Battery); + return; + } + + switch (type) { + case Core::HID::ControllerTriggerType::Connected: + is_connected = true; + led_pattern = controller->GetLedPattern(); + needs_redraw = true; + break; + case Core::HID::ControllerTriggerType::Disconnected: + is_connected = false; + led_pattern.raw = 0; + needs_redraw = true; + break; + case Core::HID::ControllerTriggerType::Type: + controller_type = controller->GetNpadStyleIndex(true); + needs_redraw = true; + break; + case Core::HID::ControllerTriggerType::Color: + UpdateColors(); + needs_redraw = true; + break; + case Core::HID::ControllerTriggerType::Button: + button_values = controller->GetButtonsValues(); + needs_redraw = true; + break; + case Core::HID::ControllerTriggerType::Stick: + using namespace Settings::NativeAnalog; + stick_values = controller->GetSticksValues(); + // Y axis is inverted + stick_values[LStick].y.value = -stick_values[LStick].y.value; + stick_values[LStick].y.raw_value = -stick_values[LStick].y.raw_value; + stick_values[RStick].y.value = -stick_values[RStick].y.value; + stick_values[RStick].y.raw_value = -stick_values[RStick].y.raw_value; + needs_redraw = true; + break; + case Core::HID::ControllerTriggerType::Trigger: + trigger_values = controller->GetTriggersValues(); + needs_redraw = true; + break; + case Core::HID::ControllerTriggerType::Battery: + battery_values = controller->GetBatteryValues(); + needs_redraw = true; + break; + case Core::HID::ControllerTriggerType::Motion: + motion_values = controller->GetMotions(); + needs_redraw = true; + break; + default: + break; + } +} + +void PlayerControlPreview::UpdateInput() { + if (mapping_active) { + + for (std::size_t index = 0; index < button_values.size(); ++index) { + bool blink = index == button_mapping_index; + if (analog_mapping_index == Settings::NativeAnalog::NumAnalogs) { + blink &= blink_counter > 25; + } + if (button_values[index].value != blink) { + needs_redraw = true; + } + button_values[index].value = blink; + } + + for (std::size_t index = 0; index < stick_values.size(); ++index) { + const bool blink_analog = index == analog_mapping_index; + if (blink_analog) { + needs_redraw = true; + stick_values[index].x.value = blink_counter < 25 ? -blink_counter / 25.0f : 0; + stick_values[index].y.value = + blink_counter > 25 ? -(blink_counter - 25) / 25.0f : 0; + } + } + } + if (needs_redraw) { + update(); + } + + if (mapping_active) { + blink_counter = (blink_counter + 1) % 50; + } +} + +void PlayerControlPreview::paintEvent(QPaintEvent* event) { + QFrame::paintEvent(event); + QPainter p(this); + p.setRenderHint(QPainter::Antialiasing); + const QPointF center = rect().center(); + + switch (controller_type) { + case Core::HID::NpadStyleIndex::Handheld: + DrawHandheldController(p, center); + break; + case Core::HID::NpadStyleIndex::JoyconDual: + DrawDualController(p, center); + break; + case Core::HID::NpadStyleIndex::JoyconLeft: + DrawLeftController(p, center); + break; + case Core::HID::NpadStyleIndex::JoyconRight: + DrawRightController(p, center); + break; + case Core::HID::NpadStyleIndex::GameCube: + DrawGCController(p, center); + break; + case Core::HID::NpadStyleIndex::Fullkey: + default: + DrawProController(p, center); + break; + } +} + +void PlayerControlPreview::DrawLeftController(QPainter& p, const QPointF center) { + { + using namespace Settings::NativeButton; + + // Sideview left joystick + DrawJoystickSideview(p, center + QPoint(142, -69), + -stick_values[Settings::NativeAnalog::LStick].y.value, 1.15f, + button_values[LStick]); + + // Topview D-pad buttons + p.setPen(colors.outline); + button_color = colors.button; + DrawRoundButton(p, center + QPoint(-163, -21), button_values[DLeft], 11, 5, Direction::Up); + DrawRoundButton(p, center + QPoint(-117, -21), button_values[DRight], 11, 5, Direction::Up); + + // Topview left joystick + DrawJoystickSideview(p, center + QPointF(-140.5f, -28), + -stick_values[Settings::NativeAnalog::LStick].x.value + 15.0f, 1.15f, + button_values[LStick]); + + // Topview minus button + p.setPen(colors.outline); + button_color = colors.button; + DrawRoundButton(p, center + QPoint(-111, -22), button_values[Minus], 8, 4, Direction::Up, + 1); + + // Left trigger + DrawLeftTriggers(p, center, button_values[L]); + DrawRoundButton(p, center + QPoint(151, -146), button_values[L], 8, 4, Direction::Down); + DrawLeftZTriggers(p, center, button_values[ZL]); + + // Sideview D-pad buttons + DrawRoundButton(p, center + QPoint(135, 14), button_values[DLeft], 5, 11, Direction::Right); + DrawRoundButton(p, center + QPoint(135, 36), button_values[DDown], 5, 11, Direction::Right); + DrawRoundButton(p, center + QPoint(135, -10), button_values[DUp], 5, 11, Direction::Right); + DrawRoundButton(p, center + QPoint(135, 14), button_values[DRight], 5, 11, + Direction::Right); + DrawRoundButton(p, center + QPoint(135, 71), button_values[Screenshot], 3, 8, + Direction::Right, 1); + + // Sideview minus button + DrawRoundButton(p, center + QPoint(135, -118), button_values[Minus], 4, 2.66f, + Direction::Right, 1); + + // Sideview SL and SR buttons + button_color = colors.slider_button; + DrawRoundButton(p, center + QPoint(59, 52), button_values[SRLeft], 5, 12, Direction::Left); + DrawRoundButton(p, center + QPoint(59, -69), button_values[SLLeft], 5, 12, Direction::Left); + + DrawLeftBody(p, center); + + // Left trigger top view + DrawLeftTriggersTopView(p, center, button_values[L]); + DrawLeftZTriggersTopView(p, center, button_values[ZL]); + } + + { + // Draw joysticks + using namespace Settings::NativeAnalog; + DrawJoystick(p, + center + QPointF(9, -69) + + (QPointF(stick_values[LStick].x.value, stick_values[LStick].y.value) * 8), + 1.8f, button_values[Settings::NativeButton::LStick]); + DrawRawJoystick(p, center + QPointF(-140, 90), QPointF(0, 0)); + } + + { + // Draw motion cubes + using namespace Settings::NativeMotion; + p.setPen(colors.outline); + p.setBrush(colors.transparent); + Draw3dCube(p, center + QPointF(-140, 90), + motion_values[Settings::NativeMotion::MotionLeft].euler, 20.0f); + } + + using namespace Settings::NativeButton; + + // D-pad constants + const QPointF dpad_center = center + QPoint(9, 14); + constexpr int dpad_distance = 23; + constexpr int dpad_radius = 11; + constexpr float dpad_arrow_size = 1.2f; + + // D-pad buttons + p.setPen(colors.outline); + button_color = colors.button; + DrawCircleButton(p, dpad_center + QPoint(dpad_distance, 0), button_values[DRight], dpad_radius); + DrawCircleButton(p, dpad_center + QPoint(0, dpad_distance), button_values[DDown], dpad_radius); + DrawCircleButton(p, dpad_center + QPoint(0, -dpad_distance), button_values[DUp], dpad_radius); + DrawCircleButton(p, dpad_center + QPoint(-dpad_distance, 0), button_values[DLeft], dpad_radius); + + // D-pad arrows + p.setPen(colors.font2); + p.setBrush(colors.font2); + DrawArrow(p, dpad_center + QPoint(dpad_distance, 0), Direction::Right, dpad_arrow_size); + DrawArrow(p, dpad_center + QPoint(0, dpad_distance), Direction::Down, dpad_arrow_size); + DrawArrow(p, dpad_center + QPoint(0, -dpad_distance), Direction::Up, dpad_arrow_size); + DrawArrow(p, dpad_center + QPoint(-dpad_distance, 0), Direction::Left, dpad_arrow_size); + + // SR and SL buttons + p.setPen(colors.outline); + button_color = colors.slider_button; + DrawRoundButton(p, center + QPoint(155, 52), button_values[SRLeft], 5.2f, 12, Direction::None, + 4); + DrawRoundButton(p, center + QPoint(155, -69), button_values[SLLeft], 5.2f, 12, Direction::None, + 4); + + // SR and SL text + p.setPen(colors.transparent); + p.setBrush(colors.font2); + DrawSymbol(p, center + QPointF(155, 52), Symbol::SR, 1.0f); + DrawSymbol(p, center + QPointF(155, -69), Symbol::SL, 1.0f); + + // Minus button + button_color = colors.button; + DrawMinusButton(p, center + QPoint(39, -118), button_values[Minus], 16); + + // Screenshot button + DrawRoundButton(p, center + QPoint(26, 71), button_values[Screenshot], 8, 8); + p.setPen(colors.font2); + p.setBrush(colors.font2); + DrawCircle(p, center + QPoint(26, 71), 5); + + // Draw battery + DrawBattery(p, center + QPoint(-160, -140), + battery_values[Core::HID::EmulatedDeviceIndex::LeftIndex]); +} + +void PlayerControlPreview::DrawRightController(QPainter& p, const QPointF center) { + { + using namespace Settings::NativeButton; + + // Sideview right joystick + DrawJoystickSideview(p, center + QPoint(173 - 315, 11), + stick_values[Settings::NativeAnalog::RStick].y.value + 10.0f, 1.15f, + button_values[Settings::NativeButton::RStick]); + + // Topview right joystick + DrawJoystickSideview(p, center + QPointF(140, -28), + -stick_values[Settings::NativeAnalog::RStick].x.value + 15.0f, 1.15f, + button_values[RStick]); + + // Topview face buttons + p.setPen(colors.outline); + button_color = colors.button; + DrawRoundButton(p, center + QPoint(163, -21), button_values[A], 11, 5, Direction::Up); + DrawRoundButton(p, center + QPoint(140, -21), button_values[B], 11, 5, Direction::Up); + DrawRoundButton(p, center + QPoint(140, -21), button_values[X], 11, 5, Direction::Up); + DrawRoundButton(p, center + QPoint(117, -21), button_values[Y], 11, 5, Direction::Up); + + // Topview plus button + p.setPen(colors.outline); + button_color = colors.button; + DrawRoundButton(p, center + QPoint(111, -22), button_values[Plus], 8, 4, Direction::Up, 1); + DrawRoundButton(p, center + QPoint(111, -22), button_values[Plus], 2.66f, 4, Direction::Up, + 1); + + // Right trigger + p.setPen(colors.outline); + button_color = colors.button; + DrawRightTriggers(p, center, button_values[R]); + DrawRoundButton(p, center + QPoint(-151, -146), button_values[R], 8, 4, Direction::Down); + DrawRightZTriggers(p, center, button_values[ZR]); + + // Sideview face buttons + DrawRoundButton(p, center + QPoint(-135, -73), button_values[A], 5, 11, Direction::Left); + DrawRoundButton(p, center + QPoint(-135, -50), button_values[B], 5, 11, Direction::Left); + DrawRoundButton(p, center + QPoint(-135, -95), button_values[X], 5, 11, Direction::Left); + DrawRoundButton(p, center + QPoint(-135, -73), button_values[Y], 5, 11, Direction::Left); + + // Sideview home and plus button + DrawRoundButton(p, center + QPoint(-135, 66), button_values[Home], 3, 12, Direction::Left); + DrawRoundButton(p, center + QPoint(-135, -118), button_values[Plus], 4, 8, Direction::Left, + 1); + DrawRoundButton(p, center + QPoint(-135, -118), button_values[Plus], 4, 2.66f, + Direction::Left, 1); + + // Sideview SL and SR buttons + button_color = colors.slider_button; + DrawRoundButton(p, center + QPoint(-59, 52), button_values[SLRight], 5, 11, + Direction::Right); + DrawRoundButton(p, center + QPoint(-59, -69), button_values[SRRight], 5, 11, + Direction::Right); + + DrawRightBody(p, center); + + // Right trigger top view + DrawRightTriggersTopView(p, center, button_values[R]); + DrawRightZTriggersTopView(p, center, button_values[ZR]); + } + + { + // Draw joysticks + using namespace Settings::NativeAnalog; + DrawJoystick(p, + center + QPointF(-9, 11) + + (QPointF(stick_values[RStick].x.value, stick_values[RStick].y.value) * 8), + 1.8f, button_values[Settings::NativeButton::RStick]); + DrawRawJoystick(p, QPointF(0, 0), center + QPointF(140, 90)); + } + + { + // Draw motion cubes + using namespace Settings::NativeMotion; + p.setPen(colors.outline); + p.setBrush(colors.transparent); + Draw3dCube(p, center + QPointF(140, 90), + motion_values[Settings::NativeMotion::MotionRight].euler, 20.0f); + } + + using namespace Settings::NativeButton; + + // Face buttons constants + const QPointF face_center = center + QPoint(-9, -73); + constexpr int face_distance = 23; + constexpr int face_radius = 11; + constexpr float text_size = 1.1f; + + // Face buttons + p.setPen(colors.outline); + button_color = colors.button; + DrawCircleButton(p, face_center + QPoint(face_distance, 0), button_values[A], face_radius); + DrawCircleButton(p, face_center + QPoint(0, face_distance), button_values[B], face_radius); + DrawCircleButton(p, face_center + QPoint(0, -face_distance), button_values[X], face_radius); + DrawCircleButton(p, face_center + QPoint(-face_distance, 0), button_values[Y], face_radius); + + // Face buttons text + p.setPen(colors.transparent); + p.setBrush(colors.font); + DrawSymbol(p, face_center + QPoint(face_distance, 0), Symbol::A, text_size); + DrawSymbol(p, face_center + QPoint(0, face_distance), Symbol::B, text_size); + DrawSymbol(p, face_center + QPoint(0, -face_distance), Symbol::X, text_size); + DrawSymbol(p, face_center + QPoint(-face_distance, 1), Symbol::Y, text_size); + + // SR and SL buttons + p.setPen(colors.outline); + button_color = colors.slider_button; + DrawRoundButton(p, center + QPoint(-155, 52), button_values[SLRight], 5, 12, Direction::None, + 4.0f); + DrawRoundButton(p, center + QPoint(-155, -69), button_values[SRRight], 5, 12, Direction::None, + 4.0f); + + // SR and SL text + p.setPen(colors.transparent); + p.setBrush(colors.font2); + p.rotate(-180); + DrawSymbol(p, QPointF(-center.x(), -center.y()) + QPointF(155, 69), Symbol::SR, 1.0f); + DrawSymbol(p, QPointF(-center.x(), -center.y()) + QPointF(155, -52), Symbol::SL, 1.0f); + p.rotate(180); + + // Plus Button + DrawPlusButton(p, center + QPoint(-40, -118), button_values[Plus], 16); + + // Home Button + p.setPen(colors.outline); + button_color = colors.slider_button; + DrawCircleButton(p, center + QPoint(-26, 66), button_values[Home], 12); + button_color = colors.button; + DrawCircleButton(p, center + QPoint(-26, 66), button_values[Home], 9); + p.setPen(colors.transparent); + p.setBrush(colors.font2); + DrawSymbol(p, center + QPoint(-26, 66), Symbol::House, 5); + + // Draw battery + DrawBattery(p, center + QPoint(120, -140), + battery_values[Core::HID::EmulatedDeviceIndex::RightIndex]); +} + +void PlayerControlPreview::DrawDualController(QPainter& p, const QPointF center) { + { + using namespace Settings::NativeButton; + + // Left/Right trigger + DrawDualTriggers(p, center, button_values[L], button_values[R]); + + // Topview right joystick + DrawJoystickSideview(p, center + QPointF(180, -78), + -stick_values[Settings::NativeAnalog::RStick].x.value + 15.0f, 1, + button_values[RStick]); + + // Topview face buttons + p.setPen(colors.outline); + button_color = colors.button; + DrawRoundButton(p, center + QPoint(200, -71), button_values[A], 10, 5, Direction::Up); + DrawRoundButton(p, center + QPoint(180, -71), button_values[B], 10, 5, Direction::Up); + DrawRoundButton(p, center + QPoint(180, -71), button_values[X], 10, 5, Direction::Up); + DrawRoundButton(p, center + QPoint(160, -71), button_values[Y], 10, 5, Direction::Up); + + // Topview plus button + p.setPen(colors.outline); + button_color = colors.button; + DrawRoundButton(p, center + QPoint(154, -72), button_values[Plus], 7, 4, Direction::Up, 1); + DrawRoundButton(p, center + QPoint(154, -72), button_values[Plus], 2.33f, 4, Direction::Up, + 1); + + // Topview D-pad buttons + p.setPen(colors.outline); + button_color = colors.button; + DrawRoundButton(p, center + QPoint(-200, -71), button_values[DLeft], 10, 5, Direction::Up); + DrawRoundButton(p, center + QPoint(-160, -71), button_values[DRight], 10, 5, Direction::Up); + + // Topview left joystick + DrawJoystickSideview(p, center + QPointF(-180.5f, -78), + -stick_values[Settings::NativeAnalog::LStick].x.value + 15.0f, 1, + button_values[LStick]); + + // Topview minus button + p.setPen(colors.outline); + button_color = colors.button; + DrawRoundButton(p, center + QPoint(-154, -72), button_values[Minus], 7, 4, Direction::Up, + 1); + + // Left SR and SL sideview buttons + button_color = colors.slider_button; + DrawRoundButton(p, center + QPoint(-20, -62), button_values[SLLeft], 4, 11, + Direction::Left); + DrawRoundButton(p, center + QPoint(-20, 47), button_values[SRLeft], 4, 11, Direction::Left); + + // Right SR and SL sideview buttons + button_color = colors.slider_button; + DrawRoundButton(p, center + QPoint(20, 47), button_values[SLRight], 4, 11, + Direction::Right); + DrawRoundButton(p, center + QPoint(20, -62), button_values[SRRight], 4, 11, + Direction::Right); + + DrawDualBody(p, center); + + // Right trigger top view + DrawDualTriggersTopView(p, center, button_values[L], button_values[R]); + DrawDualZTriggersTopView(p, center, button_values[ZL], button_values[ZR]); + } + + { + // Draw joysticks + using namespace Settings::NativeAnalog; + const auto l_stick = QPointF(stick_values[LStick].x.value, stick_values[LStick].y.value); + const auto l_button = button_values[Settings::NativeButton::LStick]; + const auto r_stick = QPointF(stick_values[RStick].x.value, stick_values[RStick].y.value); + const auto r_button = button_values[Settings::NativeButton::RStick]; + + DrawJoystick(p, center + QPointF(-65, -65) + (l_stick * 7), 1.62f, l_button); + DrawJoystick(p, center + QPointF(65, 12) + (r_stick * 7), 1.62f, r_button); + DrawRawJoystick(p, center + QPointF(-180, 90), center + QPointF(180, 90)); + } + + { + // Draw motion cubes + using namespace Settings::NativeMotion; + p.setPen(colors.outline); + p.setBrush(colors.transparent); + Draw3dCube(p, center + QPointF(-180, 90), + motion_values[Settings::NativeMotion::MotionLeft].euler, 20.0f); + Draw3dCube(p, center + QPointF(180, 90), + motion_values[Settings::NativeMotion::MotionRight].euler, 20.0f); + } + + using namespace Settings::NativeButton; + + // Face buttons constants + const QPointF face_center = center + QPoint(65, -65); + constexpr int face_distance = 20; + constexpr int face_radius = 10; + constexpr float text_size = 1.0f; + + // Face buttons + p.setPen(colors.outline); + button_color = colors.button; + DrawCircleButton(p, face_center + QPoint(face_distance, 0), button_values[A], face_radius); + DrawCircleButton(p, face_center + QPoint(0, face_distance), button_values[B], face_radius); + DrawCircleButton(p, face_center + QPoint(0, -face_distance), button_values[X], face_radius); + DrawCircleButton(p, face_center + QPoint(-face_distance, 0), button_values[Y], face_radius); + + // Face buttons text + p.setPen(colors.transparent); + p.setBrush(colors.font); + DrawSymbol(p, face_center + QPoint(face_distance, 0), Symbol::A, text_size); + DrawSymbol(p, face_center + QPoint(0, face_distance), Symbol::B, text_size); + DrawSymbol(p, face_center + QPoint(0, -face_distance), Symbol::X, text_size); + DrawSymbol(p, face_center + QPoint(-face_distance, 1), Symbol::Y, text_size); + + // D-pad constants + const QPointF dpad_center = center + QPoint(-65, 12); + constexpr int dpad_distance = 20; + constexpr int dpad_radius = 10; + constexpr float dpad_arrow_size = 1.1f; + + // D-pad buttons + p.setPen(colors.outline); + button_color = colors.button; + DrawCircleButton(p, dpad_center + QPoint(dpad_distance, 0), button_values[DRight], dpad_radius); + DrawCircleButton(p, dpad_center + QPoint(0, dpad_distance), button_values[DDown], dpad_radius); + DrawCircleButton(p, dpad_center + QPoint(0, -dpad_distance), button_values[DUp], dpad_radius); + DrawCircleButton(p, dpad_center + QPoint(-dpad_distance, 0), button_values[DLeft], dpad_radius); + + // D-pad arrows + p.setPen(colors.font2); + p.setBrush(colors.font2); + DrawArrow(p, dpad_center + QPoint(dpad_distance, 0), Direction::Right, dpad_arrow_size); + DrawArrow(p, dpad_center + QPoint(0, dpad_distance), Direction::Down, dpad_arrow_size); + DrawArrow(p, dpad_center + QPoint(0, -dpad_distance), Direction::Up, dpad_arrow_size); + DrawArrow(p, dpad_center + QPoint(-dpad_distance, 0), Direction::Left, dpad_arrow_size); + + // Minus and Plus button + button_color = colors.button; + DrawMinusButton(p, center + QPoint(-39, -106), button_values[Minus], 14); + DrawPlusButton(p, center + QPoint(39, -106), button_values[Plus], 14); + + // Screenshot button + p.setPen(colors.outline); + DrawRoundButton(p, center + QPoint(-52, 63), button_values[Screenshot], 8, 8); + p.setPen(colors.font2); + p.setBrush(colors.font2); + DrawCircle(p, center + QPoint(-52, 63), 5); + + // Home Button + p.setPen(colors.outline); + button_color = colors.slider_button; + DrawCircleButton(p, center + QPoint(50, 60), button_values[Home], 11); + button_color = colors.button; + DrawCircleButton(p, center + QPoint(50, 60), button_values[Home], 8.5f); + p.setPen(colors.transparent); + p.setBrush(colors.font2); + DrawSymbol(p, center + QPoint(50, 60), Symbol::House, 4.2f); + + // Draw battery + DrawBattery(p, center + QPoint(-200, -10), + battery_values[Core::HID::EmulatedDeviceIndex::LeftIndex]); + DrawBattery(p, center + QPoint(160, -10), + battery_values[Core::HID::EmulatedDeviceIndex::RightIndex]); +} + +void PlayerControlPreview::DrawHandheldController(QPainter& p, const QPointF center) { + DrawHandheldTriggers(p, center, button_values[Settings::NativeButton::L], + button_values[Settings::NativeButton::R]); + DrawHandheldBody(p, center); + { + // Draw joysticks + using namespace Settings::NativeAnalog; + const auto l_stick = QPointF(stick_values[LStick].x.value, stick_values[LStick].y.value); + const auto l_button = button_values[Settings::NativeButton::LStick]; + const auto r_stick = QPointF(stick_values[RStick].x.value, stick_values[RStick].y.value); + const auto r_button = button_values[Settings::NativeButton::RStick]; + + DrawJoystick(p, center + QPointF(-171, -41) + (l_stick * 4), 1.0f, l_button); + DrawJoystick(p, center + QPointF(171, 8) + (r_stick * 4), 1.0f, r_button); + DrawRawJoystick(p, center + QPointF(-50, 0), center + QPointF(50, 0)); + } + + { + // Draw motion cubes + using namespace Settings::NativeMotion; + p.setPen(colors.outline); + p.setBrush(colors.transparent); + Draw3dCube(p, center + QPointF(0, -115), + motion_values[Settings::NativeMotion::MotionLeft].euler, 15.0f); + } + + using namespace Settings::NativeButton; + + // Face buttons constants + const QPointF face_center = center + QPoint(171, -41); + constexpr float face_distance = 12.8f; + constexpr float face_radius = 6.4f; + constexpr float text_size = 0.6f; + + // Face buttons + p.setPen(colors.outline); + button_color = colors.button; + DrawCircleButton(p, face_center + QPointF(face_distance, 0), button_values[A], face_radius); + DrawCircleButton(p, face_center + QPointF(0, face_distance), button_values[B], face_radius); + DrawCircleButton(p, face_center + QPointF(0, -face_distance), button_values[X], face_radius); + DrawCircleButton(p, face_center + QPointF(-face_distance, 0), button_values[Y], face_radius); + + // Face buttons text + p.setPen(colors.transparent); + p.setBrush(colors.font); + DrawSymbol(p, face_center + QPointF(face_distance, 0), Symbol::A, text_size); + DrawSymbol(p, face_center + QPointF(0, face_distance), Symbol::B, text_size); + DrawSymbol(p, face_center + QPointF(0, -face_distance), Symbol::X, text_size); + DrawSymbol(p, face_center + QPointF(-face_distance, 1), Symbol::Y, text_size); + + // D-pad constants + const QPointF dpad_center = center + QPoint(-171, 8); + constexpr float dpad_distance = 12.8f; + constexpr float dpad_radius = 6.4f; + constexpr float dpad_arrow_size = 0.68f; + + // D-pad buttons + p.setPen(colors.outline); + button_color = colors.button; + DrawCircleButton(p, dpad_center + QPointF(dpad_distance, 0), button_values[DRight], + dpad_radius); + DrawCircleButton(p, dpad_center + QPointF(0, dpad_distance), button_values[DDown], dpad_radius); + DrawCircleButton(p, dpad_center + QPointF(0, -dpad_distance), button_values[DUp], dpad_radius); + DrawCircleButton(p, dpad_center + QPointF(-dpad_distance, 0), button_values[DLeft], + dpad_radius); + + // D-pad arrows + p.setPen(colors.font2); + p.setBrush(colors.font2); + DrawArrow(p, dpad_center + QPointF(dpad_distance, 0), Direction::Right, dpad_arrow_size); + DrawArrow(p, dpad_center + QPointF(0, dpad_distance), Direction::Down, dpad_arrow_size); + DrawArrow(p, dpad_center + QPointF(0, -dpad_distance), Direction::Up, dpad_arrow_size); + DrawArrow(p, dpad_center + QPointF(-dpad_distance, 0), Direction::Left, dpad_arrow_size); + + // ZL and ZR buttons + p.setPen(colors.outline); + DrawTriggerButton(p, center + QPoint(-210, -120), Direction::Left, button_values[ZL]); + DrawTriggerButton(p, center + QPoint(210, -120), Direction::Right, button_values[ZR]); + p.setPen(colors.transparent); + p.setBrush(colors.font); + DrawSymbol(p, center + QPoint(-210, -120), Symbol::ZL, 1.5f); + DrawSymbol(p, center + QPoint(210, -120), Symbol::ZR, 1.5f); + + // Minus and Plus button + p.setPen(colors.outline); + button_color = colors.button; + DrawMinusButton(p, center + QPoint(-155, -67), button_values[Minus], 8); + DrawPlusButton(p, center + QPoint(155, -67), button_values[Plus], 8); + + // Screenshot button + p.setPen(colors.outline); + DrawRoundButton(p, center + QPoint(-162, 39), button_values[Screenshot], 5, 5); + p.setPen(colors.font2); + p.setBrush(colors.font2); + DrawCircle(p, center + QPoint(-162, 39), 3); + + // Home Button + p.setPen(colors.outline); + button_color = colors.slider_button; + DrawCircleButton(p, center + QPoint(161, 37), button_values[Home], 7); + button_color = colors.button; + DrawCircleButton(p, center + QPoint(161, 37), button_values[Home], 5); + p.setPen(colors.transparent); + p.setBrush(colors.font2); + DrawSymbol(p, center + QPoint(161, 37), Symbol::House, 2.75f); + + // Draw battery + DrawBattery(p, center + QPoint(-188, 95), + battery_values[Core::HID::EmulatedDeviceIndex::LeftIndex]); + DrawBattery(p, center + QPoint(150, 95), + battery_values[Core::HID::EmulatedDeviceIndex::RightIndex]); +} + +void PlayerControlPreview::DrawProController(QPainter& p, const QPointF center) { + DrawProTriggers(p, center, button_values[Settings::NativeButton::L], + button_values[Settings::NativeButton::R]); + DrawProBody(p, center); + { + // Draw joysticks + using namespace Settings::NativeAnalog; + const auto l_stick = QPointF(stick_values[LStick].x.value, stick_values[LStick].y.value); + const auto r_stick = QPointF(stick_values[RStick].x.value, stick_values[RStick].y.value); + DrawProJoystick(p, center + QPointF(-111, -55), l_stick, 11, + button_values[Settings::NativeButton::LStick]); + DrawProJoystick(p, center + QPointF(51, 0), r_stick, 11, + button_values[Settings::NativeButton::RStick]); + DrawRawJoystick(p, center + QPointF(-50, 105), center + QPointF(50, 105)); + } + + { + // Draw motion cubes + using namespace Settings::NativeMotion; + p.setPen(colors.button); + p.setBrush(colors.transparent); + Draw3dCube(p, center + QPointF(0, -100), + motion_values[Settings::NativeMotion::MotionLeft].euler, 15.0f); + } + + using namespace Settings::NativeButton; + + // Face buttons constants + const QPointF face_center = center + QPoint(105, -56); + constexpr int face_distance = 31; + constexpr int face_radius = 15; + constexpr float text_size = 1.5f; + + // Face buttons + p.setPen(colors.outline); + button_color = colors.button; + DrawCircleButton(p, face_center + QPoint(face_distance, 0), button_values[A], face_radius); + DrawCircleButton(p, face_center + QPoint(0, face_distance), button_values[B], face_radius); + DrawCircleButton(p, face_center + QPoint(0, -face_distance), button_values[X], face_radius); + DrawCircleButton(p, face_center + QPoint(-face_distance, 0), button_values[Y], face_radius); + + // Face buttons text + p.setPen(colors.transparent); + p.setBrush(colors.font); + DrawSymbol(p, face_center + QPoint(face_distance, 0), Symbol::A, text_size); + DrawSymbol(p, face_center + QPoint(0, face_distance), Symbol::B, text_size); + DrawSymbol(p, face_center + QPoint(0, -face_distance), Symbol::X, text_size); + DrawSymbol(p, face_center + QPoint(-face_distance, 1), Symbol::Y, text_size); + + // D-pad buttons + const QPointF dpad_position = center + QPoint(-61, 0); + DrawArrowButton(p, dpad_position, Direction::Up, button_values[DUp]); + DrawArrowButton(p, dpad_position, Direction::Left, button_values[DLeft]); + DrawArrowButton(p, dpad_position, Direction::Right, button_values[DRight]); + DrawArrowButton(p, dpad_position, Direction::Down, button_values[DDown]); + DrawArrowButtonOutline(p, dpad_position); + + // ZL and ZR buttons + p.setPen(colors.outline); + DrawTriggerButton(p, center + QPoint(-210, -120), Direction::Left, button_values[ZL]); + DrawTriggerButton(p, center + QPoint(210, -120), Direction::Right, button_values[ZR]); + p.setPen(colors.transparent); + p.setBrush(colors.font); + DrawSymbol(p, center + QPoint(-210, -120), Symbol::ZL, 1.5f); + DrawSymbol(p, center + QPoint(210, -120), Symbol::ZR, 1.5f); + + // Minus and Plus buttons + p.setPen(colors.outline); + DrawCircleButton(p, center + QPoint(-50, -86), button_values[Minus], 9); + DrawCircleButton(p, center + QPoint(50, -86), button_values[Plus], 9); + + // Minus and Plus symbols + p.setPen(colors.font2); + p.setBrush(colors.font2); + DrawRectangle(p, center + QPoint(-50, -86), 9, 1.5f); + DrawRectangle(p, center + QPoint(50, -86), 9, 1.5f); + DrawRectangle(p, center + QPoint(50, -86), 1.5f, 9); + + // Screenshot button + p.setPen(colors.outline); + DrawRoundButton(p, center + QPoint(-29, -56), button_values[Screenshot], 7, 7); + p.setPen(colors.font2); + p.setBrush(colors.font2); + DrawCircle(p, center + QPoint(-29, -56), 4.5f); + + // Home Button + p.setPen(colors.outline); + button_color = colors.slider_button; + DrawCircleButton(p, center + QPoint(29, -56), button_values[Home], 10.0f); + button_color = colors.button; + DrawCircleButton(p, center + QPoint(29, -56), button_values[Home], 7.1f); + p.setPen(colors.transparent); + p.setBrush(colors.font2); + DrawSymbol(p, center + QPoint(29, -56), Symbol::House, 3.9f); + + // Draw battery + DrawBattery(p, center + QPoint(-20, -160), + battery_values[Core::HID::EmulatedDeviceIndex::LeftIndex]); +} + +void PlayerControlPreview::DrawGCController(QPainter& p, const QPointF center) { + DrawGCTriggers(p, center, trigger_values[0], trigger_values[1]); + DrawGCButtonZ(p, center, button_values[Settings::NativeButton::R]); + DrawGCBody(p, center); + { + // Draw joysticks + using namespace Settings::NativeAnalog; + const auto l_stick = QPointF(stick_values[LStick].x.value, stick_values[LStick].y.value); + const auto r_stick = QPointF(stick_values[RStick].x.value, stick_values[RStick].y.value); + DrawGCJoystick(p, center + QPointF(-111, -44) + (l_stick * 10), {}); + button_color = colors.button2; + DrawCircleButton(p, center + QPointF(61, 37) + (r_stick * 9.5f), {}, 15); + p.setPen(colors.transparent); + p.setBrush(colors.font); + DrawSymbol(p, center + QPointF(61, 37) + (r_stick * 9.5f), Symbol::C, 1.0f); + DrawRawJoystick(p, center + QPointF(-198, -125), center + QPointF(198, -125)); + } + + using namespace Settings::NativeButton; + + // Face buttons constants + constexpr float text_size = 1.1f; + + // Face buttons + p.setPen(colors.outline); + button_color = colors.button; + DrawCircleButton(p, center + QPoint(111, -44), button_values[A], 21); + DrawCircleButton(p, center + QPoint(70, -23), button_values[B], 13); + DrawGCButtonX(p, center, button_values[Settings::NativeButton::X]); + DrawGCButtonY(p, center, button_values[Settings::NativeButton::Y]); + + // Face buttons text + p.setPen(colors.transparent); + p.setBrush(colors.font); + DrawSymbol(p, center + QPoint(111, -44), Symbol::A, 1.5f); + DrawSymbol(p, center + QPoint(70, -23), Symbol::B, text_size); + DrawSymbol(p, center + QPoint(151, -53), Symbol::X, text_size); + DrawSymbol(p, center + QPoint(100, -83), Symbol::Y, text_size); + + // D-pad buttons + const QPointF dpad_position = center + QPoint(-61, 37); + const float dpad_size = 0.8f; + DrawArrowButton(p, dpad_position, Direction::Up, button_values[DUp], dpad_size); + DrawArrowButton(p, dpad_position, Direction::Left, button_values[DLeft], dpad_size); + DrawArrowButton(p, dpad_position, Direction::Right, button_values[DRight], dpad_size); + DrawArrowButton(p, dpad_position, Direction::Down, button_values[DDown], dpad_size); + DrawArrowButtonOutline(p, dpad_position, dpad_size); + + // Minus and Plus buttons + p.setPen(colors.outline); + DrawCircleButton(p, center + QPoint(0, -44), button_values[Plus], 8); + + // Draw battery + DrawBattery(p, center + QPoint(-20, 110), + battery_values[Core::HID::EmulatedDeviceIndex::LeftIndex]); +} + +constexpr std::array symbol_a = { + -1.085f, -5.2f, 1.085f, -5.2f, 5.085f, 5.0f, 2.785f, 5.0f, 1.785f, + 2.65f, -1.785f, 2.65f, -2.785f, 5.0f, -5.085f, 5.0f, -1.4f, 1.0f, + 0.0f, -2.8f, 1.4f, 1.0f, -1.4f, 1.0f, -5.085f, 5.0f, +}; +constexpr std::array symbol_b = { + -4.0f, 0.0f, -4.0f, 0.0f, -4.0f, -0.1f, -3.8f, -5.1f, 1.8f, -5.0f, 2.3f, -4.9f, 2.6f, + -4.8f, 2.8f, -4.7f, 2.9f, -4.6f, 3.1f, -4.5f, 3.2f, -4.4f, 3.4f, -4.3f, 3.4f, -4.2f, + 3.5f, -4.1f, 3.7f, -4.0f, 3.7f, -3.9f, 3.8f, -3.8f, 3.8f, -3.7f, 3.9f, -3.6f, 3.9f, + -3.5f, 4.0f, -3.4f, 4.0f, -3.3f, 4.1f, -3.1f, 4.1f, -3.0f, 4.0f, -2.0f, 4.0f, -1.9f, + 3.9f, -1.7f, 3.9f, -1.6f, 3.8f, -1.5f, 3.8f, -1.4f, 3.7f, -1.3f, 3.7f, -1.2f, 3.6f, + -1.1f, 3.6f, -1.0f, 3.5f, -0.9f, 3.3f, -0.8f, 3.3f, -0.7f, 3.2f, -0.6f, 3.0f, -0.5f, + 2.9f, -0.4f, 2.7f, -0.3f, 2.9f, -0.2f, 3.2f, -0.1f, 3.3f, 0.0f, 3.5f, 0.1f, 3.6f, + 0.2f, 3.8f, 0.3f, 3.9f, 0.4f, 4.0f, 0.6f, 4.1f, 0.7f, 4.3f, 0.8f, 4.3f, 0.9f, + 4.4f, 1.0f, 4.4f, 1.1f, 4.5f, 1.3f, 4.5f, 1.4f, 4.6f, 1.6f, 4.6f, 1.7f, 4.5f, + 2.8f, 4.5f, 2.9f, 4.4f, 3.1f, 4.4f, 3.2f, 4.3f, 3.4f, 4.3f, 3.5f, 4.2f, 3.6f, + 4.2f, 3.7f, 4.1f, 3.8f, 4.1f, 3.9f, 4.0f, 4.0f, 3.9f, 4.2f, 3.8f, 4.3f, 3.6f, + 4.4f, 3.6f, 4.5f, 3.4f, 4.6f, 3.3f, 4.7f, 3.1f, 4.8f, 2.8f, 4.9f, 2.6f, 5.0f, + 2.1f, 5.1f, -4.0f, 5.0f, -4.0f, 4.9f, + + -4.0f, 0.0f, 1.1f, 3.4f, 1.1f, 3.4f, 1.5f, 3.3f, 1.8f, 3.2f, 2.0f, 3.1f, 2.1f, + 3.0f, 2.3f, 2.9f, 2.3f, 2.8f, 2.4f, 2.7f, 2.4f, 2.6f, 2.5f, 2.3f, 2.5f, 2.2f, + 2.4f, 1.7f, 2.4f, 1.6f, 2.3f, 1.4f, 2.3f, 1.3f, 2.2f, 1.2f, 2.2f, 1.1f, 2.1f, + 1.0f, 1.9f, 0.9f, 1.6f, 0.8f, 1.4f, 0.7f, -1.9f, 0.6f, -1.9f, 0.7f, -1.8f, 3.4f, + 1.1f, 3.4f, -4.0f, 0.0f, + + 0.3f, -1.1f, 0.3f, -1.1f, 1.3f, -1.2f, 1.5f, -1.3f, 1.8f, -1.4f, 1.8f, -1.5f, 1.9f, + -1.6f, 2.0f, -1.8f, 2.0f, -1.9f, 2.1f, -2.0f, 2.1f, -2.1f, 2.0f, -2.7f, 2.0f, -2.8f, + 1.9f, -2.9f, 1.9f, -3.0f, 1.8f, -3.1f, 1.6f, -3.2f, 1.6f, -3.3f, 1.3f, -3.4f, -1.9f, + -3.3f, -1.9f, -3.2f, -1.8f, -1.0f, 0.2f, -1.1f, 0.3f, -1.1f, -4.0f, 0.0f, +}; + +constexpr std::array symbol_y = { + -4.79f, -4.9f, -2.44f, -4.9f, 0.0f, -0.9f, 2.44f, -4.9f, 4.79f, + -4.9f, 1.05f, 1.0f, 1.05f, 5.31f, -1.05f, 5.31f, -1.05f, 1.0f, + +}; + +constexpr std::array symbol_x = { + -4.4f, -5.0f, -2.0f, -5.0f, 0.0f, -1.7f, 2.0f, -5.0f, 4.4f, -5.0f, 1.2f, 0.0f, + 4.4f, 5.0f, 2.0f, 5.0f, 0.0f, 1.7f, -2.0f, 5.0f, -4.4f, 5.0f, -1.2f, 0.0f, + +}; + +constexpr std::array symbol_l = { + 2.4f, -3.23f, 2.4f, 2.1f, 5.43f, 2.1f, 5.43f, 3.22f, 0.98f, 3.22f, 0.98f, -3.23f, 2.4f, -3.23f, +}; + +constexpr std::array symbol_r = { + 1.0f, 0.0f, 1.0f, -0.1f, 1.1f, -3.3f, 4.3f, -3.2f, 5.1f, -3.1f, 5.4f, -3.0f, 5.6f, -2.9f, + 5.7f, -2.8f, 5.9f, -2.7f, 5.9f, -2.6f, 6.0f, -2.5f, 6.1f, -2.3f, 6.2f, -2.2f, 6.2f, -2.1f, + 6.3f, -2.0f, 6.3f, -1.9f, 6.2f, -0.8f, 6.2f, -0.7f, 6.1f, -0.6f, 6.1f, -0.5f, 6.0f, -0.4f, + 6.0f, -0.3f, 5.9f, -0.2f, 5.7f, -0.1f, 5.7f, 0.0f, 5.6f, 0.1f, 5.4f, 0.2f, 5.1f, 0.3f, + 4.7f, 0.4f, 4.7f, 0.5f, 4.9f, 0.6f, 5.0f, 0.7f, 5.2f, 0.8f, 5.2f, 0.9f, 5.3f, 1.0f, + 5.5f, 1.1f, 5.5f, 1.2f, 5.6f, 1.3f, 5.7f, 1.5f, 5.8f, 1.6f, 5.9f, 1.8f, 6.0f, 1.9f, + 6.1f, 2.1f, 6.2f, 2.2f, 6.2f, 2.3f, 6.3f, 2.4f, 6.4f, 2.6f, 6.5f, 2.7f, 6.6f, 2.9f, + 6.7f, 3.0f, 6.7f, 3.1f, 6.8f, 3.2f, 6.8f, 3.3f, 5.3f, 3.2f, 5.2f, 3.1f, 5.2f, 3.0f, + 5.1f, 2.9f, 5.0f, 2.7f, 4.9f, 2.6f, 4.8f, 2.4f, 4.7f, 2.3f, 4.6f, 2.1f, 4.5f, 2.0f, + 4.4f, 1.8f, 4.3f, 1.7f, 4.1f, 1.4f, 4.0f, 1.3f, 3.9f, 1.1f, 3.8f, 1.0f, 3.6f, 0.9f, + 3.6f, 0.8f, 3.5f, 0.7f, 3.3f, 0.6f, 2.9f, 0.5f, 2.3f, 0.6f, 2.3f, 0.7f, 2.2f, 3.3f, + 1.0f, 3.2f, 1.0f, 3.1f, 1.0f, 0.0f, + + 4.2f, -0.5f, 4.4f, -0.6f, 4.7f, -0.7f, 4.8f, -0.8f, 4.9f, -1.0f, 5.0f, -1.1f, 5.0f, -1.2f, + 4.9f, -1.7f, 4.9f, -1.8f, 4.8f, -1.9f, 4.8f, -2.0f, 4.6f, -2.1f, 4.3f, -2.2f, 2.3f, -2.1f, + 2.3f, -2.0f, 2.4f, -0.5f, 4.2f, -0.5f, 1.0f, 0.0f, +}; + +constexpr std::array symbol_zl = { + -2.6f, -2.13f, -5.6f, -2.13f, -5.6f, -3.23f, -0.8f, -3.23f, -0.8f, -2.13f, -4.4f, 2.12f, + -0.7f, 2.12f, -0.7f, 3.22f, -6.0f, 3.22f, -6.0f, 2.12f, 2.4f, -3.23f, 2.4f, 2.1f, + 5.43f, 2.1f, 5.43f, 3.22f, 0.98f, 3.22f, 0.98f, -3.23f, 2.4f, -3.23f, -6.0f, 2.12f, +}; + +constexpr std::array symbol_sl = { + -3.0f, -3.65f, -2.76f, -4.26f, -2.33f, -4.76f, -1.76f, -5.09f, -1.13f, -5.26f, -0.94f, + -4.77f, -0.87f, -4.11f, -1.46f, -3.88f, -1.91f, -3.41f, -2.05f, -2.78f, -1.98f, -2.13f, + -1.59f, -1.61f, -0.96f, -1.53f, -0.56f, -2.04f, -0.38f, -2.67f, -0.22f, -3.31f, 0.0f, + -3.93f, 0.34f, -4.49f, 0.86f, -4.89f, 1.49f, -5.05f, 2.14f, -4.95f, 2.69f, -4.6f, + 3.07f, -4.07f, 3.25f, -3.44f, 3.31f, -2.78f, 3.25f, -2.12f, 3.07f, -1.49f, 2.7f, + -0.95f, 2.16f, -0.58f, 1.52f, -0.43f, 1.41f, -0.99f, 1.38f, -1.65f, 1.97f, -1.91f, + 2.25f, -2.49f, 2.25f, -3.15f, 1.99f, -3.74f, 1.38f, -3.78f, 1.06f, -3.22f, 0.88f, + -2.58f, 0.71f, -1.94f, 0.49f, -1.32f, 0.13f, -0.77f, -0.4f, -0.4f, -1.04f, -0.25f, + -1.69f, -0.32f, -2.28f, -0.61f, -2.73f, -1.09f, -2.98f, -1.69f, -3.09f, -2.34f, + + 3.23f, 2.4f, -2.1f, 2.4f, -2.1f, 5.43f, -3.22f, 5.43f, -3.22f, 0.98f, 3.23f, + 0.98f, 3.23f, 2.4f, -3.09f, -2.34f, +}; +constexpr std::array symbol_zr = { + -2.6f, -2.13f, -5.6f, -2.13f, -5.6f, -3.23f, -0.8f, -3.23f, -0.8f, -2.13f, -4.4f, 2.12f, -0.7f, + 2.12f, -0.7f, 3.22f, -6.0f, 3.22f, -6.0f, 2.12f, + + 1.0f, 0.0f, 1.0f, -0.1f, 1.1f, -3.3f, 4.3f, -3.2f, 5.1f, -3.1f, 5.4f, -3.0f, 5.6f, + -2.9f, 5.7f, -2.8f, 5.9f, -2.7f, 5.9f, -2.6f, 6.0f, -2.5f, 6.1f, -2.3f, 6.2f, -2.2f, + 6.2f, -2.1f, 6.3f, -2.0f, 6.3f, -1.9f, 6.2f, -0.8f, 6.2f, -0.7f, 6.1f, -0.6f, 6.1f, + -0.5f, 6.0f, -0.4f, 6.0f, -0.3f, 5.9f, -0.2f, 5.7f, -0.1f, 5.7f, 0.0f, 5.6f, 0.1f, + 5.4f, 0.2f, 5.1f, 0.3f, 4.7f, 0.4f, 4.7f, 0.5f, 4.9f, 0.6f, 5.0f, 0.7f, 5.2f, + 0.8f, 5.2f, 0.9f, 5.3f, 1.0f, 5.5f, 1.1f, 5.5f, 1.2f, 5.6f, 1.3f, 5.7f, 1.5f, + 5.8f, 1.6f, 5.9f, 1.8f, 6.0f, 1.9f, 6.1f, 2.1f, 6.2f, 2.2f, 6.2f, 2.3f, 6.3f, + 2.4f, 6.4f, 2.6f, 6.5f, 2.7f, 6.6f, 2.9f, 6.7f, 3.0f, 6.7f, 3.1f, 6.8f, 3.2f, + 6.8f, 3.3f, 5.3f, 3.2f, 5.2f, 3.1f, 5.2f, 3.0f, 5.1f, 2.9f, 5.0f, 2.7f, 4.9f, + 2.6f, 4.8f, 2.4f, 4.7f, 2.3f, 4.6f, 2.1f, 4.5f, 2.0f, 4.4f, 1.8f, 4.3f, 1.7f, + 4.1f, 1.4f, 4.0f, 1.3f, 3.9f, 1.1f, 3.8f, 1.0f, 3.6f, 0.9f, 3.6f, 0.8f, 3.5f, + 0.7f, 3.3f, 0.6f, 2.9f, 0.5f, 2.3f, 0.6f, 2.3f, 0.7f, 2.2f, 3.3f, 1.0f, 3.2f, + 1.0f, 3.1f, 1.0f, 0.0f, + + 4.2f, -0.5f, 4.4f, -0.6f, 4.7f, -0.7f, 4.8f, -0.8f, 4.9f, -1.0f, 5.0f, -1.1f, 5.0f, + -1.2f, 4.9f, -1.7f, 4.9f, -1.8f, 4.8f, -1.9f, 4.8f, -2.0f, 4.6f, -2.1f, 4.3f, -2.2f, + 2.3f, -2.1f, 2.3f, -2.0f, 2.4f, -0.5f, 4.2f, -0.5f, 1.0f, 0.0f, -6.0f, 2.12f, +}; + +constexpr std::array symbol_sr = { + -3.0f, -3.65f, -2.76f, -4.26f, -2.33f, -4.76f, -1.76f, -5.09f, -1.13f, -5.26f, -0.94f, -4.77f, + -0.87f, -4.11f, -1.46f, -3.88f, -1.91f, -3.41f, -2.05f, -2.78f, -1.98f, -2.13f, -1.59f, -1.61f, + -0.96f, -1.53f, -0.56f, -2.04f, -0.38f, -2.67f, -0.22f, -3.31f, 0.0f, -3.93f, 0.34f, -4.49f, + 0.86f, -4.89f, 1.49f, -5.05f, 2.14f, -4.95f, 2.69f, -4.6f, 3.07f, -4.07f, 3.25f, -3.44f, + 3.31f, -2.78f, 3.25f, -2.12f, 3.07f, -1.49f, 2.7f, -0.95f, 2.16f, -0.58f, 1.52f, -0.43f, + 1.41f, -0.99f, 1.38f, -1.65f, 1.97f, -1.91f, 2.25f, -2.49f, 2.25f, -3.15f, 1.99f, -3.74f, + 1.38f, -3.78f, 1.06f, -3.22f, 0.88f, -2.58f, 0.71f, -1.94f, 0.49f, -1.32f, 0.13f, -0.77f, + -0.4f, -0.4f, -1.04f, -0.25f, -1.69f, -0.32f, -2.28f, -0.61f, -2.73f, -1.09f, -2.98f, -1.69f, + -3.09f, -2.34f, + + -1.0f, 0.0f, 0.1f, 1.0f, 3.3f, 1.1f, 3.2f, 4.3f, 3.1f, 5.1f, 3.0f, 5.4f, + 2.9f, 5.6f, 2.8f, 5.7f, 2.7f, 5.9f, 2.6f, 5.9f, 2.5f, 6.0f, 2.3f, 6.1f, + 2.2f, 6.2f, 2.1f, 6.2f, 2.0f, 6.3f, 1.9f, 6.3f, 0.8f, 6.2f, 0.7f, 6.2f, + 0.6f, 6.1f, 0.5f, 6.1f, 0.4f, 6.0f, 0.3f, 6.0f, 0.2f, 5.9f, 0.1f, 5.7f, + 0.0f, 5.7f, -0.1f, 5.6f, -0.2f, 5.4f, -0.3f, 5.1f, -0.4f, 4.7f, -0.5f, 4.7f, + -0.6f, 4.9f, -0.7f, 5.0f, -0.8f, 5.2f, -0.9f, 5.2f, -1.0f, 5.3f, -1.1f, 5.5f, + -1.2f, 5.5f, -1.3f, 5.6f, -1.5f, 5.7f, -1.6f, 5.8f, -1.8f, 5.9f, -1.9f, 6.0f, + -2.1f, 6.1f, -2.2f, 6.2f, -2.3f, 6.2f, -2.4f, 6.3f, -2.6f, 6.4f, -2.7f, 6.5f, + -2.9f, 6.6f, -3.0f, 6.7f, -3.1f, 6.7f, -3.2f, 6.8f, -3.3f, 6.8f, -3.2f, 5.3f, + -3.1f, 5.2f, -3.0f, 5.2f, -2.9f, 5.1f, -2.7f, 5.0f, -2.6f, 4.9f, -2.4f, 4.8f, + -2.3f, 4.7f, -2.1f, 4.6f, -2.0f, 4.5f, -1.8f, 4.4f, -1.7f, 4.3f, -1.4f, 4.1f, + -1.3f, 4.0f, -1.1f, 3.9f, -1.0f, 3.8f, -0.9f, 3.6f, -0.8f, 3.6f, -0.7f, 3.5f, + -0.6f, 3.3f, -0.5f, 2.9f, -0.6f, 2.3f, -0.7f, 2.3f, -3.3f, 2.2f, -3.2f, 1.0f, + -3.1f, 1.0f, 0.0f, 1.0f, + + 0.5f, 4.2f, 0.6f, 4.4f, 0.7f, 4.7f, 0.8f, 4.8f, 1.0f, 4.9f, 1.1f, 5.0f, + 1.2f, 5.0f, 1.7f, 4.9f, 1.8f, 4.9f, 1.9f, 4.8f, 2.0f, 4.8f, 2.1f, 4.6f, + 2.2f, 4.3f, 2.1f, 2.3f, 2.0f, 2.3f, 0.5f, 2.4f, 0.5f, 4.2f, -0.0f, 1.0f, + -3.09f, -2.34f, + +}; + +constexpr std::array symbol_c = { + 2.86f, 7.57f, 0.99f, 7.94f, -0.91f, 7.87f, -2.73f, 7.31f, -4.23f, 6.14f, -5.2f, 4.51f, + -5.65f, 2.66f, -5.68f, 0.75f, -5.31f, -1.12f, -4.43f, -2.81f, -3.01f, -4.08f, -1.24f, -4.78f, + 0.66f, -4.94f, 2.54f, -4.67f, 4.33f, -4.0f, 4.63f, -2.27f, 3.37f, -2.7f, 1.6f, -3.4f, + -0.3f, -3.5f, -2.09f, -2.87f, -3.34f, -1.45f, -3.91f, 0.37f, -3.95f, 2.27f, -3.49f, 4.12f, + -2.37f, 5.64f, -0.65f, 6.44f, 1.25f, 6.47f, 3.06f, 5.89f, 4.63f, 4.92f, 4.63f, 6.83f, +}; + +constexpr std::array symbol_charging = { + 6.5f, -1.0f, 1.0f, -1.0f, 1.0f, -3.0f, -6.5f, 1.0f, -1.0f, 1.0f, -1.0f, 3.0f, +}; + +constexpr std::array house = { + -1.3f, 0.0f, -0.93f, 0.0f, -0.93f, 1.15f, 0.93f, 1.15f, 0.93f, 0.0f, 1.3f, 0.0f, + 0.0f, -1.2f, -1.3f, 0.0f, -0.43f, 0.0f, -0.43f, .73f, 0.43f, .73f, 0.43f, 0.0f, +}; + +constexpr std::array up_arrow_button = { + 9.1f, -9.1f, 9.1f, -30.0f, 8.1f, -30.1f, 7.7f, -30.1f, -8.6f, -30.0f, -9.0f, + -29.8f, -9.3f, -29.5f, -9.5f, -29.1f, -9.1f, -28.7f, -9.1f, -9.1f, 0.0f, 0.6f, +}; + +constexpr std::array up_arrow_symbol = { + 0.0f, -3.0f, -3.0f, 2.0f, 3.0f, 2.0f, +}; + +constexpr std::array trigger_button = { + 5.5f, -12.6f, 5.8f, -12.6f, 6.7f, -12.5f, 8.1f, -12.3f, 8.6f, -12.2f, 9.2f, -12.0f, + 9.5f, -11.9f, 9.9f, -11.8f, 10.6f, -11.5f, 11.0f, -11.3f, 11.2f, -11.2f, 11.4f, -11.1f, + 11.8f, -10.9f, 12.0f, -10.8f, 12.2f, -10.7f, 12.4f, -10.5f, 12.6f, -10.4f, 12.8f, -10.3f, + 13.6f, -9.7f, 13.8f, -9.6f, 13.9f, -9.4f, 14.1f, -9.3f, 14.8f, -8.6f, 15.0f, -8.5f, + 15.1f, -8.3f, 15.6f, -7.8f, 15.7f, -7.6f, 16.1f, -7.0f, 16.3f, -6.8f, 16.4f, -6.6f, + 16.5f, -6.4f, 16.8f, -6.0f, 16.9f, -5.8f, 17.0f, -5.6f, 17.1f, -5.4f, 17.2f, -5.2f, + 17.3f, -5.0f, 17.4f, -4.8f, 17.5f, -4.6f, 17.6f, -4.4f, 17.7f, -4.1f, 17.8f, -3.9f, + 17.9f, -3.5f, 18.0f, -3.3f, 18.1f, -3.0f, 18.2f, -2.6f, 18.2f, -2.3f, 18.3f, -2.1f, + 18.3f, -1.9f, 18.4f, -1.4f, 18.5f, -1.2f, 18.6f, -0.3f, 18.6f, 0.0f, 18.3f, 13.9f, + -17.0f, 13.8f, -17.0f, 13.6f, -16.4f, -11.4f, -16.3f, -11.6f, -16.1f, -11.8f, -15.7f, -12.0f, + -15.5f, -12.1f, -15.1f, -12.3f, -14.6f, -12.4f, -13.4f, -12.5f, +}; + +constexpr std::array pro_left_trigger = { + -65.2f, -132.6f, -68.2f, -134.1f, -71.3f, -135.5f, -74.4f, -136.7f, -77.6f, + -137.6f, -80.9f, -138.1f, -84.3f, -138.3f, -87.6f, -138.3f, -91.0f, -138.1f, + -94.3f, -137.8f, -97.6f, -137.3f, -100.9f, -136.7f, -107.5f, -135.3f, -110.7f, + -134.5f, -120.4f, -131.8f, -123.6f, -130.8f, -126.8f, -129.7f, -129.9f, -128.5f, + -132.9f, -127.1f, -135.9f, -125.6f, -138.8f, -123.9f, -141.6f, -122.0f, -144.1f, + -119.8f, -146.3f, -117.3f, -148.4f, -114.7f, -150.4f, -112.0f, -152.3f, -109.2f, + -155.3f, -104.0f, -152.0f, -104.3f, -148.7f, -104.5f, -145.3f, -104.8f, -35.5f, + -117.2f, -38.5f, -118.7f, -41.4f, -120.3f, -44.4f, -121.8f, -50.4f, -124.9f, +}; + +constexpr std::array pro_body_top = { + 0.0f, -115.4f, -4.4f, -116.1f, -69.7f, -131.3f, -66.4f, -131.9f, -63.1f, -132.3f, + -56.4f, -133.0f, -53.1f, -133.3f, -49.8f, -133.5f, -43.1f, -133.8f, -39.8f, -134.0f, + -36.5f, -134.1f, -16.4f, -134.4f, -13.1f, -134.4f, 0.0f, -134.1f, +}; + +constexpr std::array pro_left_handle = { + -178.7f, -47.5f, -179.0f, -46.1f, -179.3f, -44.6f, -182.0f, -29.8f, -182.3f, -28.4f, + -182.6f, -26.9f, -182.8f, -25.4f, -183.1f, -23.9f, -183.3f, -22.4f, -183.6f, -21.0f, + -183.8f, -19.5f, -184.1f, -18.0f, -184.3f, -16.5f, -184.6f, -15.1f, -184.8f, -13.6f, + -185.1f, -12.1f, -185.3f, -10.6f, -185.6f, -9.1f, -185.8f, -7.7f, -186.1f, -6.2f, + -186.3f, -4.7f, -186.6f, -3.2f, -186.8f, -1.7f, -187.1f, -0.3f, -187.3f, 1.2f, + -187.6f, 2.7f, -187.8f, 4.2f, -188.3f, 7.1f, -188.5f, 8.6f, -188.8f, 10.1f, + -189.0f, 11.6f, -189.3f, 13.1f, -189.5f, 14.5f, -190.0f, 17.5f, -190.2f, 19.0f, + -190.5f, 20.5f, -190.7f, 21.9f, -191.2f, 24.9f, -191.4f, 26.4f, -191.7f, 27.9f, + -191.9f, 29.3f, -192.4f, 32.3f, -192.6f, 33.8f, -193.1f, 36.8f, -193.3f, 38.2f, + -193.8f, 41.2f, -194.0f, 42.7f, -194.7f, 47.1f, -194.9f, 48.6f, -199.0f, 82.9f, + -199.1f, 84.4f, -199.1f, 85.9f, -199.2f, 87.4f, -199.2f, 88.9f, -199.1f, 94.9f, + -198.9f, 96.4f, -198.8f, 97.8f, -198.5f, 99.3f, -198.3f, 100.8f, -198.0f, 102.3f, + -197.7f, 103.7f, -197.4f, 105.2f, -197.0f, 106.7f, -196.6f, 108.1f, -195.7f, 111.0f, + -195.2f, 112.4f, -194.1f, 115.2f, -193.5f, 116.5f, -192.8f, 117.9f, -192.1f, 119.2f, + -190.6f, 121.8f, -189.8f, 123.1f, -188.9f, 124.3f, -187.0f, 126.6f, -186.0f, 127.7f, + -183.9f, 129.8f, -182.7f, 130.8f, -180.3f, 132.6f, -179.1f, 133.4f, -177.8f, 134.1f, + -176.4f, 134.8f, -175.1f, 135.5f, -173.7f, 136.0f, -169.4f, 137.3f, -167.9f, 137.7f, + -166.5f, 138.0f, -165.0f, 138.3f, -163.5f, 138.4f, -162.0f, 138.4f, -160.5f, 138.3f, + -159.0f, 138.0f, -157.6f, 137.7f, -156.1f, 137.3f, -154.7f, 136.9f, -153.2f, 136.5f, + -151.8f, 136.0f, -150.4f, 135.4f, -149.1f, 134.8f, -147.7f, 134.1f, -146.5f, 133.3f, + -145.2f, 132.5f, -144.0f, 131.6f, -142.8f, 130.6f, -141.7f, 129.6f, -139.6f, 127.5f, + -138.6f, 126.4f, -137.7f, 125.2f, -135.1f, 121.5f, -134.3f, 120.3f, -133.5f, 119.0f, + -131.9f, 116.5f, -131.1f, 115.2f, -128.8f, 111.3f, -128.0f, 110.1f, -127.2f, 108.8f, + -126.5f, 107.5f, -125.7f, 106.2f, -125.0f, 104.9f, -124.2f, 103.6f, -123.5f, 102.3f, + -122.0f, 99.6f, -121.3f, 98.3f, -115.8f, 87.7f, -115.1f, 86.4f, -114.4f, 85.0f, + -113.7f, 83.7f, -112.3f, 81.0f, -111.6f, 79.7f, -110.1f, 77.1f, -109.4f, 75.8f, + -108.0f, 73.1f, -107.2f, 71.8f, -106.4f, 70.6f, -105.7f, 69.3f, -104.8f, 68.0f, + -104.0f, 66.8f, -103.1f, 65.6f, -101.1f, 63.3f, -100.0f, 62.3f, -98.8f, 61.4f, + -97.6f, 60.6f, -97.9f, 59.5f, -98.8f, 58.3f, -101.5f, 54.6f, -102.4f, 53.4f, +}; + +constexpr std::array pro_body = { + -0.7f, -129.1f, -54.3f, -129.1f, -55.0f, -129.1f, -57.8f, -129.0f, -58.5f, -129.0f, + -60.7f, -128.9f, -61.4f, -128.9f, -62.8f, -128.8f, -63.5f, -128.8f, -65.7f, -128.7f, + -66.4f, -128.7f, -67.8f, -128.6f, -68.5f, -128.6f, -69.2f, -128.5f, -70.0f, -128.5f, + -70.7f, -128.4f, -71.4f, -128.4f, -72.1f, -128.3f, -72.8f, -128.3f, -73.5f, -128.2f, + -74.2f, -128.2f, -74.9f, -128.1f, -75.7f, -128.1f, -76.4f, -128.0f, -77.1f, -128.0f, + -77.8f, -127.9f, -78.5f, -127.9f, -79.2f, -127.8f, -80.6f, -127.7f, -81.4f, -127.6f, + -82.1f, -127.5f, -82.8f, -127.5f, -83.5f, -127.4f, -84.9f, -127.3f, -85.6f, -127.2f, + -87.0f, -127.1f, -87.7f, -127.0f, -88.5f, -126.9f, -89.2f, -126.8f, -89.9f, -126.8f, + -90.6f, -126.7f, -94.1f, -126.3f, -94.8f, -126.2f, -113.2f, -123.3f, -113.9f, -123.2f, + -114.6f, -123.0f, -115.3f, -122.9f, -116.7f, -122.6f, -117.4f, -122.5f, -118.1f, -122.3f, + -118.8f, -122.2f, -119.5f, -122.0f, -120.9f, -121.7f, -121.6f, -121.5f, -122.3f, -121.4f, + -122.9f, -121.2f, -123.6f, -121.0f, -126.4f, -120.3f, -127.1f, -120.1f, -127.8f, -119.8f, + -128.4f, -119.6f, -129.1f, -119.4f, -131.2f, -118.7f, -132.5f, -118.3f, -133.2f, -118.0f, + -133.8f, -117.7f, -134.5f, -117.4f, -135.1f, -117.2f, -135.8f, -116.9f, -136.4f, -116.5f, + -137.0f, -116.2f, -137.7f, -115.8f, -138.3f, -115.4f, -138.9f, -115.1f, -139.5f, -114.7f, + -160.0f, -100.5f, -160.5f, -100.0f, -162.5f, -97.9f, -162.9f, -97.4f, -163.4f, -96.8f, + -163.8f, -96.2f, -165.3f, -93.8f, -165.7f, -93.2f, -166.0f, -92.6f, -166.4f, -91.9f, + -166.7f, -91.3f, -167.3f, -90.0f, -167.6f, -89.4f, -167.8f, -88.7f, -168.1f, -88.0f, + -168.4f, -87.4f, -168.6f, -86.7f, -168.9f, -86.0f, -169.1f, -85.4f, -169.3f, -84.7f, + -169.6f, -84.0f, -169.8f, -83.3f, -170.2f, -82.0f, -170.4f, -81.3f, -172.8f, -72.3f, + -173.0f, -71.6f, -173.5f, -69.5f, -173.7f, -68.8f, -173.9f, -68.2f, -174.0f, -67.5f, + -174.2f, -66.8f, -174.5f, -65.4f, -174.7f, -64.7f, -174.8f, -64.0f, -175.0f, -63.3f, + -175.3f, -61.9f, -175.5f, -61.2f, -175.8f, -59.8f, -176.0f, -59.1f, -176.1f, -58.4f, + -176.3f, -57.7f, -176.6f, -56.3f, -176.8f, -55.6f, -176.9f, -54.9f, -177.1f, -54.2f, + -177.3f, -53.6f, -177.4f, -52.9f, -177.6f, -52.2f, -177.9f, -50.8f, -178.1f, -50.1f, + -178.2f, -49.4f, -178.2f, -48.7f, -177.8f, -48.1f, -177.1f, -46.9f, -176.7f, -46.3f, + -176.4f, -45.6f, -176.0f, -45.0f, -175.3f, -43.8f, -174.9f, -43.2f, -174.2f, -42.0f, + -173.4f, -40.7f, -173.1f, -40.1f, -172.7f, -39.5f, -172.0f, -38.3f, -171.6f, -37.7f, + -170.5f, -35.9f, -170.1f, -35.3f, -169.7f, -34.6f, -169.3f, -34.0f, -168.6f, -32.8f, + -168.2f, -32.2f, -166.3f, -29.2f, -165.9f, -28.6f, -163.2f, -24.4f, -162.8f, -23.8f, + -141.8f, 6.8f, -141.4f, 7.4f, -139.4f, 10.3f, -139.0f, 10.9f, -138.5f, 11.5f, + -138.1f, 12.1f, -137.3f, 13.2f, -136.9f, 13.8f, -136.0f, 15.0f, -135.6f, 15.6f, + -135.2f, 16.1f, -134.8f, 16.7f, -133.9f, 17.9f, -133.5f, 18.4f, -133.1f, 19.0f, + -131.8f, 20.7f, -131.4f, 21.3f, -130.1f, 23.0f, -129.7f, 23.6f, -128.4f, 25.3f, + -128.0f, 25.9f, -126.7f, 27.6f, -126.3f, 28.2f, -125.4f, 29.3f, -125.0f, 29.9f, + -124.1f, 31.0f, -123.7f, 31.6f, -122.8f, 32.7f, -122.4f, 33.3f, -121.5f, 34.4f, + -121.1f, 35.0f, -120.6f, 35.6f, -120.2f, 36.1f, -119.7f, 36.7f, -119.3f, 37.2f, + -118.9f, 37.8f, -118.4f, 38.4f, -118.0f, 38.9f, -117.5f, 39.5f, -117.1f, 40.0f, + -116.6f, 40.6f, -116.2f, 41.1f, -115.7f, 41.7f, -115.2f, 42.2f, -114.8f, 42.8f, + -114.3f, 43.3f, -113.9f, 43.9f, -113.4f, 44.4f, -112.4f, 45.5f, -112.0f, 46.0f, + -111.5f, 46.5f, -110.5f, 47.6f, -110.0f, 48.1f, -109.6f, 48.6f, -109.1f, 49.2f, + -108.6f, 49.7f, -107.7f, 50.8f, -107.2f, 51.3f, -105.7f, 52.9f, -105.3f, 53.4f, + -104.8f, 53.9f, -104.3f, 54.5f, -103.8f, 55.0f, -100.7f, 58.0f, -100.2f, 58.4f, + -99.7f, 58.9f, -99.1f, 59.3f, -97.2f, 60.3f, -96.5f, 60.1f, -95.9f, 59.7f, + -95.3f, 59.4f, -94.6f, 59.1f, -93.9f, 58.9f, -92.6f, 58.5f, -91.9f, 58.4f, + -91.2f, 58.2f, -90.5f, 58.1f, -89.7f, 58.0f, -89.0f, 57.9f, -86.2f, 57.6f, + -85.5f, 57.5f, -84.1f, 57.4f, -83.4f, 57.3f, -82.6f, 57.3f, -81.9f, 57.2f, + -81.2f, 57.2f, -80.5f, 57.1f, -79.8f, 57.1f, -78.4f, 57.0f, -77.7f, 57.0f, + -75.5f, 56.9f, -74.8f, 56.9f, -71.9f, 56.8f, -71.2f, 56.8f, 0.0f, 56.8f, +}; + +constexpr std::array gc_body = { + 0.0f, -138.03f, -4.91f, -138.01f, -8.02f, -137.94f, -11.14f, -137.82f, -14.25f, + -137.67f, -17.37f, -137.48f, -20.48f, -137.25f, -23.59f, -137.0f, -26.69f, -136.72f, + -29.8f, -136.41f, -32.9f, -136.07f, -35.99f, -135.71f, -39.09f, -135.32f, -42.18f, + -134.91f, -45.27f, -134.48f, -48.35f, -134.03f, -51.43f, -133.55f, -54.51f, -133.05f, + -57.59f, -132.52f, -60.66f, -131.98f, -63.72f, -131.41f, -66.78f, -130.81f, -69.84f, + -130.2f, -72.89f, -129.56f, -75.94f, -128.89f, -78.98f, -128.21f, -82.02f, -127.49f, + -85.05f, -126.75f, -88.07f, -125.99f, -91.09f, -125.19f, -94.1f, -124.37f, -97.1f, + -123.52f, -100.09f, -122.64f, -103.07f, -121.72f, -106.04f, -120.77f, -109.0f, -119.79f, + -111.95f, -118.77f, -114.88f, -117.71f, -117.8f, -116.61f, -120.7f, -115.46f, -123.58f, + -114.27f, -126.44f, -113.03f, -129.27f, -111.73f, -132.08f, -110.38f, -134.86f, -108.96f, + -137.6f, -107.47f, -140.3f, -105.91f, -142.95f, -104.27f, -145.55f, -102.54f, -148.07f, + -100.71f, -150.51f, -98.77f, -152.86f, -96.71f, -155.09f, -94.54f, -157.23f, -92.27f, + -159.26f, -89.9f, -161.2f, -87.46f, -163.04f, -84.94f, -164.78f, -82.35f, -166.42f, + -79.7f, -167.97f, -77.0f, -169.43f, -74.24f, -170.8f, -71.44f, -172.09f, -68.6f, + -173.29f, -65.72f, -174.41f, -62.81f, -175.45f, -59.87f, -176.42f, -56.91f, -177.31f, + -53.92f, -178.14f, -50.91f, -178.9f, -47.89f, -179.6f, -44.85f, -180.24f, -41.8f, + -180.82f, -38.73f, -181.34f, -35.66f, -181.8f, -32.57f, -182.21f, -29.48f, -182.57f, + -26.38f, -182.88f, -23.28f, -183.15f, -20.17f, -183.36f, -17.06f, -183.54f, -13.95f, + -183.71f, -10.84f, -184.0f, -7.73f, -184.23f, -4.62f, -184.44f, -1.51f, -184.62f, + 1.6f, -184.79f, 4.72f, -184.95f, 7.83f, -185.11f, 10.95f, -185.25f, 14.06f, + -185.38f, 17.18f, -185.51f, 20.29f, -185.63f, 23.41f, -185.74f, 26.53f, -185.85f, + 29.64f, -185.95f, 32.76f, -186.04f, 35.88f, -186.12f, 39.0f, -186.19f, 42.11f, + -186.26f, 45.23f, -186.32f, 48.35f, -186.37f, 51.47f, -186.41f, 54.59f, -186.44f, + 57.7f, -186.46f, 60.82f, -186.46f, 63.94f, -186.44f, 70.18f, -186.41f, 73.3f, + -186.36f, 76.42f, -186.3f, 79.53f, -186.22f, 82.65f, -186.12f, 85.77f, -185.99f, + 88.88f, -185.84f, 92.0f, -185.66f, 95.11f, -185.44f, 98.22f, -185.17f, 101.33f, + -184.85f, 104.43f, -184.46f, 107.53f, -183.97f, 110.61f, -183.37f, 113.67f, -182.65f, + 116.7f, -181.77f, 119.69f, -180.71f, 122.62f, -179.43f, 125.47f, -177.89f, 128.18f, + -176.05f, 130.69f, -173.88f, 132.92f, -171.36f, 134.75f, -168.55f, 136.1f, -165.55f, + 136.93f, -162.45f, 137.29f, -156.23f, 137.03f, -153.18f, 136.41f, -150.46f, 134.9f, + -148.14f, 132.83f, -146.14f, 130.43f, -144.39f, 127.85f, -142.83f, 125.16f, -141.41f, + 122.38f, -140.11f, 119.54f, -138.9f, 116.67f, -137.77f, 113.76f, -136.7f, 110.84f, + -135.68f, 107.89f, -134.71f, 104.93f, -133.77f, 101.95f, -132.86f, 98.97f, -131.97f, + 95.98f, -131.09f, 92.99f, -130.23f, 89.99f, -129.36f, 86.99f, -128.49f, 84.0f, + -127.63f, 81.0f, -126.76f, 78.01f, -125.9f, 75.01f, -124.17f, 69.02f, -123.31f, + 66.02f, -121.59f, 60.03f, -120.72f, 57.03f, -119.86f, 54.03f, -118.13f, 48.04f, + -117.27f, 45.04f, -115.55f, 39.05f, -114.68f, 36.05f, -113.82f, 33.05f, -112.96f, + 30.06f, -110.4f, 28.29f, -107.81f, 26.55f, -105.23f, 24.8f, -97.48f, 19.55f, + -94.9f, 17.81f, -92.32f, 16.06f, -87.15f, 12.56f, -84.57f, 10.81f, -81.99f, + 9.07f, -79.4f, 7.32f, -76.82f, 5.57f, -69.07f, 0.33f, -66.49f, -1.42f, + -58.74f, -6.66f, -56.16f, -8.41f, -48.4f, -13.64f, -45.72f, -15.22f, -42.93f, + -16.62f, -40.07f, -17.86f, -37.15f, -18.96f, -34.19f, -19.94f, -31.19f, -20.79f, + -28.16f, -21.55f, -25.12f, -22.21f, -22.05f, -22.79f, -18.97f, -23.28f, -15.88f, + -23.7f, -12.78f, -24.05f, -9.68f, -24.33f, -6.57f, -24.55f, -3.45f, -24.69f, + 0.0f, -24.69f, +}; + +constexpr std::array gc_left_body = { + -74.59f, -97.22f, -70.17f, -94.19f, -65.95f, -90.89f, -62.06f, -87.21f, -58.58f, + -83.14f, -55.58f, -78.7f, -53.08f, -73.97f, -51.05f, -69.01f, -49.46f, -63.89f, + -48.24f, -58.67f, -47.36f, -53.39f, -46.59f, -48.09f, -45.7f, -42.8f, -44.69f, + -37.54f, -43.54f, -32.31f, -42.25f, -27.11f, -40.8f, -21.95f, -39.19f, -16.84f, + -37.38f, -11.8f, -35.34f, -6.84f, -33.04f, -2.0f, -30.39f, 2.65f, -27.26f, + 7.0f, -23.84f, 11.11f, -21.19f, 15.76f, -19.18f, 20.73f, -17.73f, 25.88f, + -16.82f, 31.16f, -16.46f, 36.5f, -16.7f, 41.85f, -17.63f, 47.13f, -19.31f, + 52.21f, -21.8f, 56.95f, -24.91f, 61.3f, -28.41f, 65.36f, -32.28f, 69.06f, + -36.51f, 72.35f, -41.09f, 75.13f, -45.97f, 77.32f, -51.1f, 78.86f, -56.39f, + 79.7f, -61.74f, 79.84f, -67.07f, 79.3f, -72.3f, 78.15f, -77.39f, 76.48f, + -82.29f, 74.31f, -86.76f, 71.37f, -90.7f, 67.75f, -94.16f, 63.66f, -97.27f, + 59.3f, -100.21f, 54.81f, -103.09f, 50.3f, -106.03f, 45.82f, -109.11f, 41.44f, + -112.37f, 37.19f, -115.85f, 33.11f, -119.54f, 29.22f, -123.45f, 25.56f, -127.55f, + 22.11f, -131.77f, 18.81f, -136.04f, 15.57f, -140.34f, 12.37f, -144.62f, 9.15f, + -148.86f, 5.88f, -153.03f, 2.51f, -157.05f, -1.03f, -160.83f, -4.83f, -164.12f, + -9.05f, -166.71f, -13.73f, -168.91f, -18.62f, -170.77f, -23.64f, -172.3f, -28.78f, + -173.49f, -34.0f, -174.3f, -39.3f, -174.72f, -44.64f, -174.72f, -49.99f, -174.28f, + -55.33f, -173.37f, -60.61f, -172.0f, -65.79f, -170.17f, -70.82f, -167.79f, -75.62f, + -164.84f, -80.09f, -161.43f, -84.22f, -157.67f, -88.03f, -153.63f, -91.55f, -149.37f, + -94.81f, -144.94f, -97.82f, -140.37f, -100.61f, -135.65f, -103.16f, -130.73f, -105.26f, + -125.62f, -106.86f, -120.37f, -107.95f, -115.05f, -108.56f, -109.7f, -108.69f, -104.35f, + -108.36f, -99.05f, -107.6f, -93.82f, -106.41f, -88.72f, -104.79f, -83.78f, -102.7f, +}; + +constexpr std::array left_gc_trigger = { + -99.69f, -125.04f, -101.81f, -126.51f, -104.02f, -127.85f, -106.3f, -129.06f, -108.65f, + -130.12f, -111.08f, -130.99f, -113.58f, -131.62f, -116.14f, -131.97f, -121.26f, -131.55f, + -123.74f, -130.84f, -126.17f, -129.95f, -128.53f, -128.9f, -130.82f, -127.71f, -133.03f, + -126.38f, -135.15f, -124.92f, -137.18f, -123.32f, -139.11f, -121.6f, -140.91f, -119.75f, + -142.55f, -117.77f, -144.0f, -115.63f, -145.18f, -113.34f, -146.17f, -110.95f, -147.05f, + -108.53f, -147.87f, -106.08f, -148.64f, -103.61f, -149.37f, -101.14f, -149.16f, -100.12f, + -147.12f, -101.71f, -144.99f, -103.16f, -142.8f, -104.53f, -140.57f, -105.83f, -138.31f, + -107.08f, -136.02f, -108.27f, -133.71f, -109.42f, -131.38f, -110.53f, -129.04f, -111.61f, + -126.68f, -112.66f, -124.31f, -113.68f, -121.92f, -114.67f, -119.53f, -115.64f, -117.13f, + -116.58f, -114.72f, -117.51f, -112.3f, -118.41f, -109.87f, -119.29f, -107.44f, -120.16f, + -105.0f, -121.0f, -100.11f, -122.65f, +}; + +constexpr std::array gc_button_x = { + 142.1f, -50.67f, 142.44f, -48.65f, 142.69f, -46.62f, 142.8f, -44.57f, 143.0f, -42.54f, + 143.56f, -40.57f, 144.42f, -38.71f, 145.59f, -37.04f, 147.08f, -35.64f, 148.86f, -34.65f, + 150.84f, -34.11f, 152.88f, -34.03f, 154.89f, -34.38f, 156.79f, -35.14f, 158.49f, -36.28f, + 159.92f, -37.74f, 161.04f, -39.45f, 161.85f, -41.33f, 162.4f, -43.3f, 162.72f, -45.32f, + 162.85f, -47.37f, 162.82f, -49.41f, 162.67f, -51.46f, 162.39f, -53.48f, 162.0f, -55.5f, + 161.51f, -57.48f, 160.9f, -59.44f, 160.17f, -61.35f, 159.25f, -63.18f, 158.19f, -64.93f, + 157.01f, -66.61f, 155.72f, -68.2f, 154.31f, -69.68f, 152.78f, -71.04f, 151.09f, -72.2f, + 149.23f, -73.04f, 147.22f, -73.36f, 145.19f, -73.11f, 143.26f, -72.42f, 141.51f, -71.37f, + 140.0f, -69.99f, 138.82f, -68.32f, 138.13f, -66.4f, 138.09f, -64.36f, 138.39f, -62.34f, + 139.05f, -60.41f, 139.91f, -58.55f, 140.62f, -56.63f, 141.21f, -54.67f, 141.67f, -52.67f, +}; + +constexpr std::array gc_button_y = { + 104.02f, -75.23f, 106.01f, -75.74f, 108.01f, -76.15f, 110.04f, -76.42f, 112.05f, -76.78f, + 113.97f, -77.49f, 115.76f, -78.49f, 117.33f, -79.79f, 118.6f, -81.39f, 119.46f, -83.25f, + 119.84f, -85.26f, 119.76f, -87.3f, 119.24f, -89.28f, 118.33f, -91.11f, 117.06f, -92.71f, + 115.49f, -94.02f, 113.7f, -95.01f, 111.77f, -95.67f, 109.76f, -96.05f, 107.71f, -96.21f, + 105.67f, -96.18f, 103.63f, -95.99f, 101.61f, -95.67f, 99.61f, -95.24f, 97.63f, -94.69f, + 95.69f, -94.04f, 93.79f, -93.28f, 91.94f, -92.4f, 90.19f, -91.34f, 88.53f, -90.14f, + 86.95f, -88.84f, 85.47f, -87.42f, 84.1f, -85.9f, 82.87f, -84.26f, 81.85f, -82.49f, + 81.15f, -80.57f, 81.0f, -78.54f, 81.41f, -76.54f, 82.24f, -74.67f, 83.43f, -73.01f, + 84.92f, -71.61f, 86.68f, -70.57f, 88.65f, -70.03f, 90.69f, -70.15f, 92.68f, -70.61f, + 94.56f, -71.42f, 96.34f, -72.43f, 98.2f, -73.29f, 100.11f, -74.03f, 102.06f, -74.65f, +}; + +constexpr std::array gc_button_z = { + 95.74f, -126.41f, 98.34f, -126.38f, 100.94f, -126.24f, 103.53f, -126.01f, 106.11f, -125.7f, + 108.69f, -125.32f, 111.25f, -124.87f, 113.8f, -124.34f, 116.33f, -123.73f, 118.84f, -123.05f, + 121.33f, -122.3f, 123.79f, -121.47f, 126.23f, -120.56f, 128.64f, -119.58f, 131.02f, -118.51f, + 133.35f, -117.37f, 135.65f, -116.14f, 137.9f, -114.84f, 140.1f, -113.46f, 142.25f, -111.99f, + 144.35f, -110.45f, 146.38f, -108.82f, 148.35f, -107.13f, 150.25f, -105.35f, 151.89f, -103.38f, + 151.43f, -100.86f, 149.15f, -100.15f, 146.73f, -101.06f, 144.36f, -102.12f, 141.98f, -103.18f, + 139.6f, -104.23f, 137.22f, -105.29f, 134.85f, -106.35f, 132.47f, -107.41f, 127.72f, -109.53f, + 125.34f, -110.58f, 122.96f, -111.64f, 120.59f, -112.7f, 118.21f, -113.76f, 113.46f, -115.88f, + 111.08f, -116.93f, 108.7f, -117.99f, 106.33f, -119.05f, 103.95f, -120.11f, 99.2f, -122.23f, + 96.82f, -123.29f, 94.44f, -124.34f, +}; + +constexpr std::array left_joycon_body = { + -145.0f, -78.9f, -145.0f, -77.9f, -145.0f, 85.6f, -145.0f, 85.6f, -168.3f, 85.5f, + -169.3f, 85.4f, -171.3f, 85.1f, -172.3f, 84.9f, -173.4f, 84.7f, -174.3f, 84.5f, + -175.3f, 84.2f, -176.3f, 83.8f, -177.3f, 83.5f, -178.2f, 83.1f, -179.2f, 82.7f, + -180.1f, 82.2f, -181.0f, 81.8f, -181.9f, 81.3f, -182.8f, 80.7f, -183.7f, 80.2f, + -184.5f, 79.6f, -186.2f, 78.3f, -186.9f, 77.7f, -187.7f, 77.0f, -189.2f, 75.6f, + -189.9f, 74.8f, -190.6f, 74.1f, -191.3f, 73.3f, -191.9f, 72.5f, -192.5f, 71.6f, + -193.1f, 70.8f, -193.7f, 69.9f, -194.3f, 69.1f, -194.8f, 68.2f, -196.2f, 65.5f, + -196.6f, 64.5f, -197.0f, 63.6f, -197.4f, 62.6f, -198.1f, 60.7f, -198.4f, 59.7f, + -198.6f, 58.7f, -199.2f, 55.6f, -199.3f, 54.6f, -199.5f, 51.5f, -199.5f, 50.5f, + -199.5f, -49.4f, -199.4f, -50.5f, -199.3f, -51.5f, -199.1f, -52.5f, -198.2f, -56.5f, + -197.9f, -57.5f, -197.2f, -59.4f, -196.8f, -60.4f, -196.4f, -61.3f, -195.9f, -62.2f, + -194.3f, -64.9f, -193.7f, -65.7f, -193.1f, -66.6f, -192.5f, -67.4f, -191.8f, -68.2f, + -191.2f, -68.9f, -190.4f, -69.7f, -188.2f, -71.8f, -187.4f, -72.5f, -186.6f, -73.1f, + -185.8f, -73.8f, -185.0f, -74.4f, -184.1f, -74.9f, -183.2f, -75.5f, -182.4f, -76.0f, + -181.5f, -76.5f, -179.6f, -77.5f, -178.7f, -77.9f, -177.8f, -78.4f, -176.8f, -78.8f, + -175.9f, -79.1f, -174.9f, -79.5f, -173.9f, -79.8f, -170.9f, -80.6f, -169.9f, -80.8f, + -167.9f, -81.1f, -166.9f, -81.2f, -165.8f, -81.2f, -145.0f, -80.9f, +}; + +constexpr std::array left_joycon_trigger = { + -166.8f, -83.3f, -167.9f, -83.2f, -168.9f, -83.1f, -170.0f, -83.0f, -171.0f, -82.8f, + -172.1f, -82.6f, -173.1f, -82.4f, -174.2f, -82.1f, -175.2f, -81.9f, -176.2f, -81.5f, + -177.2f, -81.2f, -178.2f, -80.8f, -180.1f, -80.0f, -181.1f, -79.5f, -182.0f, -79.0f, + -183.0f, -78.5f, -183.9f, -78.0f, -184.8f, -77.4f, -185.7f, -76.9f, -186.6f, -76.3f, + -187.4f, -75.6f, -188.3f, -75.0f, -189.1f, -74.3f, -192.2f, -71.5f, -192.9f, -70.7f, + -193.7f, -69.9f, -194.3f, -69.1f, -195.0f, -68.3f, -195.6f, -67.4f, -196.8f, -65.7f, + -197.3f, -64.7f, -197.8f, -63.8f, -198.2f, -62.8f, -198.9f, -60.8f, -198.6f, -59.8f, + -197.6f, -59.7f, -196.6f, -60.0f, -195.6f, -60.5f, -194.7f, -60.9f, -193.7f, -61.4f, + -192.8f, -61.9f, -191.8f, -62.4f, -190.9f, -62.8f, -189.9f, -63.3f, -189.0f, -63.8f, + -187.1f, -64.8f, -186.2f, -65.2f, -185.2f, -65.7f, -184.3f, -66.2f, -183.3f, -66.7f, + -182.4f, -67.1f, -181.4f, -67.6f, -180.5f, -68.1f, -179.5f, -68.6f, -178.6f, -69.0f, + -177.6f, -69.5f, -176.7f, -70.0f, -175.7f, -70.5f, -174.8f, -70.9f, -173.8f, -71.4f, + -172.9f, -71.9f, -171.9f, -72.4f, -171.0f, -72.8f, -170.0f, -73.3f, -169.1f, -73.8f, + -168.1f, -74.3f, -167.2f, -74.7f, -166.2f, -75.2f, -165.3f, -75.7f, -164.3f, -76.2f, + -163.4f, -76.6f, -162.4f, -77.1f, -161.5f, -77.6f, -160.5f, -78.1f, -159.6f, -78.5f, + -158.7f, -79.0f, -157.7f, -79.5f, -156.8f, -80.0f, -155.8f, -80.4f, -154.9f, -80.9f, + -154.2f, -81.6f, -154.3f, -82.6f, -155.2f, -83.3f, -156.2f, -83.3f, +}; + +constexpr std::array handheld_body = { + -137.3f, -81.9f, -137.6f, -81.8f, -137.8f, -81.6f, -138.0f, -81.3f, -138.1f, -81.1f, + -138.1f, -80.8f, -138.2f, -78.7f, -138.2f, -78.4f, -138.3f, -78.1f, -138.7f, -77.3f, + -138.9f, -77.0f, -139.0f, -76.8f, -139.2f, -76.5f, -139.5f, -76.3f, -139.7f, -76.1f, + -139.9f, -76.0f, -140.2f, -75.8f, -140.5f, -75.7f, -140.7f, -75.6f, -141.0f, -75.5f, + -141.9f, -75.3f, -142.2f, -75.3f, -142.5f, -75.2f, -143.0f, -74.9f, -143.2f, -74.7f, + -143.3f, -74.4f, -143.0f, -74.1f, -143.0f, 85.3f, -143.0f, 85.6f, -142.7f, 85.8f, + -142.4f, 85.9f, -142.2f, 85.9f, 143.0f, 85.6f, 143.1f, 85.4f, 143.3f, 85.1f, + 143.0f, 84.8f, 143.0f, -74.9f, 142.8f, -75.1f, 142.5f, -75.2f, 141.9f, -75.3f, + 141.6f, -75.3f, 141.3f, -75.4f, 141.1f, -75.4f, 140.8f, -75.5f, 140.5f, -75.7f, + 140.2f, -75.8f, 140.0f, -76.0f, 139.7f, -76.1f, 139.5f, -76.3f, 139.1f, -76.8f, + 138.9f, -77.0f, 138.6f, -77.5f, 138.4f, -77.8f, 138.3f, -78.1f, 138.3f, -78.3f, + 138.2f, -78.6f, 138.2f, -78.9f, 138.1f, -79.2f, 138.1f, -79.5f, 138.0f, -81.3f, + 137.8f, -81.6f, 137.6f, -81.8f, 137.3f, -81.9f, 137.1f, -81.9f, 120.0f, -70.0f, + -120.0f, -70.0f, -120.0f, 70.0f, 120.0f, 70.0f, 120.0f, -70.0f, 137.1f, -81.9f, +}; + +constexpr std::array handheld_bezel = { + -131.4f, -75.9f, -132.2f, -75.7f, -132.9f, -75.3f, -134.2f, -74.3f, -134.7f, -73.6f, + -135.1f, -72.8f, -135.4f, -72.0f, -135.5f, -71.2f, -135.5f, -70.4f, -135.2f, 76.7f, + -134.8f, 77.5f, -134.3f, 78.1f, -133.7f, 78.8f, -133.1f, 79.2f, -132.3f, 79.6f, + -131.5f, 79.9f, -130.7f, 80.0f, -129.8f, 80.0f, 132.2f, 79.7f, 133.0f, 79.3f, + 133.7f, 78.8f, 134.3f, 78.3f, 134.8f, 77.6f, 135.1f, 76.8f, 135.5f, 75.2f, + 135.5f, 74.3f, 135.2f, -72.7f, 134.8f, -73.5f, 134.4f, -74.2f, 133.8f, -74.8f, + 133.1f, -75.3f, 132.3f, -75.6f, 130.7f, -76.0f, 129.8f, -76.0f, -112.9f, -62.2f, + 112.9f, -62.2f, 112.9f, 62.2f, -112.9f, 62.2f, -112.9f, -62.2f, 129.8f, -76.0f, +}; + +constexpr std::array handheld_buttons = { + -82.48f, -82.95f, -82.53f, -82.95f, -106.69f, -82.96f, -106.73f, -82.98f, -106.78f, -83.01f, + -106.81f, -83.05f, -106.83f, -83.1f, -106.83f, -83.15f, -106.82f, -83.93f, -106.81f, -83.99f, + -106.8f, -84.04f, -106.78f, -84.08f, -106.76f, -84.13f, -106.73f, -84.18f, -106.7f, -84.22f, + -106.6f, -84.34f, -106.56f, -84.37f, -106.51f, -84.4f, -106.47f, -84.42f, -106.42f, -84.45f, + -106.37f, -84.47f, -106.32f, -84.48f, -106.17f, -84.5f, -98.9f, -84.48f, -98.86f, -84.45f, + -98.83f, -84.41f, -98.81f, -84.36f, -98.8f, -84.31f, -98.8f, -84.26f, -98.79f, -84.05f, + -90.26f, -84.1f, -90.26f, -84.15f, -90.25f, -84.36f, -90.23f, -84.41f, -90.2f, -84.45f, + -90.16f, -84.48f, -90.11f, -84.5f, -82.79f, -84.49f, -82.74f, -84.48f, -82.69f, -84.46f, + -82.64f, -84.45f, -82.59f, -84.42f, -82.55f, -84.4f, -82.5f, -84.37f, -82.46f, -84.33f, + -82.42f, -84.3f, -82.39f, -84.26f, -82.3f, -84.13f, -82.28f, -84.08f, -82.25f, -83.98f, + -82.24f, -83.93f, -82.23f, -83.83f, -82.23f, -83.78f, -82.24f, -83.1f, -82.26f, -83.05f, + -82.29f, -83.01f, -82.33f, -82.97f, -82.38f, -82.95f, +}; + +constexpr std::array left_joycon_slider = { + -23.7f, -118.2f, -23.7f, -117.3f, -23.7f, 96.6f, -22.8f, 96.6f, -21.5f, 97.2f, -21.5f, + 98.1f, -21.2f, 106.7f, -20.8f, 107.5f, -20.1f, 108.2f, -19.2f, 108.2f, -16.4f, 108.1f, + -15.8f, 107.5f, -15.8f, 106.5f, -15.8f, 62.8f, -16.3f, 61.9f, -15.8f, 61.0f, -17.3f, + 60.3f, -19.1f, 58.9f, -19.1f, 58.1f, -19.1f, 57.2f, -19.1f, 34.5f, -17.9f, 33.9f, + -17.2f, 33.2f, -16.6f, 32.4f, -16.2f, 31.6f, -15.8f, 30.7f, -15.8f, 29.7f, -15.8f, + 28.8f, -15.8f, -46.4f, -16.3f, -47.3f, -15.8f, -48.1f, -17.4f, -48.8f, -19.1f, -49.4f, + -19.1f, -50.1f, -19.1f, -51.0f, -19.1f, -51.9f, -19.1f, -73.7f, -19.1f, -74.5f, -17.5f, + -75.2f, -16.4f, -76.7f, -16.0f, -77.6f, -15.8f, -78.5f, -15.8f, -79.4f, -15.8f, -80.4f, + -15.8f, -118.2f, -15.8f, -118.2f, -18.3f, -118.2f, +}; + +constexpr std::array left_joycon_sideview = { + -158.8f, -133.5f, -159.8f, -133.5f, -173.5f, -133.3f, -174.5f, -133.0f, -175.4f, -132.6f, + -176.2f, -132.1f, -177.0f, -131.5f, -177.7f, -130.9f, -178.3f, -130.1f, -179.4f, -128.5f, + -179.8f, -127.6f, -180.4f, -125.7f, -180.6f, -124.7f, -180.7f, -123.8f, -180.7f, -122.8f, + -180.0f, 128.8f, -179.6f, 129.7f, -179.1f, 130.5f, -177.9f, 132.1f, -177.2f, 132.7f, + -176.4f, 133.3f, -175.6f, 133.8f, -174.7f, 134.3f, -173.8f, 134.6f, -172.8f, 134.8f, + -170.9f, 135.0f, -169.9f, 135.0f, -156.1f, 134.8f, -155.2f, 134.6f, -154.2f, 134.3f, + -153.3f, 134.0f, -152.4f, 133.6f, -151.6f, 133.1f, -150.7f, 132.6f, -149.9f, 132.0f, + -149.2f, 131.4f, -148.5f, 130.7f, -147.1f, 129.2f, -146.5f, 128.5f, -146.0f, 127.7f, + -145.5f, 126.8f, -145.0f, 126.0f, -144.6f, 125.1f, -144.2f, 124.1f, -143.9f, 123.2f, + -143.7f, 122.2f, -143.6f, 121.3f, -143.5f, 120.3f, -143.5f, 119.3f, -144.4f, -123.4f, + -144.8f, -124.3f, -145.3f, -125.1f, -145.8f, -126.0f, -146.3f, -126.8f, -147.0f, -127.5f, + -147.6f, -128.3f, -148.3f, -129.0f, -149.0f, -129.6f, -149.8f, -130.3f, -150.6f, -130.8f, + -151.4f, -131.4f, -152.2f, -131.9f, -153.1f, -132.3f, -155.9f, -133.3f, -156.8f, -133.5f, + -157.8f, -133.5f, +}; + +constexpr std::array left_joycon_body_trigger = { + -146.1f, -124.3f, -146.0f, -122.0f, -145.8f, -119.7f, -145.7f, -117.4f, -145.4f, -112.8f, + -145.3f, -110.5f, -145.0f, -105.9f, -144.9f, -103.6f, -144.6f, -99.1f, -144.5f, -96.8f, + -144.5f, -89.9f, -144.5f, -87.6f, -144.5f, -83.0f, -144.5f, -80.7f, -144.5f, -80.3f, + -142.4f, -82.4f, -141.4f, -84.5f, -140.2f, -86.4f, -138.8f, -88.3f, -137.4f, -90.1f, + -134.5f, -93.6f, -133.0f, -95.3f, -130.0f, -98.8f, -128.5f, -100.6f, -127.1f, -102.4f, + -125.8f, -104.3f, -124.7f, -106.3f, -123.9f, -108.4f, -125.1f, -110.2f, -127.4f, -110.3f, + -129.7f, -110.3f, -134.2f, -110.5f, -136.4f, -111.4f, -138.1f, -112.8f, -139.4f, -114.7f, + -140.5f, -116.8f, -141.4f, -118.9f, -143.3f, -123.1f, -144.6f, -124.9f, -146.2f, -126.0f, +}; + +constexpr std::array left_joycon_topview = { + -184.8f, -20.8f, -185.6f, -21.1f, -186.4f, -21.5f, -187.1f, -22.1f, -187.8f, -22.6f, + -188.4f, -23.2f, -189.6f, -24.5f, -190.2f, -25.2f, -190.7f, -25.9f, -191.1f, -26.7f, + -191.4f, -27.5f, -191.6f, -28.4f, -191.7f, -29.2f, -191.7f, -30.1f, -191.5f, -47.7f, + -191.2f, -48.5f, -191.0f, -49.4f, -190.7f, -50.2f, -190.3f, -51.0f, -190.0f, -51.8f, + -189.6f, -52.6f, -189.1f, -53.4f, -188.6f, -54.1f, -187.5f, -55.4f, -186.9f, -56.1f, + -186.2f, -56.7f, -185.5f, -57.2f, -184.0f, -58.1f, -183.3f, -58.5f, -182.5f, -58.9f, + -181.6f, -59.2f, -180.8f, -59.5f, -179.9f, -59.7f, -179.1f, -59.9f, -178.2f, -60.0f, + -174.7f, -60.1f, -168.5f, -60.2f, -162.4f, -60.3f, -156.2f, -60.4f, -149.2f, -60.5f, + -143.0f, -60.6f, -136.9f, -60.7f, -130.7f, -60.8f, -123.7f, -60.9f, -117.5f, -61.0f, + -110.5f, -61.1f, -94.4f, -60.4f, -94.4f, -59.5f, -94.4f, -20.6f, +}; + +constexpr std::array left_joycon_slider_topview = { + -95.1f, -51.5f, -95.0f, -51.5f, -91.2f, -51.6f, -91.2f, -51.7f, -91.1f, -52.4f, -91.1f, -52.6f, + -91.0f, -54.1f, -86.3f, -54.0f, -86.0f, -53.9f, -85.9f, -53.8f, -85.6f, -53.4f, -85.5f, -53.2f, + -85.5f, -53.1f, -85.4f, -52.9f, -85.4f, -52.8f, -85.3f, -52.4f, -85.3f, -52.3f, -85.4f, -27.2f, + -85.4f, -27.1f, -85.5f, -27.0f, -85.5f, -26.9f, -85.6f, -26.7f, -85.6f, -26.6f, -85.7f, -26.5f, + -85.9f, -26.4f, -86.0f, -26.3f, -86.4f, -26.0f, -86.5f, -25.9f, -86.7f, -25.8f, -87.1f, -25.7f, + -90.4f, -25.8f, -90.7f, -25.9f, -90.8f, -26.0f, -90.9f, -26.3f, -91.0f, -26.4f, -91.0f, -26.5f, + -91.1f, -26.7f, -91.1f, -26.9f, -91.2f, -28.9f, -95.2f, -29.1f, -95.2f, -29.2f, +}; + +constexpr std::array left_joycon_sideview_zl = { + -148.9f, -128.2f, -148.7f, -126.6f, -148.4f, -124.9f, -148.2f, -123.3f, -147.9f, -121.7f, + -147.7f, -120.1f, -147.4f, -118.5f, -147.2f, -116.9f, -146.9f, -115.3f, -146.4f, -112.1f, + -146.1f, -110.5f, -145.9f, -108.9f, -145.6f, -107.3f, -144.2f, -107.3f, -142.6f, -107.5f, + -141.0f, -107.8f, -137.8f, -108.3f, -136.2f, -108.6f, -131.4f, -109.4f, -129.8f, -109.7f, + -125.6f, -111.4f, -124.5f, -112.7f, -123.9f, -114.1f, -123.8f, -115.8f, -123.8f, -117.4f, + -123.9f, -120.6f, -124.5f, -122.1f, -125.8f, -123.1f, -127.4f, -123.4f, -129.0f, -123.6f, + -130.6f, -124.0f, -132.1f, -124.4f, -133.7f, -124.8f, -135.3f, -125.3f, -136.8f, -125.9f, + -138.3f, -126.4f, -139.9f, -126.9f, -141.4f, -127.5f, -142.9f, -128.0f, -144.5f, -128.5f, + -146.0f, -129.0f, -147.6f, -129.4f, +}; + +constexpr std::array left_joystick_sideview = { + -14.7f, -3.8f, -15.2f, -5.6f, -15.2f, -7.6f, -15.5f, -17.6f, -17.4f, -18.3f, -19.4f, -18.2f, + -21.3f, -17.6f, -22.8f, -16.4f, -23.4f, -14.5f, -23.4f, -12.5f, -24.1f, -8.6f, -24.8f, -6.7f, + -25.3f, -4.8f, -25.7f, -2.8f, -25.9f, -0.8f, -26.0f, 1.2f, -26.0f, 3.2f, -25.8f, 5.2f, + -25.5f, 7.2f, -25.0f, 9.2f, -24.4f, 11.1f, -23.7f, 13.0f, -23.4f, 14.9f, -23.4f, 16.9f, + -23.3f, 18.9f, -22.0f, 20.5f, -20.2f, 21.3f, -18.3f, 21.6f, -16.3f, 21.4f, -15.3f, 19.9f, + -15.3f, 17.8f, -15.2f, 7.8f, -13.5f, 6.4f, -12.4f, 7.2f, -11.4f, 8.9f, -10.2f, 10.5f, + -8.7f, 11.8f, -7.1f, 13.0f, -5.3f, 14.0f, -3.5f, 14.7f, -1.5f, 15.0f, 0.5f, 15.0f, + 2.5f, 14.7f, 4.4f, 14.2f, 6.3f, 13.4f, 8.0f, 12.4f, 9.6f, 11.1f, 10.9f, 9.6f, + 12.0f, 7.9f, 12.7f, 6.0f, 13.2f, 4.1f, 13.3f, 2.1f, 13.2f, 0.1f, 12.9f, -1.9f, + 12.2f, -3.8f, 11.3f, -5.6f, 10.2f, -7.2f, 8.8f, -8.6f, 7.1f, -9.8f, 5.4f, -10.8f, + 3.5f, -11.5f, 1.5f, -11.9f, -0.5f, -12.0f, -2.5f, -11.8f, -4.4f, -11.3f, -6.2f, -10.4f, + -8.0f, -9.4f, -9.6f, -8.2f, -10.9f, -6.7f, -11.9f, -4.9f, -12.8f, -3.2f, -13.5f, -3.8f, +}; + +constexpr std::array left_joystick_L_topview = { + -186.7f, -43.7f, -186.4f, -43.7f, -110.6f, -43.4f, -110.6f, -43.1f, -110.7f, -34.3f, + -110.7f, -34.0f, -110.8f, -33.7f, -111.1f, -32.9f, -111.2f, -32.6f, -111.4f, -32.3f, + -111.5f, -32.1f, -111.7f, -31.8f, -111.8f, -31.5f, -112.0f, -31.3f, -112.2f, -31.0f, + -112.4f, -30.8f, -112.8f, -30.3f, -113.0f, -30.1f, -114.1f, -29.1f, -114.3f, -28.9f, + -114.6f, -28.7f, -114.8f, -28.6f, -115.1f, -28.4f, -115.3f, -28.3f, -115.6f, -28.1f, + -115.9f, -28.0f, -116.4f, -27.8f, -116.7f, -27.7f, -117.3f, -27.6f, -117.6f, -27.5f, + -182.9f, -27.6f, -183.5f, -27.7f, -183.8f, -27.8f, -184.4f, -27.9f, -184.6f, -28.1f, + -184.9f, -28.2f, -185.4f, -28.5f, -185.7f, -28.7f, -185.9f, -28.8f, -186.2f, -29.0f, + -186.4f, -29.2f, -187.0f, -29.9f, -187.2f, -30.1f, -187.6f, -30.6f, -187.8f, -30.8f, + -187.9f, -31.1f, -188.1f, -31.3f, -188.2f, -31.6f, -188.4f, -31.9f, -188.5f, -32.1f, + -188.6f, -32.4f, -188.8f, -33.3f, -188.9f, -33.6f, -188.9f, -33.9f, -188.8f, -39.9f, + -188.8f, -40.2f, -188.7f, -41.1f, -188.7f, -41.4f, -188.6f, -41.7f, -188.0f, -43.1f, + -187.9f, -43.4f, -187.6f, -43.6f, -187.3f, -43.7f, +}; + +constexpr std::array left_joystick_ZL_topview = { + -179.4f, -53.3f, -177.4f, -53.3f, -111.2f, -53.3f, -111.3f, -53.3f, -111.5f, -58.6f, + -111.8f, -60.5f, -112.2f, -62.4f, -113.1f, -66.1f, -113.8f, -68.0f, -114.5f, -69.8f, + -115.3f, -71.5f, -116.3f, -73.2f, -117.3f, -74.8f, -118.5f, -76.4f, -119.8f, -77.8f, + -121.2f, -79.1f, -122.8f, -80.2f, -124.4f, -81.2f, -126.2f, -82.0f, -128.1f, -82.6f, + -130.0f, -82.9f, -131.9f, -83.0f, -141.5f, -82.9f, -149.3f, -82.8f, -153.1f, -82.6f, + -155.0f, -82.1f, -156.8f, -81.6f, -158.7f, -80.9f, -160.4f, -80.2f, -162.2f, -79.3f, + -163.8f, -78.3f, -165.4f, -77.2f, -166.9f, -76.0f, -168.4f, -74.7f, -169.7f, -73.3f, + -172.1f, -70.3f, -173.2f, -68.7f, -174.2f, -67.1f, -175.2f, -65.4f, -176.1f, -63.7f, + -178.7f, -58.5f, -179.6f, -56.8f, -180.4f, -55.1f, -181.3f, -53.3f, +}; + +void PlayerControlPreview::DrawProBody(QPainter& p, const QPointF center) { + std::array qleft_handle; + std::array qright_handle; + std::array qbody; + constexpr int radius1 = 32; + + for (std::size_t point = 0; point < pro_left_handle.size() / 2; ++point) { + const float left_x = pro_left_handle[point * 2 + 0]; + const float left_y = pro_left_handle[point * 2 + 1]; + + qleft_handle[point] = center + QPointF(left_x, left_y); + qright_handle[point] = center + QPointF(-left_x, left_y); + } + for (std::size_t point = 0; point < pro_body.size() / 2; ++point) { + const float body_x = pro_body[point * 2 + 0]; + const float body_y = pro_body[point * 2 + 1]; + + qbody[point] = center + QPointF(body_x, body_y); + qbody[pro_body.size() - 1 - point] = center + QPointF(-body_x, body_y); + } + + // Draw left handle body + p.setPen(colors.outline); + p.setBrush(colors.left); + DrawPolygon(p, qleft_handle); + + // Draw right handle body + p.setBrush(colors.right); + DrawPolygon(p, qright_handle); + + // Draw body + p.setBrush(colors.primary); + DrawPolygon(p, qbody); + + // Draw joycon circles + p.setBrush(colors.transparent); + p.drawEllipse(center + QPoint(-111, -55), radius1, radius1); + p.drawEllipse(center + QPoint(51, 0), radius1, radius1); +} + +void PlayerControlPreview::DrawGCBody(QPainter& p, const QPointF center) { + std::array qleft_handle; + std::array qright_handle; + std::array qbody; + std::array left_hex; + std::array right_hex; + constexpr float angle = 2 * 3.1415f / 8; + + for (std::size_t point = 0; point < gc_left_body.size() / 2; ++point) { + const float body_x = gc_left_body[point * 2 + 0]; + const float body_y = gc_left_body[point * 2 + 1]; + + qleft_handle[point] = center + QPointF(body_x, body_y); + qright_handle[point] = center + QPointF(-body_x, body_y); + } + for (std::size_t point = 0; point < gc_body.size() / 2; ++point) { + const float body_x = gc_body[point * 2 + 0]; + const float body_y = gc_body[point * 2 + 1]; + + qbody[point] = center + QPointF(body_x, body_y); + qbody[gc_body.size() - 1 - point] = center + QPointF(-body_x, body_y); + } + for (std::size_t point = 0; point < 8; ++point) { + const float point_cos = std::cos(point * angle); + const float point_sin = std::sin(point * angle); + + left_hex[point] = center + QPointF(34 * point_cos - 111, 34 * point_sin - 44); + right_hex[point] = center + QPointF(26 * point_cos + 61, 26 * point_sin + 37); + } + + // Draw body + p.setPen(colors.outline); + p.setBrush(colors.primary); + DrawPolygon(p, qbody); + + // Draw left handle body + p.setBrush(colors.left); + DrawPolygon(p, qleft_handle); + + // Draw right handle body + p.setBrush(colors.right); + DrawPolygon(p, qright_handle); + + DrawText(p, center + QPoint(0, -58), 4.7f, tr("START/PAUSE")); + + // Draw right joystick body + p.setBrush(colors.button); + DrawCircle(p, center + QPointF(61, 37), 23.5f); + + // Draw joystick details + p.setBrush(colors.transparent); + DrawPolygon(p, left_hex); + DrawPolygon(p, right_hex); +} + +void PlayerControlPreview::DrawHandheldBody(QPainter& p, const QPointF center) { + const std::size_t body_outline_end = handheld_body.size() / 2 - 6; + const std::size_t bezel_outline_end = handheld_bezel.size() / 2 - 6; + const std::size_t bezel_inline_size = 4; + const std::size_t bezel_inline_start = 35; + std::array left_joycon; + std::array right_joycon; + std::array qhandheld_body; + std::array qhandheld_body_outline; + std::array qhandheld_bezel; + std::array qhandheld_bezel_inline; + std::array qhandheld_bezel_outline; + std::array qhandheld_buttons; + + for (std::size_t point = 0; point < left_joycon_body.size() / 2; ++point) { + left_joycon[point] = + center + QPointF(left_joycon_body[point * 2], left_joycon_body[point * 2 + 1]); + right_joycon[point] = + center + QPointF(-left_joycon_body[point * 2], left_joycon_body[point * 2 + 1]); + } + for (std::size_t point = 0; point < body_outline_end; ++point) { + qhandheld_body_outline[point] = + center + QPointF(handheld_body[point * 2], handheld_body[point * 2 + 1]); + } + for (std::size_t point = 0; point < handheld_body.size() / 2; ++point) { + qhandheld_body[point] = + center + QPointF(handheld_body[point * 2], handheld_body[point * 2 + 1]); + } + for (std::size_t point = 0; point < handheld_bezel.size() / 2; ++point) { + qhandheld_bezel[point] = + center + QPointF(handheld_bezel[point * 2], handheld_bezel[point * 2 + 1]); + } + for (std::size_t point = 0; point < bezel_outline_end; ++point) { + qhandheld_bezel_outline[point] = + center + QPointF(handheld_bezel[point * 2], handheld_bezel[point * 2 + 1]); + } + for (std::size_t point = 0; point < bezel_inline_size; ++point) { + qhandheld_bezel_inline[point] = + center + QPointF(handheld_bezel[(point + bezel_inline_start) * 2], + handheld_bezel[(point + bezel_inline_start) * 2 + 1]); + } + for (std::size_t point = 0; point < handheld_buttons.size() / 2; ++point) { + qhandheld_buttons[point] = + center + QPointF(handheld_buttons[point * 2], handheld_buttons[point * 2 + 1]); + } + + // Draw left joycon + p.setPen(colors.outline); + p.setBrush(colors.left); + DrawPolygon(p, left_joycon); + + // Draw right joycon + p.setPen(colors.outline); + p.setBrush(colors.right); + DrawPolygon(p, right_joycon); + + // Draw Handheld buttons + p.setPen(colors.outline); + p.setBrush(colors.button); + DrawPolygon(p, qhandheld_buttons); + + // Draw handheld body + p.setPen(colors.transparent); + p.setBrush(colors.primary); + DrawPolygon(p, qhandheld_body); + p.setPen(colors.outline); + p.setBrush(colors.transparent); + DrawPolygon(p, qhandheld_body_outline); + + // Draw Handheld bezel + p.setPen(colors.transparent); + p.setBrush(colors.button); + DrawPolygon(p, qhandheld_bezel); + p.setPen(colors.outline); + p.setBrush(colors.transparent); + DrawPolygon(p, qhandheld_bezel_outline); + DrawPolygon(p, qhandheld_bezel_inline); +} + +void PlayerControlPreview::DrawDualBody(QPainter& p, const QPointF center) { + std::array left_joycon; + std::array right_joycon; + std::array qleft_joycon_slider; + std::array qright_joycon_slider; + std::array qleft_joycon_slider_topview; + std::array qright_joycon_slider_topview; + std::array qleft_joycon_topview; + std::array qright_joycon_topview; + constexpr float size = 1.61f; + constexpr float size2 = 0.9f; + constexpr float offset = 209.3f; + + for (std::size_t point = 0; point < left_joycon_body.size() / 2; ++point) { + const float body_x = left_joycon_body[point * 2 + 0]; + const float body_y = left_joycon_body[point * 2 + 1]; + + left_joycon[point] = center + QPointF(body_x * size + offset, body_y * size - 1); + right_joycon[point] = center + QPointF(-body_x * size - offset, body_y * size - 1); + } + for (std::size_t point = 0; point < left_joycon_slider.size() / 2; ++point) { + const float slider_x = left_joycon_slider[point * 2 + 0]; + const float slider_y = left_joycon_slider[point * 2 + 1]; + + qleft_joycon_slider[point] = center + QPointF(slider_x, slider_y); + qright_joycon_slider[point] = center + QPointF(-slider_x, slider_y); + } + for (std::size_t point = 0; point < left_joycon_topview.size() / 2; ++point) { + const float top_view_x = left_joycon_topview[point * 2 + 0]; + const float top_view_y = left_joycon_topview[point * 2 + 1]; + + qleft_joycon_topview[point] = + center + QPointF(top_view_x * size2 - 52, top_view_y * size2 - 52); + qright_joycon_topview[point] = + center + QPointF(-top_view_x * size2 + 52, top_view_y * size2 - 52); + } + for (std::size_t point = 0; point < left_joycon_slider_topview.size() / 2; ++point) { + const float top_view_x = left_joycon_slider_topview[point * 2 + 0]; + const float top_view_y = left_joycon_slider_topview[point * 2 + 1]; + + qleft_joycon_slider_topview[point] = + center + QPointF(top_view_x * size2 - 52, top_view_y * size2 - 52); + qright_joycon_slider_topview[point] = + center + QPointF(-top_view_x * size2 + 52, top_view_y * size2 - 52); + } + + // right joycon body + p.setPen(colors.outline); + p.setBrush(colors.right); + DrawPolygon(p, right_joycon); + + // Left joycon body + p.setPen(colors.outline); + p.setBrush(colors.left); + DrawPolygon(p, left_joycon); + + // Slider release button top view + p.setBrush(colors.button); + DrawRoundRectangle(p, center + QPoint(-149, -108), 12, 11, 2); + DrawRoundRectangle(p, center + QPoint(149, -108), 12, 11, 2); + + // Joycon slider top view + p.setBrush(colors.slider); + DrawPolygon(p, qleft_joycon_slider_topview); + p.drawLine(center + QPointF(-133.8f, -99.0f), center + QPointF(-133.8f, -78.5f)); + DrawPolygon(p, qright_joycon_slider_topview); + p.drawLine(center + QPointF(133.8f, -99.0f), center + QPointF(133.8f, -78.5f)); + + // Joycon body top view + p.setBrush(colors.left); + DrawPolygon(p, qleft_joycon_topview); + p.setBrush(colors.right); + DrawPolygon(p, qright_joycon_topview); + + // Right Sideview body + p.setBrush(colors.slider); + DrawPolygon(p, qright_joycon_slider); + + // Left Sideview body + p.setBrush(colors.slider); + DrawPolygon(p, qleft_joycon_slider); +} + +void PlayerControlPreview::DrawLeftBody(QPainter& p, const QPointF center) { + std::array left_joycon; + std::array qleft_joycon_sideview; + std::array qleft_joycon_trigger; + std::array qleft_joycon_slider; + std::array qleft_joycon_slider_topview; + std::array qleft_joycon_topview; + constexpr float size = 1.78f; + constexpr float size2 = 1.1115f; + constexpr float offset = 312.39f; + constexpr float offset2 = 335; + + for (std::size_t point = 0; point < left_joycon_body.size() / 2; ++point) { + left_joycon[point] = center + QPointF(left_joycon_body[point * 2] * size + offset, + left_joycon_body[point * 2 + 1] * size - 1); + } + + for (std::size_t point = 0; point < left_joycon_sideview.size() / 2; ++point) { + qleft_joycon_sideview[point] = + center + QPointF(left_joycon_sideview[point * 2] * size2 + offset2, + left_joycon_sideview[point * 2 + 1] * size2 + 2); + } + for (std::size_t point = 0; point < left_joycon_slider.size() / 2; ++point) { + qleft_joycon_slider[point] = center + QPointF(left_joycon_slider[point * 2] * size2 + 81, + left_joycon_slider[point * 2 + 1] * size2); + } + for (std::size_t point = 0; point < left_joycon_body_trigger.size() / 2; ++point) { + qleft_joycon_trigger[point] = + center + QPointF(left_joycon_body_trigger[point * 2] * size2 + offset2, + left_joycon_body_trigger[point * 2 + 1] * size2 + 2); + } + for (std::size_t point = 0; point < left_joycon_topview.size() / 2; ++point) { + qleft_joycon_topview[point] = + center + QPointF(left_joycon_topview[point * 2], left_joycon_topview[point * 2 + 1]); + } + for (std::size_t point = 0; point < left_joycon_slider_topview.size() / 2; ++point) { + qleft_joycon_slider_topview[point] = + center + QPointF(left_joycon_slider_topview[point * 2], + left_joycon_slider_topview[point * 2 + 1]); + } + + // Joycon body + p.setPen(colors.outline); + p.setBrush(colors.left); + DrawPolygon(p, left_joycon); + DrawPolygon(p, qleft_joycon_trigger); + + // Slider release button top view + p.setBrush(colors.button); + DrawRoundRectangle(p, center + QPoint(-107, -62), 14, 12, 2); + + // Joycon slider top view + p.setBrush(colors.slider); + DrawPolygon(p, qleft_joycon_slider_topview); + p.drawLine(center + QPointF(-91.1f, -51.7f), center + QPointF(-91.1f, -26.5f)); + + // Joycon body top view + p.setBrush(colors.left); + DrawPolygon(p, qleft_joycon_topview); + + // Slider release button + p.setBrush(colors.button); + DrawRoundRectangle(p, center + QPoint(175, -110), 12, 14, 2); + + // Sideview body + p.setBrush(colors.left); + DrawPolygon(p, qleft_joycon_sideview); + p.setBrush(colors.slider); + DrawPolygon(p, qleft_joycon_slider); + + const QPointF sideview_center = QPointF(155, 0) + center; + + // Sideview slider body + p.setBrush(colors.slider); + DrawRoundRectangle(p, sideview_center + QPointF(0, -5), 28, 253, 3); + p.setBrush(colors.button2); + DrawRoundRectangle(p, sideview_center + QPointF(0, 97), 22.44f, 44.66f, 3); + + // Slider decorations + p.setPen(colors.outline); + p.setBrush(colors.slider_arrow); + DrawArrow(p, sideview_center + QPoint(0, 83), Direction::Down, 2.2f); + DrawArrow(p, sideview_center + QPoint(0, 96), Direction::Down, 2.2f); + DrawArrow(p, sideview_center + QPoint(0, 109), Direction::Down, 2.2f); + DrawCircle(p, sideview_center + QPointF(0, 19), 4.44f); + + // LED indicators + const float led_size = 5.0f; + const QPointF led_position = sideview_center + QPointF(0, -36); + int led_count = 0; + p.setBrush(led_pattern.position1 ? colors.led_on : colors.led_off); + DrawRectangle(p, led_position + QPointF(0, 12 * led_count++), led_size, led_size); + p.setBrush(led_pattern.position2 ? colors.led_on : colors.led_off); + DrawRectangle(p, led_position + QPointF(0, 12 * led_count++), led_size, led_size); + p.setBrush(led_pattern.position3 ? colors.led_on : colors.led_off); + DrawRectangle(p, led_position + QPointF(0, 12 * led_count++), led_size, led_size); + p.setBrush(led_pattern.position4 ? colors.led_on : colors.led_off); + DrawRectangle(p, led_position + QPointF(0, 12 * led_count++), led_size, led_size); +} + +void PlayerControlPreview::DrawRightBody(QPainter& p, const QPointF center) { + std::array right_joycon; + std::array qright_joycon_sideview; + std::array qright_joycon_trigger; + std::array qright_joycon_slider; + std::array qright_joycon_slider_topview; + std::array qright_joycon_topview; + constexpr float size = 1.78f; + constexpr float size2 = 1.1115f; + constexpr float offset = 312.39f; + constexpr float offset2 = 335; + + for (std::size_t point = 0; point < left_joycon_body.size() / 2; ++point) { + right_joycon[point] = center + QPointF(-left_joycon_body[point * 2] * size - offset, + left_joycon_body[point * 2 + 1] * size - 1); + } + + for (std::size_t point = 0; point < left_joycon_sideview.size() / 2; ++point) { + qright_joycon_sideview[point] = + center + QPointF(-left_joycon_sideview[point * 2] * size2 - offset2, + left_joycon_sideview[point * 2 + 1] * size2 + 2); + } + for (std::size_t point = 0; point < left_joycon_body_trigger.size() / 2; ++point) { + qright_joycon_trigger[point] = + center + QPointF(-left_joycon_body_trigger[point * 2] * size2 - offset2, + left_joycon_body_trigger[point * 2 + 1] * size2 + 2); + } + for (std::size_t point = 0; point < left_joycon_slider.size() / 2; ++point) { + qright_joycon_slider[point] = center + QPointF(-left_joycon_slider[point * 2] * size2 - 81, + left_joycon_slider[point * 2 + 1] * size2); + } + for (std::size_t point = 0; point < left_joycon_topview.size() / 2; ++point) { + qright_joycon_topview[point] = + center + QPointF(-left_joycon_topview[point * 2], left_joycon_topview[point * 2 + 1]); + } + for (std::size_t point = 0; point < left_joycon_slider_topview.size() / 2; ++point) { + qright_joycon_slider_topview[point] = + center + QPointF(-left_joycon_slider_topview[point * 2], + left_joycon_slider_topview[point * 2 + 1]); + } + + // Joycon body + p.setPen(colors.outline); + p.setBrush(colors.left); + DrawPolygon(p, right_joycon); + DrawPolygon(p, qright_joycon_trigger); + + // Slider release button top view + p.setBrush(colors.button); + DrawRoundRectangle(p, center + QPoint(107, -62), 14, 12, 2); + + // Joycon slider top view + p.setBrush(colors.slider); + DrawPolygon(p, qright_joycon_slider_topview); + p.drawLine(center + QPointF(91.1f, -51.7f), center + QPointF(91.1f, -26.5f)); + + // Joycon body top view + p.setBrush(colors.left); + DrawPolygon(p, qright_joycon_topview); + + // Slider release button + p.setBrush(colors.button); + DrawRoundRectangle(p, center + QPoint(-175, -110), 12, 14, 2); + + // Sideview body + p.setBrush(colors.left); + DrawPolygon(p, qright_joycon_sideview); + p.setBrush(colors.slider); + DrawPolygon(p, qright_joycon_slider); + + const QPointF sideview_center = QPointF(-155, 0) + center; + + // Sideview slider body + p.setBrush(colors.slider); + DrawRoundRectangle(p, sideview_center + QPointF(0, -5), 28, 253, 3); + p.setBrush(colors.button2); + DrawRoundRectangle(p, sideview_center + QPointF(0, 97), 22.44f, 44.66f, 3); + + // Slider decorations + p.setPen(colors.outline); + p.setBrush(colors.slider_arrow); + DrawArrow(p, sideview_center + QPoint(0, 83), Direction::Down, 2.2f); + DrawArrow(p, sideview_center + QPoint(0, 96), Direction::Down, 2.2f); + DrawArrow(p, sideview_center + QPoint(0, 109), Direction::Down, 2.2f); + DrawCircle(p, sideview_center + QPointF(0, 19), 4.44f); + + // LED indicators + const float led_size = 5.0f; + const QPointF led_position = sideview_center + QPointF(0, -36); + int led_count = 0; + p.setBrush(led_pattern.position1 ? colors.led_on : colors.led_off); + DrawRectangle(p, led_position + QPointF(0, 12 * led_count++), led_size, led_size); + p.setBrush(led_pattern.position2 ? colors.led_on : colors.led_off); + DrawRectangle(p, led_position + QPointF(0, 12 * led_count++), led_size, led_size); + p.setBrush(led_pattern.position3 ? colors.led_on : colors.led_off); + DrawRectangle(p, led_position + QPointF(0, 12 * led_count++), led_size, led_size); + p.setBrush(led_pattern.position4 ? colors.led_on : colors.led_off); + DrawRectangle(p, led_position + QPointF(0, 12 * led_count++), led_size, led_size); +} + +void PlayerControlPreview::DrawProTriggers(QPainter& p, const QPointF center, + const Common::Input::ButtonStatus& left_pressed, + const Common::Input::ButtonStatus& right_pressed) { + std::array qleft_trigger; + std::array qright_trigger; + std::array qbody_top; + + for (std::size_t point = 0; point < pro_left_trigger.size() / 2; ++point) { + const float trigger_x = pro_left_trigger[point * 2 + 0]; + const float trigger_y = pro_left_trigger[point * 2 + 1]; + + qleft_trigger[point] = + center + QPointF(trigger_x, trigger_y + (left_pressed.value ? 2 : 0)); + qright_trigger[point] = + center + QPointF(-trigger_x, trigger_y + (right_pressed.value ? 2 : 0)); + } + + for (std::size_t point = 0; point < pro_body_top.size() / 2; ++point) { + const float top_x = pro_body_top[point * 2 + 0]; + const float top_y = pro_body_top[point * 2 + 1]; + + qbody_top[pro_body_top.size() - 1 - point] = center + QPointF(-top_x, top_y); + qbody_top[point] = center + QPointF(top_x, top_y); + } + + // Pro body detail + p.setPen(colors.outline); + p.setBrush(colors.primary); + DrawPolygon(p, qbody_top); + + // Left trigger + p.setBrush(left_pressed.value ? colors.highlight : colors.button); + DrawPolygon(p, qleft_trigger); + + // Right trigger + p.setBrush(right_pressed.value ? colors.highlight : colors.button); + DrawPolygon(p, qright_trigger); +} + +void PlayerControlPreview::DrawGCTriggers(QPainter& p, const QPointF center, + Common::Input::TriggerStatus left_trigger, + Common::Input::TriggerStatus right_trigger) { + std::array qleft_trigger; + std::array qright_trigger; + + for (std::size_t point = 0; point < left_gc_trigger.size() / 2; ++point) { + const float trigger_x = left_gc_trigger[point * 2 + 0]; + const float trigger_y = left_gc_trigger[point * 2 + 1]; + + qleft_trigger[point] = + center + QPointF(trigger_x, trigger_y + (left_trigger.analog.value * 10.0f)); + qright_trigger[point] = + center + QPointF(-trigger_x, trigger_y + (right_trigger.analog.value * 10.0f)); + } + + // Left trigger + p.setPen(colors.outline); + p.setBrush(left_trigger.pressed.value ? colors.highlight : colors.button); + DrawPolygon(p, qleft_trigger); + + // Right trigger + p.setBrush(right_trigger.pressed.value ? colors.highlight : colors.button); + DrawPolygon(p, qright_trigger); + + // Draw L text + p.setPen(colors.transparent); + p.setBrush(colors.font); + DrawSymbol(p, center + QPointF(-132, -119 + (left_trigger.analog.value * 10.0f)), Symbol::L, + 1.7f); + + // Draw R text + p.setPen(colors.transparent); + p.setBrush(colors.font); + DrawSymbol(p, center + QPointF(121.5f, -119 + (right_trigger.analog.value * 10.0f)), Symbol::R, + 1.7f); +} + +void PlayerControlPreview::DrawHandheldTriggers(QPainter& p, const QPointF center, + const Common::Input::ButtonStatus& left_pressed, + const Common::Input::ButtonStatus& right_pressed) { + std::array qleft_trigger; + std::array qright_trigger; + + for (std::size_t point = 0; point < left_joycon_trigger.size() / 2; ++point) { + const float left_trigger_x = left_joycon_trigger[point * 2 + 0]; + const float left_trigger_y = left_joycon_trigger[point * 2 + 1]; + + qleft_trigger[point] = + center + QPointF(left_trigger_x, left_trigger_y + (left_pressed.value ? 0.5f : 0)); + qright_trigger[point] = + center + QPointF(-left_trigger_x, left_trigger_y + (right_pressed.value ? 0.5f : 0)); + } + + // Left trigger + p.setPen(colors.outline); + p.setBrush(left_pressed.value ? colors.highlight : colors.button); + DrawPolygon(p, qleft_trigger); + + // Right trigger + p.setBrush(right_pressed.value ? colors.highlight : colors.button); + DrawPolygon(p, qright_trigger); +} + +void PlayerControlPreview::DrawDualTriggers(QPainter& p, const QPointF center, + const Common::Input::ButtonStatus& left_pressed, + const Common::Input::ButtonStatus& right_pressed) { + std::array qleft_trigger; + std::array qright_trigger; + constexpr float size = 1.62f; + constexpr float offset = 210.6f; + for (std::size_t point = 0; point < left_joycon_trigger.size() / 2; ++point) { + const float left_trigger_x = left_joycon_trigger[point * 2 + 0]; + const float left_trigger_y = left_joycon_trigger[point * 2 + 1]; + + qleft_trigger[point] = + center + QPointF(left_trigger_x * size + offset, + left_trigger_y * size + (left_pressed.value ? 0.5f : 0)); + qright_trigger[point] = + center + QPointF(-left_trigger_x * size - offset, + left_trigger_y * size + (right_pressed.value ? 0.5f : 0)); + } + + // Left trigger + p.setPen(colors.outline); + p.setBrush(left_pressed.value ? colors.highlight : colors.button); + DrawPolygon(p, qleft_trigger); + + // Right trigger + p.setBrush(right_pressed.value ? colors.highlight : colors.button); + DrawPolygon(p, qright_trigger); +} + +void PlayerControlPreview::DrawDualTriggersTopView( + QPainter& p, const QPointF center, const Common::Input::ButtonStatus& left_pressed, + const Common::Input::ButtonStatus& right_pressed) { + std::array qleft_trigger; + std::array qright_trigger; + constexpr float size = 0.9f; + + for (std::size_t point = 0; point < left_joystick_L_topview.size() / 2; ++point) { + const float top_view_x = left_joystick_L_topview[point * 2 + 0]; + const float top_view_y = left_joystick_L_topview[point * 2 + 1]; + + qleft_trigger[point] = center + QPointF(top_view_x * size - 50, top_view_y * size - 52); + } + for (std::size_t point = 0; point < left_joystick_L_topview.size() / 2; ++point) { + const float top_view_x = left_joystick_L_topview[point * 2 + 0]; + const float top_view_y = left_joystick_L_topview[point * 2 + 1]; + + qright_trigger[point] = center + QPointF(-top_view_x * size + 50, top_view_y * size - 52); + } + + p.setPen(colors.outline); + p.setBrush(left_pressed.value ? colors.highlight : colors.button); + DrawPolygon(p, qleft_trigger); + p.setBrush(right_pressed.value ? colors.highlight : colors.button); + DrawPolygon(p, qright_trigger); + + // Draw L text + p.setPen(colors.transparent); + p.setBrush(colors.font2); + DrawSymbol(p, center + QPointF(-183, -84), Symbol::L, 1.0f); + + // Draw R text + p.setPen(colors.transparent); + p.setBrush(colors.font2); + DrawSymbol(p, center + QPointF(177, -84), Symbol::R, 1.0f); +} + +void PlayerControlPreview::DrawDualZTriggersTopView( + QPainter& p, const QPointF center, const Common::Input::ButtonStatus& left_pressed, + const Common::Input::ButtonStatus& right_pressed) { + std::array qleft_trigger; + std::array qright_trigger; + constexpr float size = 0.9f; + + for (std::size_t point = 0; point < left_joystick_ZL_topview.size() / 2; ++point) { + qleft_trigger[point] = + center + QPointF(left_joystick_ZL_topview[point * 2] * size - 52, + left_joystick_ZL_topview[point * 2 + 1] * size - 52); + } + for (std::size_t point = 0; point < left_joystick_ZL_topview.size() / 2; ++point) { + qright_trigger[point] = + center + QPointF(-left_joystick_ZL_topview[point * 2] * size + 52, + left_joystick_ZL_topview[point * 2 + 1] * size - 52); + } + + p.setPen(colors.outline); + p.setBrush(left_pressed.value ? colors.highlight : colors.button); + DrawPolygon(p, qleft_trigger); + p.setBrush(right_pressed.value ? colors.highlight : colors.button); + DrawPolygon(p, qright_trigger); + + // Draw ZL text + p.setPen(colors.transparent); + p.setBrush(colors.font2); + DrawSymbol(p, center + QPointF(-180, -113), Symbol::ZL, 1.0f); + + // Draw ZR text + p.setPen(colors.transparent); + p.setBrush(colors.font2); + DrawSymbol(p, center + QPointF(180, -113), Symbol::ZR, 1.0f); +} + +void PlayerControlPreview::DrawLeftTriggers(QPainter& p, const QPointF center, + const Common::Input::ButtonStatus& left_pressed) { + std::array qleft_trigger; + constexpr float size = 1.78f; + constexpr float offset = 311.5f; + + for (std::size_t point = 0; point < left_joycon_trigger.size() / 2; ++point) { + qleft_trigger[point] = center + QPointF(left_joycon_trigger[point * 2] * size + offset, + left_joycon_trigger[point * 2 + 1] * size - + (left_pressed.value ? 0.5f : 1.0f)); + } + + p.setPen(colors.outline); + p.setBrush(left_pressed.value ? colors.highlight : colors.button); + DrawPolygon(p, qleft_trigger); +} + +void PlayerControlPreview::DrawLeftZTriggers(QPainter& p, const QPointF center, + const Common::Input::ButtonStatus& left_pressed) { + std::array qleft_trigger; + constexpr float size = 1.1115f; + constexpr float offset2 = 335; + + for (std::size_t point = 0; point < left_joycon_sideview_zl.size() / 2; ++point) { + qleft_trigger[point] = center + QPointF(left_joycon_sideview_zl[point * 2] * size + offset2, + left_joycon_sideview_zl[point * 2 + 1] * size + + (left_pressed.value ? 1.5f : 1.0f)); + } + + p.setPen(colors.outline); + p.setBrush(left_pressed.value ? colors.highlight : colors.button); + DrawPolygon(p, qleft_trigger); + p.drawArc(center.x() + 158, center.y() + (left_pressed.value ? -203.5f : -204.0f), 77, 77, + 225 * 16, 44 * 16); +} + +void PlayerControlPreview::DrawLeftTriggersTopView( + QPainter& p, const QPointF center, const Common::Input::ButtonStatus& left_pressed) { + std::array qleft_trigger; + + for (std::size_t point = 0; point < left_joystick_L_topview.size() / 2; ++point) { + qleft_trigger[point] = center + QPointF(left_joystick_L_topview[point * 2], + left_joystick_L_topview[point * 2 + 1]); + } + + p.setPen(colors.outline); + p.setBrush(left_pressed.value ? colors.highlight : colors.button); + DrawPolygon(p, qleft_trigger); + + // Draw L text + p.setPen(colors.transparent); + p.setBrush(colors.font2); + DrawSymbol(p, center + QPointF(-143, -36), Symbol::L, 1.0f); +} + +void PlayerControlPreview::DrawLeftZTriggersTopView( + QPainter& p, const QPointF center, const Common::Input::ButtonStatus& left_pressed) { + std::array qleft_trigger; + + for (std::size_t point = 0; point < left_joystick_ZL_topview.size() / 2; ++point) { + qleft_trigger[point] = center + QPointF(left_joystick_ZL_topview[point * 2], + left_joystick_ZL_topview[point * 2 + 1]); + } + + p.setPen(colors.outline); + p.setBrush(left_pressed.value ? colors.highlight : colors.button); + DrawPolygon(p, qleft_trigger); + + // Draw ZL text + p.setPen(colors.transparent); + p.setBrush(colors.font2); + DrawSymbol(p, center + QPointF(-140, -68), Symbol::ZL, 1.0f); +} + +void PlayerControlPreview::DrawRightTriggers(QPainter& p, const QPointF center, + const Common::Input::ButtonStatus& right_pressed) { + std::array qright_trigger; + constexpr float size = 1.78f; + constexpr float offset = 311.5f; + + for (std::size_t point = 0; point < left_joycon_trigger.size() / 2; ++point) { + qright_trigger[point] = center + QPointF(-left_joycon_trigger[point * 2] * size - offset, + left_joycon_trigger[point * 2 + 1] * size - + (right_pressed.value ? 0.5f : 1.0f)); + } + + p.setPen(colors.outline); + p.setBrush(right_pressed.value ? colors.highlight : colors.button); + DrawPolygon(p, qright_trigger); +} + +void PlayerControlPreview::DrawRightZTriggers(QPainter& p, const QPointF center, + const Common::Input::ButtonStatus& right_pressed) { + std::array qright_trigger; + constexpr float size = 1.1115f; + constexpr float offset2 = 335; + + for (std::size_t point = 0; point < left_joycon_sideview_zl.size() / 2; ++point) { + qright_trigger[point] = + center + QPointF(-left_joycon_sideview_zl[point * 2] * size - offset2, + left_joycon_sideview_zl[point * 2 + 1] * size + + (right_pressed.value ? 0.5f : 0) + 1); + } + + p.setPen(colors.outline); + p.setBrush(right_pressed.value ? colors.highlight : colors.button); + DrawPolygon(p, qright_trigger); + p.drawArc(center.x() - 236, center.y() + (right_pressed.value ? -203.5f : -204.0f), 77, 77, + 271 * 16, 44 * 16); +} + +void PlayerControlPreview::DrawRightTriggersTopView( + QPainter& p, const QPointF center, const Common::Input::ButtonStatus& right_pressed) { + std::array qright_trigger; + + for (std::size_t point = 0; point < left_joystick_L_topview.size() / 2; ++point) { + qright_trigger[point] = center + QPointF(-left_joystick_L_topview[point * 2], + left_joystick_L_topview[point * 2 + 1]); + } + + p.setPen(colors.outline); + p.setBrush(right_pressed.value ? colors.highlight : colors.button); + DrawPolygon(p, qright_trigger); + + // Draw R text + p.setPen(colors.transparent); + p.setBrush(colors.font2); + DrawSymbol(p, center + QPointF(137, -36), Symbol::R, 1.0f); +} + +void PlayerControlPreview::DrawRightZTriggersTopView( + QPainter& p, const QPointF center, const Common::Input::ButtonStatus& right_pressed) { + std::array qright_trigger; + + for (std::size_t point = 0; point < left_joystick_ZL_topview.size() / 2; ++point) { + qright_trigger[point] = center + QPointF(-left_joystick_ZL_topview[point * 2], + left_joystick_ZL_topview[point * 2 + 1]); + } + + p.setPen(colors.outline); + p.setBrush(right_pressed.value ? colors.highlight : colors.button); + DrawPolygon(p, qright_trigger); + + // Draw ZR text + p.setPen(colors.transparent); + p.setBrush(colors.font2); + DrawSymbol(p, center + QPointF(140, -68), Symbol::ZR, 1.0f); +} + +void PlayerControlPreview::DrawJoystick(QPainter& p, const QPointF center, float size, + const Common::Input::ButtonStatus& pressed) { + const float radius1 = 13.0f * size; + const float radius2 = 9.0f * size; + + // Outer circle + p.setPen(colors.outline); + p.setBrush(pressed.value ? colors.highlight : colors.button); + DrawCircle(p, center, radius1); + + // Cross + p.drawLine(center - QPoint(radius1, 0), center + QPoint(radius1, 0)); + p.drawLine(center - QPoint(0, radius1), center + QPoint(0, radius1)); + + // Inner circle + p.setBrush(pressed.value ? colors.highlight2 : colors.button2); + DrawCircle(p, center, radius2); +} + +void PlayerControlPreview::DrawJoystickSideview(QPainter& p, const QPointF center, float angle, + float size, + const Common::Input::ButtonStatus& pressed) { + QVector joystick; + joystick.reserve(static_cast(left_joystick_sideview.size() / 2)); + + for (std::size_t point = 0; point < left_joystick_sideview.size() / 2; ++point) { + joystick.append(QPointF(left_joystick_sideview[point * 2] * size + (pressed.value ? 1 : 0), + left_joystick_sideview[point * 2 + 1] * size - 1)); + } + + // Rotate joystick + QTransform t; + t.translate(center.x(), center.y()); + t.rotate(18 * angle); + QPolygonF p2 = t.map(QPolygonF(joystick)); + + // Draw joystick + p.setPen(colors.outline); + p.setBrush(pressed.value ? colors.highlight : colors.button); + p.drawPolygon(p2); + p.drawLine(p2.at(1), p2.at(30)); + p.drawLine(p2.at(32), p2.at(71)); +} + +void PlayerControlPreview::DrawProJoystick(QPainter& p, const QPointF center, const QPointF offset, + float offset_scalar, + const Common::Input::ButtonStatus& pressed) { + const float radius1 = 24.0f; + const float radius2 = 17.0f; + + const QPointF offset_center = center + offset * offset_scalar; + + const auto amplitude = static_cast( + 1.0 - std::sqrt((offset.x() * offset.x()) + (offset.y() * offset.y())) * 0.1f); + + const float rotation = + ((offset.x() == 0) ? atan(1) * 2 : atan(offset.y() / offset.x())) * (180 / (atan(1) * 4)); + + p.save(); + p.translate(offset_center); + p.rotate(rotation); + + // Outer circle + p.setPen(colors.outline); + p.setBrush(pressed.value ? colors.highlight : colors.button); + p.drawEllipse(QPointF(0, 0), radius1 * amplitude, radius1); + + // Inner circle + p.setBrush(pressed.value ? colors.highlight2 : colors.button2); + + const float inner_offset = + (radius1 - radius2) * 0.4f * ((offset.x() == 0 && offset.y() < 0) ? -1.0f : 1.0f); + const float offset_factor = (1.0f - amplitude) / 0.1f; + + p.drawEllipse(QPointF((offset.x() < 0) ? -inner_offset : inner_offset, 0) * offset_factor, + radius2 * amplitude, radius2); + + p.restore(); +} + +void PlayerControlPreview::DrawGCJoystick(QPainter& p, const QPointF center, + const Common::Input::ButtonStatus& pressed) { + // Outer circle + p.setPen(colors.outline); + p.setBrush(pressed.value ? colors.highlight : colors.button); + DrawCircle(p, center, 26.0f); + + // Inner circle + p.setBrush(pressed.value ? colors.highlight2 : colors.button2); + DrawCircle(p, center, 19.0f); + p.setBrush(colors.transparent); + DrawCircle(p, center, 13.5f); + DrawCircle(p, center, 7.5f); +} + +void PlayerControlPreview::DrawRawJoystick(QPainter& p, QPointF center_left, QPointF center_right) { + using namespace Settings::NativeAnalog; + if (center_right != QPointF(0, 0)) { + DrawJoystickProperties(p, center_right, stick_values[RStick].x.properties); + p.setPen(colors.indicator); + p.setBrush(colors.indicator); + DrawJoystickDot(p, center_right, stick_values[RStick], true); + p.setPen(colors.indicator2); + p.setBrush(colors.indicator2); + DrawJoystickDot(p, center_right, stick_values[RStick], false); + } + + if (center_left != QPointF(0, 0)) { + DrawJoystickProperties(p, center_left, stick_values[LStick].x.properties); + p.setPen(colors.indicator); + p.setBrush(colors.indicator); + DrawJoystickDot(p, center_left, stick_values[LStick], true); + p.setPen(colors.indicator2); + p.setBrush(colors.indicator2); + DrawJoystickDot(p, center_left, stick_values[LStick], false); + } +} + +void PlayerControlPreview::DrawJoystickProperties( + QPainter& p, const QPointF center, const Common::Input::AnalogProperties& properties) { + constexpr float size = 45.0f; + const float range = size * properties.range; + const float deadzone = size * properties.deadzone; + + // Max range zone circle + p.setPen(colors.outline); + p.setBrush(colors.transparent); + QPen pen = p.pen(); + pen.setStyle(Qt::DotLine); + p.setPen(pen); + DrawCircle(p, center, range); + + // Deadzone circle + pen.setColor(colors.deadzone); + p.setPen(pen); + DrawCircle(p, center, deadzone); +} + +void PlayerControlPreview::DrawJoystickDot(QPainter& p, const QPointF center, + const Common::Input::StickStatus& stick, bool raw) { + constexpr float size = 45.0f; + const float range = size * stick.x.properties.range; + + if (raw) { + const QPointF value = QPointF(stick.x.raw_value, stick.y.raw_value) * size; + DrawCircle(p, center + value, 2); + return; + } + + const QPointF value = QPointF(stick.x.value, stick.y.value) * range; + DrawCircle(p, center + value, 2); +} + +void PlayerControlPreview::DrawRoundButton(QPainter& p, QPointF center, + const Common::Input::ButtonStatus& pressed, float width, + float height, Direction direction, float radius) { + if (pressed.value) { + switch (direction) { + case Direction::Left: + center.setX(center.x() - 1); + break; + case Direction::Right: + center.setX(center.x() + 1); + break; + case Direction::Down: + center.setY(center.y() + 1); + break; + case Direction::Up: + center.setY(center.y() - 1); + break; + case Direction::None: + break; + } + } + QRectF rect = {center.x() - width, center.y() - height, width * 2.0f, height * 2.0f}; + p.setBrush(GetButtonColor(button_color, pressed.value, pressed.turbo)); + p.drawRoundedRect(rect, radius, radius); +} +void PlayerControlPreview::DrawMinusButton(QPainter& p, const QPointF center, + const Common::Input::ButtonStatus& pressed, + int button_size) { + p.setPen(colors.outline); + p.setBrush(GetButtonColor(colors.button, pressed.value, pressed.turbo)); + DrawRectangle(p, center, button_size, button_size / 3.0f); +} +void PlayerControlPreview::DrawPlusButton(QPainter& p, const QPointF center, + const Common::Input::ButtonStatus& pressed, + int button_size) { + // Draw outer line + p.setPen(colors.outline); + p.setBrush(GetButtonColor(colors.button, pressed.value, pressed.turbo)); + DrawRectangle(p, center, button_size, button_size / 3.0f); + DrawRectangle(p, center, button_size / 3.0f, button_size); + + // Scale down size + button_size *= 0.88f; + + // Draw inner color + p.setPen(colors.transparent); + DrawRectangle(p, center, button_size, button_size / 3.0f); + DrawRectangle(p, center, button_size / 3.0f, button_size); +} + +void PlayerControlPreview::DrawGCButtonX(QPainter& p, const QPointF center, + const Common::Input::ButtonStatus& pressed) { + std::array button_x; + + for (std::size_t point = 0; point < gc_button_x.size() / 2; ++point) { + button_x[point] = center + QPointF(gc_button_x[point * 2], gc_button_x[point * 2 + 1]); + } + + p.setPen(colors.outline); + p.setBrush(GetButtonColor(colors.button, pressed.value, pressed.turbo)); + DrawPolygon(p, button_x); +} + +void PlayerControlPreview::DrawGCButtonY(QPainter& p, const QPointF center, + const Common::Input::ButtonStatus& pressed) { + std::array button_x; + + for (std::size_t point = 0; point < gc_button_y.size() / 2; ++point) { + button_x[point] = center + QPointF(gc_button_y[point * 2], gc_button_y[point * 2 + 1]); + } + + p.setPen(colors.outline); + p.setBrush(GetButtonColor(colors.button, pressed.value, pressed.turbo)); + DrawPolygon(p, button_x); +} + +void PlayerControlPreview::DrawGCButtonZ(QPainter& p, const QPointF center, + const Common::Input::ButtonStatus& pressed) { + std::array button_x; + + for (std::size_t point = 0; point < gc_button_z.size() / 2; ++point) { + button_x[point] = center + QPointF(gc_button_z[point * 2], + gc_button_z[point * 2 + 1] + (pressed.value ? 1 : 0)); + } + + p.setPen(colors.outline); + p.setBrush(GetButtonColor(colors.button2, pressed.value, pressed.turbo)); + DrawPolygon(p, button_x); +} + +void PlayerControlPreview::DrawCircleButton(QPainter& p, const QPointF center, + const Common::Input::ButtonStatus& pressed, + float button_size) { + + p.setBrush(GetButtonColor(button_color, pressed.value, pressed.turbo)); + p.drawEllipse(center, button_size, button_size); +} + +void PlayerControlPreview::DrawArrowButtonOutline(QPainter& p, const QPointF center, float size) { + const std::size_t arrow_points = up_arrow_button.size() / 2; + std::array arrow_button_outline; + + for (std::size_t point = 0; point < arrow_points - 1; ++point) { + const float up_arrow_x = up_arrow_button[point * 2 + 0]; + const float up_arrow_y = up_arrow_button[point * 2 + 1]; + + arrow_button_outline[point] = center + QPointF(up_arrow_x * size, up_arrow_y * size); + arrow_button_outline[(arrow_points - 1) * 2 - point - 1] = + center + QPointF(up_arrow_y * size, up_arrow_x * size); + arrow_button_outline[(arrow_points - 1) * 2 + point] = + center + QPointF(-up_arrow_x * size, -up_arrow_y * size); + arrow_button_outline[(arrow_points - 1) * 4 - point - 1] = + center + QPointF(-up_arrow_y * size, -up_arrow_x * size); + } + // Draw arrow button outline + p.setPen(colors.outline); + p.setBrush(colors.transparent); + DrawPolygon(p, arrow_button_outline); +} + +void PlayerControlPreview::DrawArrowButton(QPainter& p, const QPointF center, + const Direction direction, + const Common::Input::ButtonStatus& pressed, float size) { + std::array arrow_button; + QPoint offset; + + for (std::size_t point = 0; point < up_arrow_button.size() / 2; ++point) { + const float up_arrow_x = up_arrow_button[point * 2 + 0]; + const float up_arrow_y = up_arrow_button[point * 2 + 1]; + + switch (direction) { + case Direction::Up: + arrow_button[point] = center + QPointF(up_arrow_x * size, up_arrow_y * size); + break; + case Direction::Right: + arrow_button[point] = center + QPointF(-up_arrow_y * size, up_arrow_x * size); + break; + case Direction::Down: + arrow_button[point] = center + QPointF(up_arrow_x * size, -up_arrow_y * size); + break; + case Direction::Left: + // Compiler doesn't optimize this correctly check why + arrow_button[point] = center + QPointF(up_arrow_y * size, up_arrow_x * size); + break; + case Direction::None: + break; + } + } + + // Draw arrow button + p.setPen(pressed.value ? colors.highlight : colors.button); + p.setBrush(GetButtonColor(colors.button, pressed.value, pressed.turbo)); + DrawPolygon(p, arrow_button); + + switch (direction) { + case Direction::Up: + offset = QPoint(0, -20 * size); + break; + case Direction::Right: + offset = QPoint(20 * size, 0); + break; + case Direction::Down: + offset = QPoint(0, 20 * size); + break; + case Direction::Left: + offset = QPoint(-20 * size, 0); + break; + case Direction::None: + offset = QPoint(0, 0); + break; + } + + // Draw arrow icon + p.setPen(colors.font2); + p.setBrush(colors.font2); + DrawArrow(p, center + offset, direction, size); +} + +void PlayerControlPreview::DrawTriggerButton(QPainter& p, const QPointF center, + const Direction direction, + const Common::Input::ButtonStatus& pressed) { + std::array qtrigger_button; + + for (std::size_t point = 0; point < trigger_button.size() / 2; ++point) { + const float trigger_button_x = trigger_button[point * 2 + 0]; + const float trigger_button_y = trigger_button[point * 2 + 1]; + + switch (direction) { + case Direction::Left: + qtrigger_button[point] = center + QPointF(-trigger_button_x, trigger_button_y); + break; + case Direction::Right: + qtrigger_button[point] = center + QPointF(trigger_button_x, trigger_button_y); + break; + case Direction::Up: + case Direction::Down: + case Direction::None: + break; + } + } + + // Draw arrow button + p.setPen(colors.outline); + p.setBrush(GetButtonColor(colors.button, pressed.value, pressed.turbo)); + DrawPolygon(p, qtrigger_button); +} + +QColor PlayerControlPreview::GetButtonColor(QColor default_color, bool is_pressed, bool turbo) { + if (is_pressed && turbo) { + return colors.button_turbo; + } + if (is_pressed) { + return colors.highlight; + } + return default_color; +} + +void PlayerControlPreview::DrawBattery(QPainter& p, QPointF center, + Common::Input::BatteryLevel battery) { + if (battery == Common::Input::BatteryLevel::None) { + return; + } + // Draw outline + p.setPen(QPen(colors.button, 5)); + p.setBrush(colors.transparent); + p.drawRoundedRect(center.x(), center.y(), 34, 16, 2, 2); + + p.setPen(QPen(colors.button, 3)); + p.drawRect(center.x() + 35, center.y() + 4.5f, 4, 7); + + // Draw Battery shape + p.setPen(QPen(colors.indicator2, 3)); + p.setBrush(colors.transparent); + p.drawRoundedRect(center.x(), center.y(), 34, 16, 2, 2); + + p.setPen(QPen(colors.indicator2, 1)); + p.setBrush(colors.indicator2); + p.drawRect(center.x() + 35, center.y() + 4.5f, 4, 7); + switch (battery) { + case Common::Input::BatteryLevel::Charging: + p.drawRect(center.x(), center.y(), 34, 16); + p.setPen(colors.slider); + p.setBrush(colors.charging); + DrawSymbol(p, center + QPointF(17.0f, 8.0f), Symbol::Charging, 2.1f); + break; + case Common::Input::BatteryLevel::Full: + p.drawRect(center.x(), center.y(), 34, 16); + break; + case Common::Input::BatteryLevel::Medium: + p.drawRect(center.x(), center.y(), 25, 16); + break; + case Common::Input::BatteryLevel::Low: + p.drawRect(center.x(), center.y(), 17, 16); + break; + case Common::Input::BatteryLevel::Critical: + p.drawRect(center.x(), center.y(), 6, 16); + break; + case Common::Input::BatteryLevel::Empty: + p.drawRect(center.x(), center.y(), 3, 16); + break; + default: + break; + } +} + +void PlayerControlPreview::DrawSymbol(QPainter& p, const QPointF center, Symbol symbol, + float icon_size) { + std::array house_icon; + std::array a_icon; + std::array b_icon; + std::array x_icon; + std::array y_icon; + std::array l_icon; + std::array r_icon; + std::array c_icon; + std::array zl_icon; + std::array sl_icon; + std::array zr_icon; + std::array sr_icon; + std::array charging_icon; + switch (symbol) { + case Symbol::House: + for (std::size_t point = 0; point < house.size() / 2; ++point) { + house_icon[point] = center + QPointF(house[point * 2] * icon_size, + (house[point * 2 + 1] - 0.025f) * icon_size); + } + p.drawPolygon(house_icon.data(), static_cast(house_icon.size())); + break; + case Symbol::A: + for (std::size_t point = 0; point < symbol_a.size() / 2; ++point) { + a_icon[point] = center + QPointF(symbol_a[point * 2] * icon_size, + symbol_a[point * 2 + 1] * icon_size); + } + p.drawPolygon(a_icon.data(), static_cast(a_icon.size())); + break; + case Symbol::B: + for (std::size_t point = 0; point < symbol_b.size() / 2; ++point) { + b_icon[point] = center + QPointF(symbol_b[point * 2] * icon_size, + symbol_b[point * 2 + 1] * icon_size); + } + p.drawPolygon(b_icon.data(), static_cast(b_icon.size())); + break; + case Symbol::X: + for (std::size_t point = 0; point < symbol_x.size() / 2; ++point) { + x_icon[point] = center + QPointF(symbol_x[point * 2] * icon_size, + symbol_x[point * 2 + 1] * icon_size); + } + p.drawPolygon(x_icon.data(), static_cast(x_icon.size())); + break; + case Symbol::Y: + for (std::size_t point = 0; point < symbol_y.size() / 2; ++point) { + y_icon[point] = center + QPointF(symbol_y[point * 2] * icon_size, + (symbol_y[point * 2 + 1] - 1.0f) * icon_size); + } + p.drawPolygon(y_icon.data(), static_cast(y_icon.size())); + break; + case Symbol::L: + for (std::size_t point = 0; point < symbol_l.size() / 2; ++point) { + l_icon[point] = center + QPointF(symbol_l[point * 2] * icon_size, + (symbol_l[point * 2 + 1] - 1.0f) * icon_size); + } + p.drawPolygon(l_icon.data(), static_cast(l_icon.size())); + break; + case Symbol::R: + for (std::size_t point = 0; point < symbol_r.size() / 2; ++point) { + r_icon[point] = center + QPointF(symbol_r[point * 2] * icon_size, + (symbol_r[point * 2 + 1] - 1.0f) * icon_size); + } + p.drawPolygon(r_icon.data(), static_cast(r_icon.size())); + break; + case Symbol::C: + for (std::size_t point = 0; point < symbol_c.size() / 2; ++point) { + c_icon[point] = center + QPointF(symbol_c[point * 2] * icon_size, + (symbol_c[point * 2 + 1] - 1.0f) * icon_size); + } + p.drawPolygon(c_icon.data(), static_cast(c_icon.size())); + break; + case Symbol::ZL: + for (std::size_t point = 0; point < symbol_zl.size() / 2; ++point) { + zl_icon[point] = center + QPointF(symbol_zl[point * 2] * icon_size, + symbol_zl[point * 2 + 1] * icon_size); + } + p.drawPolygon(zl_icon.data(), static_cast(zl_icon.size())); + break; + case Symbol::SL: + for (std::size_t point = 0; point < symbol_sl.size() / 2; ++point) { + sl_icon[point] = center + QPointF(symbol_sl[point * 2] * icon_size, + symbol_sl[point * 2 + 1] * icon_size); + } + p.drawPolygon(sl_icon.data(), static_cast(sl_icon.size())); + break; + case Symbol::ZR: + for (std::size_t point = 0; point < symbol_zr.size() / 2; ++point) { + zr_icon[point] = center + QPointF(symbol_zr[point * 2] * icon_size, + symbol_zr[point * 2 + 1] * icon_size); + } + p.drawPolygon(zr_icon.data(), static_cast(zr_icon.size())); + break; + case Symbol::SR: + for (std::size_t point = 0; point < symbol_sr.size() / 2; ++point) { + sr_icon[point] = center + QPointF(symbol_sr[point * 2] * icon_size, + symbol_sr[point * 2 + 1] * icon_size); + } + p.drawPolygon(sr_icon.data(), static_cast(sr_icon.size())); + break; + case Symbol::Charging: + for (std::size_t point = 0; point < symbol_charging.size() / 2; ++point) { + charging_icon[point] = center + QPointF(symbol_charging[point * 2] * icon_size, + symbol_charging[point * 2 + 1] * icon_size); + } + p.drawPolygon(charging_icon.data(), static_cast(charging_icon.size())); + break; + } +} + +void PlayerControlPreview::DrawArrow(QPainter& p, const QPointF center, const Direction direction, + float size) { + + std::array arrow_symbol; + + for (std::size_t point = 0; point < up_arrow_symbol.size() / 2; ++point) { + const float up_arrow_x = up_arrow_symbol[point * 2 + 0]; + const float up_arrow_y = up_arrow_symbol[point * 2 + 1]; + + switch (direction) { + case Direction::Up: + arrow_symbol[point] = center + QPointF(up_arrow_x * size, up_arrow_y * size); + break; + case Direction::Left: + arrow_symbol[point] = center + QPointF(up_arrow_y * size, up_arrow_x * size); + break; + case Direction::Right: + arrow_symbol[point] = center + QPointF(-up_arrow_y * size, up_arrow_x * size); + break; + case Direction::Down: + arrow_symbol[point] = center + QPointF(up_arrow_x * size, -up_arrow_y * size); + break; + case Direction::None: + break; + } + } + + DrawPolygon(p, arrow_symbol); +} + +// Draw motion functions +void PlayerControlPreview::Draw3dCube(QPainter& p, QPointF center, const Common::Vec3f& euler, + float size) { + std::array cube{ + Common::Vec3f{-0.7f, -1, -0.5f}, + {-0.7f, 1, -0.5f}, + {0.7f, 1, -0.5f}, + {0.7f, -1, -0.5f}, + {-0.7f, -1, 0.5f}, + {-0.7f, 1, 0.5f}, + {0.7f, 1, 0.5f}, + {0.7f, -1, 0.5f}, + }; + + for (Common::Vec3f& point : cube) { + point.RotateFromOrigin(euler.x, euler.y, euler.z); + point *= size; + } + + const std::array front_face{ + center + QPointF{cube[0].x, cube[0].y}, + center + QPointF{cube[1].x, cube[1].y}, + center + QPointF{cube[2].x, cube[2].y}, + center + QPointF{cube[3].x, cube[3].y}, + }; + const std::array back_face{ + center + QPointF{cube[4].x, cube[4].y}, + center + QPointF{cube[5].x, cube[5].y}, + center + QPointF{cube[6].x, cube[6].y}, + center + QPointF{cube[7].x, cube[7].y}, + }; + + DrawPolygon(p, front_face); + DrawPolygon(p, back_face); + p.drawLine(center + QPointF{cube[0].x, cube[0].y}, center + QPointF{cube[4].x, cube[4].y}); + p.drawLine(center + QPointF{cube[1].x, cube[1].y}, center + QPointF{cube[5].x, cube[5].y}); + p.drawLine(center + QPointF{cube[2].x, cube[2].y}, center + QPointF{cube[6].x, cube[6].y}); + p.drawLine(center + QPointF{cube[3].x, cube[3].y}, center + QPointF{cube[7].x, cube[7].y}); +} + +template +void PlayerControlPreview::DrawPolygon(QPainter& p, const std::array& polygon) { + p.drawPolygon(polygon.data(), static_cast(polygon.size())); +} + +void PlayerControlPreview::DrawCircle(QPainter& p, const QPointF center, float size) { + p.drawEllipse(center, size, size); +} + +void PlayerControlPreview::DrawRectangle(QPainter& p, const QPointF center, float width, + float height) { + const QRectF rect = QRectF(center.x() - (width / 2), center.y() - (height / 2), width, height); + p.drawRect(rect); +} +void PlayerControlPreview::DrawRoundRectangle(QPainter& p, const QPointF center, float width, + float height, float round) { + const QRectF rect = QRectF(center.x() - (width / 2), center.y() - (height / 2), width, height); + p.drawRoundedRect(rect, round, round); +} + +void PlayerControlPreview::DrawText(QPainter& p, const QPointF center, float text_size, + const QString& text) { + SetTextFont(p, text_size); + const QFontMetrics fm(p.font()); + const QPointF offset = {fm.horizontalAdvance(text) / 2.0f, -text_size / 2.0f}; + p.drawText(center - offset, text); +} + +void PlayerControlPreview::SetTextFont(QPainter& p, float text_size, const QString& font_family) { + QFont font = p.font(); + font.setPointSizeF(text_size); + font.setFamily(font_family); + p.setFont(font); +} diff --git a/src/sudachi/configuration/configure_input_player_widget.h b/src/sudachi/configuration/configure_input_player_widget.h new file mode 100644 index 0000000..50bb862 --- /dev/null +++ b/src/sudachi/configuration/configure_input_player_widget.h @@ -0,0 +1,230 @@ +// SPDX-FileCopyrightText: Copyright 2020 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include +#include +#include + +#include "common/input.h" +#include "common/settings_input.h" +#include "common/vector_math.h" +#include "hid_core/frontend/emulated_controller.h" +#include "hid_core/hid_types.h" + +class QLabel; + +using AnalogParam = std::array; +using ButtonParam = std::array; + +// Widget for representing controller animations +class PlayerControlPreview : public QFrame { + Q_OBJECT + +public: + explicit PlayerControlPreview(QWidget* parent); + ~PlayerControlPreview() override; + + // Sets the emulated controller to be displayed + void SetController(Core::HID::EmulatedController* controller); + + // Disables events from the emulated controller + void UnloadController(); + + // Starts blinking animation at the button specified + void BeginMappingButton(std::size_t button_id); + + // Starts moving animation at the stick specified + void BeginMappingAnalog(std::size_t stick_id); + + // Stops any ongoing animation + void EndMapping(); + + // Handles emulated controller events + void ControllerUpdate(Core::HID::ControllerTriggerType type); + + // Updates input on scheduled interval + void UpdateInput(); + +protected: + void paintEvent(QPaintEvent* event) override; + +private: + enum class Direction : std::size_t { + None, + Up, + Right, + Down, + Left, + }; + + enum class Symbol { + House, + A, + B, + X, + Y, + L, + R, + C, + SL, + ZL, + ZR, + SR, + Charging, + }; + + struct ColorMapping { + QColor outline{}; + QColor primary{}; + QColor left{}; + QColor right{}; + QColor button{}; + QColor button2{}; + QColor button_turbo{}; + QColor font{}; + QColor font2{}; + QColor highlight{}; + QColor highlight2{}; + QColor transparent{}; + QColor indicator{}; + QColor indicator2{}; + QColor led_on{}; + QColor led_off{}; + QColor slider{}; + QColor slider_button{}; + QColor slider_arrow{}; + QColor deadzone{}; + QColor charging{}; + }; + + void UpdateColors(); + void ResetInputs(); + + // Draw controller functions + void DrawHandheldController(QPainter& p, QPointF center); + void DrawDualController(QPainter& p, QPointF center); + void DrawLeftController(QPainter& p, QPointF center); + void DrawRightController(QPainter& p, QPointF center); + void DrawProController(QPainter& p, QPointF center); + void DrawGCController(QPainter& p, QPointF center); + + // Draw body functions + void DrawHandheldBody(QPainter& p, QPointF center); + void DrawDualBody(QPainter& p, QPointF center); + void DrawLeftBody(QPainter& p, QPointF center); + void DrawRightBody(QPainter& p, QPointF center); + void DrawProBody(QPainter& p, QPointF center); + void DrawGCBody(QPainter& p, QPointF center); + + // Draw triggers functions + void DrawProTriggers(QPainter& p, QPointF center, + const Common::Input::ButtonStatus& left_pressed, + const Common::Input::ButtonStatus& right_pressed); + void DrawGCTriggers(QPainter& p, QPointF center, Common::Input::TriggerStatus left_trigger, + Common::Input::TriggerStatus right_trigger); + void DrawHandheldTriggers(QPainter& p, QPointF center, + const Common::Input::ButtonStatus& left_pressed, + const Common::Input::ButtonStatus& right_pressed); + void DrawDualTriggers(QPainter& p, QPointF center, + const Common::Input::ButtonStatus& left_pressed, + const Common::Input::ButtonStatus& right_pressed); + void DrawDualTriggersTopView(QPainter& p, QPointF center, + const Common::Input::ButtonStatus& left_pressed, + const Common::Input::ButtonStatus& right_pressed); + void DrawDualZTriggersTopView(QPainter& p, QPointF center, + const Common::Input::ButtonStatus& left_pressed, + const Common::Input::ButtonStatus& right_pressed); + void DrawLeftTriggers(QPainter& p, QPointF center, + const Common::Input::ButtonStatus& left_pressed); + void DrawLeftZTriggers(QPainter& p, QPointF center, + const Common::Input::ButtonStatus& left_pressed); + void DrawLeftTriggersTopView(QPainter& p, QPointF center, + const Common::Input::ButtonStatus& left_pressed); + void DrawLeftZTriggersTopView(QPainter& p, QPointF center, + const Common::Input::ButtonStatus& left_pressed); + void DrawRightTriggers(QPainter& p, QPointF center, + const Common::Input::ButtonStatus& right_pressed); + void DrawRightZTriggers(QPainter& p, QPointF center, + const Common::Input::ButtonStatus& right_pressed); + void DrawRightTriggersTopView(QPainter& p, QPointF center, + const Common::Input::ButtonStatus& right_pressed); + void DrawRightZTriggersTopView(QPainter& p, QPointF center, + const Common::Input::ButtonStatus& right_pressed); + + // Draw joystick functions + void DrawJoystick(QPainter& p, QPointF center, float size, + const Common::Input::ButtonStatus& pressed); + void DrawJoystickSideview(QPainter& p, QPointF center, float angle, float size, + const Common::Input::ButtonStatus& pressed); + void DrawRawJoystick(QPainter& p, QPointF center_left, QPointF center_right); + void DrawJoystickProperties(QPainter& p, QPointF center, + const Common::Input::AnalogProperties& properties); + void DrawJoystickDot(QPainter& p, QPointF center, const Common::Input::StickStatus& stick, + bool raw); + void DrawProJoystick(QPainter& p, QPointF center, QPointF offset, float scalar, + const Common::Input::ButtonStatus& pressed); + void DrawGCJoystick(QPainter& p, QPointF center, const Common::Input::ButtonStatus& pressed); + + // Draw button functions + void DrawCircleButton(QPainter& p, QPointF center, const Common::Input::ButtonStatus& pressed, + float button_size); + void DrawRoundButton(QPainter& p, QPointF center, const Common::Input::ButtonStatus& pressed, + float width, float height, Direction direction = Direction::None, + float radius = 2); + void DrawMinusButton(QPainter& p, QPointF center, const Common::Input::ButtonStatus& pressed, + int button_size); + void DrawPlusButton(QPainter& p, QPointF center, const Common::Input::ButtonStatus& pressed, + int button_size); + void DrawGCButtonX(QPainter& p, QPointF center, const Common::Input::ButtonStatus& pressed); + void DrawGCButtonY(QPainter& p, QPointF center, const Common::Input::ButtonStatus& pressed); + void DrawGCButtonZ(QPainter& p, QPointF center, const Common::Input::ButtonStatus& pressed); + void DrawArrowButtonOutline(QPainter& p, const QPointF center, float size = 1.0f); + void DrawArrowButton(QPainter& p, QPointF center, Direction direction, + const Common::Input::ButtonStatus& pressed, float size = 1.0f); + void DrawTriggerButton(QPainter& p, QPointF center, Direction direction, + const Common::Input::ButtonStatus& pressed); + QColor GetButtonColor(QColor default_color, bool is_pressed, bool turbo); + + // Draw battery functions + void DrawBattery(QPainter& p, QPointF center, Common::Input::BatteryLevel battery); + + // Draw icon functions + void DrawSymbol(QPainter& p, QPointF center, Symbol symbol, float icon_size); + void DrawArrow(QPainter& p, QPointF center, Direction direction, float size); + + // Draw motion functions + void Draw3dCube(QPainter& p, QPointF center, const Common::Vec3f& euler, float size); + + // Draw primitive types + template + void DrawPolygon(QPainter& p, const std::array& polygon); + void DrawCircle(QPainter& p, QPointF center, float size); + void DrawRectangle(QPainter& p, QPointF center, float width, float height); + void DrawRoundRectangle(QPainter& p, QPointF center, float width, float height, float round); + void DrawText(QPainter& p, QPointF center, float text_size, const QString& text); + void SetTextFont(QPainter& p, float text_size, + const QString& font_family = QStringLiteral("sans-serif")); + + bool is_controller_set{}; + bool is_connected{}; + bool needs_redraw{}; + Core::HID::NpadStyleIndex controller_type; + + bool mapping_active{}; + int blink_counter{}; + int callback_key; + QColor button_color{}; + ColorMapping colors{}; + Core::HID::LedPattern led_pattern{0, 0, 0, 0}; + std::size_t player_index{}; + Core::HID::EmulatedController* controller; + std::size_t button_mapping_index{Settings::NativeButton::NumButtons}; + std::size_t analog_mapping_index{Settings::NativeAnalog::NumAnalogs}; + Core::HID::ButtonValues button_values{}; + Core::HID::SticksValues stick_values{}; + Core::HID::TriggerValues trigger_values{}; + Core::HID::BatteryValues battery_values{}; + Core::HID::MotionState motion_values{}; +}; diff --git a/src/sudachi/configuration/configure_input_profile_dialog.cpp b/src/sudachi/configuration/configure_input_profile_dialog.cpp new file mode 100644 index 0000000..1b68a26 --- /dev/null +++ b/src/sudachi/configuration/configure_input_profile_dialog.cpp @@ -0,0 +1,39 @@ +// SPDX-FileCopyrightText: Copyright 2020 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include "core/core.h" +#include "ui_configure_input_profile_dialog.h" +#include "sudachi/configuration/configure_input_player.h" +#include "sudachi/configuration/configure_input_profile_dialog.h" + +ConfigureInputProfileDialog::ConfigureInputProfileDialog( + QWidget* parent, InputCommon::InputSubsystem* input_subsystem, InputProfiles* profiles, + Core::System& system) + : QDialog(parent), ui(std::make_unique()), + profile_widget(new ConfigureInputPlayer(this, 9, nullptr, input_subsystem, profiles, + system.HIDCore(), system.IsPoweredOn(), false)) { + ui->setupUi(this); + + ui->controllerLayout->addWidget(profile_widget); + + connect(ui->clear_all_button, &QPushButton::clicked, this, + [this] { profile_widget->ClearAll(); }); + connect(ui->restore_defaults_button, &QPushButton::clicked, this, + [this] { profile_widget->RestoreDefaults(); }); + + RetranslateUI(); +} + +ConfigureInputProfileDialog::~ConfigureInputProfileDialog() = default; + +void ConfigureInputProfileDialog::changeEvent(QEvent* event) { + if (event->type() == QEvent::LanguageChange) { + RetranslateUI(); + } + + QDialog::changeEvent(event); +} + +void ConfigureInputProfileDialog::RetranslateUI() { + ui->retranslateUi(this); +} diff --git a/src/sudachi/configuration/configure_input_profile_dialog.h b/src/sudachi/configuration/configure_input_profile_dialog.h new file mode 100644 index 0000000..4b8334c --- /dev/null +++ b/src/sudachi/configuration/configure_input_profile_dialog.h @@ -0,0 +1,43 @@ +// SPDX-FileCopyrightText: Copyright 2020 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include +#include + +class QPushButton; + +class ConfigureInputPlayer; + +class InputProfiles; + +namespace Core { +class System; +} + +namespace InputCommon { +class InputSubsystem; +} + +namespace Ui { +class ConfigureInputProfileDialog; +} + +class ConfigureInputProfileDialog : public QDialog { + Q_OBJECT + +public: + explicit ConfigureInputProfileDialog(QWidget* parent, + InputCommon::InputSubsystem* input_subsystem, + InputProfiles* profiles, Core::System& system); + ~ConfigureInputProfileDialog() override; + +private: + void changeEvent(QEvent* event) override; + void RetranslateUI(); + + std::unique_ptr ui; + + ConfigureInputPlayer* profile_widget; +}; diff --git a/src/sudachi/configuration/configure_input_profile_dialog.ui b/src/sudachi/configuration/configure_input_profile_dialog.ui new file mode 100644 index 0000000..726cf69 --- /dev/null +++ b/src/sudachi/configuration/configure_input_profile_dialog.ui @@ -0,0 +1,71 @@ + + + ConfigureInputProfileDialog + + + + 0 + 0 + 70 + 540 + + + + Create Input Profile + + + + 2 + + + 9 + + + 9 + + + 9 + + + 9 + + + + + + + + + + Clear + + + + + + + Defaults + + + + + + + QDialogButtonBox::Ok + + + + + + + + + + + buttonBox + accepted() + ConfigureInputProfileDialog + accept() + + + diff --git a/src/sudachi/configuration/configure_linux_tab.cpp b/src/sudachi/configuration/configure_linux_tab.cpp new file mode 100644 index 0000000..d5256a7 --- /dev/null +++ b/src/sudachi/configuration/configure_linux_tab.cpp @@ -0,0 +1,75 @@ +// SPDX-FileCopyrightText: Copyright 2019 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include "common/settings.h" +#include "core/core.h" +#include "ui_configure_linux_tab.h" +#include "sudachi/configuration/configuration_shared.h" +#include "sudachi/configuration/configure_linux_tab.h" +#include "sudachi/configuration/shared_widget.h" + +ConfigureLinuxTab::ConfigureLinuxTab(const Core::System& system_, + std::shared_ptr> group_, + const ConfigurationShared::Builder& builder, QWidget* parent) + : Tab(group_, parent), ui(std::make_unique()), system{system_} { + ui->setupUi(this); + + Setup(builder); + + SetConfiguration(); +} + +ConfigureLinuxTab::~ConfigureLinuxTab() = default; + +void ConfigureLinuxTab::SetConfiguration() {} +void ConfigureLinuxTab::Setup(const ConfigurationShared::Builder& builder) { + QLayout& linux_layout = *ui->linux_widget->layout(); + + std::map linux_hold{}; + + std::vector settings; + const auto push = [&](Settings::Category category) { + for (const auto setting : Settings::values.linkage.by_category[category]) { + settings.push_back(setting); + } + }; + + push(Settings::Category::Linux); + + for (auto* setting : settings) { + auto* widget = builder.BuildWidget(setting, apply_funcs); + + if (widget == nullptr) { + continue; + } + if (!widget->Valid()) { + widget->deleteLater(); + continue; + } + + linux_hold.insert({setting->Id(), widget}); + } + + for (const auto& [id, widget] : linux_hold) { + linux_layout.addWidget(widget); + } +} + +void ConfigureLinuxTab::ApplyConfiguration() { + const bool is_powered_on = system.IsPoweredOn(); + for (const auto& apply_func : apply_funcs) { + apply_func(is_powered_on); + } +} + +void ConfigureLinuxTab::changeEvent(QEvent* event) { + if (event->type() == QEvent::LanguageChange) { + RetranslateUI(); + } + + QWidget::changeEvent(event); +} + +void ConfigureLinuxTab::RetranslateUI() { + ui->retranslateUi(this); +} diff --git a/src/sudachi/configuration/configure_linux_tab.h b/src/sudachi/configuration/configure_linux_tab.h new file mode 100644 index 0000000..c7b85d8 --- /dev/null +++ b/src/sudachi/configuration/configure_linux_tab.h @@ -0,0 +1,44 @@ +// SPDX-FileCopyrightText: Copyright 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include + +namespace Core { +class System; +} + +namespace Ui { +class ConfigureLinuxTab; +} + +namespace ConfigurationShared { +class Builder; +} + +class ConfigureLinuxTab : public ConfigurationShared::Tab { + Q_OBJECT + +public: + explicit ConfigureLinuxTab(const Core::System& system_, + std::shared_ptr> group, + const ConfigurationShared::Builder& builder, + QWidget* parent = nullptr); + ~ConfigureLinuxTab() override; + + void ApplyConfiguration() override; + void SetConfiguration() override; + +private: + void changeEvent(QEvent* event) override; + void RetranslateUI(); + + void Setup(const ConfigurationShared::Builder& builder); + + std::unique_ptr ui; + + const Core::System& system; + + std::vector> apply_funcs{}; +}; diff --git a/src/sudachi/configuration/configure_linux_tab.ui b/src/sudachi/configuration/configure_linux_tab.ui new file mode 100644 index 0000000..f8e07f5 --- /dev/null +++ b/src/sudachi/configuration/configure_linux_tab.ui @@ -0,0 +1,53 @@ + + + ConfigureLinuxTab + + + Linux + + + + + + Linux + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + diff --git a/src/sudachi/configuration/configure_motion_touch.cpp b/src/sudachi/configuration/configure_motion_touch.cpp new file mode 100644 index 0000000..0d92ada --- /dev/null +++ b/src/sudachi/configuration/configure_motion_touch.cpp @@ -0,0 +1,326 @@ +// SPDX-FileCopyrightText: 2018 Citra Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include + +#include +#include +#include + +#include "common/logging/log.h" +#include "common/settings.h" +#include "input_common/drivers/udp_client.h" +#include "input_common/helpers/udp_protocol.h" +#include "input_common/main.h" +#include "ui_configure_motion_touch.h" +#include "sudachi/configuration/configure_motion_touch.h" +#include "sudachi/configuration/configure_touch_from_button.h" + +CalibrationConfigurationDialog::CalibrationConfigurationDialog(QWidget* parent, + const std::string& host, u16 port) + : QDialog(parent) { + layout = new QVBoxLayout; + status_label = new QLabel(tr("Communicating with the server...")); + cancel_button = new QPushButton(tr("Cancel")); + connect(cancel_button, &QPushButton::clicked, this, [this] { + if (!completed) { + job->Stop(); + } + accept(); + }); + layout->addWidget(status_label); + layout->addWidget(cancel_button); + setLayout(layout); + + using namespace InputCommon::CemuhookUDP; + job = std::make_unique( + host, port, + [this](CalibrationConfigurationJob::Status status) { + QMetaObject::invokeMethod(this, [status, this] { + QString text; + switch (status) { + case CalibrationConfigurationJob::Status::Ready: + text = tr("Touch the top left corner
of your touchpad."); + break; + case CalibrationConfigurationJob::Status::Stage1Completed: + text = tr("Now touch the bottom right corner
of your touchpad."); + break; + case CalibrationConfigurationJob::Status::Completed: + text = tr("Configuration completed!"); + break; + default: + break; + } + UpdateLabelText(text); + }); + if (status == CalibrationConfigurationJob::Status::Completed) { + QMetaObject::invokeMethod(this, [this] { UpdateButtonText(tr("OK")); }); + } + }, + [this](u16 min_x_, u16 min_y_, u16 max_x_, u16 max_y_) { + completed = true; + min_x = min_x_; + min_y = min_y_; + max_x = max_x_; + max_y = max_y_; + }); +} + +CalibrationConfigurationDialog::~CalibrationConfigurationDialog() = default; + +void CalibrationConfigurationDialog::UpdateLabelText(const QString& text) { + status_label->setText(text); +} + +void CalibrationConfigurationDialog::UpdateButtonText(const QString& text) { + cancel_button->setText(text); +} + +ConfigureMotionTouch::ConfigureMotionTouch(QWidget* parent, + InputCommon::InputSubsystem* input_subsystem_) + : QDialog(parent), input_subsystem{input_subsystem_}, + ui(std::make_unique()) { + ui->setupUi(this); + + ui->udp_learn_more->setOpenExternalLinks(true); + ui->udp_learn_more->setText( + tr("Learn More")); + + SetConfiguration(); + UpdateUiDisplay(); + ConnectEvents(); +} + +ConfigureMotionTouch::~ConfigureMotionTouch() = default; + +void ConfigureMotionTouch::SetConfiguration() { + const Common::ParamPackage touch_param(Settings::values.touch_device.GetValue()); + + touch_from_button_maps = Settings::values.touch_from_button_maps; + for (const auto& touch_map : touch_from_button_maps) { + ui->touch_from_button_map->addItem(QString::fromStdString(touch_map.name)); + } + ui->touch_from_button_map->setCurrentIndex( + Settings::values.touch_from_button_map_index.GetValue()); + + min_x = touch_param.Get("min_x", 100); + min_y = touch_param.Get("min_y", 50); + max_x = touch_param.Get("max_x", 1800); + max_y = touch_param.Get("max_y", 850); + + ui->udp_server->setText(QString::fromStdString("127.0.0.1")); + ui->udp_port->setText(QString::number(26760)); + + udp_server_list_model = new QStringListModel(this); + udp_server_list_model->setStringList({}); + ui->udp_server_list->setModel(udp_server_list_model); + + std::stringstream ss(Settings::values.udp_input_servers.GetValue()); + std::string token; + + while (std::getline(ss, token, ',')) { + const int row = udp_server_list_model->rowCount(); + udp_server_list_model->insertRows(row, 1); + const QModelIndex index = udp_server_list_model->index(row); + udp_server_list_model->setData(index, QString::fromStdString(token)); + } +} + +void ConfigureMotionTouch::UpdateUiDisplay() { + const QString cemuhook_udp = QStringLiteral("cemuhookudp"); + + ui->touch_calibration->setVisible(true); + ui->touch_calibration_config->setVisible(true); + ui->touch_calibration_label->setVisible(true); + ui->touch_calibration->setText( + QStringLiteral("(%1, %2) - (%3, %4)").arg(min_x).arg(min_y).arg(max_x).arg(max_y)); + + ui->udp_config_group_box->setVisible(true); +} + +void ConfigureMotionTouch::ConnectEvents() { + connect(ui->udp_test, &QPushButton::clicked, this, &ConfigureMotionTouch::OnCemuhookUDPTest); + connect(ui->udp_add, &QPushButton::clicked, this, &ConfigureMotionTouch::OnUDPAddServer); + connect(ui->udp_remove, &QPushButton::clicked, this, &ConfigureMotionTouch::OnUDPDeleteServer); + connect(ui->touch_calibration_config, &QPushButton::clicked, this, + &ConfigureMotionTouch::OnConfigureTouchCalibration); + connect(ui->touch_from_button_config_btn, &QPushButton::clicked, this, + &ConfigureMotionTouch::OnConfigureTouchFromButton); + connect(ui->buttonBox, &QDialogButtonBox::accepted, this, + &ConfigureMotionTouch::ApplyConfiguration); + connect(ui->buttonBox, &QDialogButtonBox::rejected, this, [this] { + if (CanCloseDialog()) { + reject(); + } + }); +} + +void ConfigureMotionTouch::OnUDPAddServer() { + // Validator for IP address + const QRegularExpression re(QStringLiteral( + R"re(^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$)re")); + bool ok; + const QString port_text = ui->udp_port->text(); + const QString server_text = ui->udp_server->text(); + const QString server_string = tr("%1:%2").arg(server_text, port_text); + const int port_number = port_text.toInt(&ok, 10); + const int row = udp_server_list_model->rowCount(); + + if (!ok) { + QMessageBox::warning(this, tr("sudachi"), tr("Port number has invalid characters")); + return; + } + if (port_number < 0 || port_number > 65353) { + QMessageBox::warning(this, tr("sudachi"), tr("Port has to be in range 0 and 65353")); + return; + } + if (!re.match(server_text).hasMatch()) { + QMessageBox::warning(this, tr("sudachi"), tr("IP address is not valid")); + return; + } + // Search for duplicates + for (const auto& item : udp_server_list_model->stringList()) { + if (item == server_string) { + QMessageBox::warning(this, tr("sudachi"), tr("This UDP server already exists")); + return; + } + } + // Limit server count to 8 + if (row == 8) { + QMessageBox::warning(this, tr("sudachi"), tr("Unable to add more than 8 servers")); + return; + } + + udp_server_list_model->insertRows(row, 1); + QModelIndex index = udp_server_list_model->index(row); + udp_server_list_model->setData(index, server_string); + ui->udp_server_list->setCurrentIndex(index); +} + +void ConfigureMotionTouch::OnUDPDeleteServer() { + udp_server_list_model->removeRows(ui->udp_server_list->currentIndex().row(), 1); +} + +void ConfigureMotionTouch::OnCemuhookUDPTest() { + ui->udp_test->setEnabled(false); + ui->udp_test->setText(tr("Testing")); + udp_test_in_progress = true; + InputCommon::CemuhookUDP::TestCommunication( + ui->udp_server->text().toStdString(), static_cast(ui->udp_port->text().toInt()), + [this] { + LOG_INFO(Frontend, "UDP input test success"); + QMetaObject::invokeMethod(this, [this] { ShowUDPTestResult(true); }); + }, + [this] { + LOG_ERROR(Frontend, "UDP input test failed"); + QMetaObject::invokeMethod(this, [this] { ShowUDPTestResult(false); }); + }); +} + +void ConfigureMotionTouch::OnConfigureTouchCalibration() { + ui->touch_calibration_config->setEnabled(false); + ui->touch_calibration_config->setText(tr("Configuring")); + CalibrationConfigurationDialog dialog(this, ui->udp_server->text().toStdString(), + static_cast(ui->udp_port->text().toUInt())); + dialog.exec(); + if (dialog.completed) { + min_x = dialog.min_x; + min_y = dialog.min_y; + max_x = dialog.max_x; + max_y = dialog.max_y; + LOG_INFO(Frontend, + "UDP touchpad calibration config success: min_x={}, min_y={}, max_x={}, max_y={}", + min_x, min_y, max_x, max_y); + UpdateUiDisplay(); + } else { + LOG_ERROR(Frontend, "UDP touchpad calibration config failed"); + } + ui->touch_calibration_config->setEnabled(true); + ui->touch_calibration_config->setText(tr("Configure")); +} + +void ConfigureMotionTouch::closeEvent(QCloseEvent* event) { + if (CanCloseDialog()) { + event->accept(); + } else { + event->ignore(); + } +} + +void ConfigureMotionTouch::ShowUDPTestResult(bool result) { + udp_test_in_progress = false; + if (result) { + QMessageBox::information(this, tr("Test Successful"), + tr("Successfully received data from the server.")); + } else { + QMessageBox::warning(this, tr("Test Failed"), + tr("Could not receive valid data from the server.
Please verify " + "that the server is set up correctly and " + "the address and port are correct.")); + } + ui->udp_test->setEnabled(true); + ui->udp_test->setText(tr("Test")); +} + +void ConfigureMotionTouch::OnConfigureTouchFromButton() { + ConfigureTouchFromButton dialog{this, touch_from_button_maps, input_subsystem, + ui->touch_from_button_map->currentIndex()}; + if (dialog.exec() != QDialog::Accepted) { + return; + } + touch_from_button_maps = dialog.GetMaps(); + + while (ui->touch_from_button_map->count() > 0) { + ui->touch_from_button_map->removeItem(0); + } + for (const auto& touch_map : touch_from_button_maps) { + ui->touch_from_button_map->addItem(QString::fromStdString(touch_map.name)); + } + ui->touch_from_button_map->setCurrentIndex(dialog.GetSelectedIndex()); +} + +bool ConfigureMotionTouch::CanCloseDialog() { + if (udp_test_in_progress) { + QMessageBox::warning(this, tr("sudachi"), + tr("UDP Test or calibration configuration is in progress.
Please " + "wait for them to finish.")); + return false; + } + return true; +} + +void ConfigureMotionTouch::ApplyConfiguration() { + if (!CanCloseDialog()) { + return; + } + + Common::ParamPackage touch_param{}; + touch_param.Set("min_x", min_x); + touch_param.Set("min_y", min_y); + touch_param.Set("max_x", max_x); + touch_param.Set("max_y", max_y); + + Settings::values.touch_device = touch_param.Serialize(); + Settings::values.touch_from_button_map_index = ui->touch_from_button_map->currentIndex(); + Settings::values.touch_from_button_maps = touch_from_button_maps; + Settings::values.udp_input_servers = GetUDPServerString(); + input_subsystem->ReloadInputDevices(); + + accept(); +} + +std::string ConfigureMotionTouch::GetUDPServerString() const { + QString input_servers; + + for (const auto& item : udp_server_list_model->stringList()) { + input_servers += item; + input_servers += QLatin1Char{','}; + } + + // Remove last comma + input_servers.chop(1); + return input_servers.toStdString(); +} diff --git a/src/sudachi/configuration/configure_motion_touch.h b/src/sudachi/configuration/configure_motion_touch.h new file mode 100644 index 0000000..a5db0de --- /dev/null +++ b/src/sudachi/configuration/configure_motion_touch.h @@ -0,0 +1,93 @@ +// SPDX-FileCopyrightText: 2018 Citra Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include +#include + +class QLabel; +class QPushButton; +class QStringListModel; +class QVBoxLayout; + +namespace InputCommon { +class InputSubsystem; +} + +namespace InputCommon::CemuhookUDP { +class CalibrationConfigurationJob; +} + +namespace Ui { +class ConfigureMotionTouch; +} + +/// A dialog for touchpad calibration configuration. +class CalibrationConfigurationDialog : public QDialog { + Q_OBJECT + +public: + explicit CalibrationConfigurationDialog(QWidget* parent, const std::string& host, u16 port); + ~CalibrationConfigurationDialog() override; + +private: + Q_INVOKABLE void UpdateLabelText(const QString& text); + Q_INVOKABLE void UpdateButtonText(const QString& text); + + QVBoxLayout* layout; + QLabel* status_label; + QPushButton* cancel_button; + std::unique_ptr job; + + // Configuration results + bool completed{}; + u16 min_x{}; + u16 min_y{}; + u16 max_x{}; + u16 max_y{}; + + friend class ConfigureMotionTouch; +}; + +class ConfigureMotionTouch : public QDialog { + Q_OBJECT + +public: + explicit ConfigureMotionTouch(QWidget* parent, InputCommon::InputSubsystem* input_subsystem_); + ~ConfigureMotionTouch() override; + +public slots: + void ApplyConfiguration(); + +private slots: + void OnUDPAddServer(); + void OnUDPDeleteServer(); + void OnCemuhookUDPTest(); + void OnConfigureTouchCalibration(); + void OnConfigureTouchFromButton(); + +private: + void closeEvent(QCloseEvent* event) override; + Q_INVOKABLE void ShowUDPTestResult(bool result); + void SetConfiguration(); + void UpdateUiDisplay(); + void ConnectEvents(); + bool CanCloseDialog(); + std::string GetUDPServerString() const; + + InputCommon::InputSubsystem* input_subsystem; + + std::unique_ptr ui; + QStringListModel* udp_server_list_model; + + // Coordinate system of the CemuhookUDP touch provider + int min_x{}; + int min_y{}; + int max_x{}; + int max_y{}; + + bool udp_test_in_progress{}; + + std::vector touch_from_button_maps; +}; diff --git a/src/sudachi/configuration/configure_motion_touch.ui b/src/sudachi/configuration/configure_motion_touch.ui new file mode 100644 index 0000000..0237fae --- /dev/null +++ b/src/sudachi/configuration/configure_motion_touch.ui @@ -0,0 +1,297 @@ + + + ConfigureMotionTouch + + + Configure Motion / Touch + + + + + + + + + Touch + + + + + + + + UDP Calibration: + + + + + + + (100, 50) - (1800, 850) + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + + 0 + 0 + + + + Configure + + + + + + + + + + + Touch from button profile: + + + + + + + + + + + 0 + 0 + + + + Configure + + + + + + + + + + + + CemuhookUDP Config + + + + + + You may use any Cemuhook compatible UDP input source to provide motion and touch input. + + + Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop + + + true + + + + + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + 3 + + + 3 + + + 0 + + + + + Server: + + + + + + + + 0 + 0 + + + + + + + + + + 3 + + + 0 + + + + + Port: + + + + + + + + 0 + 0 + + + + + + + + + + 3 + + + 0 + + + + + Learn More + + + + + + + + 0 + 0 + + + + Test + + + + + + + + 0 + 0 + + + + Add Server + + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + 0 + + + + + + 0 + 0 + + + + Remove Server + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + + + + + + + + Qt::Vertical + + + + 167 + 55 + + + + + + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + + + + + + diff --git a/src/sudachi/configuration/configure_mouse_panning.cpp b/src/sudachi/configuration/configure_mouse_panning.cpp new file mode 100644 index 0000000..f618a52 --- /dev/null +++ b/src/sudachi/configuration/configure_mouse_panning.cpp @@ -0,0 +1,90 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include +#include + +#include "common/settings.h" +#include "ui_configure_mouse_panning.h" +#include "sudachi/configuration/configure_mouse_panning.h" + +ConfigureMousePanning::ConfigureMousePanning(QWidget* parent, + InputCommon::InputSubsystem* input_subsystem_, + float right_stick_deadzone, float right_stick_range) + : QDialog(parent), input_subsystem{input_subsystem_}, + ui(std::make_unique()) { + ui->setupUi(this); + SetConfiguration(right_stick_deadzone, right_stick_range); + ConnectEvents(); +} + +ConfigureMousePanning::~ConfigureMousePanning() = default; + +void ConfigureMousePanning::closeEvent(QCloseEvent* event) { + event->accept(); +} + +void ConfigureMousePanning::SetConfiguration(float right_stick_deadzone, float right_stick_range) { + ui->enable->setChecked(Settings::values.mouse_panning.GetValue()); + ui->x_sensitivity->setValue(Settings::values.mouse_panning_x_sensitivity.GetValue()); + ui->y_sensitivity->setValue(Settings::values.mouse_panning_y_sensitivity.GetValue()); + ui->deadzone_counterweight->setValue( + Settings::values.mouse_panning_deadzone_counterweight.GetValue()); + ui->decay_strength->setValue(Settings::values.mouse_panning_decay_strength.GetValue()); + ui->min_decay->setValue(Settings::values.mouse_panning_min_decay.GetValue()); + + if (right_stick_deadzone > 0.0f || right_stick_range != 1.0f) { + const QString right_stick_deadzone_str = + QString::fromStdString(std::to_string(static_cast(right_stick_deadzone * 100.0f))); + const QString right_stick_range_str = + QString::fromStdString(std::to_string(static_cast(right_stick_range * 100.0f))); + + ui->warning_label->setText( + tr("Mouse panning works better with a deadzone of 0% and a range of 100%.\nCurrent " + "values are %1% and %2% respectively.") + .arg(right_stick_deadzone_str, right_stick_range_str)); + } + + if (Settings::values.mouse_enabled) { + ui->warning_label->setText( + tr("Emulated mouse is enabled. This is incompatible with mouse panning.")); + } +} + +void ConfigureMousePanning::SetDefaultConfiguration() { + ui->x_sensitivity->setValue(Settings::values.mouse_panning_x_sensitivity.GetDefault()); + ui->y_sensitivity->setValue(Settings::values.mouse_panning_y_sensitivity.GetDefault()); + ui->deadzone_counterweight->setValue( + Settings::values.mouse_panning_deadzone_counterweight.GetDefault()); + ui->decay_strength->setValue(Settings::values.mouse_panning_decay_strength.GetDefault()); + ui->min_decay->setValue(Settings::values.mouse_panning_min_decay.GetDefault()); +} + +void ConfigureMousePanning::ConnectEvents() { + connect(ui->default_button, &QPushButton::clicked, this, + &ConfigureMousePanning::SetDefaultConfiguration); + connect(ui->button_box, &QDialogButtonBox::accepted, this, + &ConfigureMousePanning::ApplyConfiguration); + connect(ui->button_box, &QDialogButtonBox::rejected, this, [this] { reject(); }); +} + +void ConfigureMousePanning::ApplyConfiguration() { + Settings::values.mouse_panning = ui->enable->isChecked(); + Settings::values.mouse_panning_x_sensitivity = static_cast(ui->x_sensitivity->value()); + Settings::values.mouse_panning_y_sensitivity = static_cast(ui->y_sensitivity->value()); + Settings::values.mouse_panning_deadzone_counterweight = + static_cast(ui->deadzone_counterweight->value()); + Settings::values.mouse_panning_decay_strength = static_cast(ui->decay_strength->value()); + Settings::values.mouse_panning_min_decay = static_cast(ui->min_decay->value()); + + if (Settings::values.mouse_enabled && Settings::values.mouse_panning) { + Settings::values.mouse_panning = false; + QMessageBox::critical( + this, tr("Emulated mouse is enabled"), + tr("Real mouse input and mouse panning are incompatible. Please disable the " + "emulated mouse in input advanced settings to allow mouse panning.")); + return; + } + + accept(); +} diff --git a/src/sudachi/configuration/configure_mouse_panning.h b/src/sudachi/configuration/configure_mouse_panning.h new file mode 100644 index 0000000..40f6264 --- /dev/null +++ b/src/sudachi/configuration/configure_mouse_panning.h @@ -0,0 +1,36 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include +#include + +namespace InputCommon { +class InputSubsystem; +} + +namespace Ui { +class ConfigureMousePanning; +} + +class ConfigureMousePanning : public QDialog { + Q_OBJECT + +public: + explicit ConfigureMousePanning(QWidget* parent, InputCommon::InputSubsystem* input_subsystem_, + float right_stick_deadzone, float right_stick_range); + ~ConfigureMousePanning() override; + +public slots: + void ApplyConfiguration(); + +private: + void closeEvent(QCloseEvent* event) override; + void SetConfiguration(float right_stick_deadzone, float right_stick_range); + void SetDefaultConfiguration(); + void ConnectEvents(); + + InputCommon::InputSubsystem* input_subsystem; + std::unique_ptr ui; +}; diff --git a/src/sudachi/configuration/configure_mouse_panning.ui b/src/sudachi/configuration/configure_mouse_panning.ui new file mode 100644 index 0000000..84fb7ee --- /dev/null +++ b/src/sudachi/configuration/configure_mouse_panning.ui @@ -0,0 +1,212 @@ + + + ConfigureMousePanning + + + Configure mouse panning + + + + + + Enable mouse panning + + + Can be toggled via a hotkey. Default hotkey is Ctrl + F9 + + + + + + + + + Sensitivity + + + + + + Horizontal + + + + + + + Qt::AlignCenter + + + % + + + 1 + + + 100 + + + 50 + + + + + + + Vertical + + + + + + + Qt::AlignCenter + + + % + + + 1 + + + 100 + + + 50 + + + + + + + + + + Deadzone counterweight + + + Counteracts a game's built-in deadzone + + + + + + Deadzone + + + + + + + Qt::AlignCenter + + + % + + + 0 + + + 100 + + + 0 + + + + + + + + + + Stick decay + + + + + + Strength + + + + + + + Qt::AlignCenter + + + % + + + 0 + + + 100 + + + 22 + + + + + + + Minimum + + + + + + + Qt::AlignCenter + + + % + + + 0 + + + 100 + + + 5 + + + + + + + + + + + + + + + + + + + + + Default + + + + + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + + + + + + + + diff --git a/src/sudachi/configuration/configure_network.cpp b/src/sudachi/configuration/configure_network.cpp new file mode 100644 index 0000000..6fe15e6 --- /dev/null +++ b/src/sudachi/configuration/configure_network.cpp @@ -0,0 +1,48 @@ +// SPDX-FileCopyrightText: Copyright 2019 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include +#include "common/settings.h" +#include "core/core.h" +#include "core/internal_network/network_interface.h" +#include "ui_configure_network.h" +#include "sudachi/configuration/configure_network.h" + +ConfigureNetwork::ConfigureNetwork(const Core::System& system_, QWidget* parent) + : QWidget(parent), ui(std::make_unique()), system{system_} { + ui->setupUi(this); + + ui->network_interface->addItem(tr("None")); + for (const auto& iface : Network::GetAvailableNetworkInterfaces()) { + ui->network_interface->addItem(QString::fromStdString(iface.name)); + } + + this->SetConfiguration(); +} + +ConfigureNetwork::~ConfigureNetwork() = default; + +void ConfigureNetwork::ApplyConfiguration() { + Settings::values.network_interface = ui->network_interface->currentText().toStdString(); +} + +void ConfigureNetwork::changeEvent(QEvent* event) { + if (event->type() == QEvent::LanguageChange) { + RetranslateUI(); + } + + QWidget::changeEvent(event); +} + +void ConfigureNetwork::RetranslateUI() { + ui->retranslateUi(this); +} + +void ConfigureNetwork::SetConfiguration() { + const bool runtime_lock = !system.IsPoweredOn(); + + const std::string& network_interface = Settings::values.network_interface.GetValue(); + + ui->network_interface->setCurrentText(QString::fromStdString(network_interface)); + ui->network_interface->setEnabled(runtime_lock); +} diff --git a/src/sudachi/configuration/configure_network.h b/src/sudachi/configuration/configure_network.h new file mode 100644 index 0000000..9051276 --- /dev/null +++ b/src/sudachi/configuration/configure_network.h @@ -0,0 +1,30 @@ +// SPDX-FileCopyrightText: Copyright 2019 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include +#include + +namespace Ui { +class ConfigureNetwork; +} + +class ConfigureNetwork : public QWidget { + Q_OBJECT + +public: + explicit ConfigureNetwork(const Core::System& system_, QWidget* parent = nullptr); + ~ConfigureNetwork() override; + + void ApplyConfiguration(); + +private: + void changeEvent(QEvent*) override; + void RetranslateUI(); + void SetConfiguration(); + + std::unique_ptr ui; + + const Core::System& system; +}; diff --git a/src/sudachi/configuration/configure_network.ui b/src/sudachi/configuration/configure_network.ui new file mode 100644 index 0000000..f10e973 --- /dev/null +++ b/src/sudachi/configuration/configure_network.ui @@ -0,0 +1,60 @@ + + + ConfigureNetwork + + + + 0 + 0 + 433 + 561 + + + + Form + + + Network + + + + + + + + General + + + + + + + + + Network Interface + + + + + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + diff --git a/src/sudachi/configuration/configure_per_game.cpp b/src/sudachi/configuration/configure_per_game.cpp new file mode 100644 index 0000000..ab889e5 --- /dev/null +++ b/src/sudachi/configuration/configure_per_game.cpp @@ -0,0 +1,204 @@ +// SPDX-FileCopyrightText: Copyright 2020 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include +#include +#include +#include +#include +#include + +#include + +#include +#include +#include +#include +#include + +#include "common/fs/fs_util.h" +#include "common/settings_enums.h" +#include "common/settings_input.h" +#include "configuration/shared_widget.h" +#include "core/core.h" +#include "core/file_sys/control_metadata.h" +#include "core/file_sys/patch_manager.h" +#include "core/file_sys/xts_archive.h" +#include "core/loader/loader.h" +#include "frontend_common/config.h" +#include "ui_configure_per_game.h" +#include "sudachi/configuration/configuration_shared.h" +#include "sudachi/configuration/configure_audio.h" +#include "sudachi/configuration/configure_cpu.h" +#include "sudachi/configuration/configure_graphics.h" +#include "sudachi/configuration/configure_graphics_advanced.h" +#include "sudachi/configuration/configure_input_per_game.h" +#include "sudachi/configuration/configure_linux_tab.h" +#include "sudachi/configuration/configure_per_game.h" +#include "sudachi/configuration/configure_per_game_addons.h" +#include "sudachi/configuration/configure_system.h" +#include "sudachi/uisettings.h" +#include "sudachi/util/util.h" +#include "sudachi/vk_device_info.h" + +ConfigurePerGame::ConfigurePerGame(QWidget* parent, u64 title_id_, const std::string& file_name, + std::vector& vk_device_records, + Core::System& system_) + : QDialog(parent), + ui(std::make_unique()), title_id{title_id_}, system{system_}, + builder{std::make_unique(this, !system_.IsPoweredOn())}, + tab_group{std::make_shared>()} { + const auto file_path = std::filesystem::path(Common::FS::ToU8String(file_name)); + const auto config_file_name = title_id == 0 ? Common::FS::PathToUTF8String(file_path.filename()) + : fmt::format("{:016X}", title_id); + game_config = std::make_unique(config_file_name, Config::ConfigType::PerGameConfig); + addons_tab = std::make_unique(system_, this); + audio_tab = std::make_unique(system_, tab_group, *builder, this); + cpu_tab = std::make_unique(system_, tab_group, *builder, this); + graphics_advanced_tab = + std::make_unique(system_, tab_group, *builder, this); + graphics_tab = std::make_unique( + system_, vk_device_records, [&]() { graphics_advanced_tab->ExposeComputeOption(); }, + [](Settings::AspectRatio, Settings::ResolutionSetup) {}, tab_group, *builder, this); + input_tab = std::make_unique(system_, game_config.get(), this); + linux_tab = std::make_unique(system_, tab_group, *builder, this); + system_tab = std::make_unique(system_, tab_group, *builder, this); + + ui->setupUi(this); + + ui->tabWidget->addTab(addons_tab.get(), tr("Add-Ons")); + ui->tabWidget->addTab(system_tab.get(), tr("System")); + ui->tabWidget->addTab(cpu_tab.get(), tr("CPU")); + ui->tabWidget->addTab(graphics_tab.get(), tr("Graphics")); + ui->tabWidget->addTab(graphics_advanced_tab.get(), tr("Adv. Graphics")); + ui->tabWidget->addTab(audio_tab.get(), tr("Audio")); + ui->tabWidget->addTab(input_tab.get(), tr("Input Profiles")); + + // Only show Linux tab on Unix + linux_tab->setVisible(false); +#ifdef __unix__ + linux_tab->setVisible(true); + ui->tabWidget->addTab(linux_tab.get(), tr("Linux")); +#endif + + setFocusPolicy(Qt::ClickFocus); + setWindowTitle(tr("Properties")); + + addons_tab->SetTitleId(title_id); + + scene = new QGraphicsScene; + ui->icon_view->setScene(scene); + + if (system.IsPoweredOn()) { + QPushButton* apply_button = ui->buttonBox->addButton(QDialogButtonBox::Apply); + connect(apply_button, &QAbstractButton::clicked, this, + &ConfigurePerGame::HandleApplyButtonClicked); + } + + LoadConfiguration(); +} + +ConfigurePerGame::~ConfigurePerGame() = default; + +void ConfigurePerGame::ApplyConfiguration() { + for (const auto tab : *tab_group) { + tab->ApplyConfiguration(); + } + addons_tab->ApplyConfiguration(); + input_tab->ApplyConfiguration(); + + if (Settings::IsDockedMode() && Settings::values.players.GetValue()[0].controller_type == + Settings::ControllerType::Handheld) { + Settings::values.use_docked_mode.SetValue(Settings::ConsoleMode::Handheld); + Settings::values.use_docked_mode.SetGlobal(true); + } + + system.ApplySettings(); + Settings::LogSettings(); + + game_config->SaveAllValues(); +} + +void ConfigurePerGame::changeEvent(QEvent* event) { + if (event->type() == QEvent::LanguageChange) { + RetranslateUI(); + } + + QDialog::changeEvent(event); +} + +void ConfigurePerGame::RetranslateUI() { + ui->retranslateUi(this); +} + +void ConfigurePerGame::HandleApplyButtonClicked() { + UISettings::values.configuration_applied = true; + ApplyConfiguration(); +} + +void ConfigurePerGame::LoadFromFile(FileSys::VirtualFile file_) { + file = std::move(file_); + LoadConfiguration(); +} + +void ConfigurePerGame::LoadConfiguration() { + if (file == nullptr) { + return; + } + + addons_tab->LoadFromFile(file); + + ui->display_title_id->setText( + QStringLiteral("%1").arg(title_id, 16, 16, QLatin1Char{'0'}).toUpper()); + + const FileSys::PatchManager pm{title_id, system.GetFileSystemController(), + system.GetContentProvider()}; + const auto control = pm.GetControlMetadata(); + const auto loader = Loader::GetLoader(system, file); + + if (control.first != nullptr) { + ui->display_version->setText(QString::fromStdString(control.first->GetVersionString())); + ui->display_name->setText(QString::fromStdString(control.first->GetApplicationName())); + ui->display_developer->setText(QString::fromStdString(control.first->GetDeveloperName())); + } else { + std::string title; + if (loader->ReadTitle(title) == Loader::ResultStatus::Success) + ui->display_name->setText(QString::fromStdString(title)); + + FileSys::NACP nacp; + if (loader->ReadControlData(nacp) == Loader::ResultStatus::Success) + ui->display_developer->setText(QString::fromStdString(nacp.GetDeveloperName())); + + ui->display_version->setText(QStringLiteral("1.0.0")); + } + + if (control.second != nullptr) { + scene->clear(); + + QPixmap map; + const auto bytes = control.second->ReadAllBytes(); + map.loadFromData(bytes.data(), static_cast(bytes.size())); + + scene->addPixmap(map.scaled(ui->icon_view->width(), ui->icon_view->height(), + Qt::IgnoreAspectRatio, Qt::SmoothTransformation)); + } else { + std::vector bytes; + if (loader->ReadIcon(bytes) == Loader::ResultStatus::Success) { + scene->clear(); + + QPixmap map; + map.loadFromData(bytes.data(), static_cast(bytes.size())); + + scene->addPixmap(map.scaled(ui->icon_view->width(), ui->icon_view->height(), + Qt::IgnoreAspectRatio, Qt::SmoothTransformation)); + } + } + + ui->display_filename->setText(QString::fromStdString(file->GetName())); + + ui->display_format->setText( + QString::fromStdString(Loader::GetFileTypeString(loader->GetFileType()))); + + const auto valueText = ReadableByteSize(file->GetSize()); + ui->display_size->setText(valueText); +} diff --git a/src/sudachi/configuration/configure_per_game.h b/src/sudachi/configuration/configure_per_game.h new file mode 100644 index 0000000..89dc540 --- /dev/null +++ b/src/sudachi/configuration/configure_per_game.h @@ -0,0 +1,91 @@ +// SPDX-FileCopyrightText: Copyright 2020 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include +#include +#include + +#include +#include + +#include "configuration/shared_widget.h" +#include "core/file_sys/vfs/vfs_types.h" +#include "frontend_common/config.h" +#include "vk_device_info.h" +#include "sudachi/configuration/configuration_shared.h" +#include "sudachi/configuration/qt_config.h" +#include "sudachi/configuration/shared_translation.h" + +namespace Core { +class System; +} + +namespace InputCommon { +class InputSubsystem; +} + +class ConfigurePerGameAddons; +class ConfigureAudio; +class ConfigureCpu; +class ConfigureGraphics; +class ConfigureGraphicsAdvanced; +class ConfigureInputPerGame; +class ConfigureLinuxTab; +class ConfigureSystem; + +class QGraphicsScene; +class QStandardItem; +class QStandardItemModel; +class QTreeView; +class QVBoxLayout; + +namespace Ui { +class ConfigurePerGame; +} + +class ConfigurePerGame : public QDialog { + Q_OBJECT + +public: + // Cannot use std::filesystem::path due to https://bugreports.qt.io/browse/QTBUG-73263 + explicit ConfigurePerGame(QWidget* parent, u64 title_id_, const std::string& file_name, + std::vector& vk_device_records, + Core::System& system_); + ~ConfigurePerGame() override; + + /// Save all button configurations to settings file + void ApplyConfiguration(); + + void LoadFromFile(FileSys::VirtualFile file_); + +private: + void changeEvent(QEvent* event) override; + void RetranslateUI(); + + void HandleApplyButtonClicked(); + + void LoadConfiguration(); + + std::unique_ptr ui; + FileSys::VirtualFile file; + u64 title_id; + + QGraphicsScene* scene; + + std::unique_ptr game_config; + + Core::System& system; + std::unique_ptr builder; + std::shared_ptr> tab_group; + + std::unique_ptr addons_tab; + std::unique_ptr audio_tab; + std::unique_ptr cpu_tab; + std::unique_ptr graphics_advanced_tab; + std::unique_ptr graphics_tab; + std::unique_ptr input_tab; + std::unique_ptr linux_tab; + std::unique_ptr system_tab; +}; diff --git a/src/sudachi/configuration/configure_per_game.ui b/src/sudachi/configuration/configure_per_game.ui new file mode 100644 index 0000000..99ba2fd --- /dev/null +++ b/src/sudachi/configuration/configure_per_game.ui @@ -0,0 +1,299 @@ + + + ConfigurePerGame + + + + 0 + 0 + 900 + 607 + + + + + 900 + 0 + + + + Dialog + + + + + + + + + 0 + 0 + + + + Info + + + + + + + 0 + 0 + + + + + 256 + 256 + + + + + 256 + 256 + + + + Qt::ScrollBarAlwaysOff + + + Qt::ScrollBarAlwaysOff + + + false + + + + + + + + + true + + + true + + + + + + + true + + + true + + + + + + + Name + + + + + + + Title ID + + + + + + + true + + + true + + + + + + + true + + + true + + + + + + + true + + + true + + + + + + + Filename + + + + + + + true + + + true + + + + + + + true + + + true + + + + + + + Format + + + + + + + Version + + + + + + + Size + + + + + + + Developer + + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + + + + + + + + true + + + -1 + + + true + + + false + + + false + + + + + + + + + + + + + Some settings are only available when a game is not running. + + + + + + + + 0 + 0 + + + + Qt::Horizontal + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + + + + + + + + + buttonBox + accepted() + ConfigurePerGame + accept() + + + 20 + 20 + + + 20 + 20 + + + + + buttonBox + rejected() + ConfigurePerGame + reject() + + + 20 + 20 + + + 20 + 20 + + + + + diff --git a/src/sudachi/configuration/configure_per_game_addons.cpp b/src/sudachi/configuration/configure_per_game_addons.cpp new file mode 100644 index 0000000..0a39296 --- /dev/null +++ b/src/sudachi/configuration/configure_per_game_addons.cpp @@ -0,0 +1,143 @@ +// SPDX-FileCopyrightText: 2016 Citra Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +#include "common/fs/fs.h" +#include "common/fs/path_util.h" +#include "core/core.h" +#include "core/file_sys/patch_manager.h" +#include "core/file_sys/xts_archive.h" +#include "core/loader/loader.h" +#include "ui_configure_per_game_addons.h" +#include "sudachi/configuration/configure_input.h" +#include "sudachi/configuration/configure_per_game_addons.h" +#include "sudachi/uisettings.h" + +ConfigurePerGameAddons::ConfigurePerGameAddons(Core::System& system_, QWidget* parent) + : QWidget(parent), ui{std::make_unique()}, system{system_} { + ui->setupUi(this); + + layout = new QVBoxLayout; + tree_view = new QTreeView; + item_model = new QStandardItemModel(tree_view); + tree_view->setModel(item_model); + tree_view->setAlternatingRowColors(true); + tree_view->setSelectionMode(QHeaderView::SingleSelection); + tree_view->setSelectionBehavior(QHeaderView::SelectRows); + tree_view->setVerticalScrollMode(QHeaderView::ScrollPerPixel); + tree_view->setHorizontalScrollMode(QHeaderView::ScrollPerPixel); + tree_view->setSortingEnabled(true); + tree_view->setEditTriggers(QHeaderView::NoEditTriggers); + tree_view->setUniformRowHeights(true); + tree_view->setContextMenuPolicy(Qt::NoContextMenu); + + item_model->insertColumns(0, 2); + item_model->setHeaderData(0, Qt::Horizontal, tr("Patch Name")); + item_model->setHeaderData(1, Qt::Horizontal, tr("Version")); + + tree_view->header()->setStretchLastSection(false); + tree_view->header()->setSectionResizeMode(0, QHeaderView::ResizeMode::Stretch); + tree_view->header()->setMinimumSectionSize(150); + + // We must register all custom types with the Qt Automoc system so that we are able to use it + // with signals/slots. In this case, QList falls under the umbrella of custom types. + qRegisterMetaType>("QList"); + + layout->setContentsMargins(0, 0, 0, 0); + layout->setSpacing(0); + layout->addWidget(tree_view); + + ui->scrollArea->setLayout(layout); + + ui->scrollArea->setEnabled(!system.IsPoweredOn()); + + connect(item_model, &QStandardItemModel::itemChanged, + [] { UISettings::values.is_game_list_reload_pending.exchange(true); }); +} + +ConfigurePerGameAddons::~ConfigurePerGameAddons() = default; + +void ConfigurePerGameAddons::ApplyConfiguration() { + std::vector disabled_addons; + + for (const auto& item : list_items) { + const auto disabled = item.front()->checkState() == Qt::Unchecked; + if (disabled) + disabled_addons.push_back(item.front()->text().toStdString()); + } + + auto current = Settings::values.disabled_addons[title_id]; + std::sort(disabled_addons.begin(), disabled_addons.end()); + std::sort(current.begin(), current.end()); + if (disabled_addons != current) { + Common::FS::RemoveFile(Common::FS::GetSudachiPath(Common::FS::SudachiPath::CacheDir) / + "game_list" / fmt::format("{:016X}.pv.txt", title_id)); + } + + Settings::values.disabled_addons[title_id] = disabled_addons; +} + +void ConfigurePerGameAddons::LoadFromFile(FileSys::VirtualFile file_) { + file = std::move(file_); + LoadConfiguration(); +} + +void ConfigurePerGameAddons::SetTitleId(u64 id) { + this->title_id = id; +} + +void ConfigurePerGameAddons::changeEvent(QEvent* event) { + if (event->type() == QEvent::LanguageChange) { + RetranslateUI(); + } + + QWidget::changeEvent(event); +} + +void ConfigurePerGameAddons::RetranslateUI() { + ui->retranslateUi(this); +} + +void ConfigurePerGameAddons::LoadConfiguration() { + if (file == nullptr) { + return; + } + + const FileSys::PatchManager pm{title_id, system.GetFileSystemController(), + system.GetContentProvider()}; + const auto loader = Loader::GetLoader(system, file); + + FileSys::VirtualFile update_raw; + loader->ReadUpdateRaw(update_raw); + + const auto& disabled = Settings::values.disabled_addons[title_id]; + + for (const auto& patch : pm.GetPatches(update_raw)) { + const auto name = QString::fromStdString(patch.name); + + auto* const first_item = new QStandardItem; + first_item->setText(name); + first_item->setCheckable(true); + + const auto patch_disabled = + std::find(disabled.begin(), disabled.end(), name.toStdString()) != disabled.end(); + + first_item->setCheckState(patch_disabled ? Qt::Unchecked : Qt::Checked); + + list_items.push_back(QList{ + first_item, new QStandardItem{QString::fromStdString(patch.version)}}); + item_model->appendRow(list_items.back()); + } + + tree_view->resizeColumnToContents(1); +} diff --git a/src/sudachi/configuration/configure_per_game_addons.h b/src/sudachi/configuration/configure_per_game_addons.h new file mode 100644 index 0000000..32dc5dd --- /dev/null +++ b/src/sudachi/configuration/configure_per_game_addons.h @@ -0,0 +1,58 @@ +// SPDX-FileCopyrightText: 2016 Citra Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include +#include + +#include + +#include "core/file_sys/vfs/vfs_types.h" + +namespace Core { +class System; +} + +class QGraphicsScene; +class QStandardItem; +class QStandardItemModel; +class QTreeView; +class QVBoxLayout; + +namespace Ui { +class ConfigurePerGameAddons; +} + +class ConfigurePerGameAddons : public QWidget { + Q_OBJECT + +public: + explicit ConfigurePerGameAddons(Core::System& system_, QWidget* parent = nullptr); + ~ConfigurePerGameAddons() override; + + /// Save all button configurations to settings file + void ApplyConfiguration(); + + void LoadFromFile(FileSys::VirtualFile file_); + + void SetTitleId(u64 id); + +private: + void changeEvent(QEvent* event) override; + void RetranslateUI(); + + void LoadConfiguration(); + + std::unique_ptr ui; + FileSys::VirtualFile file; + u64 title_id; + + QVBoxLayout* layout; + QTreeView* tree_view; + QStandardItemModel* item_model; + + std::vector> list_items; + + Core::System& system; +}; diff --git a/src/sudachi/configuration/configure_per_game_addons.ui b/src/sudachi/configuration/configure_per_game_addons.ui new file mode 100644 index 0000000..f9cf6f2 --- /dev/null +++ b/src/sudachi/configuration/configure_per_game_addons.ui @@ -0,0 +1,41 @@ + + + ConfigurePerGameAddons + + + + 0 + 0 + 400 + 300 + + + + Form + + + Add-Ons + + + + + + true + + + + + 0 + 0 + 380 + 280 + + + + + + + + + + diff --git a/src/sudachi/configuration/configure_profile_manager.cpp b/src/sudachi/configuration/configure_profile_manager.cpp new file mode 100644 index 0000000..49109c0 --- /dev/null +++ b/src/sudachi/configuration/configure_profile_manager.cpp @@ -0,0 +1,372 @@ +// SPDX-FileCopyrightText: 2016 Citra Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "common/assert.h" +#include "common/fs/path_util.h" +#include "common/settings.h" +#include "common/string_util.h" +#include "core/core.h" +#include "core/hle/service/acc/profile_manager.h" +#include "ui_configure_profile_manager.h" +#include "sudachi/configuration/configure_profile_manager.h" +#include "sudachi/util/limitable_input_dialog.h" + +namespace { +// Same backup JPEG used by acc IProfile::GetImage if no jpeg found +constexpr std::array backup_jpeg{ + 0xff, 0xd8, 0xff, 0xdb, 0x00, 0x43, 0x00, 0x03, 0x02, 0x02, 0x02, 0x02, 0x02, 0x03, 0x02, 0x02, + 0x02, 0x03, 0x03, 0x03, 0x03, 0x04, 0x06, 0x04, 0x04, 0x04, 0x04, 0x04, 0x08, 0x06, 0x06, 0x05, + 0x06, 0x09, 0x08, 0x0a, 0x0a, 0x09, 0x08, 0x09, 0x09, 0x0a, 0x0c, 0x0f, 0x0c, 0x0a, 0x0b, 0x0e, + 0x0b, 0x09, 0x09, 0x0d, 0x11, 0x0d, 0x0e, 0x0f, 0x10, 0x10, 0x11, 0x10, 0x0a, 0x0c, 0x12, 0x13, + 0x12, 0x10, 0x13, 0x0f, 0x10, 0x10, 0x10, 0xff, 0xc9, 0x00, 0x0b, 0x08, 0x00, 0x01, 0x00, 0x01, + 0x01, 0x01, 0x11, 0x00, 0xff, 0xcc, 0x00, 0x06, 0x00, 0x10, 0x10, 0x05, 0xff, 0xda, 0x00, 0x08, + 0x01, 0x01, 0x00, 0x00, 0x3f, 0x00, 0xd2, 0xcf, 0x20, 0xff, 0xd9, +}; + +QString GetImagePath(const Common::UUID& uuid) { + const auto path = + Common::FS::GetSudachiPath(Common::FS::SudachiPath::NANDDir) / + fmt::format("system/save/8000000000000010/su/avators/{}.jpg", uuid.FormattedString()); + return QString::fromStdString(Common::FS::PathToUTF8String(path)); +} + +QString GetAccountUsername(const Service::Account::ProfileManager& manager, Common::UUID uuid) { + Service::Account::ProfileBase profile{}; + if (!manager.GetProfileBase(uuid, profile)) { + return {}; + } + + const auto text = Common::StringFromFixedZeroTerminatedBuffer( + reinterpret_cast(profile.username.data()), profile.username.size()); + return QString::fromStdString(text); +} + +QString FormatUserEntryText(const QString& username, Common::UUID uuid) { + return ConfigureProfileManager::tr("%1\n%2", + "%1 is the profile username, %2 is the formatted UUID (e.g. " + "00112233-4455-6677-8899-AABBCCDDEEFF))") + .arg(username, QString::fromStdString(uuid.FormattedString())); +} + +QPixmap GetIcon(const Common::UUID& uuid) { + QPixmap icon{GetImagePath(uuid)}; + + if (!icon) { + icon.fill(Qt::black); + icon.loadFromData(backup_jpeg.data(), static_cast(backup_jpeg.size())); + } + + return icon.scaled(64, 64, Qt::IgnoreAspectRatio, Qt::SmoothTransformation); +} + +QString GetProfileUsernameFromUser(QWidget* parent, const QString& description_text) { + return LimitableInputDialog::GetText(parent, ConfigureProfileManager::tr("Enter Username"), + description_text, 1, + static_cast(Service::Account::profile_username_size)); +} +} // Anonymous namespace + +ConfigureProfileManager::ConfigureProfileManager(Core::System& system_, QWidget* parent) + : QWidget(parent), ui{std::make_unique()}, + profile_manager{system_.GetProfileManager()}, system{system_} { + ui->setupUi(this); + + tree_view = new QTreeView; + item_model = new QStandardItemModel(tree_view); + item_model->insertColumns(0, 1); + tree_view->setModel(item_model); + tree_view->setAlternatingRowColors(true); + tree_view->setSelectionMode(QHeaderView::SingleSelection); + tree_view->setSelectionBehavior(QHeaderView::SelectRows); + tree_view->setVerticalScrollMode(QHeaderView::ScrollPerPixel); + tree_view->setHorizontalScrollMode(QHeaderView::ScrollPerPixel); + tree_view->setSortingEnabled(true); + tree_view->setEditTriggers(QHeaderView::NoEditTriggers); + tree_view->setUniformRowHeights(true); + tree_view->setIconSize({64, 64}); + tree_view->setContextMenuPolicy(Qt::NoContextMenu); + + // We must register all custom types with the Qt Automoc system so that we are able to use it + // with signals/slots. In this case, QList falls under the umbrells of custom types. + qRegisterMetaType>("QList"); + + layout = new QVBoxLayout; + layout->setContentsMargins(0, 0, 0, 0); + layout->setSpacing(0); + layout->addWidget(tree_view); + + ui->scrollArea->setLayout(layout); + + connect(tree_view, &QTreeView::clicked, this, &ConfigureProfileManager::SelectUser); + + connect(ui->pm_add, &QPushButton::clicked, this, &ConfigureProfileManager::AddUser); + connect(ui->pm_rename, &QPushButton::clicked, this, &ConfigureProfileManager::RenameUser); + connect(ui->pm_remove, &QPushButton::clicked, this, + &ConfigureProfileManager::ConfirmDeleteUser); + connect(ui->pm_set_image, &QPushButton::clicked, this, &ConfigureProfileManager::SetUserImage); + + confirm_dialog = new ConfigureProfileManagerDeleteDialog(this); + + scene = new QGraphicsScene; + ui->current_user_icon->setScene(scene); + + RetranslateUI(); + SetConfiguration(); +} + +ConfigureProfileManager::~ConfigureProfileManager() = default; + +void ConfigureProfileManager::changeEvent(QEvent* event) { + if (event->type() == QEvent::LanguageChange) { + RetranslateUI(); + } + + QWidget::changeEvent(event); +} + +void ConfigureProfileManager::RetranslateUI() { + ui->retranslateUi(this); + item_model->setHeaderData(0, Qt::Horizontal, tr("Users")); +} + +void ConfigureProfileManager::SetConfiguration() { + enabled = !system.IsPoweredOn(); + item_model->removeRows(0, item_model->rowCount()); + list_items.clear(); + + PopulateUserList(); + UpdateCurrentUser(); +} + +void ConfigureProfileManager::PopulateUserList() { + const auto& profiles = profile_manager.GetAllUsers(); + for (const auto& user : profiles) { + Service::Account::ProfileBase profile{}; + if (!profile_manager.GetProfileBase(user, profile)) + continue; + + const auto username = Common::StringFromFixedZeroTerminatedBuffer( + reinterpret_cast(profile.username.data()), profile.username.size()); + + list_items.push_back(QList{new QStandardItem{ + GetIcon(user), FormatUserEntryText(QString::fromStdString(username), user)}}); + } + + for (const auto& item : list_items) + item_model->appendRow(item); +} + +void ConfigureProfileManager::UpdateCurrentUser() { + ui->pm_add->setEnabled(profile_manager.GetUserCount() < Service::Account::MAX_USERS); + + const auto& current_user = profile_manager.GetUser(Settings::values.current_user.GetValue()); + ASSERT(current_user); + const auto username = GetAccountUsername(profile_manager, *current_user); + + scene->clear(); + scene->addPixmap( + GetIcon(*current_user).scaled(48, 48, Qt::IgnoreAspectRatio, Qt::SmoothTransformation)); + ui->current_user_username->setText(username); +} + +void ConfigureProfileManager::ApplyConfiguration() { + if (!enabled) { + return; + } +} + +void ConfigureProfileManager::SelectUser(const QModelIndex& index) { + Settings::values.current_user = + std::clamp(index.row(), 0, static_cast(profile_manager.GetUserCount() - 1)); + + UpdateCurrentUser(); + + ui->pm_remove->setEnabled(profile_manager.GetUserCount() >= 2); + ui->pm_rename->setEnabled(true); + ui->pm_set_image->setEnabled(true); +} + +void ConfigureProfileManager::AddUser() { + const auto username = + GetProfileUsernameFromUser(this, tr("Enter a username for the new user:")); + if (username.isEmpty()) { + return; + } + + const auto uuid = Common::UUID::MakeRandom(); + profile_manager.CreateNewUser(uuid, username.toStdString()); + profile_manager.WriteUserSaveFile(); + + item_model->appendRow(new QStandardItem{GetIcon(uuid), FormatUserEntryText(username, uuid)}); +} + +void ConfigureProfileManager::RenameUser() { + const auto user = tree_view->currentIndex().row(); + const auto uuid = profile_manager.GetUser(user); + ASSERT(uuid); + + Service::Account::ProfileBase profile{}; + if (!profile_manager.GetProfileBase(*uuid, profile)) + return; + + const auto new_username = GetProfileUsernameFromUser(this, tr("Enter a new username:")); + if (new_username.isEmpty()) { + return; + } + + const auto username_std = new_username.toStdString(); + std::fill(profile.username.begin(), profile.username.end(), '\0'); + std::copy(username_std.begin(), username_std.end(), profile.username.begin()); + + profile_manager.SetProfileBase(*uuid, profile); + profile_manager.WriteUserSaveFile(); + + item_model->setItem( + user, 0, + new QStandardItem{GetIcon(*uuid), + FormatUserEntryText(QString::fromStdString(username_std), *uuid)}); + UpdateCurrentUser(); +} + +void ConfigureProfileManager::ConfirmDeleteUser() { + const auto index = tree_view->currentIndex().row(); + const auto uuid = profile_manager.GetUser(index); + ASSERT(uuid); + const auto username = GetAccountUsername(profile_manager, *uuid); + + confirm_dialog->SetInfo(username, *uuid, [this, uuid]() { DeleteUser(*uuid); }); + confirm_dialog->show(); +} + +void ConfigureProfileManager::DeleteUser(const Common::UUID& uuid) { + if (Settings::values.current_user.GetValue() == tree_view->currentIndex().row()) { + Settings::values.current_user = 0; + } + UpdateCurrentUser(); + + if (!profile_manager.RemoveUser(uuid)) { + return; + } + + profile_manager.WriteUserSaveFile(); + + item_model->removeRows(tree_view->currentIndex().row(), 1); + tree_view->clearSelection(); + + ui->pm_remove->setEnabled(false); + ui->pm_rename->setEnabled(false); +} + +void ConfigureProfileManager::SetUserImage() { + const auto index = tree_view->currentIndex().row(); + const auto uuid = profile_manager.GetUser(index); + ASSERT(uuid); + + const auto file = QFileDialog::getOpenFileName(this, tr("Select User Image"), QString(), + tr("JPEG Images (*.jpg *.jpeg)")); + + if (file.isEmpty()) { + return; + } + + const auto image_path = GetImagePath(*uuid); + if (QFile::exists(image_path) && !QFile::remove(image_path)) { + QMessageBox::warning( + this, tr("Error deleting image"), + tr("Error occurred attempting to overwrite previous image at: %1.").arg(image_path)); + return; + } + + const auto raw_path = QString::fromStdString(Common::FS::PathToUTF8String( + Common::FS::GetSudachiPath(Common::FS::SudachiPath::NANDDir) / "system/save/8000000000000010")); + const QFileInfo raw_info{raw_path}; + if (raw_info.exists() && !raw_info.isDir() && !QFile::remove(raw_path)) { + QMessageBox::warning(this, tr("Error deleting file"), + tr("Unable to delete existing file: %1.").arg(raw_path)); + return; + } + + const QString absolute_dst_path = QFileInfo{image_path}.absolutePath(); + if (!QDir{raw_path}.mkpath(absolute_dst_path)) { + QMessageBox::warning( + this, tr("Error creating user image directory"), + tr("Unable to create directory %1 for storing user images.").arg(absolute_dst_path)); + return; + } + + if (!QFile::copy(file, image_path)) { + QMessageBox::warning(this, tr("Error copying user image"), + tr("Unable to copy image from %1 to %2").arg(file, image_path)); + return; + } + + // Profile image must be 256x256 + QImage image(image_path); + if (image.width() != 256 || image.height() != 256) { + image = image.scaled(256, 256, Qt::KeepAspectRatioByExpanding, Qt::SmoothTransformation); + if (!image.save(image_path)) { + QMessageBox::warning(this, tr("Error resizing user image"), + tr("Unable to resize image")); + return; + } + } + + const auto username = GetAccountUsername(profile_manager, *uuid); + item_model->setItem(index, 0, + new QStandardItem{GetIcon(*uuid), FormatUserEntryText(username, *uuid)}); + UpdateCurrentUser(); +} + +ConfigureProfileManagerDeleteDialog::ConfigureProfileManagerDeleteDialog(QWidget* parent) + : QDialog{parent} { + auto dialog_vbox_layout = new QVBoxLayout(this); + dialog_button_box = + new QDialogButtonBox(QDialogButtonBox::Yes | QDialogButtonBox::No, Qt::Horizontal, parent); + auto label_message = + new QLabel(tr("Delete this user? All of the user's save data will be deleted."), this); + label_info = new QLabel(this); + auto dialog_hbox_layout_widget = new QWidget(this); + auto dialog_hbox_layout = new QHBoxLayout(dialog_hbox_layout_widget); + icon_scene = new QGraphicsScene(0, 0, 64, 64, this); + auto icon_view = new QGraphicsView(icon_scene, this); + + dialog_hbox_layout_widget->setLayout(dialog_hbox_layout); + icon_view->setMaximumSize(64, 64); + icon_view->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + icon_view->setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + this->setLayout(dialog_vbox_layout); + this->setWindowTitle(tr("Confirm Delete")); + this->setSizeGripEnabled(false); + dialog_vbox_layout->addWidget(label_message); + dialog_vbox_layout->addWidget(dialog_hbox_layout_widget); + dialog_vbox_layout->addWidget(dialog_button_box); + dialog_hbox_layout->addWidget(icon_view); + dialog_hbox_layout->addWidget(label_info); + + connect(dialog_button_box, &QDialogButtonBox::rejected, this, [this]() { close(); }); +} + +ConfigureProfileManagerDeleteDialog::~ConfigureProfileManagerDeleteDialog() = default; + +void ConfigureProfileManagerDeleteDialog::SetInfo(const QString& username, const Common::UUID& uuid, + std::function accept_callback) { + label_info->setText( + tr("Name: %1\nUUID: %2").arg(username, QString::fromStdString(uuid.FormattedString()))); + icon_scene->clear(); + icon_scene->addPixmap(GetIcon(uuid)); + + connect(dialog_button_box, &QDialogButtonBox::accepted, this, [this, accept_callback]() { + close(); + accept_callback(); + }); +} diff --git a/src/sudachi/configuration/configure_profile_manager.h b/src/sudachi/configuration/configure_profile_manager.h new file mode 100644 index 0000000..39560fd --- /dev/null +++ b/src/sudachi/configuration/configure_profile_manager.h @@ -0,0 +1,90 @@ +// SPDX-FileCopyrightText: 2016 Citra Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include +#include + +#include +#include +#include + +namespace Common { +struct UUID; +} + +namespace Core { +class System; +} + +class QGraphicsScene; +class QDialogButtonBox; +class QLabel; +class QStandardItem; +class QStandardItemModel; +class QTreeView; +class QVBoxLayout; + +namespace Service::Account { +class ProfileManager; +} + +namespace Ui { +class ConfigureProfileManager; +} + +class ConfigureProfileManagerDeleteDialog : public QDialog { +public: + explicit ConfigureProfileManagerDeleteDialog(QWidget* parent); + ~ConfigureProfileManagerDeleteDialog(); + + void SetInfo(const QString& username, const Common::UUID& uuid, + std::function accept_callback); + +private: + QDialogButtonBox* dialog_button_box; + QGraphicsScene* icon_scene; + QLabel* label_info; +}; + +class ConfigureProfileManager : public QWidget { + Q_OBJECT + +public: + explicit ConfigureProfileManager(Core::System& system_, QWidget* parent = nullptr); + ~ConfigureProfileManager() override; + + void ApplyConfiguration(); + +private: + void changeEvent(QEvent* event) override; + void RetranslateUI(); + + void SetConfiguration(); + + void PopulateUserList(); + void UpdateCurrentUser(); + + void SelectUser(const QModelIndex& index); + void AddUser(); + void RenameUser(); + void ConfirmDeleteUser(); + void DeleteUser(const Common::UUID& uuid); + void SetUserImage(); + + QVBoxLayout* layout; + QTreeView* tree_view; + QStandardItemModel* item_model; + QGraphicsScene* scene; + + ConfigureProfileManagerDeleteDialog* confirm_dialog; + + std::vector> list_items; + + std::unique_ptr ui; + bool enabled = false; + + Service::Account::ProfileManager& profile_manager; + const Core::System& system; +}; diff --git a/src/sudachi/configuration/configure_profile_manager.ui b/src/sudachi/configuration/configure_profile_manager.ui new file mode 100644 index 0000000..bd6dea4 --- /dev/null +++ b/src/sudachi/configuration/configure_profile_manager.ui @@ -0,0 +1,181 @@ + + + ConfigureProfileManager + + + + 0 + 0 + 390 + 483 + + + + Form + + + Profiles + + + + + + + + Profile Manager + + + + QLayout::SetNoConstraint + + + + + + + + 0 + 0 + + + + Current User + + + + + + + + 48 + 48 + + + + + 48 + 48 + + + + QFrame::NoFrame + + + QFrame::Plain + + + Qt::ScrollBarAlwaysOff + + + Qt::ScrollBarAlwaysOff + + + false + + + + + + + + 0 + 0 + + + + Username + + + + + + + + + + 0 + 0 + + + + QFrame::StyledPanel + + + false + + + + + + + + + false + + + Set Image + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Add + + + + + + + false + + + Rename + + + + + + + false + + + Remove + + + + + + + + + + + + Profile management is available only when game is not running. + + + true + + + + + + + + + + diff --git a/src/sudachi/configuration/configure_ringcon.cpp b/src/sudachi/configuration/configure_ringcon.cpp new file mode 100644 index 0000000..00a5dba --- /dev/null +++ b/src/sudachi/configuration/configure_ringcon.cpp @@ -0,0 +1,497 @@ +// SPDX-FileCopyrightText: Copyright 2022 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include +#include +#include +#include +#include +#include + +#include "configuration/qt_config.h" +#include "hid_core/frontend/emulated_controller.h" +#include "hid_core/hid_core.h" +#include "input_common/drivers/keyboard.h" +#include "input_common/drivers/mouse.h" +#include "input_common/main.h" +#include "ui_configure_ringcon.h" +#include "sudachi/bootmanager.h" +#include "sudachi/configuration/configure_ringcon.h" + +const std::array + ConfigureRingController::analog_sub_buttons{{ + "left", + "right", + }}; + +namespace { + +QString GetKeyName(int key_code) { + switch (key_code) { + case Qt::Key_Shift: + return QObject::tr("Shift"); + case Qt::Key_Control: + return QObject::tr("Ctrl"); + case Qt::Key_Alt: + return QObject::tr("Alt"); + case Qt::Key_Meta: + return {}; + default: + return QKeySequence(key_code).toString(); + } +} + +QString GetButtonName(Common::Input::ButtonNames button_name) { + switch (button_name) { + case Common::Input::ButtonNames::ButtonLeft: + return QObject::tr("Left"); + case Common::Input::ButtonNames::ButtonRight: + return QObject::tr("Right"); + case Common::Input::ButtonNames::ButtonDown: + return QObject::tr("Down"); + case Common::Input::ButtonNames::ButtonUp: + return QObject::tr("Up"); + case Common::Input::ButtonNames::TriggerZ: + return QObject::tr("Z"); + case Common::Input::ButtonNames::TriggerR: + return QObject::tr("R"); + case Common::Input::ButtonNames::TriggerL: + return QObject::tr("L"); + case Common::Input::ButtonNames::ButtonA: + return QObject::tr("A"); + case Common::Input::ButtonNames::ButtonB: + return QObject::tr("B"); + case Common::Input::ButtonNames::ButtonX: + return QObject::tr("X"); + case Common::Input::ButtonNames::ButtonY: + return QObject::tr("Y"); + case Common::Input::ButtonNames::ButtonStart: + return QObject::tr("Start"); + case Common::Input::ButtonNames::L1: + return QObject::tr("L1"); + case Common::Input::ButtonNames::L2: + return QObject::tr("L2"); + case Common::Input::ButtonNames::L3: + return QObject::tr("L3"); + case Common::Input::ButtonNames::R1: + return QObject::tr("R1"); + case Common::Input::ButtonNames::R2: + return QObject::tr("R2"); + case Common::Input::ButtonNames::R3: + return QObject::tr("R3"); + case Common::Input::ButtonNames::Circle: + return QObject::tr("Circle"); + case Common::Input::ButtonNames::Cross: + return QObject::tr("Cross"); + case Common::Input::ButtonNames::Square: + return QObject::tr("Square"); + case Common::Input::ButtonNames::Triangle: + return QObject::tr("Triangle"); + case Common::Input::ButtonNames::Share: + return QObject::tr("Share"); + case Common::Input::ButtonNames::Options: + return QObject::tr("Options"); + default: + return QObject::tr("[undefined]"); + } +} + +void SetAnalogParam(const Common::ParamPackage& input_param, Common::ParamPackage& analog_param, + const std::string& button_name) { + // The poller returned a complete axis, so set all the buttons + if (input_param.Has("axis_x") && input_param.Has("axis_y")) { + analog_param = input_param; + return; + } + // Check if the current configuration has either no engine or an axis binding. + // Clears out the old binding and adds one with analog_from_button. + if (!analog_param.Has("engine") || analog_param.Has("axis_x") || analog_param.Has("axis_y")) { + analog_param = { + {"engine", "analog_from_button"}, + }; + } + analog_param.Set(button_name, input_param.Serialize()); +} +} // namespace + +ConfigureRingController::ConfigureRingController(QWidget* parent, + InputCommon::InputSubsystem* input_subsystem_, + Core::HID::HIDCore& hid_core_) + : QDialog(parent), timeout_timer(std::make_unique()), + poll_timer(std::make_unique()), input_subsystem{input_subsystem_}, + + ui(std::make_unique()) { + ui->setupUi(this); + + analog_map_buttons = { + ui->buttonRingAnalogPull, + ui->buttonRingAnalogPush, + }; + + emulated_controller = hid_core_.GetEmulatedController(Core::HID::NpadIdType::Player1); + emulated_controller->SaveCurrentConfig(); + emulated_controller->EnableConfiguration(); + + Core::HID::ControllerUpdateCallback engine_callback{ + .on_change = [this](Core::HID::ControllerTriggerType type) { ControllerUpdate(type); }, + .is_npad_service = false, + }; + callback_key = emulated_controller->SetCallback(engine_callback); + is_controller_set = true; + + LoadConfiguration(); + + for (int sub_button_id = 0; sub_button_id < ANALOG_SUB_BUTTONS_NUM; ++sub_button_id) { + auto* const analog_button = analog_map_buttons[sub_button_id]; + + if (analog_button == nullptr) { + continue; + } + + connect(analog_button, &QPushButton::clicked, [=, this] { + HandleClick( + analog_map_buttons[sub_button_id], + [=, this](const Common::ParamPackage& params) { + Common::ParamPackage param = emulated_controller->GetRingParam(); + SetAnalogParam(params, param, analog_sub_buttons[sub_button_id]); + emulated_controller->SetRingParam(param); + }, + InputCommon::Polling::InputType::Stick); + }); + + analog_button->setContextMenuPolicy(Qt::CustomContextMenu); + + connect(analog_button, &QPushButton::customContextMenuRequested, + [=, this](const QPoint& menu_location) { + QMenu context_menu; + Common::ParamPackage param = emulated_controller->GetRingParam(); + context_menu.addAction(tr("Clear"), [&] { + emulated_controller->SetRingParam(param); + analog_map_buttons[sub_button_id]->setText(tr("[not set]")); + }); + context_menu.addAction(tr("Invert axis"), [&] { + const bool invert_value = param.Get("invert_x", "+") == "-"; + const std::string invert_str = invert_value ? "+" : "-"; + param.Set("invert_x", invert_str); + emulated_controller->SetRingParam(param); + for (int sub_button_id2 = 0; sub_button_id2 < ANALOG_SUB_BUTTONS_NUM; + ++sub_button_id2) { + analog_map_buttons[sub_button_id2]->setText( + AnalogToText(param, analog_sub_buttons[sub_button_id2])); + } + }); + context_menu.exec( + analog_map_buttons[sub_button_id]->mapToGlobal(menu_location)); + }); + } + + connect(ui->sliderRingAnalogDeadzone, &QSlider::valueChanged, [=, this] { + Common::ParamPackage param = emulated_controller->GetRingParam(); + const auto slider_value = ui->sliderRingAnalogDeadzone->value(); + ui->labelRingAnalogDeadzone->setText(tr("Deadzone: %1%").arg(slider_value)); + param.Set("deadzone", slider_value / 100.0f); + emulated_controller->SetRingParam(param); + }); + + connect(ui->restore_defaults_button, &QPushButton::clicked, this, + &ConfigureRingController::RestoreDefaults); + + connect(ui->enable_ring_controller_button, &QPushButton::clicked, this, + &ConfigureRingController::EnableRingController); + + timeout_timer->setSingleShot(true); + connect(timeout_timer.get(), &QTimer::timeout, [this] { SetPollingResult({}, true); }); + + connect(poll_timer.get(), &QTimer::timeout, [this] { + const auto& params = input_subsystem->GetNextInput(); + if (params.Has("engine") && IsInputAcceptable(params)) { + SetPollingResult(params, false); + return; + } + }); + + resize(0, 0); +} + +ConfigureRingController::~ConfigureRingController() { + emulated_controller->SetPollingMode(Core::HID::EmulatedDeviceIndex::RightIndex, + Common::Input::PollingMode::Active); + emulated_controller->DisableConfiguration(); + + if (is_controller_set) { + emulated_controller->DeleteCallback(callback_key); + is_controller_set = false; + } +}; + +void ConfigureRingController::changeEvent(QEvent* event) { + if (event->type() == QEvent::LanguageChange) { + RetranslateUI(); + } + + QDialog::changeEvent(event); +} + +void ConfigureRingController::RetranslateUI() { + ui->retranslateUi(this); +} + +void ConfigureRingController::UpdateUI() { + RetranslateUI(); + const Common::ParamPackage param = emulated_controller->GetRingParam(); + + for (int sub_button_id = 0; sub_button_id < ANALOG_SUB_BUTTONS_NUM; ++sub_button_id) { + auto* const analog_button = analog_map_buttons[sub_button_id]; + + if (analog_button == nullptr) { + continue; + } + + analog_button->setText(AnalogToText(param, analog_sub_buttons[sub_button_id])); + } + + const auto deadzone_label = ui->labelRingAnalogDeadzone; + const auto deadzone_slider = ui->sliderRingAnalogDeadzone; + + int slider_value = static_cast(param.Get("deadzone", 0.15f) * 100); + deadzone_label->setText(tr("Deadzone: %1%").arg(slider_value)); + deadzone_slider->setValue(slider_value); +} + +void ConfigureRingController::ApplyConfiguration() { + emulated_controller->DisableConfiguration(); + emulated_controller->SaveCurrentConfig(); + emulated_controller->EnableConfiguration(); +} + +void ConfigureRingController::LoadConfiguration() { + UpdateUI(); +} + +void ConfigureRingController::RestoreDefaults() { + const std::string default_ring_string = InputCommon::GenerateAnalogParamFromKeys( + 0, 0, QtConfig::default_ringcon_analogs[0], QtConfig::default_ringcon_analogs[1], 0, 0.05f); + emulated_controller->SetRingParam(Common::ParamPackage(default_ring_string)); + UpdateUI(); +} + +void ConfigureRingController::EnableRingController() { + const auto dialog_title = tr("Error enabling ring input"); + + is_ring_enabled = false; + ui->ring_controller_sensor_value->setText(tr("Not connected")); + + if (!Settings::values.enable_joycon_driver) { + QMessageBox::warning(this, dialog_title, tr("Direct Joycon driver is not enabled")); + return; + } + + ui->enable_ring_controller_button->setEnabled(false); + ui->enable_ring_controller_button->setText(tr("Configuring")); + // SetPollingMode is blocking. Allow to update the button status before calling the command + repaint(); + + const auto result = emulated_controller->SetPollingMode( + Core::HID::EmulatedDeviceIndex::RightIndex, Common::Input::PollingMode::Ring); + switch (result) { + case Common::Input::DriverResult::Success: + is_ring_enabled = true; + break; + case Common::Input::DriverResult::NotSupported: + QMessageBox::warning(this, dialog_title, + tr("The current mapped device doesn't support the ring controller")); + break; + case Common::Input::DriverResult::NoDeviceDetected: + QMessageBox::warning(this, dialog_title, + tr("The current mapped device doesn't have a ring attached")); + break; + case Common::Input::DriverResult::InvalidHandle: + QMessageBox::warning(this, dialog_title, tr("The current mapped device is not connected")); + break; + default: + QMessageBox::warning(this, dialog_title, + tr("Unexpected driver result %1").arg(static_cast(result))); + break; + } + ui->enable_ring_controller_button->setEnabled(true); + ui->enable_ring_controller_button->setText(tr("Enable")); +} + +void ConfigureRingController::ControllerUpdate(Core::HID::ControllerTriggerType type) { + if (!is_ring_enabled) { + return; + } + if (type != Core::HID::ControllerTriggerType::RingController) { + return; + } + + const auto value = emulated_controller->GetRingSensorValues(); + const auto tex_value = QString::fromStdString(fmt::format("{:.3f}", value.raw_value)); + ui->ring_controller_sensor_value->setText(tex_value); +} + +void ConfigureRingController::HandleClick( + QPushButton* button, std::function new_input_setter, + InputCommon::Polling::InputType type) { + button->setText(tr("[waiting]")); + button->setFocus(); + + input_setter = new_input_setter; + + input_subsystem->BeginMapping(type); + + QWidget::grabMouse(); + QWidget::grabKeyboard(); + + timeout_timer->start(2500); // Cancel after 2.5 seconds + poll_timer->start(25); // Check for new inputs every 25ms +} + +void ConfigureRingController::SetPollingResult(const Common::ParamPackage& params, bool abort) { + timeout_timer->stop(); + poll_timer->stop(); + input_subsystem->StopMapping(); + + QWidget::releaseMouse(); + QWidget::releaseKeyboard(); + + if (!abort) { + (*input_setter)(params); + } + + UpdateUI(); + + input_setter = std::nullopt; +} + +bool ConfigureRingController::IsInputAcceptable(const Common::ParamPackage& params) const { + return true; +} + +void ConfigureRingController::mousePressEvent(QMouseEvent* event) { + if (!input_setter || !event) { + return; + } + + const auto button = GRenderWindow::QtButtonToMouseButton(event->button()); + input_subsystem->GetMouse()->PressButton(0, 0, button); +} + +void ConfigureRingController::keyPressEvent(QKeyEvent* event) { + if (!input_setter || !event) { + return; + } + event->ignore(); + if (event->key() != Qt::Key_Escape) { + input_subsystem->GetKeyboard()->PressKey(event->key()); + } +} + +QString ConfigureRingController::ButtonToText(const Common::ParamPackage& param) { + if (!param.Has("engine")) { + return QObject::tr("[not set]"); + } + + const QString toggle = QString::fromStdString(param.Get("toggle", false) ? "~" : ""); + const QString inverted = QString::fromStdString(param.Get("inverted", false) ? "!" : ""); + const auto common_button_name = input_subsystem->GetButtonName(param); + + // Retrieve the names from Qt + if (param.Get("engine", "") == "keyboard") { + const QString button_str = GetKeyName(param.Get("code", 0)); + return QObject::tr("%1%2").arg(toggle, button_str); + } + + if (common_button_name == Common::Input::ButtonNames::Invalid) { + return QObject::tr("[invalid]"); + } + + if (common_button_name == Common::Input::ButtonNames::Engine) { + return QString::fromStdString(param.Get("engine", "")); + } + + if (common_button_name == Common::Input::ButtonNames::Value) { + if (param.Has("hat")) { + const QString hat = QString::fromStdString(param.Get("direction", "")); + return QObject::tr("%1%2Hat %3").arg(toggle, inverted, hat); + } + if (param.Has("axis")) { + const QString axis = QString::fromStdString(param.Get("axis", "")); + return QObject::tr("%1%2Axis %3").arg(toggle, inverted, axis); + } + if (param.Has("axis_x") && param.Has("axis_y") && param.Has("axis_z")) { + const QString axis_x = QString::fromStdString(param.Get("axis_x", "")); + const QString axis_y = QString::fromStdString(param.Get("axis_y", "")); + const QString axis_z = QString::fromStdString(param.Get("axis_z", "")); + return QObject::tr("%1%2Axis %3,%4,%5").arg(toggle, inverted, axis_x, axis_y, axis_z); + } + if (param.Has("motion")) { + const QString motion = QString::fromStdString(param.Get("motion", "")); + return QObject::tr("%1%2Motion %3").arg(toggle, inverted, motion); + } + if (param.Has("button")) { + const QString button = QString::fromStdString(param.Get("button", "")); + return QObject::tr("%1%2Button %3").arg(toggle, inverted, button); + } + } + + QString button_name = GetButtonName(common_button_name); + if (param.Has("hat")) { + return QObject::tr("%1%2Hat %3").arg(toggle, inverted, button_name); + } + if (param.Has("axis")) { + return QObject::tr("%1%2Axis %3").arg(toggle, inverted, button_name); + } + if (param.Has("motion")) { + return QObject::tr("%1%2Axis %3").arg(toggle, inverted, button_name); + } + if (param.Has("button")) { + return QObject::tr("%1%2Button %3").arg(toggle, inverted, button_name); + } + + return QObject::tr("[unknown]"); +} + +QString ConfigureRingController::AnalogToText(const Common::ParamPackage& param, + const std::string& dir) { + if (!param.Has("engine")) { + return QObject::tr("[not set]"); + } + + if (param.Get("engine", "") == "analog_from_button") { + return ButtonToText(Common::ParamPackage{param.Get(dir, "")}); + } + + if (!param.Has("axis_x") || !param.Has("axis_y")) { + return QObject::tr("[unknown]"); + } + + const auto engine_str = param.Get("engine", ""); + const QString axis_x_str = QString::fromStdString(param.Get("axis_x", "")); + const QString axis_y_str = QString::fromStdString(param.Get("axis_y", "")); + const bool invert_x = param.Get("invert_x", "+") == "-"; + const bool invert_y = param.Get("invert_y", "+") == "-"; + + if (dir == "modifier") { + return QObject::tr("[unused]"); + } + + if (dir == "left") { + const QString invert_x_str = QString::fromStdString(invert_x ? "+" : "-"); + return QObject::tr("Axis %1%2").arg(axis_x_str, invert_x_str); + } + if (dir == "right") { + const QString invert_x_str = QString::fromStdString(invert_x ? "-" : "+"); + return QObject::tr("Axis %1%2").arg(axis_x_str, invert_x_str); + } + if (dir == "up") { + const QString invert_y_str = QString::fromStdString(invert_y ? "-" : "+"); + return QObject::tr("Axis %1%2").arg(axis_y_str, invert_y_str); + } + if (dir == "down") { + const QString invert_y_str = QString::fromStdString(invert_y ? "+" : "-"); + return QObject::tr("Axis %1%2").arg(axis_y_str, invert_y_str); + } + + return QObject::tr("[unknown]"); +} diff --git a/src/sudachi/configuration/configure_ringcon.h b/src/sudachi/configuration/configure_ringcon.h new file mode 100644 index 0000000..c419430 --- /dev/null +++ b/src/sudachi/configuration/configure_ringcon.h @@ -0,0 +1,94 @@ +// SPDX-FileCopyrightText: Copyright 2022 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include +#include +#include + +namespace InputCommon { +class InputSubsystem; +} // namespace InputCommon + +namespace Core::HID { +class HIDCore; +class EmulatedController; +} // namespace Core::HID + +namespace Ui { +class ConfigureRingController; +} // namespace Ui + +class ConfigureRingController : public QDialog { + Q_OBJECT + +public: + explicit ConfigureRingController(QWidget* parent, InputCommon::InputSubsystem* input_subsystem_, + Core::HID::HIDCore& hid_core_); + ~ConfigureRingController() override; + + void ApplyConfiguration(); + +private: + void changeEvent(QEvent* event) override; + void RetranslateUI(); + + void UpdateUI(); + + /// Load configuration settings. + void LoadConfiguration(); + + /// Restore all buttons to their default values. + void RestoreDefaults(); + + /// Sets current polling mode to ring input + void EnableRingController(); + + // Handles emulated controller events + void ControllerUpdate(Core::HID::ControllerTriggerType type); + + /// Called when the button was pressed. + void HandleClick(QPushButton* button, + std::function new_input_setter, + InputCommon::Polling::InputType type); + + /// Finish polling and configure input using the input_setter. + void SetPollingResult(const Common::ParamPackage& params, bool abort); + + /// Checks whether a given input can be accepted. + bool IsInputAcceptable(const Common::ParamPackage& params) const; + + /// Handle mouse button press events. + void mousePressEvent(QMouseEvent* event) override; + + /// Handle key press events. + void keyPressEvent(QKeyEvent* event) override; + + QString ButtonToText(const Common::ParamPackage& param); + + QString AnalogToText(const Common::ParamPackage& param, const std::string& dir); + + static constexpr int ANALOG_SUB_BUTTONS_NUM = 2; + + // A group of four QPushButtons represent one analog input. The buttons each represent left, + // right, respectively. + std::array analog_map_buttons; + + static const std::array analog_sub_buttons; + + std::unique_ptr timeout_timer; + std::unique_ptr poll_timer; + + /// This will be the the setting function when an input is awaiting configuration. + std::optional> input_setter; + + InputCommon::InputSubsystem* input_subsystem; + Core::HID::EmulatedController* emulated_controller; + + bool is_ring_enabled{}; + bool is_controller_set{}; + int callback_key; + + std::unique_ptr ui; +}; diff --git a/src/sudachi/configuration/configure_ringcon.ui b/src/sudachi/configuration/configure_ringcon.ui new file mode 100644 index 0000000..38ecccc --- /dev/null +++ b/src/sudachi/configuration/configure_ringcon.ui @@ -0,0 +1,374 @@ + + + ConfigureRingController + + + + 0 + 0 + 315 + 400 + + + + Configure Ring Controller + + + + + + + 280 + 0 + + + + To use Ring-Con, configure player 1 as right Joy-Con (both physical and emulated), and player 2 as left Joy-Con (left physical and dual emulated) before starting the game. + + + true + + + + + + + Qt::Vertical + + + QSizePolicy::Fixed + + + + 20 + 10 + + + + + + + + Virtual Ring Sensor Parameters + + + Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop + + + + 0 + + + QLayout::SetDefaultConstraint + + + 3 + + + 6 + + + 3 + + + 0 + + + + + 3 + + + + + Pull + + + Qt::AlignCenter + + + + 3 + + + 3 + + + 3 + + + 3 + + + 3 + + + + + + 70 + 0 + + + + + 68 + 16777215 + + + + min-width: 68px; + + + Pull + + + + + + + + + + Push + + + Qt::AlignCenter + + + + 3 + + + 3 + + + 3 + + + 3 + + + 3 + + + + + + 70 + 0 + + + + + 68 + 16777215 + + + + min-width: 68px; + + + Push + + + + + + + + + + + + 3 + + + QLayout::SetDefaultConstraint + + + 0 + + + 10 + + + 0 + + + 3 + + + + + + + Deadzone: 0% + + + Qt::AlignHCenter + + + + + + + + + 100 + + + Qt::Horizontal + + + + + + + + + + + + Direct Joycon Driver + + + Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop + + + + 0 + + + QLayout::SetDefaultConstraint + + + 3 + + + 6 + + + 3 + + + 10 + + + + + 10 + + + 6 + + + 10 + + + 10 + + + 10 + + + + + Qt::Horizontal + + + QSizePolicy::Fixed + + + + 76 + 20 + + + + + + + + Enable Ring Input + + + + + + + Enable + + + + + + + Ring Sensor Value + + + + + + + Not connected + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + + Restore Defaults + + + + + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + + + + + + + + + buttonBox + accepted() + ConfigureRingController + accept() + + + buttonBox + rejected() + ConfigureRingController + reject() + + + diff --git a/src/sudachi/configuration/configure_system.cpp b/src/sudachi/configuration/configure_system.cpp new file mode 100644 index 0000000..6de4460 --- /dev/null +++ b/src/sudachi/configuration/configure_system.cpp @@ -0,0 +1,206 @@ +// SPDX-FileCopyrightText: 2016 Citra Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "common/settings.h" +#include "core/core.h" +#include "ui_configure_system.h" +#include "sudachi/configuration/configuration_shared.h" +#include "sudachi/configuration/configure_system.h" +#include "sudachi/configuration/shared_widget.h" + +constexpr std::array LOCALE_BLOCKLIST{ + // pzzefezrpnkzeidfej + // thhsrnhutlohsternp + // BHH4CG U + // Raa1AB S + // nn9 + // ts + 0b0100011100001100000, // Japan + 0b0000001101001100100, // Americas + 0b0100110100001000010, // Europe + 0b0100110100001000010, // Australia + 0b0000000000000000000, // China + 0b0100111100001000000, // Korea + 0b0100111100001000000, // Taiwan +}; + +static bool IsValidLocale(u32 region_index, u32 language_index) { + if (region_index >= LOCALE_BLOCKLIST.size()) { + return false; + } + return ((LOCALE_BLOCKLIST.at(region_index) >> language_index) & 1) == 0; +} + +ConfigureSystem::ConfigureSystem(Core::System& system_, + std::shared_ptr> group_, + const ConfigurationShared::Builder& builder, QWidget* parent) + : Tab(group_, parent), ui{std::make_unique()}, system{system_} { + ui->setupUi(this); + + const auto posix_time = std::chrono::system_clock::now().time_since_epoch(); + const auto current_time_s = + std::chrono::duration_cast(posix_time).count(); + previous_time = current_time_s + Settings::values.custom_rtc_offset.GetValue(); + + Setup(builder); + + const auto locale_check = [this]() { + const auto region_index = combo_region->currentIndex(); + const auto language_index = combo_language->currentIndex(); + const bool valid_locale = IsValidLocale(region_index, language_index); + ui->label_warn_invalid_locale->setVisible(!valid_locale); + if (!valid_locale) { + ui->label_warn_invalid_locale->setText( + tr("Warning: \"%1\" is not a valid language for region \"%2\"") + .arg(combo_language->currentText()) + .arg(combo_region->currentText())); + } + }; + + const auto update_date_offset = [this]() { + if (!checkbox_rtc->isChecked()) { + return; + } + auto offset = date_rtc_offset->value(); + offset += date_rtc->dateTime().toSecsSinceEpoch() - previous_time; + previous_time = date_rtc->dateTime().toSecsSinceEpoch(); + date_rtc_offset->setValue(offset); + }; + const auto update_rtc_date = [this]() { UpdateRtcTime(); }; + + connect(combo_language, qOverload(&QComboBox::currentIndexChanged), this, locale_check); + connect(combo_region, qOverload(&QComboBox::currentIndexChanged), this, locale_check); + connect(checkbox_rtc, qOverload(&QCheckBox::stateChanged), this, update_rtc_date); + connect(date_rtc_offset, qOverload(&QSpinBox::valueChanged), this, update_rtc_date); + connect(date_rtc, &QDateTimeEdit::dateTimeChanged, this, update_date_offset); + + ui->label_warn_invalid_locale->setVisible(false); + locale_check(); + + SetConfiguration(); + UpdateRtcTime(); +} + +ConfigureSystem::~ConfigureSystem() = default; + +void ConfigureSystem::changeEvent(QEvent* event) { + if (event->type() == QEvent::LanguageChange) { + RetranslateUI(); + } + + QWidget::changeEvent(event); +} + +void ConfigureSystem::RetranslateUI() { + ui->retranslateUi(this); +} + +void ConfigureSystem::Setup(const ConfigurationShared::Builder& builder) { + auto& core_layout = *ui->core_widget->layout(); + auto& system_layout = *ui->system_widget->layout(); + + std::map core_hold{}; + std::map system_hold{}; + + std::vector settings; + auto push = [&settings](auto& list) { + for (auto setting : list) { + settings.push_back(setting); + } + }; + + push(Settings::values.linkage.by_category[Settings::Category::Core]); + push(Settings::values.linkage.by_category[Settings::Category::System]); + + for (auto setting : settings) { + if (setting->Id() == Settings::values.use_docked_mode.Id() && + Settings::IsConfiguringGlobal()) { + continue; + } + + ConfigurationShared::Widget* widget = builder.BuildWidget(setting, apply_funcs); + + if (widget == nullptr) { + continue; + } + if (!widget->Valid()) { + widget->deleteLater(); + continue; + } + + // Keep track of the region_index (and language_index) combobox to validate the selected + // settings + if (setting->Id() == Settings::values.region_index.Id()) { + combo_region = widget->combobox; + } + + if (setting->Id() == Settings::values.language_index.Id()) { + combo_language = widget->combobox; + } + + if (setting->Id() == Settings::values.custom_rtc.Id()) { + checkbox_rtc = widget->checkbox; + } + + if (setting->Id() == Settings::values.custom_rtc.Id()) { + date_rtc = widget->date_time_edit; + } + + if (setting->Id() == Settings::values.custom_rtc_offset.Id()) { + date_rtc_offset = widget->spinbox; + } + + switch (setting->GetCategory()) { + case Settings::Category::Core: + core_hold.emplace(setting->Id(), widget); + break; + case Settings::Category::System: + system_hold.emplace(setting->Id(), widget); + break; + default: + widget->deleteLater(); + } + } + for (const auto& [label, widget] : core_hold) { + core_layout.addWidget(widget); + } + for (const auto& [id, widget] : system_hold) { + system_layout.addWidget(widget); + } +} + +void ConfigureSystem::UpdateRtcTime() { + const auto posix_time = std::chrono::system_clock::now().time_since_epoch(); + previous_time = std::chrono::duration_cast(posix_time).count(); + date_rtc_offset->setEnabled(checkbox_rtc->isChecked()); + + if (checkbox_rtc->isChecked()) { + previous_time += date_rtc_offset->value(); + } + + const auto date = QDateTime::fromSecsSinceEpoch(previous_time); + date_rtc->setDateTime(date); +} + +void ConfigureSystem::SetConfiguration() {} + +void ConfigureSystem::ApplyConfiguration() { + const bool powered_on = system.IsPoweredOn(); + for (const auto& func : apply_funcs) { + func(powered_on); + } + UpdateRtcTime(); +} diff --git a/src/sudachi/configuration/configure_system.h b/src/sudachi/configuration/configure_system.h new file mode 100644 index 0000000..8d69ad2 --- /dev/null +++ b/src/sudachi/configuration/configure_system.h @@ -0,0 +1,63 @@ +// SPDX-FileCopyrightText: 2016 Citra Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include +#include +#include + +#include +#include "sudachi/configuration/configuration_shared.h" + +class QCheckBox; +class QLineEdit; +class QComboBox; +class QDateTimeEdit; +namespace Core { +class System; +} + +namespace Ui { +class ConfigureSystem; +} + +namespace ConfigurationShared { +class Builder; +} + +class ConfigureSystem : public ConfigurationShared::Tab { + Q_OBJECT + +public: + explicit ConfigureSystem(Core::System& system_, + std::shared_ptr> group, + const ConfigurationShared::Builder& builder, + QWidget* parent = nullptr); + ~ConfigureSystem() override; + + void ApplyConfiguration() override; + void SetConfiguration() override; + +private: + void changeEvent(QEvent* event) override; + void RetranslateUI(); + + void Setup(const ConfigurationShared::Builder& builder); + + void UpdateRtcTime(); + + std::vector> apply_funcs{}; + + std::unique_ptr ui; + bool enabled = false; + + Core::System& system; + + QComboBox* combo_region; + QComboBox* combo_language; + QCheckBox* checkbox_rtc; + QDateTimeEdit* date_rtc; + QSpinBox* date_rtc_offset; + u64 previous_time; +}; diff --git a/src/sudachi/configuration/configure_system.ui b/src/sudachi/configuration/configure_system.ui new file mode 100644 index 0000000..04b7711 --- /dev/null +++ b/src/sudachi/configuration/configure_system.ui @@ -0,0 +1,105 @@ + + + ConfigureSystem + + + + 0 + 0 + 605 + 483 + + + + Form + + + System + + + + + + + + System + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + + + + + + true + + + + + + + + + + Core + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + + + diff --git a/src/sudachi/configuration/configure_tas.cpp b/src/sudachi/configuration/configure_tas.cpp new file mode 100644 index 0000000..36aec75 --- /dev/null +++ b/src/sudachi/configuration/configure_tas.cpp @@ -0,0 +1,81 @@ +// SPDX-FileCopyrightText: Copyright 2021 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include +#include +#include "common/fs/fs.h" +#include "common/fs/path_util.h" +#include "common/settings.h" +#include "ui_configure_tas.h" +#include "sudachi/configuration/configure_tas.h" +#include "sudachi/uisettings.h" + +ConfigureTasDialog::ConfigureTasDialog(QWidget* parent) + : QDialog(parent), ui(std::make_unique()) { + + ui->setupUi(this); + + setFocusPolicy(Qt::ClickFocus); + setWindowTitle(tr("TAS Configuration")); + + connect(ui->tas_path_button, &QToolButton::pressed, this, + [this] { SetDirectory(DirectoryTarget::TAS, ui->tas_path_edit); }); + + LoadConfiguration(); +} + +ConfigureTasDialog::~ConfigureTasDialog() = default; + +void ConfigureTasDialog::LoadConfiguration() { + ui->tas_path_edit->setText( + QString::fromStdString(Common::FS::GetSudachiPathString(Common::FS::SudachiPath::TASDir))); + ui->tas_enable->setChecked(Settings::values.tas_enable.GetValue()); + ui->tas_loop_script->setChecked(Settings::values.tas_loop.GetValue()); + ui->tas_pause_on_load->setChecked(Settings::values.pause_tas_on_load.GetValue()); +} + +void ConfigureTasDialog::ApplyConfiguration() { + Common::FS::SetSudachiPath(Common::FS::SudachiPath::TASDir, ui->tas_path_edit->text().toStdString()); + Settings::values.tas_enable.SetValue(ui->tas_enable->isChecked()); + Settings::values.tas_loop.SetValue(ui->tas_loop_script->isChecked()); + Settings::values.pause_tas_on_load.SetValue(ui->tas_pause_on_load->isChecked()); +} + +void ConfigureTasDialog::SetDirectory(DirectoryTarget target, QLineEdit* edit) { + QString caption; + + switch (target) { + case DirectoryTarget::TAS: + caption = tr("Select TAS Load Directory..."); + break; + } + + QString str = QFileDialog::getExistingDirectory(this, caption, edit->text()); + + if (str.isEmpty()) { + return; + } + + if (str.back() != QChar::fromLatin1('/')) { + str.append(QChar::fromLatin1('/')); + } + + edit->setText(str); +} + +void ConfigureTasDialog::changeEvent(QEvent* event) { + if (event->type() == QEvent::LanguageChange) { + RetranslateUI(); + } + + QDialog::changeEvent(event); +} + +void ConfigureTasDialog::RetranslateUI() { + ui->retranslateUi(this); +} + +void ConfigureTasDialog::HandleApplyButtonClicked() { + UISettings::values.configuration_applied = true; + ApplyConfiguration(); +} diff --git a/src/sudachi/configuration/configure_tas.h b/src/sudachi/configuration/configure_tas.h new file mode 100644 index 0000000..eb3d36d --- /dev/null +++ b/src/sudachi/configuration/configure_tas.h @@ -0,0 +1,39 @@ +// SPDX-FileCopyrightText: Copyright 2021 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include + +class QLineEdit; + +namespace Ui { +class ConfigureTas; +} + +class ConfigureTasDialog : public QDialog { + Q_OBJECT + +public: + explicit ConfigureTasDialog(QWidget* parent); + ~ConfigureTasDialog() override; + + /// Save all button configurations to settings file + void ApplyConfiguration(); + +private: + enum class DirectoryTarget { + TAS, + }; + + void LoadConfiguration(); + + void SetDirectory(DirectoryTarget target, QLineEdit* edit); + + void changeEvent(QEvent* event) override; + void RetranslateUI(); + + void HandleApplyButtonClicked(); + + std::unique_ptr ui; +}; diff --git a/src/sudachi/configuration/configure_tas.ui b/src/sudachi/configuration/configure_tas.ui new file mode 100644 index 0000000..b045078 --- /dev/null +++ b/src/sudachi/configuration/configure_tas.ui @@ -0,0 +1,182 @@ + + + ConfigureTas + + + + + + + + TAS + + + + + + <html><head/><body><p>Reads controller input from scripts in the same format as TAS-nx scripts.<br/>For a more detailed explanation, please consult the <a href="https://sudachi-emu.org/help/feature/tas/"><span style=" text-decoration: underline; color:#039be5;">help page</span></a> on the sudachi website.</p></body></html> + + + true + + + + + + + To check which hotkeys control the playback/recording, please refer to the Hotkey settings (Configure -> General -> Hotkeys). + + + true + + + + + + + WARNING: This is an experimental feature.<br/>It will not play back scripts frame perfectly with the current, imperfect syncing method. + + + true + + + + + + + + + + + + + + Settings + + + + + + Enable TAS features + + + + + + + Loop script + + + + + + + false + + + Pause execution during loads + + + + + + + + + + + + + + Script Directory + + + + + + Path + + + + + + + ... + + + + + + + + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + 0 + 0 + + + + Qt::Horizontal + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + + + + + + + buttonBox + accepted() + ConfigureTas + accept() + + + 20 + 20 + + + 20 + 20 + + + + + buttonBox + rejected() + ConfigureTas + reject() + + + 20 + 20 + + + 20 + 20 + + + + + diff --git a/src/sudachi/configuration/configure_touch_from_button.cpp b/src/sudachi/configuration/configure_touch_from_button.cpp new file mode 100644 index 0000000..db3fa83 --- /dev/null +++ b/src/sudachi/configuration/configure_touch_from_button.cpp @@ -0,0 +1,617 @@ +// SPDX-FileCopyrightText: 2020 Citra Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include +#include +#include +#include +#include +#include +#include "common/param_package.h" +#include "common/settings.h" +#include "core/frontend/framebuffer_layout.h" +#include "input_common/main.h" +#include "ui_configure_touch_from_button.h" +#include "sudachi/configuration/configure_touch_from_button.h" +#include "sudachi/configuration/configure_touch_widget.h" + +static QString GetKeyName(int key_code) { + switch (key_code) { + case Qt::Key_Shift: + return QObject::tr("Shift"); + case Qt::Key_Control: + return QObject::tr("Ctrl"); + case Qt::Key_Alt: + return QObject::tr("Alt"); + case Qt::Key_Meta: + return QString{}; + default: + return QKeySequence(key_code).toString(); + } +} + +static QString ButtonToText(const Common::ParamPackage& param) { + if (!param.Has("engine")) { + return QObject::tr("[not set]"); + } + + if (param.Get("engine", "") == "keyboard") { + return GetKeyName(param.Get("code", 0)); + } + + if (param.Get("engine", "") == "sdl") { + if (param.Has("hat")) { + const QString hat_str = QString::fromStdString(param.Get("hat", "")); + const QString direction_str = QString::fromStdString(param.Get("direction", "")); + + return QObject::tr("Hat %1 %2").arg(hat_str, direction_str); + } + + if (param.Has("axis")) { + const QString axis_str = QString::fromStdString(param.Get("axis", "")); + const QString direction_str = QString::fromStdString(param.Get("direction", "")); + + return QObject::tr("Axis %1%2").arg(axis_str, direction_str); + } + + if (param.Has("button")) { + const QString button_str = QString::fromStdString(param.Get("button", "")); + + return QObject::tr("Button %1").arg(button_str); + } + + return {}; + } + + return QObject::tr("[unknown]"); +} + +ConfigureTouchFromButton::ConfigureTouchFromButton( + QWidget* parent, const std::vector& touch_maps_, + InputCommon::InputSubsystem* input_subsystem_, const int default_index) + : QDialog(parent), ui(std::make_unique()), + touch_maps{touch_maps_}, input_subsystem{input_subsystem_}, selected_index{default_index}, + timeout_timer(std::make_unique()), poll_timer(std::make_unique()) { + ui->setupUi(this); + binding_list_model = new QStandardItemModel(0, 3, this); + binding_list_model->setHorizontalHeaderLabels( + {tr("Button"), tr("X", "X axis"), tr("Y", "Y axis")}); + ui->binding_list->setModel(binding_list_model); + ui->bottom_screen->SetCoordLabel(ui->coord_label); + + SetConfiguration(); + UpdateUiDisplay(); + ConnectEvents(); +} + +ConfigureTouchFromButton::~ConfigureTouchFromButton() = default; + +void ConfigureTouchFromButton::showEvent(QShowEvent* ev) { + QWidget::showEvent(ev); + + // width values are not valid in the constructor + const int w = + ui->binding_list->viewport()->contentsRect().width() / binding_list_model->columnCount(); + if (w <= 0) { + return; + } + ui->binding_list->setColumnWidth(0, w); + ui->binding_list->setColumnWidth(1, w); + ui->binding_list->setColumnWidth(2, w); +} + +void ConfigureTouchFromButton::SetConfiguration() { + for (const auto& touch_map : touch_maps) { + ui->mapping->addItem(QString::fromStdString(touch_map.name)); + } + + ui->mapping->setCurrentIndex(selected_index); +} + +void ConfigureTouchFromButton::UpdateUiDisplay() { + ui->button_delete->setEnabled(touch_maps.size() > 1); + ui->button_delete_bind->setEnabled(false); + + binding_list_model->removeRows(0, binding_list_model->rowCount()); + + for (const auto& button_str : touch_maps[selected_index].buttons) { + Common::ParamPackage package{button_str}; + QStandardItem* button = new QStandardItem(ButtonToText(package)); + button->setData(QString::fromStdString(button_str)); + button->setEditable(false); + QStandardItem* xcoord = new QStandardItem(QString::number(package.Get("x", 0))); + QStandardItem* ycoord = new QStandardItem(QString::number(package.Get("y", 0))); + binding_list_model->appendRow({button, xcoord, ycoord}); + + const int dot = ui->bottom_screen->AddDot(package.Get("x", 0), package.Get("y", 0)); + button->setData(dot, DataRoleDot); + } +} + +void ConfigureTouchFromButton::ConnectEvents() { + connect(ui->mapping, qOverload(&QComboBox::currentIndexChanged), this, [this](int index) { + SaveCurrentMapping(); + selected_index = index; + UpdateUiDisplay(); + }); + connect(ui->button_new, &QPushButton::clicked, this, &ConfigureTouchFromButton::NewMapping); + connect(ui->button_delete, &QPushButton::clicked, this, + &ConfigureTouchFromButton::DeleteMapping); + connect(ui->button_rename, &QPushButton::clicked, this, + &ConfigureTouchFromButton::RenameMapping); + connect(ui->button_delete_bind, &QPushButton::clicked, this, + &ConfigureTouchFromButton::DeleteBinding); + connect(ui->binding_list, &QTreeView::doubleClicked, this, + &ConfigureTouchFromButton::EditBinding); + connect(ui->binding_list->selectionModel(), &QItemSelectionModel::selectionChanged, this, + &ConfigureTouchFromButton::OnBindingSelection); + connect(binding_list_model, &QStandardItemModel::itemChanged, this, + &ConfigureTouchFromButton::OnBindingChanged); + connect(ui->binding_list->model(), &QStandardItemModel::rowsAboutToBeRemoved, this, + &ConfigureTouchFromButton::OnBindingDeleted); + connect(ui->bottom_screen, &TouchScreenPreview::DotAdded, this, + &ConfigureTouchFromButton::NewBinding); + connect(ui->bottom_screen, &TouchScreenPreview::DotSelected, this, + &ConfigureTouchFromButton::SetActiveBinding); + connect(ui->bottom_screen, &TouchScreenPreview::DotMoved, this, + &ConfigureTouchFromButton::SetCoordinates); + connect(ui->buttonBox, &QDialogButtonBox::accepted, this, + &ConfigureTouchFromButton::ApplyConfiguration); + + connect(timeout_timer.get(), &QTimer::timeout, [this]() { SetPollingResult({}, true); }); + + connect(poll_timer.get(), &QTimer::timeout, [this]() { + const auto& params = input_subsystem->GetNextInput(); + if (params.Has("engine")) { + SetPollingResult(params, false); + return; + } + }); +} + +void ConfigureTouchFromButton::SaveCurrentMapping() { + auto& map = touch_maps[selected_index]; + map.buttons.clear(); + for (int i = 0, rc = binding_list_model->rowCount(); i < rc; ++i) { + const auto bind_str = binding_list_model->index(i, 0) + .data(Qt::ItemDataRole::UserRole + 1) + .toString() + .toStdString(); + if (bind_str.empty()) { + continue; + } + Common::ParamPackage params{bind_str}; + if (!params.Has("engine")) { + continue; + } + params.Set("x", binding_list_model->index(i, 1).data().toInt()); + params.Set("y", binding_list_model->index(i, 2).data().toInt()); + map.buttons.emplace_back(params.Serialize()); + } +} + +void ConfigureTouchFromButton::NewMapping() { + const QString name = + QInputDialog::getText(this, tr("New Profile"), tr("Enter the name for the new profile.")); + if (name.isEmpty()) { + return; + } + touch_maps.emplace_back(Settings::TouchFromButtonMap{name.toStdString(), {}}); + ui->mapping->addItem(name); + ui->mapping->setCurrentIndex(ui->mapping->count() - 1); +} + +void ConfigureTouchFromButton::DeleteMapping() { + const auto answer = QMessageBox::question( + this, tr("Delete Profile"), tr("Delete profile %1?").arg(ui->mapping->currentText())); + if (answer != QMessageBox::Yes) { + return; + } + const bool blocked = ui->mapping->blockSignals(true); + ui->mapping->removeItem(selected_index); + ui->mapping->blockSignals(blocked); + touch_maps.erase(touch_maps.begin() + selected_index); + selected_index = ui->mapping->currentIndex(); + UpdateUiDisplay(); +} + +void ConfigureTouchFromButton::RenameMapping() { + const QString new_name = QInputDialog::getText(this, tr("Rename Profile"), tr("New name:")); + if (new_name.isEmpty()) { + return; + } + ui->mapping->setItemText(selected_index, new_name); + touch_maps[selected_index].name = new_name.toStdString(); +} + +void ConfigureTouchFromButton::GetButtonInput(const int row_index, const bool is_new) { + if (timeout_timer->isActive()) { + return; + } + binding_list_model->item(row_index, 0)->setText(tr("[press key]")); + + input_setter = [this, row_index, is_new](const Common::ParamPackage& params, + const bool cancel) { + auto* cell = binding_list_model->item(row_index, 0); + if (cancel) { + if (is_new) { + binding_list_model->removeRow(row_index); + } else { + cell->setText( + ButtonToText(Common::ParamPackage{cell->data().toString().toStdString()})); + } + } else { + cell->setText(ButtonToText(params)); + cell->setData(QString::fromStdString(params.Serialize())); + } + }; + + input_subsystem->BeginMapping(InputCommon::Polling::InputType::Button); + + grabKeyboard(); + grabMouse(); + qApp->setOverrideCursor(QCursor(Qt::CursorShape::ArrowCursor)); + timeout_timer->start(5000); // Cancel after 5 seconds + poll_timer->start(200); // Check for new inputs every 200ms +} + +void ConfigureTouchFromButton::NewBinding(const QPoint& pos) { + auto* button = new QStandardItem(); + button->setEditable(false); + auto* x_coord = new QStandardItem(QString::number(pos.x())); + auto* y_coord = new QStandardItem(QString::number(pos.y())); + + const int dot_id = ui->bottom_screen->AddDot(pos.x(), pos.y()); + button->setData(dot_id, DataRoleDot); + + binding_list_model->appendRow({button, x_coord, y_coord}); + ui->binding_list->setFocus(); + ui->binding_list->setCurrentIndex(button->index()); + + GetButtonInput(binding_list_model->rowCount() - 1, true); +} + +void ConfigureTouchFromButton::EditBinding(const QModelIndex& qi) { + if (qi.row() >= 0 && qi.column() == 0) { + GetButtonInput(qi.row(), false); + } +} + +void ConfigureTouchFromButton::DeleteBinding() { + const int row_index = ui->binding_list->currentIndex().row(); + if (row_index < 0) { + return; + } + ui->bottom_screen->RemoveDot(binding_list_model->index(row_index, 0).data(DataRoleDot).toInt()); + binding_list_model->removeRow(row_index); +} + +void ConfigureTouchFromButton::OnBindingSelection(const QItemSelection& selected, + const QItemSelection& deselected) { + ui->button_delete_bind->setEnabled(!selected.isEmpty()); + if (!selected.isEmpty()) { + const auto dot_data = selected.indexes().first().data(DataRoleDot); + if (dot_data.isValid()) { + ui->bottom_screen->HighlightDot(dot_data.toInt()); + } + } + if (!deselected.isEmpty()) { + const auto dot_data = deselected.indexes().first().data(DataRoleDot); + if (dot_data.isValid()) { + ui->bottom_screen->HighlightDot(dot_data.toInt(), false); + } + } +} + +void ConfigureTouchFromButton::OnBindingChanged(QStandardItem* item) { + if (item->column() == 0) { + return; + } + + const bool blocked = binding_list_model->blockSignals(true); + item->setText(QString::number( + std::clamp(item->text().toInt(), 0, + static_cast((item->column() == 1 ? Layout::ScreenUndocked::Width + : Layout::ScreenUndocked::Height) - + 1)))); + binding_list_model->blockSignals(blocked); + + const auto dot_data = binding_list_model->index(item->row(), 0).data(DataRoleDot); + if (dot_data.isValid()) { + ui->bottom_screen->MoveDot(dot_data.toInt(), + binding_list_model->item(item->row(), 1)->text().toInt(), + binding_list_model->item(item->row(), 2)->text().toInt()); + } +} + +void ConfigureTouchFromButton::OnBindingDeleted(const QModelIndex& parent, int first, int last) { + for (int i = first; i <= last; ++i) { + const auto ix = binding_list_model->index(i, 0); + if (!ix.isValid()) { + return; + } + const auto dot_data = ix.data(DataRoleDot); + if (dot_data.isValid()) { + ui->bottom_screen->RemoveDot(dot_data.toInt()); + } + } +} + +void ConfigureTouchFromButton::SetActiveBinding(const int dot_id) { + for (int i = 0; i < binding_list_model->rowCount(); ++i) { + if (binding_list_model->index(i, 0).data(DataRoleDot) == dot_id) { + ui->binding_list->setCurrentIndex(binding_list_model->index(i, 0)); + ui->binding_list->setFocus(); + return; + } + } +} + +void ConfigureTouchFromButton::SetCoordinates(const int dot_id, const QPoint& pos) { + for (int i = 0; i < binding_list_model->rowCount(); ++i) { + if (binding_list_model->item(i, 0)->data(DataRoleDot) == dot_id) { + binding_list_model->item(i, 1)->setText(QString::number(pos.x())); + binding_list_model->item(i, 2)->setText(QString::number(pos.y())); + return; + } + } +} + +void ConfigureTouchFromButton::SetPollingResult(const Common::ParamPackage& params, + const bool cancel) { + timeout_timer->stop(); + poll_timer->stop(); + input_subsystem->StopMapping(); + + releaseKeyboard(); + releaseMouse(); + qApp->restoreOverrideCursor(); + + if (input_setter) { + (*input_setter)(params, cancel); + input_setter.reset(); + } +} + +void ConfigureTouchFromButton::keyPressEvent(QKeyEvent* event) { + if (!input_setter && event->key() == Qt::Key_Delete) { + DeleteBinding(); + return; + } + + if (!input_setter) { + return QDialog::keyPressEvent(event); + } + + if (event->key() != Qt::Key_Escape) { + SetPollingResult(Common::ParamPackage{InputCommon::GenerateKeyboardParam(event->key())}, + false); + } else { + SetPollingResult({}, true); + } +} + +void ConfigureTouchFromButton::ApplyConfiguration() { + SaveCurrentMapping(); + accept(); +} + +int ConfigureTouchFromButton::GetSelectedIndex() const { + return selected_index; +} + +std::vector ConfigureTouchFromButton::GetMaps() const { + return touch_maps; +} + +TouchScreenPreview::TouchScreenPreview(QWidget* parent) : QFrame(parent) { + setBackgroundRole(QPalette::ColorRole::Base); +} + +TouchScreenPreview::~TouchScreenPreview() = default; + +void TouchScreenPreview::SetCoordLabel(QLabel* const label) { + coord_label = label; +} + +int TouchScreenPreview::AddDot(const int device_x, const int device_y) { + QFont dot_font{QStringLiteral("monospace")}; + dot_font.setStyleHint(QFont::Monospace); + dot_font.setPointSize(20); + + auto* dot = new QLabel(this); + dot->setAttribute(Qt::WA_TranslucentBackground); + dot->setFont(dot_font); + dot->setText(QChar(0xD7)); // U+00D7 Multiplication Sign + dot->setAlignment(Qt::AlignmentFlag::AlignCenter); + dot->setProperty(PropId, ++max_dot_id); + dot->setProperty(PropX, device_x); + dot->setProperty(PropY, device_y); + dot->setCursor(Qt::CursorShape::PointingHandCursor); + dot->setMouseTracking(true); + dot->installEventFilter(this); + dot->show(); + PositionDot(dot, device_x, device_y); + dots.emplace_back(max_dot_id, dot); + return max_dot_id; +} + +void TouchScreenPreview::RemoveDot(const int id) { + const auto iter = std::find_if(dots.begin(), dots.end(), + [id](const auto& entry) { return entry.first == id; }); + if (iter == dots.cend()) { + return; + } + + iter->second->deleteLater(); + dots.erase(iter); +} + +void TouchScreenPreview::HighlightDot(const int id, const bool active) const { + for (const auto& dot : dots) { + if (dot.first == id) { + // use color property from the stylesheet, or fall back to the default palette + if (dot_highlight_color.isValid()) { + dot.second->setStyleSheet( + active ? QStringLiteral("color: %1").arg(dot_highlight_color.name()) + : QString{}); + } else { + dot.second->setForegroundRole(active ? QPalette::ColorRole::LinkVisited + : QPalette::ColorRole::NoRole); + } + if (active) { + dot.second->raise(); + } + return; + } + } +} + +void TouchScreenPreview::MoveDot(const int id, const int device_x, const int device_y) const { + const auto iter = std::find_if(dots.begin(), dots.end(), + [id](const auto& entry) { return entry.first == id; }); + if (iter == dots.cend()) { + return; + } + + iter->second->setProperty(PropX, device_x); + iter->second->setProperty(PropY, device_y); + PositionDot(iter->second, device_x, device_y); +} + +void TouchScreenPreview::resizeEvent(QResizeEvent* event) { + if (ignore_resize) { + return; + } + + const int target_width = std::min(width(), height() * 4 / 3); + const int target_height = std::min(height(), width() * 3 / 4); + if (target_width == width() && target_height == height()) { + return; + } + ignore_resize = true; + setGeometry((parentWidget()->contentsRect().width() - target_width) / 2, y(), target_width, + target_height); + ignore_resize = false; + + if (event->oldSize().width() != target_width || event->oldSize().height() != target_height) { + for (const auto& dot : dots) { + PositionDot(dot.second); + } + } +} + +void TouchScreenPreview::mouseMoveEvent(QMouseEvent* event) { + if (!coord_label) { + return; + } + const auto pos = MapToDeviceCoords(event->x(), event->y()); + if (pos) { + coord_label->setText(QStringLiteral("X: %1, Y: %2").arg(pos->x()).arg(pos->y())); + } else { + coord_label->clear(); + } +} + +void TouchScreenPreview::leaveEvent(QEvent* event) { + if (coord_label) { + coord_label->clear(); + } +} + +void TouchScreenPreview::mousePressEvent(QMouseEvent* event) { + if (event->button() != Qt::MouseButton::LeftButton) { + return; + } + const auto pos = MapToDeviceCoords(event->x(), event->y()); + if (pos) { + emit DotAdded(*pos); + } +} + +bool TouchScreenPreview::eventFilter(QObject* obj, QEvent* event) { + switch (event->type()) { + case QEvent::Type::MouseButtonPress: { + const auto mouse_event = static_cast(event); + if (mouse_event->button() != Qt::MouseButton::LeftButton) { + break; + } + emit DotSelected(obj->property(PropId).toInt()); + + drag_state.dot = qobject_cast(obj); + drag_state.start_pos = mouse_event->globalPos(); + return true; + } + case QEvent::Type::MouseMove: { + if (!drag_state.dot) { + break; + } + const auto mouse_event = static_cast(event); + if (!drag_state.active) { + drag_state.active = + (mouse_event->globalPos() - drag_state.start_pos).manhattanLength() >= + QApplication::startDragDistance(); + if (!drag_state.active) { + break; + } + } + auto current_pos = mapFromGlobal(mouse_event->globalPos()); + current_pos.setX(std::clamp(current_pos.x(), contentsMargins().left(), + contentsMargins().left() + contentsRect().width() - 1)); + current_pos.setY(std::clamp(current_pos.y(), contentsMargins().top(), + contentsMargins().top() + contentsRect().height() - 1)); + const auto device_coord = MapToDeviceCoords(current_pos.x(), current_pos.y()); + if (device_coord) { + drag_state.dot->setProperty(PropX, device_coord->x()); + drag_state.dot->setProperty(PropY, device_coord->y()); + PositionDot(drag_state.dot, device_coord->x(), device_coord->y()); + emit DotMoved(drag_state.dot->property(PropId).toInt(), *device_coord); + if (coord_label) { + coord_label->setText( + QStringLiteral("X: %1, Y: %2").arg(device_coord->x()).arg(device_coord->y())); + } + } + return true; + } + case QEvent::Type::MouseButtonRelease: { + drag_state.dot.clear(); + drag_state.active = false; + return true; + } + default: + break; + } + return obj->eventFilter(obj, event); +} + +std::optional TouchScreenPreview::MapToDeviceCoords(const int screen_x, + const int screen_y) const { + const float t_x = 0.5f + static_cast(screen_x - contentsMargins().left()) * + (Layout::ScreenUndocked::Width - 1) / (contentsRect().width() - 1); + const float t_y = 0.5f + static_cast(screen_y - contentsMargins().top()) * + (Layout::ScreenUndocked::Height - 1) / + (contentsRect().height() - 1); + if (t_x >= 0.5f && t_x < Layout::ScreenUndocked::Width && t_y >= 0.5f && + t_y < Layout::ScreenUndocked::Height) { + + return QPoint{static_cast(t_x), static_cast(t_y)}; + } + return std::nullopt; +} + +void TouchScreenPreview::PositionDot(QLabel* const dot, const int device_x, + const int device_y) const { + const float device_coord_x = + static_cast(device_x >= 0 ? device_x : dot->property(PropX).toInt()); + int x_coord = static_cast( + device_coord_x * (contentsRect().width() - 1) / (Layout::ScreenUndocked::Width - 1) + + contentsMargins().left() - static_cast(dot->width()) / 2 + 0.5f); + + const float device_coord_y = + static_cast(device_y >= 0 ? device_y : dot->property(PropY).toInt()); + const int y_coord = static_cast( + device_coord_y * (contentsRect().height() - 1) / (Layout::ScreenUndocked::Height - 1) + + contentsMargins().top() - static_cast(dot->height()) / 2 + 0.5f); + + dot->move(x_coord, y_coord); +} diff --git a/src/sudachi/configuration/configure_touch_from_button.h b/src/sudachi/configuration/configure_touch_from_button.h new file mode 100644 index 0000000..5a1416d --- /dev/null +++ b/src/sudachi/configuration/configure_touch_from_button.h @@ -0,0 +1,86 @@ +// SPDX-FileCopyrightText: 2020 Citra Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include +#include +#include +#include +#include + +class QItemSelection; +class QModelIndex; +class QStandardItemModel; +class QStandardItem; +class QTimer; + +namespace Common { +class ParamPackage; +} + +namespace InputCommon { +class InputSubsystem; +} + +namespace Settings { +struct TouchFromButtonMap; +} + +namespace Ui { +class ConfigureTouchFromButton; +} + +class ConfigureTouchFromButton : public QDialog { + Q_OBJECT + +public: + explicit ConfigureTouchFromButton(QWidget* parent, + const std::vector& touch_maps_, + InputCommon::InputSubsystem* input_subsystem_, + int default_index = 0); + ~ConfigureTouchFromButton() override; + + int GetSelectedIndex() const; + std::vector GetMaps() const; + +public slots: + void ApplyConfiguration(); + void NewBinding(const QPoint& pos); + void SetActiveBinding(int dot_id); + void SetCoordinates(int dot_id, const QPoint& pos); + +protected: + void showEvent(QShowEvent* ev) override; + void keyPressEvent(QKeyEvent* event) override; + +private slots: + void NewMapping(); + void DeleteMapping(); + void RenameMapping(); + void EditBinding(const QModelIndex& qi); + void DeleteBinding(); + void OnBindingSelection(const QItemSelection& selected, const QItemSelection& deselected); + void OnBindingChanged(QStandardItem* item); + void OnBindingDeleted(const QModelIndex& parent, int first, int last); + +private: + void SetConfiguration(); + void UpdateUiDisplay(); + void ConnectEvents(); + void GetButtonInput(int row_index, bool is_new); + void SetPollingResult(const Common::ParamPackage& params, bool cancel); + void SaveCurrentMapping(); + + std::unique_ptr ui; + std::vector touch_maps; + QStandardItemModel* binding_list_model; + InputCommon::InputSubsystem* input_subsystem; + int selected_index; + + std::unique_ptr timeout_timer; + std::unique_ptr poll_timer; + std::optional> input_setter; + + static constexpr int DataRoleDot = Qt::ItemDataRole::UserRole + 2; +}; diff --git a/src/sudachi/configuration/configure_touch_from_button.ui b/src/sudachi/configuration/configure_touch_from_button.ui new file mode 100644 index 0000000..2b2cdf4 --- /dev/null +++ b/src/sudachi/configuration/configure_touch_from_button.ui @@ -0,0 +1,221 @@ + + + ConfigureTouchFromButton + + + + 0 + 0 + 500 + 500 + + + + Configure Touchscreen Mappings + + + + + + + + Mapping: + + + Qt::PlainText + + + + + + + + 0 + 0 + + + + + + + + + 0 + 0 + + + + New + + + + + + + + 0 + 0 + + + + Delete + + + + + + + + 0 + 0 + + + + Rename + + + + + + + + + Qt::Horizontal + + + + + + + + + Click the bottom area to add a point, then press a button to bind. +Drag points to change position, or double-click table cells to edit values. + + + Qt::PlainText + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Delete Point + + + + + + + + + + 0 + 0 + + + + false + + + true + + + false + + + + + + + + 0 + 0 + + + + + 160 + 120 + + + + + 320 + 240 + + + + CrossCursor + + + true + + + true + + + QFrame::StyledPanel + + + QFrame::Sunken + + + + + + + + + + 0 + 0 + + + + Qt::PlainText + + + + + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + + + + + + + + TouchScreenPreview + QFrame +
sudachi/configuration/configure_touch_widget.h
+ 1 +
+
+ + + + buttonBox + rejected() + ConfigureTouchFromButton + reject() + + +
diff --git a/src/sudachi/configuration/configure_touch_widget.h b/src/sudachi/configuration/configure_touch_widget.h new file mode 100644 index 0000000..49f533a --- /dev/null +++ b/src/sudachi/configuration/configure_touch_widget.h @@ -0,0 +1,61 @@ +// SPDX-FileCopyrightText: 2020 Citra Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include +#include +#include +#include +#include + +class QLabel; + +// Widget for representing touchscreen coordinates +class TouchScreenPreview : public QFrame { + Q_OBJECT + Q_PROPERTY(QColor dotHighlightColor MEMBER dot_highlight_color) + +public: + explicit TouchScreenPreview(QWidget* parent); + ~TouchScreenPreview() override; + + void SetCoordLabel(QLabel*); + int AddDot(int device_x, int device_y); + void RemoveDot(int id); + void HighlightDot(int id, bool active = true) const; + void MoveDot(int id, int device_x, int device_y) const; + +signals: + void DotAdded(const QPoint& pos); + void DotSelected(int dot_id); + void DotMoved(int dot_id, const QPoint& pos); + +protected: + void resizeEvent(QResizeEvent*) override; + void mouseMoveEvent(QMouseEvent*) override; + void leaveEvent(QEvent*) override; + void mousePressEvent(QMouseEvent*) override; + bool eventFilter(QObject*, QEvent*) override; + +private: + std::optional MapToDeviceCoords(int screen_x, int screen_y) const; + void PositionDot(QLabel* dot, int device_x = -1, int device_y = -1) const; + + bool ignore_resize = false; + QPointer coord_label; + + std::vector> dots; + int max_dot_id = 0; + QColor dot_highlight_color; + static constexpr char PropId[] = "dot_id"; + static constexpr char PropX[] = "device_x"; + static constexpr char PropY[] = "device_y"; + + struct DragState { + bool active = false; + QPointer dot; + QPoint start_pos; + }; + DragState drag_state; +}; diff --git a/src/sudachi/configuration/configure_touchscreen_advanced.cpp b/src/sudachi/configuration/configure_touchscreen_advanced.cpp new file mode 100644 index 0000000..12bfcac --- /dev/null +++ b/src/sudachi/configuration/configure_touchscreen_advanced.cpp @@ -0,0 +1,50 @@ +// SPDX-FileCopyrightText: 2016 Citra Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include +#include "common/settings.h" +#include "ui_configure_touchscreen_advanced.h" +#include "sudachi/configuration/configure_touchscreen_advanced.h" + +ConfigureTouchscreenAdvanced::ConfigureTouchscreenAdvanced(QWidget* parent) + : QDialog(parent), ui(std::make_unique()) { + ui->setupUi(this); + + connect(ui->restore_defaults_button, &QPushButton::clicked, this, + &ConfigureTouchscreenAdvanced::RestoreDefaults); + + LoadConfiguration(); + resize(0, 0); +} + +ConfigureTouchscreenAdvanced::~ConfigureTouchscreenAdvanced() = default; + +void ConfigureTouchscreenAdvanced::changeEvent(QEvent* event) { + if (event->type() == QEvent::LanguageChange) { + RetranslateUI(); + } + + QDialog::changeEvent(event); +} + +void ConfigureTouchscreenAdvanced::RetranslateUI() { + ui->retranslateUi(this); +} + +void ConfigureTouchscreenAdvanced::ApplyConfiguration() { + Settings::values.touchscreen.diameter_x = ui->diameter_x_box->value(); + Settings::values.touchscreen.diameter_y = ui->diameter_y_box->value(); + Settings::values.touchscreen.rotation_angle = ui->angle_box->value(); +} + +void ConfigureTouchscreenAdvanced::LoadConfiguration() { + ui->diameter_x_box->setValue(Settings::values.touchscreen.diameter_x); + ui->diameter_y_box->setValue(Settings::values.touchscreen.diameter_y); + ui->angle_box->setValue(Settings::values.touchscreen.rotation_angle); +} + +void ConfigureTouchscreenAdvanced::RestoreDefaults() { + ui->diameter_x_box->setValue(15); + ui->diameter_y_box->setValue(15); + ui->angle_box->setValue(0); +} diff --git a/src/sudachi/configuration/configure_touchscreen_advanced.h b/src/sudachi/configuration/configure_touchscreen_advanced.h new file mode 100644 index 0000000..b6fdffd --- /dev/null +++ b/src/sudachi/configuration/configure_touchscreen_advanced.h @@ -0,0 +1,32 @@ +// SPDX-FileCopyrightText: 2016 Citra Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include +#include + +namespace Ui { +class ConfigureTouchscreenAdvanced; +} + +class ConfigureTouchscreenAdvanced : public QDialog { + Q_OBJECT + +public: + explicit ConfigureTouchscreenAdvanced(QWidget* parent); + ~ConfigureTouchscreenAdvanced() override; + + void ApplyConfiguration(); + +private: + void changeEvent(QEvent* event) override; + void RetranslateUI(); + + /// Load configuration settings. + void LoadConfiguration(); + /// Restore all buttons to their default values. + void RestoreDefaults(); + + std::unique_ptr ui; +}; diff --git a/src/sudachi/configuration/configure_touchscreen_advanced.ui b/src/sudachi/configuration/configure_touchscreen_advanced.ui new file mode 100644 index 0000000..e1a8033 --- /dev/null +++ b/src/sudachi/configuration/configure_touchscreen_advanced.ui @@ -0,0 +1,162 @@ + + + ConfigureTouchscreenAdvanced + + + + 0 + 0 + 298 + 339 + + + + Configure Touchscreen + + + + + + + 280 + 0 + + + + Warning: The settings in this page affect the inner workings of sudachi's emulated touchscreen. Changing them may result in undesirable behavior, such as the touchscreen partially or not working. You should only use this page if you know what you are doing. + + + true + + + + + + + Qt::Vertical + + + QSizePolicy::Fixed + + + + 20 + 20 + + + + + + + + Touch Parameters + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Touch Diameter Y + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Touch Diameter X + + + + + + + Rotational Angle + + + + + + + + + + + + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + + Restore Defaults + + + + + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + + + + + + + + + buttonBox + accepted() + ConfigureTouchscreenAdvanced + accept() + + + buttonBox + rejected() + ConfigureTouchscreenAdvanced + reject() + + + diff --git a/src/sudachi/configuration/configure_ui.cpp b/src/sudachi/configuration/configure_ui.cpp new file mode 100644 index 0000000..f5beda5 --- /dev/null +++ b/src/sudachi/configuration/configure_ui.cpp @@ -0,0 +1,354 @@ +// SPDX-FileCopyrightText: 2016 Citra Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include "sudachi/configuration/configure_ui.h" + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "common/common_types.h" +#include "common/fs/path_util.h" +#include "common/logging/log.h" +#include "common/settings.h" +#include "common/settings_enums.h" +#include "core/core.h" +#include "core/frontend/framebuffer_layout.h" +#include "ui_configure_ui.h" +#include "sudachi/uisettings.h" + +namespace { +constexpr std::array default_game_icon_sizes{ + std::make_pair(0, QT_TRANSLATE_NOOP("ConfigureUI", "None")), + std::make_pair(32, QT_TRANSLATE_NOOP("ConfigureUI", "Small (32x32)")), + std::make_pair(64, QT_TRANSLATE_NOOP("ConfigureUI", "Standard (64x64)")), + std::make_pair(128, QT_TRANSLATE_NOOP("ConfigureUI", "Large (128x128)")), + std::make_pair(256, QT_TRANSLATE_NOOP("ConfigureUI", "Full Size (256x256)")), +}; + +constexpr std::array default_folder_icon_sizes{ + std::make_pair(0, QT_TRANSLATE_NOOP("ConfigureUI", "None")), + std::make_pair(24, QT_TRANSLATE_NOOP("ConfigureUI", "Small (24x24)")), + std::make_pair(48, QT_TRANSLATE_NOOP("ConfigureUI", "Standard (48x48)")), + std::make_pair(72, QT_TRANSLATE_NOOP("ConfigureUI", "Large (72x72)")), +}; + +// clang-format off +constexpr std::array row_text_names{ + QT_TRANSLATE_NOOP("ConfigureUI", "Filename"), + QT_TRANSLATE_NOOP("ConfigureUI", "Filetype"), + QT_TRANSLATE_NOOP("ConfigureUI", "Title ID"), + QT_TRANSLATE_NOOP("ConfigureUI", "Title Name"), + QT_TRANSLATE_NOOP("ConfigureUI", "None"), +}; +// clang-format on + +QString GetTranslatedGameIconSize(size_t index) { + return QCoreApplication::translate("ConfigureUI", default_game_icon_sizes[index].second); +} + +QString GetTranslatedFolderIconSize(size_t index) { + return QCoreApplication::translate("ConfigureUI", default_folder_icon_sizes[index].second); +} + +QString GetTranslatedRowTextName(size_t index) { + return QCoreApplication::translate("ConfigureUI", row_text_names[index]); +} +} // Anonymous namespace + +static float GetUpFactor(Settings::ResolutionSetup res_setup) { + Settings::ResolutionScalingInfo info{}; + Settings::TranslateResolutionInfo(res_setup, info); + return info.up_factor; +} + +static void PopulateResolutionComboBox(QComboBox* screenshot_height, QWidget* parent) { + screenshot_height->clear(); + + const auto& enumeration = + Settings::EnumMetadata::Canonicalizations(); + std::set resolutions{}; + for (const auto& [name, value] : enumeration) { + const float up_factor = GetUpFactor(value); + u32 height_undocked = Layout::ScreenUndocked::Height * up_factor; + u32 height_docked = Layout::ScreenDocked::Height * up_factor; + resolutions.emplace(height_undocked); + resolutions.emplace(height_docked); + } + + screenshot_height->addItem(parent->tr("Auto", "Screenshot height option")); + for (const auto res : resolutions) { + screenshot_height->addItem(QString::fromStdString(std::to_string(res))); + } +} + +static u32 ScreenshotDimensionToInt(const QString& height) { + return std::strtoul(height.toUtf8(), nullptr, 0); +} + +ConfigureUi::ConfigureUi(Core::System& system_, QWidget* parent) + : QWidget(parent), ui{std::make_unique()}, + ratio{Settings::values.aspect_ratio.GetValue()}, + resolution_setting{Settings::values.resolution_setup.GetValue()}, system{system_} { + ui->setupUi(this); + + InitializeLanguageComboBox(); + + for (const auto& theme : UISettings::themes) { + ui->theme_combobox->addItem(QString::fromUtf8(theme.first), + QString::fromUtf8(theme.second)); + } + + InitializeIconSizeComboBox(); + InitializeRowComboBoxes(); + + PopulateResolutionComboBox(ui->screenshot_height, this); + + SetConfiguration(); + + // Force game list reload if any of the relevant settings are changed. + connect(ui->show_add_ons, &QCheckBox::stateChanged, this, &ConfigureUi::RequestGameListUpdate); + connect(ui->show_compat, &QCheckBox::stateChanged, this, &ConfigureUi::RequestGameListUpdate); + connect(ui->show_size, &QCheckBox::stateChanged, this, &ConfigureUi::RequestGameListUpdate); + connect(ui->show_types, &QCheckBox::stateChanged, this, &ConfigureUi::RequestGameListUpdate); + connect(ui->show_play_time, &QCheckBox::stateChanged, this, + &ConfigureUi::RequestGameListUpdate); + connect(ui->game_icon_size_combobox, QOverload::of(&QComboBox::currentIndexChanged), this, + &ConfigureUi::RequestGameListUpdate); + connect(ui->folder_icon_size_combobox, QOverload::of(&QComboBox::currentIndexChanged), + this, &ConfigureUi::RequestGameListUpdate); + connect(ui->row_1_text_combobox, QOverload::of(&QComboBox::currentIndexChanged), this, + &ConfigureUi::RequestGameListUpdate); + connect(ui->row_2_text_combobox, QOverload::of(&QComboBox::currentIndexChanged), this, + &ConfigureUi::RequestGameListUpdate); + + // Update text ComboBoxes after user interaction. + connect(ui->row_1_text_combobox, QOverload::of(&QComboBox::activated), + [this] { ConfigureUi::UpdateSecondRowComboBox(); }); + connect(ui->row_2_text_combobox, QOverload::of(&QComboBox::activated), + [this] { ConfigureUi::UpdateFirstRowComboBox(); }); + + // Set screenshot path to user specification. + connect(ui->screenshot_path_button, &QToolButton::pressed, this, [this] { + auto dir = + QFileDialog::getExistingDirectory(this, tr("Select Screenshots Path..."), + QString::fromStdString(Common::FS::GetSudachiPathString( + Common::FS::SudachiPath::ScreenshotsDir))); + if (!dir.isEmpty()) { + if (dir.back() != QChar::fromLatin1('/')) { + dir.append(QChar::fromLatin1('/')); + } + + ui->screenshot_path_edit->setText(dir); + } + }); + + connect(ui->screenshot_height, &QComboBox::currentTextChanged, [this]() { UpdateWidthText(); }); + + UpdateWidthText(); +} + +ConfigureUi::~ConfigureUi() = default; + +void ConfigureUi::ApplyConfiguration() { + UISettings::values.theme = + ui->theme_combobox->itemData(ui->theme_combobox->currentIndex()).toString().toStdString(); + UISettings::values.show_add_ons = ui->show_add_ons->isChecked(); + UISettings::values.show_compat = ui->show_compat->isChecked(); + UISettings::values.show_size = ui->show_size->isChecked(); + UISettings::values.show_types = ui->show_types->isChecked(); + UISettings::values.show_play_time = ui->show_play_time->isChecked(); + UISettings::values.game_icon_size = ui->game_icon_size_combobox->currentData().toUInt(); + UISettings::values.folder_icon_size = ui->folder_icon_size_combobox->currentData().toUInt(); + UISettings::values.row_1_text_id = ui->row_1_text_combobox->currentData().toUInt(); + UISettings::values.row_2_text_id = ui->row_2_text_combobox->currentData().toUInt(); + + UISettings::values.enable_screenshot_save_as = ui->enable_screenshot_save_as->isChecked(); + Common::FS::SetSudachiPath(Common::FS::SudachiPath::ScreenshotsDir, + ui->screenshot_path_edit->text().toStdString()); + + const u32 height = ScreenshotDimensionToInt(ui->screenshot_height->currentText()); + UISettings::values.screenshot_height.SetValue(height); + + RequestGameListUpdate(); + system.ApplySettings(); +} + +void ConfigureUi::RequestGameListUpdate() { + UISettings::values.is_game_list_reload_pending.exchange(true); +} + +void ConfigureUi::SetConfiguration() { + ui->theme_combobox->setCurrentIndex( + ui->theme_combobox->findData(QString::fromStdString(UISettings::values.theme))); + ui->language_combobox->setCurrentIndex(ui->language_combobox->findData( + QString::fromStdString(UISettings::values.language.GetValue()))); + ui->show_add_ons->setChecked(UISettings::values.show_add_ons.GetValue()); + ui->show_compat->setChecked(UISettings::values.show_compat.GetValue()); + ui->show_size->setChecked(UISettings::values.show_size.GetValue()); + ui->show_types->setChecked(UISettings::values.show_types.GetValue()); + ui->show_play_time->setChecked(UISettings::values.show_play_time.GetValue()); + ui->game_icon_size_combobox->setCurrentIndex( + ui->game_icon_size_combobox->findData(UISettings::values.game_icon_size.GetValue())); + ui->folder_icon_size_combobox->setCurrentIndex( + ui->folder_icon_size_combobox->findData(UISettings::values.folder_icon_size.GetValue())); + + ui->enable_screenshot_save_as->setChecked( + UISettings::values.enable_screenshot_save_as.GetValue()); + ui->screenshot_path_edit->setText(QString::fromStdString( + Common::FS::GetSudachiPathString(Common::FS::SudachiPath::ScreenshotsDir))); + + const auto height = UISettings::values.screenshot_height.GetValue(); + if (height == 0) { + ui->screenshot_height->setCurrentIndex(0); + } else { + ui->screenshot_height->setCurrentText(QStringLiteral("%1").arg(height)); + } +} + +void ConfigureUi::changeEvent(QEvent* event) { + if (event->type() == QEvent::LanguageChange) { + RetranslateUI(); + } + + QWidget::changeEvent(event); +} + +void ConfigureUi::RetranslateUI() { + ui->retranslateUi(this); + + for (int i = 0; i < ui->game_icon_size_combobox->count(); i++) { + ui->game_icon_size_combobox->setItemText(i, + GetTranslatedGameIconSize(static_cast(i))); + } + + for (int i = 0; i < ui->folder_icon_size_combobox->count(); i++) { + ui->folder_icon_size_combobox->setItemText( + i, GetTranslatedFolderIconSize(static_cast(i))); + } + + for (int i = 0; i < ui->row_1_text_combobox->count(); i++) { + const QString name = GetTranslatedRowTextName(static_cast(i)); + + ui->row_1_text_combobox->setItemText(i, name); + ui->row_2_text_combobox->setItemText(i, name); + } +} + +void ConfigureUi::InitializeLanguageComboBox() { + ui->language_combobox->addItem(tr(""), QString{}); + ui->language_combobox->addItem(tr("English"), QStringLiteral("en")); + QDirIterator it(QStringLiteral(":/languages"), QDirIterator::NoIteratorFlags); + while (it.hasNext()) { + QString locale = it.next(); + locale.truncate(locale.lastIndexOf(QLatin1Char{'.'})); + locale.remove(0, locale.lastIndexOf(QLatin1Char{'/'}) + 1); + const QString lang = QLocale::languageToString(QLocale(locale).language()); + const QString country = QLocale::countryToString(QLocale(locale).country()); + ui->language_combobox->addItem(QStringLiteral("%1 (%2)").arg(lang, country), locale); + } + + // Unlike other configuration changes, interface language changes need to be reflected on the + // interface immediately. This is done by passing a signal to the main window, and then + // retranslating when passing back. + connect(ui->language_combobox, qOverload(&QComboBox::currentIndexChanged), this, + &ConfigureUi::OnLanguageChanged); +} + +void ConfigureUi::InitializeIconSizeComboBox() { + for (size_t i = 0; i < default_game_icon_sizes.size(); i++) { + const auto size = default_game_icon_sizes[i].first; + ui->game_icon_size_combobox->addItem(GetTranslatedGameIconSize(i), size); + } + for (size_t i = 0; i < default_folder_icon_sizes.size(); i++) { + const auto size = default_folder_icon_sizes[i].first; + ui->folder_icon_size_combobox->addItem(GetTranslatedFolderIconSize(i), size); + } +} + +void ConfigureUi::InitializeRowComboBoxes() { + UpdateFirstRowComboBox(true); + UpdateSecondRowComboBox(true); +} + +void ConfigureUi::UpdateFirstRowComboBox(bool init) { + const int currentIndex = + init ? UISettings::values.row_1_text_id.GetValue() + : ui->row_1_text_combobox->findData(ui->row_1_text_combobox->currentData()); + + ui->row_1_text_combobox->clear(); + + for (std::size_t i = 0; i < row_text_names.size(); i++) { + const QString row_text_name = GetTranslatedRowTextName(i); + ui->row_1_text_combobox->addItem(row_text_name, QVariant::fromValue(i)); + } + + ui->row_1_text_combobox->setCurrentIndex(ui->row_1_text_combobox->findData(currentIndex)); + + ui->row_1_text_combobox->removeItem(4); // None + ui->row_1_text_combobox->removeItem( + ui->row_1_text_combobox->findData(ui->row_2_text_combobox->currentData())); +} + +void ConfigureUi::UpdateSecondRowComboBox(bool init) { + const int currentIndex = + init ? UISettings::values.row_2_text_id.GetValue() + : ui->row_2_text_combobox->findData(ui->row_2_text_combobox->currentData()); + + ui->row_2_text_combobox->clear(); + + for (std::size_t i = 0; i < row_text_names.size(); ++i) { + const QString row_text_name = GetTranslatedRowTextName(i); + ui->row_2_text_combobox->addItem(row_text_name, QVariant::fromValue(i)); + } + + ui->row_2_text_combobox->setCurrentIndex(ui->row_2_text_combobox->findData(currentIndex)); + + ui->row_2_text_combobox->removeItem( + ui->row_2_text_combobox->findData(ui->row_1_text_combobox->currentData())); +} + +void ConfigureUi::OnLanguageChanged(int index) { + if (index == -1) + return; + + emit LanguageChanged(ui->language_combobox->itemData(index).toString()); +} + +void ConfigureUi::UpdateWidthText() { + const u32 height = ScreenshotDimensionToInt(ui->screenshot_height->currentText()); + const u32 width = UISettings::CalculateWidth(height, ratio); + if (height == 0) { + const auto up_factor = GetUpFactor(resolution_setting); + const u32 height_docked = Layout::ScreenDocked::Height * up_factor; + const u32 width_docked = UISettings::CalculateWidth(height_docked, ratio); + const u32 height_undocked = Layout::ScreenUndocked::Height * up_factor; + const u32 width_undocked = UISettings::CalculateWidth(height_undocked, ratio); + ui->screenshot_width->setText(tr("Auto (%1 x %2, %3 x %4)", "Screenshot width value") + .arg(width_undocked) + .arg(height_undocked) + .arg(width_docked) + .arg(height_docked)); + } else { + ui->screenshot_width->setText(QStringLiteral("%1 x").arg(width)); + } +} + +void ConfigureUi::UpdateScreenshotInfo(Settings::AspectRatio ratio_, + Settings::ResolutionSetup resolution_setting_) { + ratio = ratio_; + resolution_setting = resolution_setting_; + UpdateWidthText(); +} diff --git a/src/sudachi/configuration/configure_ui.h b/src/sudachi/configuration/configure_ui.h new file mode 100644 index 0000000..2a2563a --- /dev/null +++ b/src/sudachi/configuration/configure_ui.h @@ -0,0 +1,58 @@ +// SPDX-FileCopyrightText: 2016 Citra Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include +#include +#include "common/settings_enums.h" + +namespace Core { +class System; +} + +namespace Ui { +class ConfigureUi; +} + +class ConfigureUi : public QWidget { + Q_OBJECT + +public: + explicit ConfigureUi(Core::System& system_, QWidget* parent = nullptr); + ~ConfigureUi() override; + + void ApplyConfiguration(); + + void UpdateScreenshotInfo(Settings::AspectRatio ratio, + Settings::ResolutionSetup resolution_info); + +private slots: + void OnLanguageChanged(int index); + +signals: + void LanguageChanged(const QString& locale); + +private: + void RequestGameListUpdate(); + + void SetConfiguration(); + + void changeEvent(QEvent*) override; + void RetranslateUI(); + + void InitializeLanguageComboBox(); + void InitializeIconSizeComboBox(); + void InitializeRowComboBoxes(); + + void UpdateFirstRowComboBox(bool init = false); + void UpdateSecondRowComboBox(bool init = false); + + void UpdateWidthText(); + + std::unique_ptr ui; + + Settings::AspectRatio ratio; + Settings::ResolutionSetup resolution_setting; + Core::System& system; +}; diff --git a/src/sudachi/configuration/configure_ui.ui b/src/sudachi/configuration/configure_ui.ui new file mode 100644 index 0000000..b8e6483 --- /dev/null +++ b/src/sudachi/configuration/configure_ui.ui @@ -0,0 +1,268 @@ + + + ConfigureUi + + + + 0 + 0 + 363 + 603 + + + + Form + + + UI + + + + + + General + + + + + + + + Note: Changing language will apply your configuration. + + + true + + + + + + + + + Interface language: + + + + + + + + + + + + + + Theme: + + + + + + + + + + + + + + + + + Game List + + + + + + + + Show Compatibility List + + + + + + + Show Add-Ons Column + + + + + + + Show Size Column + + + + + + + Show File Types Column + + + + + + + Show Play Time Column + + + + + + + + + Game Icon Size: + + + + + + + + + + + + + + Folder Icon Size: + + + + + + + + + + + + + + Row 1 Text: + + + + + + + + + + + + + + Row 2 Text: + + + + + + + + + + + + + + + + + Screenshots + + + + + + + + Ask Where To Save Screenshots (Windows Only) + + + + + + + + + Screenshots Path: + + + + + + + + + + ... + + + + + + + + + 6 + + + + + + + TextLabel + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + true + + + + + + + + + Resolution: + + + + + + + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + diff --git a/src/sudachi/configuration/configure_vibration.cpp b/src/sudachi/configuration/configure_vibration.cpp new file mode 100644 index 0000000..e440f50 --- /dev/null +++ b/src/sudachi/configuration/configure_vibration.cpp @@ -0,0 +1,133 @@ +// SPDX-FileCopyrightText: Copyright 2020 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include "common/settings.h" +#include "hid_core/frontend/emulated_controller.h" +#include "hid_core/hid_core.h" +#include "hid_core/hid_types.h" +#include "ui_configure_vibration.h" +#include "sudachi/configuration/configure_vibration.h" + +ConfigureVibration::ConfigureVibration(QWidget* parent, Core::HID::HIDCore& hid_core_) + : QDialog(parent), ui(std::make_unique()), hid_core{hid_core_} { + ui->setupUi(this); + + vibration_groupboxes = { + ui->vibrationGroupPlayer1, ui->vibrationGroupPlayer2, ui->vibrationGroupPlayer3, + ui->vibrationGroupPlayer4, ui->vibrationGroupPlayer5, ui->vibrationGroupPlayer6, + ui->vibrationGroupPlayer7, ui->vibrationGroupPlayer8, + }; + + vibration_spinboxes = { + ui->vibrationSpinPlayer1, ui->vibrationSpinPlayer2, ui->vibrationSpinPlayer3, + ui->vibrationSpinPlayer4, ui->vibrationSpinPlayer5, ui->vibrationSpinPlayer6, + ui->vibrationSpinPlayer7, ui->vibrationSpinPlayer8, + }; + + const auto& players = Settings::values.players.GetValue(); + + for (std::size_t i = 0; i < NUM_PLAYERS; ++i) { + auto controller = hid_core.GetEmulatedControllerByIndex(i); + Core::HID::ControllerUpdateCallback engine_callback{ + .on_change = [this, + i](Core::HID::ControllerTriggerType type) { VibrateController(type, i); }, + .is_npad_service = false, + }; + controller_callback_key[i] = controller->SetCallback(engine_callback); + vibration_groupboxes[i]->setChecked(players[i].vibration_enabled); + vibration_spinboxes[i]->setValue(players[i].vibration_strength); + } + + ui->checkBoxAccurateVibration->setChecked( + Settings::values.enable_accurate_vibrations.GetValue()); + + if (!Settings::IsConfiguringGlobal()) { + ui->checkBoxAccurateVibration->setDisabled(true); + } + + RetranslateUI(); +} + +ConfigureVibration::~ConfigureVibration() { + StopVibrations(); + + for (std::size_t i = 0; i < NUM_PLAYERS; ++i) { + auto controller = hid_core.GetEmulatedControllerByIndex(i); + controller->DeleteCallback(controller_callback_key[i]); + } +}; + +void ConfigureVibration::ApplyConfiguration() { + auto& players = Settings::values.players.GetValue(); + + for (std::size_t i = 0; i < NUM_PLAYERS; ++i) { + players[i].vibration_enabled = vibration_groupboxes[i]->isChecked(); + players[i].vibration_strength = vibration_spinboxes[i]->value(); + } + + Settings::values.enable_accurate_vibrations.SetValue( + ui->checkBoxAccurateVibration->isChecked()); +} + +void ConfigureVibration::changeEvent(QEvent* event) { + if (event->type() == QEvent::LanguageChange) { + RetranslateUI(); + } + + QDialog::changeEvent(event); +} + +void ConfigureVibration::RetranslateUI() { + ui->retranslateUi(this); +} + +void ConfigureVibration::VibrateController(Core::HID::ControllerTriggerType type, + std::size_t player_index) { + if (type != Core::HID::ControllerTriggerType::Button) { + return; + } + + auto& player = Settings::values.players.GetValue()[player_index]; + auto controller = hid_core.GetEmulatedControllerByIndex(player_index); + const int vibration_strength = vibration_spinboxes[player_index]->value(); + const auto& buttons = controller->GetButtonsValues(); + + bool button_is_pressed = false; + for (std::size_t i = 0; i < buttons.size(); ++i) { + if (buttons[i].value) { + button_is_pressed = true; + break; + } + } + + if (!button_is_pressed) { + StopVibrations(); + return; + } + + const bool old_vibration_enabled = player.vibration_enabled; + const int old_vibration_strength = player.vibration_strength; + player.vibration_enabled = true; + player.vibration_strength = vibration_strength; + + const Core::HID::VibrationValue vibration{ + .low_amplitude = 1.0f, + .low_frequency = 160.0f, + .high_amplitude = 1.0f, + .high_frequency = 320.0f, + }; + controller->SetVibration(Core::HID::DeviceIndex::Left, vibration); + controller->SetVibration(Core::HID::DeviceIndex::Right, vibration); + + // Restore previous values + player.vibration_enabled = old_vibration_enabled; + player.vibration_strength = old_vibration_strength; +} + +void ConfigureVibration::StopVibrations() { + for (std::size_t i = 0; i < NUM_PLAYERS; ++i) { + auto controller = hid_core.GetEmulatedControllerByIndex(i); + controller->SetVibration(Core::HID::DeviceIndex::Left, Core::HID::DEFAULT_VIBRATION_VALUE); + controller->SetVibration(Core::HID::DeviceIndex::Right, Core::HID::DEFAULT_VIBRATION_VALUE); + } +} diff --git a/src/sudachi/configuration/configure_vibration.h b/src/sudachi/configuration/configure_vibration.h new file mode 100644 index 0000000..831fedf --- /dev/null +++ b/src/sudachi/configuration/configure_vibration.h @@ -0,0 +1,51 @@ +// SPDX-FileCopyrightText: Copyright 2020 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include +#include +#include + +class QGroupBox; +class QSpinBox; + +namespace Ui { +class ConfigureVibration; +} + +namespace Core::HID { +enum class ControllerTriggerType; +class HIDCore; +} // namespace Core::HID + +class ConfigureVibration : public QDialog { + Q_OBJECT + +public: + explicit ConfigureVibration(QWidget* parent, Core::HID::HIDCore& hid_core_); + ~ConfigureVibration() override; + + void ApplyConfiguration(); + +private: + void changeEvent(QEvent* event) override; + void RetranslateUI(); + void VibrateController(Core::HID::ControllerTriggerType type, std::size_t player_index); + void StopVibrations(); + + std::unique_ptr ui; + + static constexpr std::size_t NUM_PLAYERS = 8; + + /// Groupboxes encapsulating the vibration strength spinbox. + std::array vibration_groupboxes; + + /// Spinboxes representing the vibration strength percentage. + std::array vibration_spinboxes; + + /// Callback index to stop the controllers events + std::array controller_callback_key; + + Core::HID::HIDCore& hid_core; +}; diff --git a/src/sudachi/configuration/configure_vibration.ui b/src/sudachi/configuration/configure_vibration.ui new file mode 100644 index 0000000..447a18e --- /dev/null +++ b/src/sudachi/configuration/configure_vibration.ui @@ -0,0 +1,553 @@ + + + ConfigureVibration + + + + 0 + 0 + 364 + 242 + + + + Configure Vibration + + + + + + + + + Press any controller button to vibrate the controller. + + + + + + + Vibration + + + + 9 + + + 9 + + + 9 + + + 9 + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Player 1 + + + true + + + + 3 + + + 3 + + + 3 + + + 3 + + + + + + 68 + 21 + + + + + 68 + 16777215 + + + + % + + + 1 + + + 150 + + + 100 + + + + + + + + + + Player 2 + + + true + + + + 3 + + + 3 + + + 3 + + + 3 + + + + + + 68 + 21 + + + + + 68 + 16777215 + + + + % + + + 1 + + + 150 + + + 100 + + + + + + + + + + Player 3 + + + true + + + + 3 + + + 3 + + + 3 + + + 3 + + + + + + 68 + 21 + + + + + 68 + 16777215 + + + + % + + + 1 + + + 150 + + + 100 + + + + + + + + + + Player 4 + + + true + + + + 3 + + + 3 + + + 3 + + + 3 + + + + + + 68 + 21 + + + + + 68 + 16777215 + + + + % + + + 1 + + + 150 + + + 100 + + + + + + + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Player 5 + + + true + + + + 3 + + + 3 + + + 3 + + + 3 + + + + + + 68 + 21 + + + + + 68 + 16777215 + + + + % + + + 1 + + + 150 + + + 100 + + + + + + + + + + Player 6 + + + true + + + + 3 + + + 3 + + + 3 + + + 3 + + + + + + 68 + 21 + + + + + 68 + 16777215 + + + + % + + + 1 + + + 150 + + + 100 + + + + + + + + + + Player 7 + + + true + + + + 3 + + + 3 + + + 3 + + + 3 + + + + + + 68 + 21 + + + + + 68 + 16777215 + + + + % + + + 1 + + + 150 + + + 100 + + + + + + + + + + Player 8 + + + true + + + + 3 + + + 3 + + + 3 + + + 3 + + + + + + 68 + 21 + + + + + 68 + 16777215 + + + + % + + + 1 + + + 150 + + + 100 + + + + + + + + + + + + + + + + Settings + + + + + + Enable Accurate Vibration + + + + + + + + + + Qt::Vertical + + + + 167 + 55 + + + + + + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + + + + + + + buttonBoxVibration + accepted() + ConfigureVibration + accept() + + + buttonBoxVibration + rejected() + ConfigureVibration + reject() + + + diff --git a/src/sudachi/configuration/configure_web.cpp b/src/sudachi/configuration/configure_web.cpp new file mode 100644 index 0000000..328888b --- /dev/null +++ b/src/sudachi/configuration/configure_web.cpp @@ -0,0 +1,180 @@ +// SPDX-FileCopyrightText: 2017 Citra Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include +#include +#include +#include "common/settings.h" +#include "core/telemetry_session.h" +#include "ui_configure_web.h" +#include "sudachi/configuration/configure_web.h" +#include "sudachi/uisettings.h" + +static constexpr char token_delimiter{':'}; + +static std::string GenerateDisplayToken(const std::string& username, const std::string& token) { + if (username.empty() || token.empty()) { + return {}; + } + + const std::string unencoded_display_token{username + token_delimiter + token}; + QByteArray b{unencoded_display_token.c_str()}; + QByteArray b64 = b.toBase64(); + return b64.toStdString(); +} + +static std::string UsernameFromDisplayToken(const std::string& display_token) { + const std::string unencoded_display_token{ + QByteArray::fromBase64(display_token.c_str()).toStdString()}; + return unencoded_display_token.substr(0, unencoded_display_token.find(token_delimiter)); +} + +static std::string TokenFromDisplayToken(const std::string& display_token) { + const std::string unencoded_display_token{ + QByteArray::fromBase64(display_token.c_str()).toStdString()}; + return unencoded_display_token.substr(unencoded_display_token.find(token_delimiter) + 1); +} + +ConfigureWeb::ConfigureWeb(QWidget* parent) + : QWidget(parent), ui(std::make_unique()) { + ui->setupUi(this); + connect(ui->button_regenerate_telemetry_id, &QPushButton::clicked, this, + &ConfigureWeb::RefreshTelemetryID); + connect(ui->button_verify_login, &QPushButton::clicked, this, &ConfigureWeb::VerifyLogin); + connect(&verify_watcher, &QFutureWatcher::finished, this, &ConfigureWeb::OnLoginVerified); + +#ifndef USE_DISCORD_PRESENCE + ui->discord_group->setVisible(false); +#endif + + SetConfiguration(); + RetranslateUI(); +} + +ConfigureWeb::~ConfigureWeb() = default; + +void ConfigureWeb::changeEvent(QEvent* event) { + if (event->type() == QEvent::LanguageChange) { + RetranslateUI(); + } + + QWidget::changeEvent(event); +} + +void ConfigureWeb::RetranslateUI() { + ui->retranslateUi(this); + + ui->telemetry_learn_more->setText( + tr("Learn more")); + + ui->web_signup_link->setText( + tr("Sign up")); + + ui->web_token_info_link->setText( + tr("What is my token?")); + + ui->label_telemetry_id->setText( + tr("Telemetry ID: 0x%1").arg(QString::number(Core::GetTelemetryId(), 16).toUpper())); +} + +void ConfigureWeb::SetConfiguration() { + ui->web_credentials_disclaimer->setWordWrap(true); + + ui->telemetry_learn_more->setOpenExternalLinks(true); + ui->web_signup_link->setOpenExternalLinks(true); + ui->web_token_info_link->setOpenExternalLinks(true); + + if (Settings::values.sudachi_username.GetValue().empty()) { + ui->username->setText(tr("Unspecified")); + } else { + ui->username->setText(QString::fromStdString(Settings::values.sudachi_username.GetValue())); + } + + ui->toggle_telemetry->setChecked(Settings::values.enable_telemetry.GetValue()); + ui->edit_token->setText(QString::fromStdString(GenerateDisplayToken( + Settings::values.sudachi_username.GetValue(), Settings::values.sudachi_token.GetValue()))); + + // Connect after setting the values, to avoid calling OnLoginChanged now + connect(ui->edit_token, &QLineEdit::textChanged, this, &ConfigureWeb::OnLoginChanged); + + user_verified = true; + + ui->toggle_discordrpc->setChecked(UISettings::values.enable_discord_presence.GetValue()); +} + +void ConfigureWeb::ApplyConfiguration() { + Settings::values.enable_telemetry = ui->toggle_telemetry->isChecked(); + UISettings::values.enable_discord_presence = ui->toggle_discordrpc->isChecked(); + if (user_verified) { + Settings::values.sudachi_username = + UsernameFromDisplayToken(ui->edit_token->text().toStdString()); + Settings::values.sudachi_token = TokenFromDisplayToken(ui->edit_token->text().toStdString()); + } else { + QMessageBox::warning( + this, tr("Token not verified"), + tr("Token was not verified. The change to your token has not been saved.")); + } +} + +void ConfigureWeb::RefreshTelemetryID() { + const u64 new_telemetry_id{Core::RegenerateTelemetryId()}; + ui->label_telemetry_id->setText( + tr("Telemetry ID: 0x%1").arg(QString::number(new_telemetry_id, 16).toUpper())); +} + +void ConfigureWeb::OnLoginChanged() { + if (ui->edit_token->text().isEmpty()) { + user_verified = true; + // Empty = no icon + ui->label_token_verified->setPixmap(QPixmap()); + ui->label_token_verified->setToolTip(QString()); + } else { + user_verified = false; + + // Show an info icon if it's been changed, clearer than showing failure + const QPixmap pixmap = QIcon::fromTheme(QStringLiteral("info")).pixmap(16); + ui->label_token_verified->setPixmap(pixmap); + ui->label_token_verified->setToolTip( + tr("Unverified, please click Verify before saving configuration", "Tooltip")); + } +} + +void ConfigureWeb::VerifyLogin() { + ui->button_verify_login->setDisabled(true); + ui->button_verify_login->setText(tr("Verifying...")); + ui->label_token_verified->setPixmap(QIcon::fromTheme(QStringLiteral("sync")).pixmap(16)); + ui->label_token_verified->setToolTip(tr("Verifying...")); + verify_watcher.setFuture(QtConcurrent::run( + [username = UsernameFromDisplayToken(ui->edit_token->text().toStdString()), + token = TokenFromDisplayToken(ui->edit_token->text().toStdString())] { + return Core::VerifyLogin(username, token); + })); +} + +void ConfigureWeb::OnLoginVerified() { + ui->button_verify_login->setEnabled(true); + ui->button_verify_login->setText(tr("Verify")); + if (verify_watcher.result()) { + user_verified = true; + + ui->label_token_verified->setPixmap(QIcon::fromTheme(QStringLiteral("checked")).pixmap(16)); + ui->label_token_verified->setToolTip(tr("Verified", "Tooltip")); + ui->username->setText( + QString::fromStdString(UsernameFromDisplayToken(ui->edit_token->text().toStdString()))); + } else { + ui->label_token_verified->setPixmap(QIcon::fromTheme(QStringLiteral("failed")).pixmap(16)); + ui->label_token_verified->setToolTip(tr("Verification failed", "Tooltip")); + ui->username->setText(tr("Unspecified")); + QMessageBox::critical(this, tr("Verification failed"), + tr("Verification failed. Check that you have entered your token " + "correctly, and that your internet connection is working.")); + } +} + +void ConfigureWeb::SetWebServiceConfigEnabled(bool enabled) { + ui->label_disable_info->setVisible(!enabled); + ui->groupBoxWebConfig->setEnabled(enabled); +} diff --git a/src/sudachi/configuration/configure_web.h b/src/sudachi/configuration/configure_web.h new file mode 100644 index 0000000..03feb55 --- /dev/null +++ b/src/sudachi/configuration/configure_web.h @@ -0,0 +1,39 @@ +// SPDX-FileCopyrightText: 2017 Citra Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include +#include +#include + +namespace Ui { +class ConfigureWeb; +} + +class ConfigureWeb : public QWidget { + Q_OBJECT + +public: + explicit ConfigureWeb(QWidget* parent = nullptr); + ~ConfigureWeb() override; + + void ApplyConfiguration(); + void SetWebServiceConfigEnabled(bool enabled); + +private: + void changeEvent(QEvent* event) override; + void RetranslateUI(); + + void RefreshTelemetryID(); + void OnLoginChanged(); + void VerifyLogin(); + void OnLoginVerified(); + + void SetConfiguration(); + + bool user_verified = true; + QFutureWatcher verify_watcher; + + std::unique_ptr ui; +}; diff --git a/src/sudachi/configuration/configure_web.ui b/src/sudachi/configuration/configure_web.ui new file mode 100644 index 0000000..eb5e44a --- /dev/null +++ b/src/sudachi/configuration/configure_web.ui @@ -0,0 +1,210 @@ + + + ConfigureWeb + + + + 0 + 0 + 926 + 561 + + + + Form + + + Web + + + + + + + + sudachi Web Service + + + + + + By providing your username and token, you agree to allow sudachi to collect additional usage data, which may include user identifying information. + + + + + + + + + + 0 + 0 + + + + Qt::RightToLeft + + + Verify + + + + + + + Sign up + + + + + + + + + + Token: + + + + + + + + + + Username: + + + + + + + 80 + + + QLineEdit::Password + + + + + + + What is my token? + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + + + + Web Service configuration can only be changed when a public room isn't being hosted. + + + true + + + + + + + Telemetry + + + + + + Share anonymous usage data with the sudachi team + + + + + + + Learn more + + + + + + + + + Telemetry ID: + + + + + + + + 0 + 0 + + + + Qt::RightToLeft + + + Regenerate + + + + + + + + + + + + + + Discord Presence + + + + + + Show Current Game in your Discord Status + + + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + diff --git a/src/sudachi/configuration/input_profiles.cpp b/src/sudachi/configuration/input_profiles.cpp new file mode 100644 index 0000000..56b3638 --- /dev/null +++ b/src/sudachi/configuration/input_profiles.cpp @@ -0,0 +1,134 @@ +// SPDX-FileCopyrightText: Copyright 2020 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include + +#include "common/fs/fs.h" +#include "common/fs/path_util.h" +#include "common/logging/log.h" +#include "frontend_common/config.h" +#include "sudachi/configuration/input_profiles.h" + +namespace FS = Common::FS; + +namespace { + +bool ProfileExistsInFilesystem(std::string_view profile_name) { + return FS::Exists(FS::GetSudachiPath(FS::SudachiPath::ConfigDir) / "input" / + fmt::format("{}.ini", profile_name)); +} + +bool IsINI(const std::filesystem::path& filename) { + return filename.extension() == ".ini"; +} + +std::filesystem::path GetNameWithoutExtension(std::filesystem::path filename) { + return filename.replace_extension(); +} + +} // namespace + +InputProfiles::InputProfiles() { + const auto input_profile_loc = FS::GetSudachiPath(FS::SudachiPath::ConfigDir) / "input"; + + if (!FS::IsDir(input_profile_loc)) { + return; + } + + FS::IterateDirEntries( + input_profile_loc, + [this](const std::filesystem::path& full_path) { + const auto filename = full_path.filename(); + const auto name_without_ext = + Common::FS::PathToUTF8String(GetNameWithoutExtension(filename)); + + if (IsINI(filename) && IsProfileNameValid(name_without_ext)) { + map_profiles.insert_or_assign( + name_without_ext, + std::make_unique(name_without_ext, Config::ConfigType::InputProfile)); + } + + return true; + }, + FS::DirEntryFilter::File); +} + +InputProfiles::~InputProfiles() = default; + +std::vector InputProfiles::GetInputProfileNames() { + std::vector profile_names; + profile_names.reserve(map_profiles.size()); + + auto it = map_profiles.cbegin(); + while (it != map_profiles.cend()) { + const auto& [profile_name, config] = *it; + if (!ProfileExistsInFilesystem(profile_name)) { + it = map_profiles.erase(it); + continue; + } + + profile_names.push_back(profile_name); + ++it; + } + + std::stable_sort(profile_names.begin(), profile_names.end()); + + return profile_names; +} + +bool InputProfiles::IsProfileNameValid(std::string_view profile_name) { + return profile_name.find_first_of("<>:;\"/\\|,.!?*") == std::string::npos; +} + +bool InputProfiles::CreateProfile(const std::string& profile_name, std::size_t player_index) { + if (ProfileExistsInMap(profile_name)) { + return false; + } + + map_profiles.insert_or_assign( + profile_name, std::make_unique(profile_name, Config::ConfigType::InputProfile)); + + return SaveProfile(profile_name, player_index); +} + +bool InputProfiles::DeleteProfile(const std::string& profile_name) { + if (!ProfileExistsInMap(profile_name)) { + return false; + } + + if (!ProfileExistsInFilesystem(profile_name) || + FS::RemoveFile(map_profiles[profile_name]->GetConfigFilePath())) { + map_profiles.erase(profile_name); + } + + return !ProfileExistsInMap(profile_name) && !ProfileExistsInFilesystem(profile_name); +} + +bool InputProfiles::LoadProfile(const std::string& profile_name, std::size_t player_index) { + if (!ProfileExistsInMap(profile_name)) { + return false; + } + + if (!ProfileExistsInFilesystem(profile_name)) { + map_profiles.erase(profile_name); + return false; + } + + LOG_INFO(Config, "Loading input profile `{}`", profile_name); + + map_profiles[profile_name]->ReadQtControlPlayerValues(player_index); + return true; +} + +bool InputProfiles::SaveProfile(const std::string& profile_name, std::size_t player_index) { + if (!ProfileExistsInMap(profile_name)) { + return false; + } + + map_profiles[profile_name]->SaveQtControlPlayerValues(player_index); + return true; +} + +bool InputProfiles::ProfileExistsInMap(const std::string& profile_name) const { + return map_profiles.find(profile_name) != map_profiles.end(); +} diff --git a/src/sudachi/configuration/input_profiles.h b/src/sudachi/configuration/input_profiles.h new file mode 100644 index 0000000..0b1bd7e --- /dev/null +++ b/src/sudachi/configuration/input_profiles.h @@ -0,0 +1,36 @@ +// SPDX-FileCopyrightText: Copyright 2020 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include +#include + +#include "configuration/qt_config.h" + +namespace Core { +class System; +} + +class Config; + +class InputProfiles { + +public: + explicit InputProfiles(); + virtual ~InputProfiles(); + + std::vector GetInputProfileNames(); + + static bool IsProfileNameValid(std::string_view profile_name); + + bool CreateProfile(const std::string& profile_name, std::size_t player_index); + bool DeleteProfile(const std::string& profile_name); + bool LoadProfile(const std::string& profile_name, std::size_t player_index); + bool SaveProfile(const std::string& profile_name, std::size_t player_index); + +private: + bool ProfileExistsInMap(const std::string& profile_name) const; + + std::unordered_map> map_profiles; +}; diff --git a/src/sudachi/configuration/qt_config.cpp b/src/sudachi/configuration/qt_config.cpp new file mode 100644 index 0000000..4f5d535 --- /dev/null +++ b/src/sudachi/configuration/qt_config.cpp @@ -0,0 +1,560 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include "common/logging/log.h" +#include "input_common/main.h" +#include "qt_config.h" +#include "uisettings.h" + +const std::array QtConfig::default_buttons = { + Qt::Key_C, Qt::Key_X, Qt::Key_V, Qt::Key_Z, Qt::Key_F, + Qt::Key_G, Qt::Key_Q, Qt::Key_E, Qt::Key_R, Qt::Key_T, + Qt::Key_M, Qt::Key_N, Qt::Key_Left, Qt::Key_Up, Qt::Key_Right, + Qt::Key_Down, Qt::Key_Q, Qt::Key_E, 0, 0, + Qt::Key_Q, Qt::Key_E, +}; + +const std::array QtConfig::default_motions = { + Qt::Key_7, + Qt::Key_8, +}; + +const std::array, Settings::NativeAnalog::NumAnalogs> QtConfig::default_analogs{{ + { + Qt::Key_W, + Qt::Key_S, + Qt::Key_A, + Qt::Key_D, + }, + { + Qt::Key_I, + Qt::Key_K, + Qt::Key_J, + Qt::Key_L, + }, +}}; + +const std::array QtConfig::default_stick_mod = { + Qt::Key_Shift, + 0, +}; + +const std::array QtConfig::default_ringcon_analogs{{ + Qt::Key_A, + Qt::Key_D, +}}; + +QtConfig::QtConfig(const std::string& config_name, const ConfigType config_type) + : Config(config_type) { + Initialize(config_name); + if (config_type != ConfigType::InputProfile) { + ReadQtValues(); + SaveQtValues(); + } +} + +QtConfig::~QtConfig() { + if (global) { + QtConfig::SaveAllValues(); + } +} + +void QtConfig::ReloadAllValues() { + Reload(); + ReadQtValues(); + SaveQtValues(); +} + +void QtConfig::SaveAllValues() { + SaveValues(); + SaveQtValues(); +} + +void QtConfig::ReadQtValues() { + if (global) { + ReadUIValues(); + } + ReadQtControlValues(); +} + +void QtConfig::ReadQtPlayerValues(const std::size_t player_index) { + std::string player_prefix; + if (type != ConfigType::InputProfile) { + player_prefix.append("player_").append(ToString(player_index)).append("_"); + } + + auto& player = Settings::values.players.GetValue()[player_index]; + if (IsCustomConfig()) { + const auto profile_name = + ReadStringSetting(std::string(player_prefix).append("profile_name")); + if (profile_name.empty()) { + // Use the global input config + player = Settings::values.players.GetValue(true)[player_index]; + player.profile_name = ""; + return; + } + } + + for (int i = 0; i < Settings::NativeButton::NumButtons; ++i) { + const std::string default_param = InputCommon::GenerateKeyboardParam(default_buttons[i]); + auto& player_buttons = player.buttons[i]; + + player_buttons = ReadStringSetting( + std::string(player_prefix).append(Settings::NativeButton::mapping[i]), default_param); + if (player_buttons.empty()) { + player_buttons = default_param; + } + } + + for (int i = 0; i < Settings::NativeAnalog::NumAnalogs; ++i) { + const std::string default_param = InputCommon::GenerateAnalogParamFromKeys( + default_analogs[i][0], default_analogs[i][1], default_analogs[i][2], + default_analogs[i][3], default_stick_mod[i], 0.5f); + auto& player_analogs = player.analogs[i]; + + player_analogs = ReadStringSetting( + std::string(player_prefix).append(Settings::NativeAnalog::mapping[i]), default_param); + if (player_analogs.empty()) { + player_analogs = default_param; + } + } + + for (int i = 0; i < Settings::NativeMotion::NumMotions; ++i) { + const std::string default_param = InputCommon::GenerateKeyboardParam(default_motions[i]); + auto& player_motions = player.motions[i]; + + player_motions = ReadStringSetting( + std::string(player_prefix).append(Settings::NativeMotion::mapping[i]), default_param); + if (player_motions.empty()) { + player_motions = default_param; + } + } +} + +void QtConfig::ReadHidbusValues() { + const std::string default_param = InputCommon::GenerateAnalogParamFromKeys( + 0, 0, default_ringcon_analogs[0], default_ringcon_analogs[1], 0, 0.05f); + auto& ringcon_analogs = Settings::values.ringcon_analogs; + + ringcon_analogs = ReadStringSetting(std::string("ring_controller"), default_param); + if (ringcon_analogs.empty()) { + ringcon_analogs = default_param; + } +} + +void QtConfig::ReadDebugControlValues() { + for (int i = 0; i < Settings::NativeButton::NumButtons; ++i) { + const std::string default_param = InputCommon::GenerateKeyboardParam(default_buttons[i]); + auto& debug_pad_buttons = Settings::values.debug_pad_buttons[i]; + + debug_pad_buttons = ReadStringSetting( + std::string("debug_pad_").append(Settings::NativeButton::mapping[i]), default_param); + if (debug_pad_buttons.empty()) { + debug_pad_buttons = default_param; + } + } + + for (int i = 0; i < Settings::NativeAnalog::NumAnalogs; ++i) { + const std::string default_param = InputCommon::GenerateAnalogParamFromKeys( + default_analogs[i][0], default_analogs[i][1], default_analogs[i][2], + default_analogs[i][3], default_stick_mod[i], 0.5f); + auto& debug_pad_analogs = Settings::values.debug_pad_analogs[i]; + + debug_pad_analogs = ReadStringSetting( + std::string("debug_pad_").append(Settings::NativeAnalog::mapping[i]), default_param); + if (debug_pad_analogs.empty()) { + debug_pad_analogs = default_param; + } + } +} + +void QtConfig::ReadQtControlValues() { + BeginGroup(Settings::TranslateCategory(Settings::Category::Controls)); + + Settings::values.players.SetGlobal(!IsCustomConfig()); + for (std::size_t p = 0; p < Settings::values.players.GetValue().size(); ++p) { + ReadQtPlayerValues(p); + } + if (IsCustomConfig()) { + EndGroup(); + return; + } + ReadDebugControlValues(); + ReadHidbusValues(); + + EndGroup(); +} + +void QtConfig::ReadPathValues() { + BeginGroup(Settings::TranslateCategory(Settings::Category::Paths)); + + UISettings::values.roms_path = ReadStringSetting(std::string("romsPath")); + UISettings::values.game_dir_deprecated = + ReadStringSetting(std::string("gameListRootDir"), std::string(".")); + UISettings::values.game_dir_deprecated_deepscan = + ReadBooleanSetting(std::string("gameListDeepScan"), std::make_optional(false)); + + const int gamedirs_size = BeginArray(std::string("gamedirs")); + for (int i = 0; i < gamedirs_size; ++i) { + SetArrayIndex(i); + UISettings::GameDir game_dir; + game_dir.path = ReadStringSetting(std::string("path")); + game_dir.deep_scan = + ReadBooleanSetting(std::string("deep_scan"), std::make_optional(false)); + game_dir.expanded = ReadBooleanSetting(std::string("expanded"), std::make_optional(true)); + UISettings::values.game_dirs.append(game_dir); + } + EndArray(); + + // Create NAND and SD card directories if empty, these are not removable through the UI, + // also carries over old game list settings if present + if (UISettings::values.game_dirs.empty()) { + UISettings::GameDir game_dir; + game_dir.path = std::string("SDMC"); + game_dir.expanded = true; + UISettings::values.game_dirs.append(game_dir); + game_dir.path = std::string("UserNAND"); + UISettings::values.game_dirs.append(game_dir); + game_dir.path = std::string("SysNAND"); + UISettings::values.game_dirs.append(game_dir); + if (UISettings::values.game_dir_deprecated != std::string(".")) { + game_dir.path = UISettings::values.game_dir_deprecated; + game_dir.deep_scan = UISettings::values.game_dir_deprecated_deepscan; + UISettings::values.game_dirs.append(game_dir); + } + } + UISettings::values.recent_files = + QString::fromStdString(ReadStringSetting(std::string("recentFiles"))) + .split(QStringLiteral(", "), Qt::SkipEmptyParts, Qt::CaseSensitive); + + ReadCategory(Settings::Category::Paths); + + EndGroup(); +} + +void QtConfig::ReadShortcutValues() { + BeginGroup(Settings::TranslateCategory(Settings::Category::Shortcuts)); + + for (const auto& [name, group, shortcut] : UISettings::default_hotkeys) { + BeginGroup(group); + BeginGroup(name); + + // No longer using ReadSetting for shortcut.second as it inaccurately returns a value of 1 + // for WidgetWithChildrenShortcut which is a value of 3. Needed to fix shortcuts the open + // a file dialog in windowed mode + UISettings::values.shortcuts.push_back( + {name, + group, + {ReadStringSetting(std::string("KeySeq"), shortcut.keyseq), + ReadStringSetting(std::string("Controller_KeySeq"), shortcut.controller_keyseq), + shortcut.context, + ReadBooleanSetting(std::string("Repeat"), std::optional(shortcut.repeat))}}); + + EndGroup(); // name + EndGroup(); // group + } + + EndGroup(); +} + +void QtConfig::ReadUIValues() { + BeginGroup(Settings::TranslateCategory(Settings::Category::Ui)); + + UISettings::values.theme = ReadStringSetting( + std::string("theme"), + std::string(UISettings::themes[static_cast(UISettings::default_theme)].second)); + + ReadUIGamelistValues(); + ReadUILayoutValues(); + ReadPathValues(); + ReadScreenshotValues(); + ReadShortcutValues(); + ReadMultiplayerValues(); + + ReadCategory(Settings::Category::Ui); + ReadCategory(Settings::Category::UiGeneral); + + EndGroup(); +} + +void QtConfig::ReadUIGamelistValues() { + BeginGroup(Settings::TranslateCategory(Settings::Category::UiGameList)); + + ReadCategory(Settings::Category::UiGameList); + + const int favorites_size = BeginArray("favorites"); + for (int i = 0; i < favorites_size; i++) { + SetArrayIndex(i); + UISettings::values.favorited_ids.append( + ReadUnsignedIntegerSetting(std::string("program_id"))); + } + EndArray(); + + EndGroup(); +} + +void QtConfig::ReadUILayoutValues() { + BeginGroup(Settings::TranslateCategory(Settings::Category::UiGameList)); + + ReadCategory(Settings::Category::UiLayout); + + EndGroup(); +} + +void QtConfig::ReadMultiplayerValues() { + BeginGroup(Settings::TranslateCategory(Settings::Category::Multiplayer)); + + ReadCategory(Settings::Category::Multiplayer); + + // Read ban list back + int size = BeginArray(std::string("username_ban_list")); + UISettings::values.multiplayer_ban_list.first.resize(size); + for (int i = 0; i < size; ++i) { + SetArrayIndex(i); + UISettings::values.multiplayer_ban_list.first[i] = + ReadStringSetting(std::string("username"), std::string("")); + } + EndArray(); + + size = BeginArray(std::string("ip_ban_list")); + UISettings::values.multiplayer_ban_list.second.resize(size); + for (int i = 0; i < size; ++i) { + UISettings::values.multiplayer_ban_list.second[i] = + ReadStringSetting("username", std::string("")); + } + EndArray(); + + EndGroup(); +} + +void QtConfig::SaveQtValues() { + if (global) { + LOG_DEBUG(Config, "Saving global Qt configuration values"); + SaveUIValues(); + } else { + LOG_DEBUG(Config, "Saving Qt configuration values"); + } + SaveQtControlValues(); + + WriteToIni(); +} + +void QtConfig::SaveQtPlayerValues(const std::size_t player_index) { + std::string player_prefix; + if (type != ConfigType::InputProfile) { + player_prefix = std::string("player_").append(ToString(player_index)).append("_"); + } + + const auto& player = Settings::values.players.GetValue()[player_index]; + if (IsCustomConfig() && player.profile_name.empty()) { + // No custom profile selected + return; + } + + for (int i = 0; i < Settings::NativeButton::NumButtons; ++i) { + const std::string default_param = InputCommon::GenerateKeyboardParam(default_buttons[i]); + WriteStringSetting(std::string(player_prefix).append(Settings::NativeButton::mapping[i]), + player.buttons[i], std::make_optional(default_param)); + } + for (int i = 0; i < Settings::NativeAnalog::NumAnalogs; ++i) { + const std::string default_param = InputCommon::GenerateAnalogParamFromKeys( + default_analogs[i][0], default_analogs[i][1], default_analogs[i][2], + default_analogs[i][3], default_stick_mod[i], 0.5f); + WriteStringSetting(std::string(player_prefix).append(Settings::NativeAnalog::mapping[i]), + player.analogs[i], std::make_optional(default_param)); + } + for (int i = 0; i < Settings::NativeMotion::NumMotions; ++i) { + const std::string default_param = InputCommon::GenerateKeyboardParam(default_motions[i]); + WriteStringSetting(std::string(player_prefix).append(Settings::NativeMotion::mapping[i]), + player.motions[i], std::make_optional(default_param)); + } +} + +void QtConfig::SaveDebugControlValues() { + for (int i = 0; i < Settings::NativeButton::NumButtons; ++i) { + const std::string default_param = InputCommon::GenerateKeyboardParam(default_buttons[i]); + WriteStringSetting(std::string("debug_pad_").append(Settings::NativeButton::mapping[i]), + Settings::values.debug_pad_buttons[i], + std::make_optional(default_param)); + } + for (int i = 0; i < Settings::NativeAnalog::NumAnalogs; ++i) { + const std::string default_param = InputCommon::GenerateAnalogParamFromKeys( + default_analogs[i][0], default_analogs[i][1], default_analogs[i][2], + default_analogs[i][3], default_stick_mod[i], 0.5f); + WriteStringSetting(std::string("debug_pad_").append(Settings::NativeAnalog::mapping[i]), + Settings::values.debug_pad_analogs[i], + std::make_optional(default_param)); + } +} + +void QtConfig::SaveHidbusValues() { + const std::string default_param = InputCommon::GenerateAnalogParamFromKeys( + 0, 0, default_ringcon_analogs[0], default_ringcon_analogs[1], 0, 0.05f); + WriteStringSetting(std::string("ring_controller"), Settings::values.ringcon_analogs, + std::make_optional(default_param)); +} + +void QtConfig::SaveQtControlValues() { + BeginGroup(Settings::TranslateCategory(Settings::Category::Controls)); + + Settings::values.players.SetGlobal(!IsCustomConfig()); + for (std::size_t p = 0; p < Settings::values.players.GetValue().size(); ++p) { + SaveQtPlayerValues(p); + } + if (IsCustomConfig()) { + EndGroup(); + return; + } + SaveDebugControlValues(); + SaveHidbusValues(); + + EndGroup(); +} + +void QtConfig::SavePathValues() { + BeginGroup(Settings::TranslateCategory(Settings::Category::Paths)); + + WriteCategory(Settings::Category::Paths); + + WriteStringSetting(std::string("romsPath"), UISettings::values.roms_path); + BeginArray(std::string("gamedirs")); + for (int i = 0; i < UISettings::values.game_dirs.size(); ++i) { + SetArrayIndex(i); + const auto& game_dir = UISettings::values.game_dirs[i]; + WriteStringSetting(std::string("path"), game_dir.path); + WriteBooleanSetting(std::string("deep_scan"), game_dir.deep_scan, + std::make_optional(false)); + WriteBooleanSetting(std::string("expanded"), game_dir.expanded, std::make_optional(true)); + } + EndArray(); + + WriteStringSetting(std::string("recentFiles"), + UISettings::values.recent_files.join(QStringLiteral(", ")).toStdString()); + + EndGroup(); +} + +void QtConfig::SaveShortcutValues() { + BeginGroup(Settings::TranslateCategory(Settings::Category::Shortcuts)); + + // Lengths of UISettings::values.shortcuts & default_hotkeys are same. + // However, their ordering must also be the same. + for (std::size_t i = 0; i < UISettings::default_hotkeys.size(); i++) { + const auto& [name, group, shortcut] = UISettings::values.shortcuts[i]; + const auto& default_hotkey = UISettings::default_hotkeys[i].shortcut; + + BeginGroup(group); + BeginGroup(name); + + WriteStringSetting(std::string("KeySeq"), shortcut.keyseq, + std::make_optional(default_hotkey.keyseq)); + WriteStringSetting(std::string("Controller_KeySeq"), shortcut.controller_keyseq, + std::make_optional(default_hotkey.controller_keyseq)); + WriteIntegerSetting(std::string("Context"), shortcut.context, + std::make_optional(default_hotkey.context)); + WriteBooleanSetting(std::string("Repeat"), shortcut.repeat, + std::make_optional(default_hotkey.repeat)); + + EndGroup(); // name + EndGroup(); // group + } + + EndGroup(); +} + +void QtConfig::SaveUIValues() { + BeginGroup(Settings::TranslateCategory(Settings::Category::Ui)); + + WriteCategory(Settings::Category::Ui); + WriteCategory(Settings::Category::UiGeneral); + + WriteStringSetting( + std::string("theme"), UISettings::values.theme, + std::make_optional(std::string( + UISettings::themes[static_cast(UISettings::default_theme)].second))); + + SaveUIGamelistValues(); + SaveUILayoutValues(); + SavePathValues(); + SaveScreenshotValues(); + SaveShortcutValues(); + SaveMultiplayerValues(); + + EndGroup(); +} + +void QtConfig::SaveUIGamelistValues() { + BeginGroup(Settings::TranslateCategory(Settings::Category::UiGameList)); + + WriteCategory(Settings::Category::UiGameList); + + BeginArray(std::string("favorites")); + for (int i = 0; i < UISettings::values.favorited_ids.size(); i++) { + SetArrayIndex(i); + WriteIntegerSetting(std::string("program_id"), UISettings::values.favorited_ids[i]); + } + EndArray(); // favorites + + EndGroup(); +} + +void QtConfig::SaveUILayoutValues() { + BeginGroup(Settings::TranslateCategory(Settings::Category::UiLayout)); + + WriteCategory(Settings::Category::UiLayout); + + EndGroup(); +} + +void QtConfig::SaveMultiplayerValues() { + BeginGroup(std::string("Multiplayer")); + + WriteCategory(Settings::Category::Multiplayer); + + // Write ban list + BeginArray(std::string("username_ban_list")); + for (std::size_t i = 0; i < UISettings::values.multiplayer_ban_list.first.size(); ++i) { + SetArrayIndex(static_cast(i)); + WriteStringSetting(std::string("username"), + UISettings::values.multiplayer_ban_list.first[i]); + } + EndArray(); // username_ban_list + + BeginArray(std::string("ip_ban_list")); + for (std::size_t i = 0; i < UISettings::values.multiplayer_ban_list.second.size(); ++i) { + SetArrayIndex(static_cast(i)); + WriteStringSetting(std::string("ip"), UISettings::values.multiplayer_ban_list.second[i]); + } + EndArray(); // ip_ban_list + + EndGroup(); +} + +std::vector& QtConfig::FindRelevantList(Settings::Category category) { + auto& map = Settings::values.linkage.by_category; + if (map.contains(category)) { + return Settings::values.linkage.by_category[category]; + } + return UISettings::values.linkage.by_category[category]; +} + +void QtConfig::ReadQtControlPlayerValues(std::size_t player_index) { + BeginGroup(Settings::TranslateCategory(Settings::Category::Controls)); + + ReadPlayerValues(player_index); + ReadQtPlayerValues(player_index); + + EndGroup(); +} + +void QtConfig::SaveQtControlPlayerValues(std::size_t player_index) { + BeginGroup(Settings::TranslateCategory(Settings::Category::Controls)); + + LOG_DEBUG(Config, "Saving players control configuration values"); + SavePlayerValues(player_index); + SaveQtPlayerValues(player_index); + + EndGroup(); + + WriteToIni(); +} diff --git a/src/sudachi/configuration/qt_config.h b/src/sudachi/configuration/qt_config.h new file mode 100644 index 0000000..2276f90 --- /dev/null +++ b/src/sudachi/configuration/qt_config.h @@ -0,0 +1,55 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include + +#include "frontend_common/config.h" + +class QtConfig final : public Config { +public: + explicit QtConfig(const std::string& config_name = "qt-config", + ConfigType config_type = ConfigType::GlobalConfig); + ~QtConfig() override; + + void ReloadAllValues() override; + void SaveAllValues() override; + + void ReadQtControlPlayerValues(std::size_t player_index); + void SaveQtControlPlayerValues(std::size_t player_index); + +protected: + void ReadQtValues(); + void ReadQtPlayerValues(std::size_t player_index); + void ReadQtControlValues(); + void ReadHidbusValues() override; + void ReadDebugControlValues() override; + void ReadPathValues() override; + void ReadShortcutValues() override; + void ReadUIValues() override; + void ReadUIGamelistValues() override; + void ReadUILayoutValues() override; + void ReadMultiplayerValues() override; + + void SaveQtValues(); + void SaveQtPlayerValues(std::size_t player_index); + void SaveQtControlValues(); + void SaveHidbusValues() override; + void SaveDebugControlValues() override; + void SavePathValues() override; + void SaveShortcutValues() override; + void SaveUIValues() override; + void SaveUIGamelistValues() override; + void SaveUILayoutValues() override; + void SaveMultiplayerValues() override; + + std::vector& FindRelevantList(Settings::Category category) override; + +public: + static const std::array default_buttons; + static const std::array default_motions; + static const std::array, Settings::NativeAnalog::NumAnalogs> default_analogs; + static const std::array default_stick_mod; + static const std::array default_ringcon_analogs; +}; diff --git a/src/sudachi/configuration/shared_translation.cpp b/src/sudachi/configuration/shared_translation.cpp new file mode 100644 index 0000000..684f0ed --- /dev/null +++ b/src/sudachi/configuration/shared_translation.cpp @@ -0,0 +1,537 @@ +// SPDX-FileCopyrightText: Copyright 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include "sudachi/configuration/shared_translation.h" + +#include +#include +#include +#include +#include +#include +#include "common/settings.h" +#include "common/settings_enums.h" +#include "common/settings_setting.h" +#include "common/time_zone.h" +#include "sudachi/uisettings.h" + +namespace ConfigurationShared { + +std::unique_ptr InitializeTranslations(QWidget* parent) { + std::unique_ptr translations = std::make_unique(); + const auto& tr = [parent](const char* text) -> QString { return parent->tr(text); }; + +#define INSERT(SETTINGS, ID, NAME, TOOLTIP) \ + translations->insert(std::pair{SETTINGS::values.ID.Id(), std::pair{(NAME), (TOOLTIP)}}) + + // A setting can be ignored by giving it a blank name + + // Applets + INSERT(Settings, cabinet_applet_mode, tr("Amiibo editor"), QStringLiteral()); + INSERT(Settings, controller_applet_mode, tr("Controller configuration"), QStringLiteral()); + INSERT(Settings, data_erase_applet_mode, tr("Data erase"), QStringLiteral()); + INSERT(Settings, error_applet_mode, tr("Error"), QStringLiteral()); + INSERT(Settings, net_connect_applet_mode, tr("Net connect"), QStringLiteral()); + INSERT(Settings, player_select_applet_mode, tr("Player select"), QStringLiteral()); + INSERT(Settings, swkbd_applet_mode, tr("Software keyboard"), QStringLiteral()); + INSERT(Settings, mii_edit_applet_mode, tr("Mii Edit"), QStringLiteral()); + INSERT(Settings, web_applet_mode, tr("Online web"), QStringLiteral()); + INSERT(Settings, shop_applet_mode, tr("Shop"), QStringLiteral()); + INSERT(Settings, photo_viewer_applet_mode, tr("Photo viewer"), QStringLiteral()); + INSERT(Settings, offline_web_applet_mode, tr("Offline web"), QStringLiteral()); + INSERT(Settings, login_share_applet_mode, tr("Login share"), QStringLiteral()); + INSERT(Settings, wifi_web_auth_applet_mode, tr("Wifi web auth"), QStringLiteral()); + INSERT(Settings, my_page_applet_mode, tr("My page"), QStringLiteral()); + + // Audio + INSERT(Settings, sink_id, tr("Output Engine:"), QStringLiteral()); + INSERT(Settings, audio_output_device_id, tr("Output Device:"), QStringLiteral()); + INSERT(Settings, audio_input_device_id, tr("Input Device:"), QStringLiteral()); + INSERT(Settings, audio_muted, tr("Mute audio"), QStringLiteral()); + INSERT(Settings, volume, tr("Volume:"), QStringLiteral()); + INSERT(Settings, dump_audio_commands, QStringLiteral(), QStringLiteral()); + INSERT(UISettings, mute_when_in_background, tr("Mute audio when in background"), + QStringLiteral()); + + // Core + INSERT( + Settings, use_multi_core, tr("Multicore CPU Emulation"), + tr("This option increases CPU emulation thread use from 1 to the Switch’s maximum of 4.\n" + "This is mainly a debug option and shouldn’t be disabled.")); + INSERT( + Settings, memory_layout_mode, tr("Memory Layout"), + tr("Increases the amount of emulated RAM from the stock 4GB of the retail Switch to the " + "developer kit's 8/6GB.\nIt’s doesn’t improve stability or performance and is intended " + "to let big texture mods fit in emulated RAM.\nEnabling it will increase memory " + "use. It is not recommended to enable unless a specific game with a texture mod needs " + "it.")); + INSERT(Settings, use_speed_limit, QStringLiteral(), QStringLiteral()); + INSERT(Settings, speed_limit, tr("Limit Speed Percent"), + tr("Controls the game's maximum rendering speed, but it’s up to each game if it runs " + "faster or not.\n200% for a 30 FPS game is 60 FPS, and for a " + "60 FPS game it will be 120 FPS.\nDisabling it means unlocking the framerate to the " + "maximum your PC can reach.")); + + // Cpu + INSERT(Settings, cpu_accuracy, tr("Accuracy:"), + tr("This setting controls the accuracy of the emulated CPU.\nDon't change this unless " + "you know what you are doing.")); + INSERT(Settings, cpu_backend, tr("Backend:"), QStringLiteral()); + + // Cpu Debug + + // Cpu Unsafe + INSERT( + Settings, cpuopt_unsafe_unfuse_fma, + tr("Unfuse FMA (improve performance on CPUs without FMA)"), + tr("This option improves speed by reducing accuracy of fused-multiply-add instructions on " + "CPUs without native FMA support.")); + INSERT( + Settings, cpuopt_unsafe_reduce_fp_error, tr("Faster FRSQRTE and FRECPE"), + tr("This option improves the speed of some approximate floating-point functions by using " + "less accurate native approximations.")); + INSERT(Settings, cpuopt_unsafe_ignore_standard_fpcr, + tr("Faster ASIMD instructions (32 bits only)"), + tr("This option improves the speed of 32 bits ASIMD floating-point functions by running " + "with incorrect rounding modes.")); + INSERT(Settings, cpuopt_unsafe_inaccurate_nan, tr("Inaccurate NaN handling"), + tr("This option improves speed by removing NaN checking.\nPlease note this also reduces " + "accuracy of certain floating-point instructions.")); + INSERT(Settings, cpuopt_unsafe_fastmem_check, tr("Disable address space checks"), + tr("This option improves speed by eliminating a safety check before every memory " + "read/write in guest.\nDisabling it may allow a game to read/write the emulator's " + "memory.")); + INSERT( + Settings, cpuopt_unsafe_ignore_global_monitor, tr("Ignore global monitor"), + tr("This option improves speed by relying only on the semantics of cmpxchg to ensure " + "safety of exclusive access instructions.\nPlease note this may result in deadlocks and " + "other race conditions.")); + + // Renderer + INSERT( + Settings, renderer_backend, tr("API:"), + tr("Switches between the available graphics APIs.\nVulkan is recommended in most cases.")); + INSERT(Settings, vulkan_device, tr("Device:"), + tr("This setting selects the GPU to use with the Vulkan backend.")); + INSERT(Settings, shader_backend, tr("Shader Backend:"), + tr("The shader backend to use for the OpenGL renderer.\nGLSL is the fastest in " + "performance and the best in rendering accuracy.\n" + "GLASM is a deprecated NVIDIA-only backend that offers much better shader building " + "performance at the cost of FPS and rendering accuracy.\n" + "SPIR-V compiles the fastest, but yields poor results on most GPU drivers.")); + INSERT(Settings, resolution_setup, tr("Resolution:"), + tr("Forces the game to render at a different resolution.\nHigher resolutions require " + "much more VRAM and bandwidth.\n" + "Options lower than 1X can cause rendering issues.")); + INSERT(Settings, scaling_filter, tr("Window Adapting Filter:"), QStringLiteral()); + INSERT(Settings, fsr_sharpening_slider, tr("FSR Sharpness:"), + tr("Determines how sharpened the image will look while using FSR’s dynamic contrast.")); + INSERT(Settings, anti_aliasing, tr("Anti-Aliasing Method:"), + tr("The anti-aliasing method to use.\nSMAA offers the best quality.\nFXAA has a " + "lower performance impact and can produce a better and more stable picture under " + "very low resolutions.")); + INSERT(Settings, fullscreen_mode, tr("Fullscreen Mode:"), + tr("The method used to render the window in fullscreen.\nBorderless offers the best " + "compatibility with the on-screen keyboard that some games request for " + "input.\nExclusive " + "fullscreen may offer better performance and better Freesync/Gsync support.")); + INSERT(Settings, aspect_ratio, tr("Aspect Ratio:"), + tr("Stretches the game to fit the specified aspect ratio.\nSwitch games only support " + "16:9, so custom game mods are required to get other ratios.\nAlso controls the " + "aspect ratio of captured screenshots.")); + INSERT(Settings, use_disk_shader_cache, tr("Use disk pipeline cache"), + tr("Allows saving shaders to storage for faster loading on following game " + "boots.\nDisabling " + "it is only intended for debugging.")); + INSERT( + Settings, use_asynchronous_gpu_emulation, tr("Use asynchronous GPU emulation"), + tr("Uses an extra CPU thread for rendering.\nThis option should always remain enabled.")); + INSERT(Settings, nvdec_emulation, tr("NVDEC emulation:"), + tr("Specifies how videos should be decoded.\nIt can either use the CPU or the GPU for " + "decoding, or perform no decoding at all (black screen on videos).\n" + "In most cases, GPU decoding provides the best performance.")); + INSERT(Settings, accelerate_astc, tr("ASTC Decoding Method:"), + tr("This option controls how ASTC textures should be decoded.\n" + "CPU: Use the CPU for decoding, slowest but safest method.\n" + "GPU: Use the GPU's compute shaders to decode ASTC textures, recommended for most " + "games and users.\n" + "CPU Asynchronously: Use the CPU to decode ASTC textures as they arrive. Completely " + "eliminates ASTC decoding\nstuttering at the cost of rendering issues while the " + "texture is being decoded.")); + INSERT( + Settings, astc_recompression, tr("ASTC Recompression Method:"), + tr("Almost all desktop and laptop dedicated GPUs lack support for ASTC textures, forcing " + "the emulator to decompress to an intermediate format any card supports, RGBA8.\n" + "This option recompresses RGBA8 to either the BC1 or BC3 format, saving VRAM but " + "negatively affecting image quality.")); + INSERT(Settings, vram_usage_mode, tr("VRAM Usage Mode:"), + tr("Selects whether the emulator should prefer to conserve memory or make maximum usage " + "of available video memory for performance. Has no effect on integrated graphics. " + "Aggressive mode may severely impact the performance of other applications such as " + "recording software.")); + INSERT( + Settings, vsync_mode, tr("VSync Mode:"), + tr("FIFO (VSync) does not drop frames or exhibit tearing but is limited by the screen " + "refresh rate.\nFIFO Relaxed is similar to FIFO but allows tearing as it recovers from " + "a slow down.\nMailbox can have lower latency than FIFO and does not tear but may drop " + "frames.\nImmediate (no synchronization) just presents whatever is available and can " + "exhibit tearing.")); + INSERT(Settings, bg_red, QStringLiteral(), QStringLiteral()); + INSERT(Settings, bg_green, QStringLiteral(), QStringLiteral()); + INSERT(Settings, bg_blue, QStringLiteral(), QStringLiteral()); + + // Renderer (Advanced Graphics) + INSERT(Settings, async_presentation, tr("Enable asynchronous presentation (Vulkan only)"), + tr("Slightly improves performance by moving presentation to a separate CPU thread.")); + INSERT( + Settings, renderer_force_max_clock, tr("Force maximum clocks (Vulkan only)"), + tr("Runs work in the background while waiting for graphics commands to keep the GPU from " + "lowering its clock speed.")); + INSERT(Settings, max_anisotropy, tr("Anisotropic Filtering:"), + tr("Controls the quality of texture rendering at oblique angles.\nIt’s a light setting " + "and safe to set at 16x on most GPUs.")); + INSERT(Settings, gpu_accuracy, tr("Accuracy Level:"), + tr("GPU emulation accuracy.\nMost games render fine with Normal, but High is still " + "required for some.\nParticles tend to only render correctly with High " + "accuracy.\nExtreme should only be used for debugging.\nThis option can " + "be changed while playing.\nSome games may require booting on high to render " + "properly.")); + INSERT(Settings, use_asynchronous_shaders, tr("Use asynchronous shader building (Hack)"), + tr("Enables asynchronous shader compilation, which may reduce shader stutter.\nThis " + "feature " + "is experimental.")); + INSERT(Settings, use_fast_gpu_time, tr("Use Fast GPU Time (Hack)"), + tr("Enables Fast GPU Time. This option will force most games to run at their highest " + "native resolution.")); + INSERT(Settings, use_vulkan_driver_pipeline_cache, tr("Use Vulkan pipeline cache"), + tr("Enables GPU vendor-specific pipeline cache.\nThis option can improve shader loading " + "time significantly in cases where the Vulkan driver does not store pipeline cache " + "files internally.")); + INSERT( + Settings, enable_compute_pipelines, tr("Enable Compute Pipelines (Intel Vulkan Only)"), + tr("Enable compute pipelines, required by some games.\nThis setting only exists for Intel " + "proprietary drivers, and may crash if enabled.\nCompute pipelines are always enabled " + "on all other drivers.")); + INSERT( + Settings, use_reactive_flushing, tr("Enable Reactive Flushing"), + tr("Uses reactive flushing instead of predictive flushing, allowing more accurate memory " + "syncing.")); + INSERT(Settings, use_video_framerate, tr("Sync to framerate of video playback"), + tr("Run the game at normal speed during video playback, even when the framerate is " + "unlocked.")); + INSERT(Settings, barrier_feedback_loops, tr("Barrier feedback loops"), + tr("Improves rendering of transparency effects in specific games.")); + + // Renderer (Debug) + + // System + INSERT(Settings, rng_seed, tr("RNG Seed"), + tr("Controls the seed of the random number generator.\nMainly used for speedrunning " + "purposes.")); + INSERT(Settings, rng_seed_enabled, QStringLiteral(), QStringLiteral()); + INSERT(Settings, device_name, tr("Device Name"), tr("The name of the emulated Switch.")); + INSERT(Settings, custom_rtc, tr("Custom RTC Date:"), + tr("This option allows to change the emulated clock of the Switch.\n" + "Can be used to manipulate time in games.")); + INSERT(Settings, custom_rtc_enabled, QStringLiteral(), QStringLiteral()); + INSERT(Settings, custom_rtc_offset, QStringLiteral(" "), + QStringLiteral("The number of seconds from the current unix time")); + INSERT(Settings, language_index, tr("Language:"), + tr("Note: this can be overridden when region setting is auto-select")); + INSERT(Settings, region_index, tr("Region:"), tr("The region of the emulated Switch.")); + INSERT(Settings, time_zone_index, tr("Time Zone:"), + tr("The time zone of the emulated Switch.")); + INSERT(Settings, sound_index, tr("Sound Output Mode:"), QStringLiteral()); + INSERT(Settings, use_docked_mode, tr("Console Mode:"), + tr("Selects if the console is emulated in Docked or Handheld mode.\nGames will change " + "their resolution, details and supported controllers and depending on this setting.\n" + "Setting to Handheld can help improve performance for low end systems.")); + INSERT(Settings, current_user, QStringLiteral(), QStringLiteral()); + + // Controls + + // Data Storage + + // Debugging + + // Debugging Graphics + + // Network + + // Web Service + + // Ui + + // Ui General + INSERT(UISettings, select_user_on_boot, tr("Prompt for user on game boot"), + tr("Ask to select a user profile on each boot, useful if multiple people use sudachi on " + "the same PC.")); + INSERT(UISettings, pause_when_in_background, tr("Pause emulation when in background"), + tr("This setting pauses sudachi when focusing other windows.")); + INSERT(UISettings, confirm_before_stopping, tr("Confirm before stopping emulation"), + tr("This setting overrides game prompts asking to confirm stopping the game.\nEnabling " + "it bypasses such prompts and directly exits the emulation.")); + INSERT(UISettings, hide_mouse, tr("Hide mouse on inactivity"), + tr("This setting hides the mouse after 2.5s of inactivity.")); + INSERT(UISettings, controller_applet_disabled, tr("Disable controller applet"), + tr("Forcibly disables the use of the controller applet by guests.\nWhen a guest " + "attempts to open the controller applet, it is immediately closed.")); + + // Linux + INSERT(Settings, enable_gamemode, tr("Enable Gamemode"), QStringLiteral()); + + // Ui Debugging + + // Ui Multiplayer + + // Ui Games list + +#undef INSERT + + return translations; +} + +std::unique_ptr ComboboxEnumeration(QWidget* parent) { + std::unique_ptr translations = + std::make_unique(); + const auto& tr = [&](const char* text, const char* context = "") { + return parent->tr(text, context); + }; + +#define PAIR(ENUM, VALUE, TRANSLATION) {static_cast(Settings::ENUM::VALUE), (TRANSLATION)} + + // Intentionally skipping VSyncMode to let the UI fill that one out + translations->insert({Settings::EnumMetadata::Index(), + { + PAIR(AppletMode, HLE, tr("Custom frontend")), + PAIR(AppletMode, LLE, tr("Real applet")), + }}); + + translations->insert({Settings::EnumMetadata::Index(), + { + PAIR(AstcDecodeMode, Cpu, tr("CPU")), + PAIR(AstcDecodeMode, Gpu, tr("GPU")), + PAIR(AstcDecodeMode, CpuAsynchronous, tr("CPU Asynchronous")), + }}); + translations->insert( + {Settings::EnumMetadata::Index(), + { + PAIR(AstcRecompression, Uncompressed, tr("Uncompressed (Best quality)")), + PAIR(AstcRecompression, Bc1, tr("BC1 (Low quality)")), + PAIR(AstcRecompression, Bc3, tr("BC3 (Medium quality)")), + }}); + translations->insert({Settings::EnumMetadata::Index(), + { + PAIR(VramUsageMode, Conservative, tr("Conservative")), + PAIR(VramUsageMode, Aggressive, tr("Aggressive")), + }}); + translations->insert({Settings::EnumMetadata::Index(), + { +#ifdef HAS_OPENGL + PAIR(RendererBackend, OpenGL, tr("OpenGL")), +#endif + PAIR(RendererBackend, Vulkan, tr("Vulkan")), + PAIR(RendererBackend, Null, tr("Null")), + }}); + translations->insert( + {Settings::EnumMetadata::Index(), + { + PAIR(ShaderBackend, Glsl, tr("GLSL")), + PAIR(ShaderBackend, Glasm, tr("GLASM (Assembly Shaders, NVIDIA Only)")), + PAIR(ShaderBackend, SpirV, tr("SPIR-V (Experimental, AMD/Mesa Only)")), + }}); + translations->insert({Settings::EnumMetadata::Index(), + { + PAIR(GpuAccuracy, Normal, tr("Normal")), + PAIR(GpuAccuracy, High, tr("High")), + PAIR(GpuAccuracy, Extreme, tr("Extreme")), + }}); + translations->insert( + {Settings::EnumMetadata::Index(), + { + PAIR(CpuAccuracy, Auto, tr("Auto")), + PAIR(CpuAccuracy, Accurate, tr("Accurate")), + PAIR(CpuAccuracy, Unsafe, tr("Unsafe")), + PAIR(CpuAccuracy, Paranoid, tr("Paranoid (disables most optimizations)")), + }}); + translations->insert({Settings::EnumMetadata::Index(), + { + PAIR(CpuBackend, Dynarmic, tr("Dynarmic")), + PAIR(CpuBackend, Nce, tr("NCE")), + }}); + translations->insert({Settings::EnumMetadata::Index(), + { + PAIR(FullscreenMode, Borderless, tr("Borderless Windowed")), + PAIR(FullscreenMode, Exclusive, tr("Exclusive Fullscreen")), + }}); + translations->insert({Settings::EnumMetadata::Index(), + { + PAIR(NvdecEmulation, Off, tr("No Video Output")), + PAIR(NvdecEmulation, Cpu, tr("CPU Video Decoding")), + PAIR(NvdecEmulation, Gpu, tr("GPU Video Decoding (Default)")), + }}); + translations->insert( + {Settings::EnumMetadata::Index(), + { + PAIR(ResolutionSetup, Res1_2X, tr("0.5X (360p/540p) [EXPERIMENTAL]")), + PAIR(ResolutionSetup, Res3_4X, tr("0.75X (540p/810p) [EXPERIMENTAL]")), + PAIR(ResolutionSetup, Res1X, tr("1X (720p/1080p)")), + PAIR(ResolutionSetup, Res3_2X, tr("1.5X (1080p/1620p) [EXPERIMENTAL]")), + PAIR(ResolutionSetup, Res2X, tr("2X (1440p/2160p)")), + PAIR(ResolutionSetup, Res3X, tr("3X (2160p/3240p)")), + PAIR(ResolutionSetup, Res4X, tr("4X (2880p/4320p)")), + PAIR(ResolutionSetup, Res5X, tr("5X (3600p/5400p)")), + PAIR(ResolutionSetup, Res6X, tr("6X (4320p/6480p)")), + PAIR(ResolutionSetup, Res7X, tr("7X (5040p/7560p)")), + PAIR(ResolutionSetup, Res8X, tr("8X (5760p/8640p)")), + }}); + translations->insert({Settings::EnumMetadata::Index(), + { + PAIR(ScalingFilter, NearestNeighbor, tr("Nearest Neighbor")), + PAIR(ScalingFilter, Bilinear, tr("Bilinear")), + PAIR(ScalingFilter, Bicubic, tr("Bicubic")), + PAIR(ScalingFilter, Gaussian, tr("Gaussian")), + PAIR(ScalingFilter, ScaleForce, tr("ScaleForce")), + PAIR(ScalingFilter, Fsr, tr("AMD FidelityFX™️ Super Resolution")), + }}); + translations->insert({Settings::EnumMetadata::Index(), + { + PAIR(AntiAliasing, None, tr("None")), + PAIR(AntiAliasing, Fxaa, tr("FXAA")), + PAIR(AntiAliasing, Smaa, tr("SMAA")), + }}); + translations->insert({Settings::EnumMetadata::Index(), + { + PAIR(AspectRatio, R16_9, tr("Default (16:9)")), + PAIR(AspectRatio, R4_3, tr("Force 4:3")), + PAIR(AspectRatio, R21_9, tr("Force 21:9")), + PAIR(AspectRatio, R16_10, tr("Force 16:10")), + PAIR(AspectRatio, Stretch, tr("Stretch to Window")), + }}); + translations->insert({Settings::EnumMetadata::Index(), + { + PAIR(AnisotropyMode, Automatic, tr("Automatic")), + PAIR(AnisotropyMode, Default, tr("Default")), + PAIR(AnisotropyMode, X2, tr("2x")), + PAIR(AnisotropyMode, X4, tr("4x")), + PAIR(AnisotropyMode, X8, tr("8x")), + PAIR(AnisotropyMode, X16, tr("16x")), + }}); + translations->insert( + {Settings::EnumMetadata::Index(), + { + PAIR(Language, Japanese, tr("Japanese (日本語)")), + PAIR(Language, EnglishAmerican, tr("American English")), + PAIR(Language, French, tr("French (français)")), + PAIR(Language, German, tr("German (Deutsch)")), + PAIR(Language, Italian, tr("Italian (italiano)")), + PAIR(Language, Spanish, tr("Spanish (español)")), + PAIR(Language, Chinese, tr("Chinese")), + PAIR(Language, Korean, tr("Korean (한국어)")), + PAIR(Language, Dutch, tr("Dutch (Nederlands)")), + PAIR(Language, Portuguese, tr("Portuguese (português)")), + PAIR(Language, Russian, tr("Russian (Русский)")), + PAIR(Language, Taiwanese, tr("Taiwanese")), + PAIR(Language, EnglishBritish, tr("British English")), + PAIR(Language, FrenchCanadian, tr("Canadian French")), + PAIR(Language, SpanishLatin, tr("Latin American Spanish")), + PAIR(Language, ChineseSimplified, tr("Simplified Chinese")), + PAIR(Language, ChineseTraditional, tr("Traditional Chinese (正體中文)")), + PAIR(Language, PortugueseBrazilian, tr("Brazilian Portuguese (português do Brasil)")), + }}); + translations->insert({Settings::EnumMetadata::Index(), + { + PAIR(Region, Japan, tr("Japan")), + PAIR(Region, Usa, tr("USA")), + PAIR(Region, Europe, tr("Europe")), + PAIR(Region, Australia, tr("Australia")), + PAIR(Region, China, tr("China")), + PAIR(Region, Korea, tr("Korea")), + PAIR(Region, Taiwan, tr("Taiwan")), + }}); + translations->insert( + {Settings::EnumMetadata::Index(), + { + {static_cast(Settings::TimeZone::Auto), + tr("Auto (%1)", "Auto select time zone") + .arg(QString::fromStdString( + Settings::GetTimeZoneString(Settings::TimeZone::Auto)))}, + {static_cast(Settings::TimeZone::Default), + tr("Default (%1)", "Default time zone") + .arg(QString::fromStdString(Common::TimeZone::GetDefaultTimeZone()))}, + PAIR(TimeZone, Cet, tr("CET")), + PAIR(TimeZone, Cst6Cdt, tr("CST6CDT")), + PAIR(TimeZone, Cuba, tr("Cuba")), + PAIR(TimeZone, Eet, tr("EET")), + PAIR(TimeZone, Egypt, tr("Egypt")), + PAIR(TimeZone, Eire, tr("Eire")), + PAIR(TimeZone, Est, tr("EST")), + PAIR(TimeZone, Est5Edt, tr("EST5EDT")), + PAIR(TimeZone, Gb, tr("GB")), + PAIR(TimeZone, GbEire, tr("GB-Eire")), + PAIR(TimeZone, Gmt, tr("GMT")), + PAIR(TimeZone, GmtPlusZero, tr("GMT+0")), + PAIR(TimeZone, GmtMinusZero, tr("GMT-0")), + PAIR(TimeZone, GmtZero, tr("GMT0")), + PAIR(TimeZone, Greenwich, tr("Greenwich")), + PAIR(TimeZone, Hongkong, tr("Hongkong")), + PAIR(TimeZone, Hst, tr("HST")), + PAIR(TimeZone, Iceland, tr("Iceland")), + PAIR(TimeZone, Iran, tr("Iran")), + PAIR(TimeZone, Israel, tr("Israel")), + PAIR(TimeZone, Jamaica, tr("Jamaica")), + PAIR(TimeZone, Japan, tr("Japan")), + PAIR(TimeZone, Kwajalein, tr("Kwajalein")), + PAIR(TimeZone, Libya, tr("Libya")), + PAIR(TimeZone, Met, tr("MET")), + PAIR(TimeZone, Mst, tr("MST")), + PAIR(TimeZone, Mst7Mdt, tr("MST7MDT")), + PAIR(TimeZone, Navajo, tr("Navajo")), + PAIR(TimeZone, Nz, tr("NZ")), + PAIR(TimeZone, NzChat, tr("NZ-CHAT")), + PAIR(TimeZone, Poland, tr("Poland")), + PAIR(TimeZone, Portugal, tr("Portugal")), + PAIR(TimeZone, Prc, tr("PRC")), + PAIR(TimeZone, Pst8Pdt, tr("PST8PDT")), + PAIR(TimeZone, Roc, tr("ROC")), + PAIR(TimeZone, Rok, tr("ROK")), + PAIR(TimeZone, Singapore, tr("Singapore")), + PAIR(TimeZone, Turkey, tr("Turkey")), + PAIR(TimeZone, Uct, tr("UCT")), + PAIR(TimeZone, Universal, tr("Universal")), + PAIR(TimeZone, Utc, tr("UTC")), + PAIR(TimeZone, WSu, tr("W-SU")), + PAIR(TimeZone, Wet, tr("WET")), + PAIR(TimeZone, Zulu, tr("Zulu")), + }}); + translations->insert({Settings::EnumMetadata::Index(), + { + PAIR(AudioMode, Mono, tr("Mono")), + PAIR(AudioMode, Stereo, tr("Stereo")), + PAIR(AudioMode, Surround, tr("Surround")), + }}); + translations->insert({Settings::EnumMetadata::Index(), + { + PAIR(MemoryLayout, Memory_4Gb, tr("4GB DRAM (Default)")), + PAIR(MemoryLayout, Memory_6Gb, tr("6GB DRAM (Unsafe)")), + PAIR(MemoryLayout, Memory_8Gb, tr("8GB DRAM (Unsafe)")), + }}); + translations->insert({Settings::EnumMetadata::Index(), + { + PAIR(ConsoleMode, Docked, tr("Docked")), + PAIR(ConsoleMode, Handheld, tr("Handheld")), + }}); + translations->insert( + {Settings::EnumMetadata::Index(), + { + PAIR(ConfirmStop, Ask_Always, tr("Always ask (Default)")), + PAIR(ConfirmStop, Ask_Based_On_Game, tr("Only if game specifies not to stop")), + PAIR(ConfirmStop, Ask_Never, tr("Never ask")), + }}); + +#undef PAIR +#undef CTX_PAIR + + return translations; +} +} // namespace ConfigurationShared diff --git a/src/sudachi/configuration/shared_translation.h b/src/sudachi/configuration/shared_translation.h new file mode 100644 index 0000000..65c4c72 --- /dev/null +++ b/src/sudachi/configuration/shared_translation.h @@ -0,0 +1,68 @@ +// SPDX-FileCopyrightText: Copyright 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include +#include +#include +#include +#include +#include +#include "common/common_types.h" +#include "common/settings.h" + +class QWidget; + +namespace ConfigurationShared { +using TranslationMap = std::map>; +using ComboboxTranslations = std::vector>; +using ComboboxTranslationMap = std::map; + +std::unique_ptr InitializeTranslations(QWidget* parent); + +std::unique_ptr ComboboxEnumeration(QWidget* parent); + +static const std::map anti_aliasing_texts_map = { + {Settings::AntiAliasing::None, QStringLiteral(QT_TRANSLATE_NOOP("GMainWindow", "None"))}, + {Settings::AntiAliasing::Fxaa, QStringLiteral(QT_TRANSLATE_NOOP("GMainWindow", "FXAA"))}, + {Settings::AntiAliasing::Smaa, QStringLiteral(QT_TRANSLATE_NOOP("GMainWindow", "SMAA"))}, +}; + +static const std::map scaling_filter_texts_map = { + {Settings::ScalingFilter::NearestNeighbor, + QStringLiteral(QT_TRANSLATE_NOOP("GMainWindow", "Nearest"))}, + {Settings::ScalingFilter::Bilinear, + QStringLiteral(QT_TRANSLATE_NOOP("GMainWindow", "Bilinear"))}, + {Settings::ScalingFilter::Bicubic, QStringLiteral(QT_TRANSLATE_NOOP("GMainWindow", "Bicubic"))}, + {Settings::ScalingFilter::Gaussian, + QStringLiteral(QT_TRANSLATE_NOOP("GMainWindow", "Gaussian"))}, + {Settings::ScalingFilter::ScaleForce, + QStringLiteral(QT_TRANSLATE_NOOP("GMainWindow", "ScaleForce"))}, + {Settings::ScalingFilter::Fsr, QStringLiteral(QT_TRANSLATE_NOOP("GMainWindow", "FSR"))}, +}; + +static const std::map use_docked_mode_texts_map = { + {Settings::ConsoleMode::Docked, QStringLiteral(QT_TRANSLATE_NOOP("GMainWindow", "Docked"))}, + {Settings::ConsoleMode::Handheld, QStringLiteral(QT_TRANSLATE_NOOP("GMainWindow", "Handheld"))}, +}; + +static const std::map gpu_accuracy_texts_map = { + {Settings::GpuAccuracy::Normal, QStringLiteral(QT_TRANSLATE_NOOP("GMainWindow", "Normal"))}, + {Settings::GpuAccuracy::High, QStringLiteral(QT_TRANSLATE_NOOP("GMainWindow", "High"))}, + {Settings::GpuAccuracy::Extreme, QStringLiteral(QT_TRANSLATE_NOOP("GMainWindow", "Extreme"))}, +}; + +static const std::map renderer_backend_texts_map = { + {Settings::RendererBackend::Vulkan, QStringLiteral(QT_TRANSLATE_NOOP("GMainWindow", "Vulkan"))}, + {Settings::RendererBackend::OpenGL, QStringLiteral(QT_TRANSLATE_NOOP("GMainWindow", "OpenGL"))}, + {Settings::RendererBackend::Null, QStringLiteral(QT_TRANSLATE_NOOP("GMainWindow", "Null"))}, +}; + +static const std::map shader_backend_texts_map = { + {Settings::ShaderBackend::Glsl, QStringLiteral(QT_TRANSLATE_NOOP("GMainWindow", "GLSL"))}, + {Settings::ShaderBackend::Glasm, QStringLiteral(QT_TRANSLATE_NOOP("GMainWindow", "GLASM"))}, + {Settings::ShaderBackend::SpirV, QStringLiteral(QT_TRANSLATE_NOOP("GMainWindow", "SPIRV"))}, +}; + +} // namespace ConfigurationShared diff --git a/src/sudachi/configuration/shared_widget.cpp b/src/sudachi/configuration/shared_widget.cpp new file mode 100644 index 0000000..6fd52f3 --- /dev/null +++ b/src/sudachi/configuration/shared_widget.cpp @@ -0,0 +1,802 @@ +// SPDX-FileCopyrightText: Copyright 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include "sudachi/configuration/shared_widget.h" + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "common/assert.h" +#include "common/common_types.h" +#include "common/logging/log.h" +#include "common/settings.h" +#include "common/settings_common.h" +#include "sudachi/configuration/shared_translation.h" + +namespace ConfigurationShared { + +static int restore_button_count = 0; + +static std::string RelevantDefault(const Settings::BasicSetting& setting) { + return Settings::IsConfiguringGlobal() ? setting.DefaultToString() : setting.ToStringGlobal(); +} + +static QString DefaultSuffix(QWidget* parent, Settings::BasicSetting& setting) { + const auto tr = [parent](const char* text, const char* context) { + return parent->tr(text, context); + }; + + if ((setting.Specialization() & Settings::SpecializationAttributeMask) == + Settings::Specialization::Percentage) { + std::string context{fmt::format("{} percentage (e.g. 50%)", setting.GetLabel())}; + return tr("%", context.c_str()); + } + + return default_suffix; +} + +QPushButton* Widget::CreateRestoreGlobalButton(bool using_global, QWidget* parent) { + restore_button_count++; + + QStyle* style = parent->style(); + QIcon* icon = new QIcon(style->standardIcon(QStyle::SP_LineEditClearButton)); + QPushButton* restore_button = new QPushButton(*icon, QStringLiteral(), parent); + restore_button->setObjectName(QStringLiteral("RestoreButton%1").arg(restore_button_count)); + restore_button->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Preferred); + + // Workaround for dark theme causing min-width to be much larger than 0 + restore_button->setStyleSheet( + QStringLiteral("QAbstractButton#%1 { min-width: 0px }").arg(restore_button->objectName())); + + QSizePolicy sp_retain = restore_button->sizePolicy(); + sp_retain.setRetainSizeWhenHidden(true); + restore_button->setSizePolicy(sp_retain); + + restore_button->setEnabled(!using_global); + restore_button->setVisible(!using_global); + + return restore_button; +} + +QLabel* Widget::CreateLabel(const QString& text) { + QLabel* qt_label = new QLabel(text, this->parent); + qt_label->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Preferred); + return qt_label; +} + +QWidget* Widget::CreateCheckBox(Settings::BasicSetting* bool_setting, const QString& label, + std::function& serializer, + std::function& restore_func, + const std::function& touch) { + checkbox = new QCheckBox(label, this); + checkbox->setCheckState(bool_setting->ToString() == "true" ? Qt::CheckState::Checked + : Qt::CheckState::Unchecked); + checkbox->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Preferred); + + if (!bool_setting->Save() && !Settings::IsConfiguringGlobal() && runtime_lock) { + checkbox->setEnabled(false); + } + + serializer = [this]() { + return checkbox->checkState() == Qt::CheckState::Checked ? "true" : "false"; + }; + + restore_func = [this, bool_setting]() { + checkbox->setCheckState(RelevantDefault(*bool_setting) == "true" ? Qt::Checked + : Qt::Unchecked); + }; + + if (!Settings::IsConfiguringGlobal()) { + QObject::connect(checkbox, &QCheckBox::clicked, [touch]() { touch(); }); + } + + return checkbox; +} + +QWidget* Widget::CreateCombobox(std::function& serializer, + std::function& restore_func, + const std::function& touch) { + const auto type = setting.EnumIndex(); + + combobox = new QComboBox(this); + combobox->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Preferred); + + const ComboboxTranslations* enumeration{nullptr}; + if (combobox_enumerations.contains(type)) { + enumeration = &combobox_enumerations.at(type); + for (const auto& [id, name] : *enumeration) { + combobox->addItem(name); + } + } else { + return combobox; + } + + const auto find_index = [=](u32 value) -> int { + for (u32 i = 0; i < enumeration->size(); i++) { + if (enumeration->at(i).first == value) { + return i; + } + } + return -1; + }; + + const u32 setting_value = std::strtoul(setting.ToString().c_str(), nullptr, 0); + combobox->setCurrentIndex(find_index(setting_value)); + + serializer = [this, enumeration]() { + int current = combobox->currentIndex(); + return std::to_string(enumeration->at(current).first); + }; + + restore_func = [this, find_index]() { + const u32 global_value = std::strtoul(RelevantDefault(setting).c_str(), nullptr, 0); + combobox->setCurrentIndex(find_index(global_value)); + }; + + if (!Settings::IsConfiguringGlobal()) { + QObject::connect(combobox, QOverload::of(&QComboBox::activated), + [touch]() { touch(); }); + } + + return combobox; +} + +QWidget* Widget::CreateRadioGroup(std::function& serializer, + std::function& restore_func, + const std::function& touch) { + const auto type = setting.EnumIndex(); + + QWidget* group = new QWidget(this); + QHBoxLayout* layout = new QHBoxLayout(group); + layout->setContentsMargins(0, 0, 0, 0); + group->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Preferred); + + const ComboboxTranslations* enumeration{nullptr}; + if (combobox_enumerations.contains(type)) { + enumeration = &combobox_enumerations.at(type); + for (const auto& [id, name] : *enumeration) { + QRadioButton* radio_button = new QRadioButton(name, group); + layout->addWidget(radio_button); + radio_buttons.push_back({id, radio_button}); + } + } else { + return group; + } + + const auto get_selected = [this]() -> int { + for (const auto& [id, button] : radio_buttons) { + if (button->isChecked()) { + return id; + } + } + return -1; + }; + + const auto set_index = [this](u32 value) { + for (const auto& [id, button] : radio_buttons) { + button->setChecked(id == value); + } + }; + + const u32 setting_value = std::strtoul(setting.ToString().c_str(), nullptr, 0); + set_index(setting_value); + + serializer = [get_selected]() { + int current = get_selected(); + return std::to_string(current); + }; + + restore_func = [this, set_index]() { + const u32 global_value = std::strtoul(RelevantDefault(setting).c_str(), nullptr, 0); + set_index(global_value); + }; + + if (!Settings::IsConfiguringGlobal()) { + for (const auto& [id, button] : radio_buttons) { + QObject::connect(button, &QAbstractButton::clicked, [touch]() { touch(); }); + } + } + + return group; +} + +QWidget* Widget::CreateLineEdit(std::function& serializer, + std::function& restore_func, + const std::function& touch, bool managed) { + const QString text = QString::fromStdString(setting.ToString()); + line_edit = new QLineEdit(this); + line_edit->setText(text); + + serializer = [this]() { return line_edit->text().toStdString(); }; + + if (!managed) { + return line_edit; + } + + restore_func = [this]() { + line_edit->setText(QString::fromStdString(RelevantDefault(setting))); + }; + + if (!Settings::IsConfiguringGlobal()) { + QObject::connect(line_edit, &QLineEdit::textChanged, [touch]() { touch(); }); + } + + return line_edit; +} + +static void CreateIntSlider(Settings::BasicSetting& setting, bool reversed, float multiplier, + QLabel* feedback, const QString& use_format, QSlider* slider, + std::function& serializer, + std::function& restore_func) { + const int max_val = std::strtol(setting.MaxVal().c_str(), nullptr, 0); + + const auto update_feedback = [=](int value) { + int present = (reversed ? max_val - value : value) * multiplier + 0.5f; + feedback->setText(use_format.arg(QVariant::fromValue(present).value())); + }; + + QObject::connect(slider, &QAbstractSlider::valueChanged, update_feedback); + update_feedback(std::strtol(setting.ToString().c_str(), nullptr, 0)); + + slider->setMinimum(std::strtol(setting.MinVal().c_str(), nullptr, 0)); + slider->setMaximum(max_val); + slider->setValue(std::strtol(setting.ToString().c_str(), nullptr, 0)); + + serializer = [slider]() { return std::to_string(slider->value()); }; + restore_func = [slider, &setting]() { + slider->setValue(std::strtol(RelevantDefault(setting).c_str(), nullptr, 0)); + }; +} + +static void CreateFloatSlider(Settings::BasicSetting& setting, bool reversed, float multiplier, + QLabel* feedback, const QString& use_format, QSlider* slider, + std::function& serializer, + std::function& restore_func) { + const float max_val = std::strtof(setting.MaxVal().c_str(), nullptr); + const float min_val = std::strtof(setting.MinVal().c_str(), nullptr); + const float use_multiplier = + multiplier == default_multiplier ? default_float_multiplier : multiplier; + + const auto update_feedback = [=](float value) { + int present = (reversed ? max_val - value : value) + 0.5f; + feedback->setText(use_format.arg(QVariant::fromValue(present).value())); + }; + + QObject::connect(slider, &QAbstractSlider::valueChanged, update_feedback); + update_feedback(std::strtof(setting.ToString().c_str(), nullptr)); + + slider->setMinimum(min_val * use_multiplier); + slider->setMaximum(max_val * use_multiplier); + slider->setValue(std::strtof(setting.ToString().c_str(), nullptr) * use_multiplier); + + serializer = [slider, use_multiplier]() { + return std::to_string(slider->value() / use_multiplier); + }; + restore_func = [slider, &setting, use_multiplier]() { + slider->setValue(std::strtof(RelevantDefault(setting).c_str(), nullptr) * use_multiplier); + }; +} + +QWidget* Widget::CreateSlider(bool reversed, float multiplier, const QString& given_suffix, + std::function& serializer, + std::function& restore_func, + const std::function& touch) { + if (!setting.Ranged()) { + LOG_ERROR(Frontend, "\"{}\" is not a ranged setting, but a slider was requested.", + setting.GetLabel()); + return nullptr; + } + + QWidget* container = new QWidget(this); + QHBoxLayout* layout = new QHBoxLayout(container); + + slider = new QSlider(Qt::Horizontal, this); + QLabel* feedback = new QLabel(this); + + layout->addWidget(slider); + layout->addWidget(feedback); + + container->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Preferred); + + layout->setContentsMargins(0, 0, 0, 0); + + QString suffix = given_suffix == default_suffix ? DefaultSuffix(this, setting) : given_suffix; + + const QString use_format = QStringLiteral("%1").append(suffix); + + if (setting.IsIntegral()) { + CreateIntSlider(setting, reversed, multiplier, feedback, use_format, slider, serializer, + restore_func); + } else { + CreateFloatSlider(setting, reversed, multiplier, feedback, use_format, slider, serializer, + restore_func); + } + + slider->setInvertedAppearance(reversed); + + if (!Settings::IsConfiguringGlobal()) { + QObject::connect(slider, &QAbstractSlider::actionTriggered, [touch]() { touch(); }); + } + + return container; +} + +QWidget* Widget::CreateSpinBox(const QString& given_suffix, + std::function& serializer, + std::function& restore_func, + const std::function& touch) { + const auto min_val = std::strtol(setting.MinVal().c_str(), nullptr, 0); + const auto max_val = std::strtol(setting.MaxVal().c_str(), nullptr, 0); + const auto default_val = std::strtol(setting.ToString().c_str(), nullptr, 0); + + QString suffix = given_suffix == default_suffix ? DefaultSuffix(this, setting) : given_suffix; + + spinbox = new QSpinBox(this); + spinbox->setRange(min_val, max_val); + spinbox->setValue(default_val); + spinbox->setSuffix(suffix); + spinbox->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Preferred); + + serializer = [this]() { return std::to_string(spinbox->value()); }; + + restore_func = [this]() { + auto value{std::strtol(RelevantDefault(setting).c_str(), nullptr, 0)}; + spinbox->setValue(value); + }; + + if (!Settings::IsConfiguringGlobal()) { + QObject::connect(spinbox, QOverload::of(&QSpinBox::valueChanged), [this, touch]() { + if (spinbox->value() != std::strtol(setting.ToStringGlobal().c_str(), nullptr, 0)) { + touch(); + } + }); + } + + return spinbox; +} + +QWidget* Widget::CreateDoubleSpinBox(const QString& given_suffix, + std::function& serializer, + std::function& restore_func, + const std::function& touch) { + const auto min_val = std::strtod(setting.MinVal().c_str(), nullptr); + const auto max_val = std::strtod(setting.MaxVal().c_str(), nullptr); + const auto default_val = std::strtod(setting.ToString().c_str(), nullptr); + + QString suffix = given_suffix == default_suffix ? DefaultSuffix(this, setting) : given_suffix; + + double_spinbox = new QDoubleSpinBox(this); + double_spinbox->setRange(min_val, max_val); + double_spinbox->setValue(default_val); + double_spinbox->setSuffix(suffix); + double_spinbox->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Preferred); + + serializer = [this]() { return fmt::format("{:f}", double_spinbox->value()); }; + + restore_func = [this]() { + auto value{std::strtod(RelevantDefault(setting).c_str(), nullptr)}; + double_spinbox->setValue(value); + }; + + if (!Settings::IsConfiguringGlobal()) { + QObject::connect(double_spinbox, QOverload::of(&QDoubleSpinBox::valueChanged), + [this, touch]() { + if (double_spinbox->value() != + std::strtod(setting.ToStringGlobal().c_str(), nullptr)) { + touch(); + } + }); + } + + return double_spinbox; +} + +QWidget* Widget::CreateHexEdit(std::function& serializer, + std::function& restore_func, + const std::function& touch) { + auto* data_component = CreateLineEdit(serializer, restore_func, touch, false); + if (data_component == nullptr) { + return nullptr; + } + + auto to_hex = [=](const std::string& input) { + return QString::fromStdString( + fmt::format("{:08x}", std::strtoul(input.c_str(), nullptr, 0))); + }; + + QRegularExpressionValidator* regex = new QRegularExpressionValidator( + QRegularExpression{QStringLiteral("^[0-9a-fA-F]{0,8}$")}, line_edit); + + const QString default_val = to_hex(setting.ToString()); + + line_edit->setText(default_val); + line_edit->setMaxLength(8); + line_edit->setValidator(regex); + + auto hex_to_dec = [this]() -> std::string { + return std::to_string(std::strtoul(line_edit->text().toStdString().c_str(), nullptr, 16)); + }; + + serializer = [hex_to_dec]() { return hex_to_dec(); }; + + restore_func = [this, to_hex]() { line_edit->setText(to_hex(RelevantDefault(setting))); }; + + if (!Settings::IsConfiguringGlobal()) { + + QObject::connect(line_edit, &QLineEdit::textChanged, [touch]() { touch(); }); + } + + return line_edit; +} + +QWidget* Widget::CreateDateTimeEdit(bool disabled, bool restrict, + std::function& serializer, + std::function& restore_func, + const std::function& touch) { + const long long current_time = QDateTime::currentSecsSinceEpoch(); + const s64 the_time = + disabled ? current_time : std::strtoll(setting.ToString().c_str(), nullptr, 0); + const auto default_val = QDateTime::fromSecsSinceEpoch(the_time); + + date_time_edit = new QDateTimeEdit(this); + date_time_edit->setDateTime(default_val); + date_time_edit->setMinimumDateTime(QDateTime::fromSecsSinceEpoch(0)); + date_time_edit->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Preferred); + + serializer = [this]() { return std::to_string(date_time_edit->dateTime().toSecsSinceEpoch()); }; + + auto get_clear_val = [this, restrict, current_time]() { + return QDateTime::fromSecsSinceEpoch([this, restrict, current_time]() { + if (restrict && checkbox->checkState() == Qt::Checked) { + return std::strtoll(RelevantDefault(setting).c_str(), nullptr, 0); + } + return current_time; + }()); + }; + + restore_func = [this, get_clear_val]() { date_time_edit->setDateTime(get_clear_val()); }; + + if (!Settings::IsConfiguringGlobal()) { + QObject::connect(date_time_edit, &QDateTimeEdit::editingFinished, + [this, get_clear_val, touch]() { + if (date_time_edit->dateTime() != get_clear_val()) { + touch(); + } + }); + } + + return date_time_edit; +} + +void Widget::SetupComponent(const QString& label, std::function& load_func, bool managed, + RequestType request, float multiplier, + Settings::BasicSetting* other_setting, const QString& suffix) { + created = true; + const auto type = setting.TypeId(); + + QLayout* layout = new QHBoxLayout(this); + layout->setContentsMargins(0, 0, 0, 0); + + if (other_setting == nullptr) { + other_setting = setting.PairedSetting(); + } + + const bool require_checkbox = + other_setting != nullptr && other_setting->TypeId() == typeid(bool); + + if (other_setting != nullptr && other_setting->TypeId() != typeid(bool)) { + LOG_WARNING( + Frontend, + "Extra setting \"{}\" specified but is not bool, refusing to create checkbox for it.", + other_setting->GetLabel()); + } + + std::function checkbox_serializer = []() -> std::string { return {}; }; + std::function checkbox_restore_func = []() {}; + + std::function touch = []() {}; + std::function serializer = []() -> std::string { return {}; }; + std::function restore_func = []() {}; + + QWidget* data_component{nullptr}; + + request = [&]() { + if (request != RequestType::Default) { + return request; + } + switch (setting.Specialization() & Settings::SpecializationTypeMask) { + case Settings::Specialization::Default: + return RequestType::Default; + case Settings::Specialization::Time: + return RequestType::DateTimeEdit; + case Settings::Specialization::Hex: + return RequestType::HexEdit; + case Settings::Specialization::RuntimeList: + managed = false; + [[fallthrough]]; + case Settings::Specialization::List: + return RequestType::ComboBox; + case Settings::Specialization::Scalar: + return RequestType::Slider; + case Settings::Specialization::Countable: + return RequestType::SpinBox; + case Settings::Specialization::Radio: + return RequestType::RadioGroup; + default: + break; + } + return request; + }(); + + if (!Settings::IsConfiguringGlobal() && managed) { + restore_button = CreateRestoreGlobalButton(setting.UsingGlobal(), this); + + touch = [this]() { + LOG_DEBUG(Frontend, "Enabling custom setting for \"{}\"", setting.GetLabel()); + restore_button->setEnabled(true); + restore_button->setVisible(true); + }; + } + + if (require_checkbox) { + QWidget* lhs = + CreateCheckBox(other_setting, label, checkbox_serializer, checkbox_restore_func, touch); + layout->addWidget(lhs); + } else if (setting.TypeId() != typeid(bool)) { + QLabel* qt_label = CreateLabel(label); + layout->addWidget(qt_label); + } + + if (setting.TypeId() == typeid(bool)) { + data_component = CreateCheckBox(&setting, label, serializer, restore_func, touch); + } else if (setting.IsEnum()) { + if (request == RequestType::RadioGroup) { + data_component = CreateRadioGroup(serializer, restore_func, touch); + } else { + data_component = CreateCombobox(serializer, restore_func, touch); + } + } else if (setting.IsIntegral()) { + switch (request) { + case RequestType::Slider: + case RequestType::ReverseSlider: + data_component = CreateSlider(request == RequestType::ReverseSlider, multiplier, suffix, + serializer, restore_func, touch); + break; + case RequestType::Default: + case RequestType::LineEdit: + data_component = CreateLineEdit(serializer, restore_func, touch); + break; + case RequestType::DateTimeEdit: + data_component = CreateDateTimeEdit(other_setting->ToString() != "true", true, + serializer, restore_func, touch); + break; + case RequestType::SpinBox: + data_component = CreateSpinBox(suffix, serializer, restore_func, touch); + break; + case RequestType::HexEdit: + data_component = CreateHexEdit(serializer, restore_func, touch); + break; + case RequestType::ComboBox: + data_component = CreateCombobox(serializer, restore_func, touch); + break; + default: + UNIMPLEMENTED(); + } + } else if (setting.IsFloatingPoint()) { + switch (request) { + case RequestType::Default: + case RequestType::SpinBox: + data_component = CreateDoubleSpinBox(suffix, serializer, restore_func, touch); + break; + case RequestType::Slider: + case RequestType::ReverseSlider: + data_component = CreateSlider(request == RequestType::ReverseSlider, multiplier, suffix, + serializer, restore_func, touch); + break; + default: + UNIMPLEMENTED(); + } + } else if (type == typeid(std::string)) { + switch (request) { + case RequestType::Default: + case RequestType::LineEdit: + data_component = CreateLineEdit(serializer, restore_func, touch); + break; + case RequestType::ComboBox: + data_component = CreateCombobox(serializer, restore_func, touch); + break; + default: + UNIMPLEMENTED(); + } + } + + if (data_component == nullptr) { + LOG_ERROR(Frontend, "Failed to create widget for \"{}\"", setting.GetLabel()); + created = false; + return; + } + + layout->addWidget(data_component); + + if (!managed) { + return; + } + + if (Settings::IsConfiguringGlobal()) { + load_func = [this, serializer, checkbox_serializer, require_checkbox, other_setting]() { + if (require_checkbox && other_setting->UsingGlobal()) { + other_setting->LoadString(checkbox_serializer()); + } + if (setting.UsingGlobal()) { + setting.LoadString(serializer()); + } + }; + } else { + layout->addWidget(restore_button); + + QObject::connect(restore_button, &QAbstractButton::clicked, + [this, restore_func, checkbox_restore_func](bool) { + LOG_DEBUG(Frontend, "Restore global state for \"{}\"", + setting.GetLabel()); + + restore_button->setEnabled(false); + restore_button->setVisible(false); + + checkbox_restore_func(); + restore_func(); + }); + + load_func = [this, serializer, require_checkbox, checkbox_serializer, other_setting]() { + bool using_global = !restore_button->isEnabled(); + setting.SetGlobal(using_global); + if (!using_global) { + setting.LoadString(serializer()); + } + if (require_checkbox) { + other_setting->SetGlobal(using_global); + if (!using_global) { + other_setting->LoadString(checkbox_serializer()); + } + } + }; + } + + if (other_setting != nullptr) { + const auto reset = [restore_func, data_component](int state) { + data_component->setEnabled(state == Qt::Checked); + if (state != Qt::Checked) { + restore_func(); + } + }; + connect(checkbox, &QCheckBox::stateChanged, reset); + reset(checkbox->checkState()); + } +} + +bool Widget::Valid() const { + return created; +} + +Widget::~Widget() = default; + +Widget::Widget(Settings::BasicSetting* setting_, const TranslationMap& translations_, + const ComboboxTranslationMap& combobox_translations_, QWidget* parent_, + bool runtime_lock_, std::vector>& apply_funcs_, + RequestType request, bool managed, float multiplier, + Settings::BasicSetting* other_setting, const QString& suffix) + : QWidget(parent_), parent{parent_}, translations{translations_}, + combobox_enumerations{combobox_translations_}, setting{*setting_}, apply_funcs{apply_funcs_}, + runtime_lock{runtime_lock_} { + if (!Settings::IsConfiguringGlobal() && !setting.Switchable()) { + LOG_DEBUG(Frontend, "\"{}\" is not switchable, skipping...", setting.GetLabel()); + return; + } + + const int id = setting.Id(); + + const auto [label, tooltip] = [&]() { + const auto& setting_label = setting.GetLabel(); + if (translations.contains(id)) { + return std::pair{translations.at(id).first, translations.at(id).second}; + } + LOG_WARNING(Frontend, "Translation table lacks entry for \"{}\"", setting_label); + return std::pair{QString::fromStdString(setting_label), QStringLiteral()}; + }(); + + if (label == QStringLiteral()) { + LOG_DEBUG(Frontend, "Translation table has empty entry for \"{}\", skipping...", + setting.GetLabel()); + return; + } + + std::function load_func = []() {}; + + SetupComponent(label, load_func, managed, request, multiplier, other_setting, suffix); + + if (!created) { + LOG_WARNING(Frontend, "No widget was created for \"{}\"", setting.GetLabel()); + return; + } + + apply_funcs.push_back([load_func, setting_](bool powered_on) { + if (setting_->RuntimeModifiable() || !powered_on) { + load_func(); + } + }); + + bool enable = runtime_lock || setting.RuntimeModifiable(); + if (setting.Switchable() && Settings::IsConfiguringGlobal() && !runtime_lock) { + enable &= setting.UsingGlobal(); + } + this->setEnabled(enable); + + this->setToolTip(tooltip); +} + +Builder::Builder(QWidget* parent_, bool runtime_lock_) + : translations{InitializeTranslations(parent_)}, + combobox_translations{ComboboxEnumeration(parent_)}, parent{parent_}, runtime_lock{ + runtime_lock_} {} + +Builder::~Builder() = default; + +Widget* Builder::BuildWidget(Settings::BasicSetting* setting, + std::vector>& apply_funcs, + RequestType request, bool managed, float multiplier, + Settings::BasicSetting* other_setting, const QString& suffix) const { + if (!Settings::IsConfiguringGlobal() && !setting->Switchable()) { + return nullptr; + } + + if (setting->Specialization() == Settings::Specialization::Paired) { + LOG_DEBUG(Frontend, "\"{}\" has specialization Paired: ignoring", setting->GetLabel()); + return nullptr; + } + + return new Widget(setting, *translations, *combobox_translations, parent, runtime_lock, + apply_funcs, request, managed, multiplier, other_setting, suffix); +} + +Widget* Builder::BuildWidget(Settings::BasicSetting* setting, + std::vector>& apply_funcs, + Settings::BasicSetting* other_setting, RequestType request, + const QString& suffix) const { + return BuildWidget(setting, apply_funcs, request, true, 1.0f, other_setting, suffix); +} + +const ComboboxTranslationMap& Builder::ComboboxTranslations() const { + return *combobox_translations; +} + +} // namespace ConfigurationShared diff --git a/src/sudachi/configuration/shared_widget.h b/src/sudachi/configuration/shared_widget.h new file mode 100644 index 0000000..bd9fe15 --- /dev/null +++ b/src/sudachi/configuration/shared_widget.h @@ -0,0 +1,178 @@ +// SPDX-FileCopyrightText: Copyright 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include "sudachi/configuration/shared_translation.h" + +class QCheckBox; +class QComboBox; +class QDateTimeEdit; +class QLabel; +class QLineEdit; +class QObject; +class QPushButton; +class QSlider; +class QSpinBox; +class QDoubleSpinBox; +class QRadioButton; + +namespace Settings { +class BasicSetting; +} // namespace Settings + +namespace ConfigurationShared { + +enum class RequestType { + Default, + ComboBox, + SpinBox, + Slider, + ReverseSlider, + LineEdit, + HexEdit, + DateTimeEdit, + RadioGroup, + MaxEnum, +}; + +constexpr float default_multiplier{1.f}; +constexpr float default_float_multiplier{100.f}; +static const QString default_suffix = QStringLiteral(); + +class Widget : public QWidget { + Q_OBJECT + +public: + /** + * @param setting The primary Setting to create the Widget for + * @param translations Map of translations to display on the left side label/checkbox + * @param combobox_translations Map of translations for enumerating combo boxes + * @param parent Qt parent + * @param runtime_lock Emulated guest powered on state, for use on settings that should be + * configured during guest execution + * @param apply_funcs_ List to append, functions to run to apply the widget state to the setting + * @param request What type of data representation component to create -- not always respected + * for the Setting data type + * @param managed Set true if the caller will set up component data and handling + * @param multiplier Value to multiply the slider feedback label + * @param other_setting Second setting to modify, to replace the label with a checkbox + * @param suffix Set to specify formats for Slider feedback labels or SpinBox + */ + explicit Widget(Settings::BasicSetting* setting, const TranslationMap& translations, + const ComboboxTranslationMap& combobox_translations, QWidget* parent, + bool runtime_lock, std::vector>& apply_funcs_, + RequestType request = RequestType::Default, bool managed = true, + float multiplier = default_multiplier, + Settings::BasicSetting* other_setting = nullptr, + const QString& suffix = default_suffix); + virtual ~Widget(); + + /** + * @returns True if the Widget successfully created the components for the setting + */ + bool Valid() const; + + /** + * Creates a button to appear when a setting has been modified. This exists for custom + * configurations and wasn't designed to work for the global configuration. It has public access + * for settings that need to be unmanaged but can be custom. + * + * @param using_global The global state of the setting this button is for + * @param parent QWidget parent + */ + [[nodiscard]] static QPushButton* CreateRestoreGlobalButton(bool using_global, QWidget* parent); + + // Direct handles to sub components created + QPushButton* restore_button{}; ///< Restore button for custom configurations + QLineEdit* line_edit{}; ///< QLineEdit, used for LineEdit and HexEdit + QSpinBox* spinbox{}; + QDoubleSpinBox* double_spinbox{}; + QCheckBox* checkbox{}; + QSlider* slider{}; + QComboBox* combobox{}; + QDateTimeEdit* date_time_edit{}; + std::vector> radio_buttons{}; + +private: + void SetupComponent(const QString& label, std::function& load_func, bool managed, + RequestType request, float multiplier, + Settings::BasicSetting* other_setting, const QString& suffix); + + QLabel* CreateLabel(const QString& text); + QWidget* CreateCheckBox(Settings::BasicSetting* bool_setting, const QString& label, + std::function& serializer, + std::function& restore_func, + const std::function& touch); + + QWidget* CreateCombobox(std::function& serializer, + std::function& restore_func, + const std::function& touch); + QWidget* CreateRadioGroup(std::function& serializer, + std::function& restore_func, + const std::function& touch); + QWidget* CreateLineEdit(std::function& serializer, + std::function& restore_func, const std::function& touch, + bool managed = true); + QWidget* CreateHexEdit(std::function& serializer, + std::function& restore_func, const std::function& touch); + QWidget* CreateSlider(bool reversed, float multiplier, const QString& suffix, + std::function& serializer, + std::function& restore_func, const std::function& touch); + QWidget* CreateDateTimeEdit(bool disabled, bool restrict, + std::function& serializer, + std::function& restore_func, + const std::function& touch); + QWidget* CreateSpinBox(const QString& suffix, std::function& serializer, + std::function& restore_func, const std::function& touch); + QWidget* CreateDoubleSpinBox(const QString& suffix, std::function& serializer, + std::function& restore_func, + const std::function& touch); + + QWidget* parent; + const TranslationMap& translations; + const ComboboxTranslationMap& combobox_enumerations; + Settings::BasicSetting& setting; + std::vector>& apply_funcs; + + bool created{false}; + bool runtime_lock{false}; +}; + +class Builder { +public: + explicit Builder(QWidget* parent, bool runtime_lock); + ~Builder(); + + Widget* BuildWidget(Settings::BasicSetting* setting, + std::vector>& apply_funcs, + RequestType request = RequestType::Default, bool managed = true, + float multiplier = default_multiplier, + Settings::BasicSetting* other_setting = nullptr, + const QString& suffix = default_suffix) const; + + Widget* BuildWidget(Settings::BasicSetting* setting, + std::vector>& apply_funcs, + Settings::BasicSetting* other_setting, + RequestType request = RequestType::Default, + const QString& suffix = default_suffix) const; + + const ComboboxTranslationMap& ComboboxTranslations() const; + +private: + std::unique_ptr translations; + std::unique_ptr combobox_translations; + + QWidget* parent; + const bool runtime_lock; +}; + +} // namespace ConfigurationShared diff --git a/src/sudachi/debugger/console.cpp b/src/sudachi/debugger/console.cpp new file mode 100644 index 0000000..7f0fdeb --- /dev/null +++ b/src/sudachi/debugger/console.cpp @@ -0,0 +1,49 @@ +// SPDX-FileCopyrightText: Copyright 2018 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#ifdef _WIN32 +#include + +#include +#endif + +#include "common/logging/backend.h" +#include "sudachi/debugger/console.h" +#include "sudachi/uisettings.h" + +namespace Debugger { +void ToggleConsole() { + static bool console_shown = false; + if (console_shown == UISettings::values.show_console.GetValue()) { + return; + } else { + console_shown = UISettings::values.show_console.GetValue(); + } + + using namespace Common::Log; +#if defined(_WIN32) && !defined(_DEBUG) + FILE* temp; + if (UISettings::values.show_console) { + if (AllocConsole()) { + // The first parameter for freopen_s is a out parameter, so we can just ignore it + freopen_s(&temp, "CONIN$", "r", stdin); + freopen_s(&temp, "CONOUT$", "w", stdout); + freopen_s(&temp, "CONOUT$", "w", stderr); + SetConsoleOutputCP(65001); + SetColorConsoleBackendEnabled(true); + } + } else { + if (FreeConsole()) { + // In order to close the console, we have to also detach the streams on it. + // Just redirect them to NUL if there is no console window + SetColorConsoleBackendEnabled(false); + freopen_s(&temp, "NUL", "r", stdin); + freopen_s(&temp, "NUL", "w", stdout); + freopen_s(&temp, "NUL", "w", stderr); + } + } +#else + SetColorConsoleBackendEnabled(UISettings::values.show_console.GetValue()); +#endif +} +} // namespace Debugger diff --git a/src/sudachi/debugger/console.h b/src/sudachi/debugger/console.h new file mode 100644 index 0000000..e8d6a91 --- /dev/null +++ b/src/sudachi/debugger/console.h @@ -0,0 +1,13 @@ +// SPDX-FileCopyrightText: Copyright 2018 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +namespace Debugger { + +/** + * Uses the WINAPI to hide or show the stderr console. This function is a placeholder until we can + * get a real qt logging window which would work for all platforms. + */ +void ToggleConsole(); +} // namespace Debugger diff --git a/src/sudachi/debugger/controller.cpp b/src/sudachi/debugger/controller.cpp new file mode 100644 index 0000000..cc31f61 --- /dev/null +++ b/src/sudachi/debugger/controller.cpp @@ -0,0 +1,116 @@ +// SPDX-FileCopyrightText: 2015 Citra Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include +#include +#include +#include "common/settings.h" +#include "hid_core/frontend/emulated_controller.h" +#include "hid_core/hid_core.h" +#include "input_common/drivers/tas_input.h" +#include "input_common/main.h" +#include "sudachi/configuration/configure_input_player_widget.h" +#include "sudachi/debugger/controller.h" + +ControllerDialog::ControllerDialog(Core::HID::HIDCore& hid_core_, + std::shared_ptr input_subsystem_, + QWidget* parent) + : QWidget(parent, Qt::Dialog), hid_core{hid_core_}, input_subsystem{input_subsystem_} { + setObjectName(QStringLiteral("Controller")); + setWindowTitle(tr("Controller P1")); + resize(500, 350); + setMinimumSize(500, 350); + // Enable the maximize button + setWindowFlags(windowFlags() | Qt::WindowMaximizeButtonHint); + + widget = new PlayerControlPreview(this); + refreshConfiguration(); + QLayout* layout = new QVBoxLayout(this); + layout->setContentsMargins(0, 0, 0, 0); + layout->addWidget(widget); + setLayout(layout); + + // Configure focus so that widget is focusable and the dialog automatically forwards focus to + // it. + setFocusProxy(widget); + widget->setFocusPolicy(Qt::StrongFocus); + widget->setFocus(); +} + +void ControllerDialog::refreshConfiguration() { + UnloadController(); + auto* player_1 = hid_core.GetEmulatedController(Core::HID::NpadIdType::Player1); + auto* handheld = hid_core.GetEmulatedController(Core::HID::NpadIdType::Handheld); + // Display the correct controller + controller = handheld->IsConnected() ? handheld : player_1; + + Core::HID::ControllerUpdateCallback engine_callback{ + .on_change = [this](Core::HID::ControllerTriggerType type) { ControllerUpdate(type); }, + .is_npad_service = true, + }; + callback_key = controller->SetCallback(engine_callback); + widget->SetController(controller); + is_controller_set = true; +} + +QAction* ControllerDialog::toggleViewAction() { + if (toggle_view_action == nullptr) { + toggle_view_action = new QAction(tr("&Controller P1"), this); + toggle_view_action->setCheckable(true); + toggle_view_action->setChecked(isVisible()); + connect(toggle_view_action, &QAction::toggled, this, &ControllerDialog::setVisible); + } + + return toggle_view_action; +} + +void ControllerDialog::UnloadController() { + widget->UnloadController(); + if (is_controller_set) { + controller->DeleteCallback(callback_key); + is_controller_set = false; + } +} + +void ControllerDialog::showEvent(QShowEvent* ev) { + if (toggle_view_action) { + toggle_view_action->setChecked(isVisible()); + } + QWidget::showEvent(ev); +} + +void ControllerDialog::hideEvent(QHideEvent* ev) { + if (toggle_view_action) { + toggle_view_action->setChecked(isVisible()); + } + QWidget::hideEvent(ev); +} + +void ControllerDialog::ControllerUpdate(Core::HID::ControllerTriggerType type) { + // TODO(german77): Remove TAS from here + switch (type) { + case Core::HID::ControllerTriggerType::Button: + case Core::HID::ControllerTriggerType::Stick: { + const auto buttons_values = controller->GetButtonsValues(); + const auto stick_values = controller->GetSticks(); + u64 buttons = 0; + std::size_t index = 0; + for (const auto& button : buttons_values) { + buttons |= button.value ? 1LLU << index : 0; + index++; + } + const InputCommon::TasInput::TasAnalog left_axis = { + .x = stick_values.left.x / 32767.f, + .y = stick_values.left.y / 32767.f, + }; + const InputCommon::TasInput::TasAnalog right_axis = { + .x = stick_values.right.x / 32767.f, + .y = stick_values.right.y / 32767.f, + }; + input_subsystem->GetTas()->RecordInput(buttons, left_axis, right_axis); + break; + } + default: + break; + } +} diff --git a/src/sudachi/debugger/controller.h b/src/sudachi/debugger/controller.h new file mode 100644 index 0000000..9651dfa --- /dev/null +++ b/src/sudachi/debugger/controller.h @@ -0,0 +1,56 @@ +// SPDX-FileCopyrightText: 2015 Citra Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include + +class QAction; +class QHideEvent; +class QShowEvent; +class PlayerControlPreview; + +namespace InputCommon { +class InputSubsystem; +} + +namespace Core::HID { +class HIDCore; +class EmulatedController; +enum class ControllerTriggerType; +} // namespace Core::HID + +class ControllerDialog : public QWidget { + Q_OBJECT + +public: + explicit ControllerDialog(Core::HID::HIDCore& hid_core_, + std::shared_ptr input_subsystem_, + QWidget* parent = nullptr); + + /// Returns a QAction that can be used to toggle visibility of this dialog. + QAction* toggleViewAction(); + + /// Reloads the widget to apply any changes in the configuration + void refreshConfiguration(); + + /// Disables events from the emulated controller + void UnloadController(); + +protected: + void showEvent(QShowEvent* ev) override; + void hideEvent(QHideEvent* ev) override; + +private: + /// Redirects input from the widget to the TAS driver + void ControllerUpdate(Core::HID::ControllerTriggerType type); + + int callback_key; + bool is_controller_set{}; + Core::HID::EmulatedController* controller; + + QAction* toggle_view_action = nullptr; + PlayerControlPreview* widget; + Core::HID::HIDCore& hid_core; + std::shared_ptr input_subsystem; +}; diff --git a/src/sudachi/debugger/profiler.cpp b/src/sudachi/debugger/profiler.cpp new file mode 100644 index 0000000..8dc1643 --- /dev/null +++ b/src/sudachi/debugger/profiler.cpp @@ -0,0 +1,229 @@ +// SPDX-FileCopyrightText: 2015 Citra Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include +#include +#include +#include +#include +#include +#include "common/common_types.h" +#include "common/microprofile.h" +#include "sudachi/debugger/profiler.h" +#include "sudachi/util/util.h" + +// Include the implementation of the UI in this file. This isn't in microprofile.cpp because the +// non-Qt frontends don't need it (and don't implement the UI drawing hooks either). +#if MICROPROFILE_ENABLED +#define MICROPROFILEUI_IMPL 1 +#include "common/microprofileui.h" + +class MicroProfileWidget : public QWidget { +public: + MicroProfileWidget(QWidget* parent = nullptr); + +protected: + void paintEvent(QPaintEvent* ev) override; + void showEvent(QShowEvent* ev) override; + void hideEvent(QHideEvent* ev) override; + + void mouseMoveEvent(QMouseEvent* ev) override; + void mousePressEvent(QMouseEvent* ev) override; + void mouseReleaseEvent(QMouseEvent* ev) override; + void wheelEvent(QWheelEvent* ev) override; + + void keyPressEvent(QKeyEvent* ev) override; + void keyReleaseEvent(QKeyEvent* ev) override; + +private: + /// This timer is used to redraw the widget's contents continuously. To save resources, it only + /// runs while the widget is visible. + QTimer update_timer; + /// Scale the coordinate system appropriately when dpi != 96. + qreal x_scale = 1.0, y_scale = 1.0; +}; + +#endif + +MicroProfileDialog::MicroProfileDialog(QWidget* parent) : QWidget(parent, Qt::Dialog) { + setObjectName(QStringLiteral("MicroProfile")); + setWindowTitle(tr("&MicroProfile")); + resize(1000, 600); + // Enable the maximize button + setWindowFlags(windowFlags() | Qt::WindowMaximizeButtonHint); + +#if MICROPROFILE_ENABLED + + MicroProfileWidget* widget = new MicroProfileWidget(this); + + QLayout* layout = new QVBoxLayout(this); + layout->setContentsMargins(0, 0, 0, 0); + layout->addWidget(widget); + setLayout(layout); + + // Configure focus so that widget is focusable and the dialog automatically forwards focus to + // it. + setFocusProxy(widget); + widget->setFocusPolicy(Qt::StrongFocus); + widget->setFocus(); +#endif +} + +QAction* MicroProfileDialog::toggleViewAction() { + if (toggle_view_action == nullptr) { + toggle_view_action = new QAction(windowTitle(), this); + toggle_view_action->setCheckable(true); + toggle_view_action->setChecked(isVisible()); + connect(toggle_view_action, &QAction::toggled, this, &MicroProfileDialog::setVisible); + } + + return toggle_view_action; +} + +void MicroProfileDialog::showEvent(QShowEvent* ev) { + if (toggle_view_action) { + toggle_view_action->setChecked(isVisible()); + } + QWidget::showEvent(ev); +} + +void MicroProfileDialog::hideEvent(QHideEvent* ev) { + if (toggle_view_action) { + toggle_view_action->setChecked(isVisible()); + } + QWidget::hideEvent(ev); +} + +#if MICROPROFILE_ENABLED + +/// There's no way to pass a user pointer to MicroProfile, so this variable is used to make the +/// QPainter available inside the drawing callbacks. +static QPainter* mp_painter = nullptr; + +MicroProfileWidget::MicroProfileWidget(QWidget* parent) : QWidget(parent) { + // Send mouse motion events even when not dragging. + setMouseTracking(true); + + MicroProfileSetDisplayMode(1); // Timers screen + MicroProfileInitUI(); + + connect(&update_timer, &QTimer::timeout, this, qOverload<>(&MicroProfileWidget::update)); +} + +void MicroProfileWidget::paintEvent(QPaintEvent* ev) { + QPainter painter(this); + + // The units used by Microprofile for drawing are based in pixels on a 96 dpi display. + x_scale = qreal(painter.device()->logicalDpiX()) / 96.0; + y_scale = qreal(painter.device()->logicalDpiY()) / 96.0; + painter.scale(x_scale, y_scale); + + painter.setBackground(Qt::black); + painter.eraseRect(rect()); + + QFont font = GetMonospaceFont(); + font.setPixelSize(MICROPROFILE_TEXT_HEIGHT); + painter.setFont(font); + + mp_painter = &painter; + MicroProfileDraw(rect().width() / x_scale, rect().height() / y_scale); + mp_painter = nullptr; +} + +void MicroProfileWidget::showEvent(QShowEvent* ev) { + update_timer.start(15); // ~60 Hz + QWidget::showEvent(ev); +} + +void MicroProfileWidget::hideEvent(QHideEvent* ev) { + update_timer.stop(); + QWidget::hideEvent(ev); +} + +void MicroProfileWidget::mouseMoveEvent(QMouseEvent* ev) { + const auto mouse_position = ev->pos(); + MicroProfileMousePosition(mouse_position.x() / x_scale, mouse_position.y() / y_scale, 0); + ev->accept(); +} + +void MicroProfileWidget::mousePressEvent(QMouseEvent* ev) { + const auto mouse_position = ev->pos(); + MicroProfileMousePosition(mouse_position.x() / x_scale, mouse_position.y() / y_scale, 0); + MicroProfileMouseButton(ev->buttons() & Qt::LeftButton, ev->buttons() & Qt::RightButton); + ev->accept(); +} + +void MicroProfileWidget::mouseReleaseEvent(QMouseEvent* ev) { + const auto mouse_position = ev->pos(); + MicroProfileMousePosition(mouse_position.x() / x_scale, mouse_position.y() / y_scale, 0); + MicroProfileMouseButton(ev->buttons() & Qt::LeftButton, ev->buttons() & Qt::RightButton); + ev->accept(); +} + +void MicroProfileWidget::wheelEvent(QWheelEvent* ev) { + const auto wheel_position = ev->position().toPoint(); + MicroProfileMousePosition(wheel_position.x() / x_scale, wheel_position.y() / y_scale, + ev->angleDelta().y() / 120); + ev->accept(); +} + +void MicroProfileWidget::keyPressEvent(QKeyEvent* ev) { + if (ev->key() == Qt::Key_Control) { + // Inform MicroProfile that the user is holding Ctrl. + MicroProfileModKey(1); + } + QWidget::keyPressEvent(ev); +} + +void MicroProfileWidget::keyReleaseEvent(QKeyEvent* ev) { + if (ev->key() == Qt::Key_Control) { + MicroProfileModKey(0); + } + QWidget::keyReleaseEvent(ev); +} + +// These functions are called by MicroProfileDraw to draw the interface elements on the screen. + +void MicroProfileDrawText(int x, int y, u32 hex_color, const char* text, u32 text_length) { + // hex_color does not include an alpha, so it must be assumed to be 255 + mp_painter->setPen(QColor::fromRgb(hex_color)); + + // It's impossible to draw a string using a monospaced font with a fixed width per cell in a + // way that's reliable across different platforms and fonts as far as I (yuriks) can tell, so + // draw each character individually in order to precisely control the text advance. + for (u32 i = 0; i < text_length; ++i) { + // Position the text baseline 1 pixel above the bottom of the text cell, this gives nice + // vertical alignment of text for a wide range of tested fonts. + mp_painter->drawText(x, y + MICROPROFILE_TEXT_HEIGHT - 2, QString{QLatin1Char{text[i]}}); + x += MICROPROFILE_TEXT_WIDTH + 1; + } +} + +void MicroProfileDrawBox(int left, int top, int right, int bottom, u32 hex_color, + MicroProfileBoxType type) { + QColor color = QColor::fromRgba(hex_color); + QBrush brush = color; + if (type == MicroProfileBoxTypeBar) { + QLinearGradient gradient(left, top, left, bottom); + gradient.setColorAt(0.f, color.lighter(125)); + gradient.setColorAt(1.f, color.darker(125)); + brush = gradient; + } + mp_painter->fillRect(left, top, right - left, bottom - top, brush); +} + +void MicroProfileDrawLine2D(u32 vertices_length, float* vertices, u32 hex_color) { + // Temporary vector used to convert between the float array and QPointF. Marked static to reuse + // the allocation across calls. + static std::vector point_buf; + + for (u32 i = 0; i < vertices_length; ++i) { + point_buf.emplace_back(vertices[i * 2 + 0], vertices[i * 2 + 1]); + } + + // hex_color does not include an alpha, so it must be assumed to be 255 + mp_painter->setPen(QColor::fromRgb(hex_color)); + mp_painter->drawPolyline(point_buf.data(), vertices_length); + point_buf.clear(); +} +#endif diff --git a/src/sudachi/debugger/profiler.h b/src/sudachi/debugger/profiler.h new file mode 100644 index 0000000..4c8ccd3 --- /dev/null +++ b/src/sudachi/debugger/profiler.h @@ -0,0 +1,27 @@ +// SPDX-FileCopyrightText: 2015 Citra Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include + +class QAction; +class QHideEvent; +class QShowEvent; + +class MicroProfileDialog : public QWidget { + Q_OBJECT + +public: + explicit MicroProfileDialog(QWidget* parent = nullptr); + + /// Returns a QAction that can be used to toggle visibility of this dialog. + QAction* toggleViewAction(); + +protected: + void showEvent(QShowEvent* ev) override; + void hideEvent(QHideEvent* ev) override; + +private: + QAction* toggle_view_action = nullptr; +}; diff --git a/src/sudachi/debugger/wait_tree.cpp b/src/sudachi/debugger/wait_tree.cpp new file mode 100644 index 0000000..fedb861 --- /dev/null +++ b/src/sudachi/debugger/wait_tree.cpp @@ -0,0 +1,431 @@ +// SPDX-FileCopyrightText: 2016 Citra Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include +#include + +#include "sudachi/debugger/wait_tree.h" +#include "sudachi/uisettings.h" + +#include "core/arm/debug.h" +#include "core/core.h" +#include "core/hle/kernel/k_class_token.h" +#include "core/hle/kernel/k_handle_table.h" +#include "core/hle/kernel/k_process.h" +#include "core/hle/kernel/k_readable_event.h" +#include "core/hle/kernel/k_scheduler.h" +#include "core/hle/kernel/k_synchronization_object.h" +#include "core/hle/kernel/k_thread.h" +#include "core/hle/kernel/svc_common.h" +#include "core/hle/kernel/svc_types.h" +#include "core/memory.h" + +namespace { + +constexpr std::array, 10> WaitTreeColors{{ + {Qt::GlobalColor::darkGreen, Qt::GlobalColor::green}, + {Qt::GlobalColor::darkBlue, Qt::GlobalColor::cyan}, + {Qt::GlobalColor::lightGray, Qt::GlobalColor::lightGray}, + {Qt::GlobalColor::lightGray, Qt::GlobalColor::lightGray}, + {Qt::GlobalColor::darkRed, Qt::GlobalColor::red}, + {Qt::GlobalColor::darkYellow, Qt::GlobalColor::yellow}, + {Qt::GlobalColor::red, Qt::GlobalColor::red}, + {Qt::GlobalColor::darkCyan, Qt::GlobalColor::cyan}, + {Qt::GlobalColor::gray, Qt::GlobalColor::gray}, +}}; + +bool IsDarkTheme() { + const auto& theme = UISettings::values.theme; + return theme == std::string("qdarkstyle") || theme == std::string("qdarkstyle_midnight_blue") || + theme == std::string("colorful_dark") || theme == std::string("colorful_midnight_blue"); +} + +} // namespace + +WaitTreeItem::WaitTreeItem() = default; +WaitTreeItem::~WaitTreeItem() = default; + +QColor WaitTreeItem::GetColor() const { + if (IsDarkTheme()) { + return QColor(Qt::GlobalColor::white); + } else { + return QColor(Qt::GlobalColor::black); + } +} + +std::vector> WaitTreeItem::GetChildren() const { + return {}; +} + +void WaitTreeItem::Expand() { + if (IsExpandable() && !expanded) { + children = GetChildren(); + for (std::size_t i = 0; i < children.size(); ++i) { + children[i]->parent = this; + children[i]->row = i; + } + expanded = true; + } +} + +WaitTreeItem* WaitTreeItem::Parent() const { + return parent; +} + +const std::vector>& WaitTreeItem::Children() const { + return children; +} + +bool WaitTreeItem::IsExpandable() const { + return false; +} + +std::size_t WaitTreeItem::Row() const { + return row; +} + +std::vector> WaitTreeItem::MakeThreadItemList( + Core::System& system) { + std::vector> item_list; + std::size_t row = 0; + auto add_threads = [&](const std::vector& threads) { + for (std::size_t i = 0; i < threads.size(); ++i) { + if (threads[i]->GetThreadType() == Kernel::ThreadType::User) { + item_list.push_back(std::make_unique(*threads[i], system)); + item_list.back()->row = row; + } + ++row; + } + }; + + add_threads(system.GlobalSchedulerContext().GetThreadList()); + + return item_list; +} + +WaitTreeText::WaitTreeText(QString t) : text(std::move(t)) {} +WaitTreeText::~WaitTreeText() = default; + +QString WaitTreeText::GetText() const { + return text; +} + +WaitTreeCallstack::WaitTreeCallstack(const Kernel::KThread& thread_, Core::System& system_) + : thread{thread_}, system{system_} {} +WaitTreeCallstack::~WaitTreeCallstack() = default; + +QString WaitTreeCallstack::GetText() const { + return tr("Call stack"); +} + +std::vector> WaitTreeCallstack::GetChildren() const { + std::vector> list; + + if (thread.GetThreadType() != Kernel::ThreadType::User) { + return list; + } + + if (thread.GetOwnerProcess() == nullptr || !thread.GetOwnerProcess()->Is64Bit()) { + return list; + } + + auto backtrace = Core::GetBacktraceFromContext(thread.GetOwnerProcess(), thread.GetContext()); + + for (auto& entry : backtrace) { + std::string s = fmt::format("{:20}{:016X} {:016X} {:016X} {}", entry.module, entry.address, + entry.original_address, entry.offset, entry.name); + list.push_back(std::make_unique(QString::fromStdString(s))); + } + + return list; +} + +WaitTreeSynchronizationObject::WaitTreeSynchronizationObject( + const Kernel::KSynchronizationObject& object_, Core::System& system_) + : object{object_}, system{system_} {} +WaitTreeSynchronizationObject::~WaitTreeSynchronizationObject() = default; + +WaitTreeExpandableItem::WaitTreeExpandableItem() = default; +WaitTreeExpandableItem::~WaitTreeExpandableItem() = default; + +bool WaitTreeExpandableItem::IsExpandable() const { + return true; +} + +QString WaitTreeSynchronizationObject::GetText() const { + return tr("[%1] %2") + .arg(object.GetId()) + .arg(QString::fromStdString(object.GetTypeObj().GetName())); +} + +std::unique_ptr WaitTreeSynchronizationObject::make( + const Kernel::KSynchronizationObject& object, Core::System& system) { + const auto type = + static_cast(object.GetTypeObj().GetClassToken()); + switch (type) { + case Kernel::KClassTokenGenerator::ObjectType::KReadableEvent: + return std::make_unique(static_cast(object), + system); + case Kernel::KClassTokenGenerator::ObjectType::KThread: + return std::make_unique(static_cast(object), + system); + default: + return std::make_unique(object, system); + } +} + +std::vector> WaitTreeSynchronizationObject::GetChildren() const { + std::vector> list; + + auto threads = object.GetWaitingThreadsForDebugging(); + if (threads.empty()) { + list.push_back(std::make_unique(tr("waited by no thread"))); + } else { + list.push_back(std::make_unique(std::move(threads), system)); + } + + return list; +} + +WaitTreeThread::WaitTreeThread(const Kernel::KThread& thread, Core::System& system_) + : WaitTreeSynchronizationObject(thread, system_), system{system_} {} +WaitTreeThread::~WaitTreeThread() = default; + +QString WaitTreeThread::GetText() const { + const auto& thread = static_cast(object); + QString status; + switch (thread.GetState()) { + case Kernel::ThreadState::Runnable: + if (!thread.IsSuspended()) { + status = tr("runnable"); + } else { + status = tr("paused"); + } + break; + case Kernel::ThreadState::Waiting: + switch (thread.GetWaitReasonForDebugging()) { + case Kernel::ThreadWaitReasonForDebugging::Sleep: + status = tr("sleeping"); + break; + case Kernel::ThreadWaitReasonForDebugging::IPC: + status = tr("waiting for IPC reply"); + break; + case Kernel::ThreadWaitReasonForDebugging::Synchronization: + status = tr("waiting for objects"); + break; + case Kernel::ThreadWaitReasonForDebugging::ConditionVar: + status = tr("waiting for condition variable"); + break; + case Kernel::ThreadWaitReasonForDebugging::Arbitration: + status = tr("waiting for address arbiter"); + break; + case Kernel::ThreadWaitReasonForDebugging::Suspended: + status = tr("waiting for suspend resume"); + break; + default: + status = tr("waiting"); + break; + } + break; + case Kernel::ThreadState::Initialized: + status = tr("initialized"); + break; + case Kernel::ThreadState::Terminated: + status = tr("terminated"); + break; + default: + status = tr("unknown"); + break; + } + + const auto& context = thread.GetContext(); + const QString pc_info = tr(" PC = 0x%1 LR = 0x%2") + .arg(context.pc, 8, 16, QLatin1Char{'0'}) + .arg(context.lr, 8, 16, QLatin1Char{'0'}); + return QStringLiteral("%1%2 (%3) ") + .arg(WaitTreeSynchronizationObject::GetText(), pc_info, status); +} + +QColor WaitTreeThread::GetColor() const { + const std::size_t color_index = IsDarkTheme() ? 1 : 0; + + const auto& thread = static_cast(object); + switch (thread.GetState()) { + case Kernel::ThreadState::Runnable: + if (!thread.IsSuspended()) { + return QColor(WaitTreeColors[0][color_index]); + } else { + return QColor(WaitTreeColors[2][color_index]); + } + case Kernel::ThreadState::Waiting: + switch (thread.GetWaitReasonForDebugging()) { + case Kernel::ThreadWaitReasonForDebugging::IPC: + return QColor(WaitTreeColors[4][color_index]); + case Kernel::ThreadWaitReasonForDebugging::Sleep: + return QColor(WaitTreeColors[5][color_index]); + case Kernel::ThreadWaitReasonForDebugging::Synchronization: + case Kernel::ThreadWaitReasonForDebugging::ConditionVar: + case Kernel::ThreadWaitReasonForDebugging::Arbitration: + case Kernel::ThreadWaitReasonForDebugging::Suspended: + return QColor(WaitTreeColors[6][color_index]); + break; + default: + return QColor(WaitTreeColors[3][color_index]); + } + case Kernel::ThreadState::Initialized: + return QColor(WaitTreeColors[7][color_index]); + case Kernel::ThreadState::Terminated: + return QColor(WaitTreeColors[8][color_index]); + default: + return WaitTreeItem::GetColor(); + } +} + +std::vector> WaitTreeThread::GetChildren() const { + std::vector> list(WaitTreeSynchronizationObject::GetChildren()); + + const auto& thread = static_cast(object); + + QString processor; + switch (thread.GetActiveCore()) { + case Kernel::Svc::IdealCoreUseProcessValue: + processor = tr("ideal"); + break; + default: + processor = tr("core %1").arg(thread.GetActiveCore()); + break; + } + + list.push_back(std::make_unique(tr("processor = %1").arg(processor))); + list.push_back(std::make_unique( + tr("affinity mask = %1").arg(thread.GetAffinityMask().GetAffinityMask()))); + list.push_back(std::make_unique(tr("thread id = %1").arg(thread.GetThreadId()))); + list.push_back(std::make_unique(tr("priority = %1(current) / %2(normal)") + .arg(thread.GetPriority()) + .arg(thread.GetBasePriority()))); + list.push_back(std::make_unique( + tr("last running ticks = %1").arg(thread.GetLastScheduledTick()))); + + list.push_back(std::make_unique(thread, system)); + + return list; +} + +WaitTreeEvent::WaitTreeEvent(const Kernel::KReadableEvent& object_, Core::System& system_) + : WaitTreeSynchronizationObject(object_, system_) {} +WaitTreeEvent::~WaitTreeEvent() = default; + +WaitTreeThreadList::WaitTreeThreadList(std::vector&& list, Core::System& system_) + : thread_list(std::move(list)), system{system_} {} +WaitTreeThreadList::~WaitTreeThreadList() = default; + +QString WaitTreeThreadList::GetText() const { + return tr("waited by thread"); +} + +std::vector> WaitTreeThreadList::GetChildren() const { + std::vector> list(thread_list.size()); + std::transform(thread_list.begin(), thread_list.end(), list.begin(), + [this](const auto& t) { return std::make_unique(*t, system); }); + return list; +} + +WaitTreeModel::WaitTreeModel(Core::System& system_, QObject* parent) + : QAbstractItemModel(parent), system{system_} {} +WaitTreeModel::~WaitTreeModel() = default; + +QModelIndex WaitTreeModel::index(int row, int column, const QModelIndex& parent) const { + if (!hasIndex(row, column, parent)) + return {}; + + if (parent.isValid()) { + WaitTreeItem* parent_item = static_cast(parent.internalPointer()); + parent_item->Expand(); + return createIndex(row, column, parent_item->Children()[row].get()); + } + + return createIndex(row, column, thread_items[row].get()); +} + +QModelIndex WaitTreeModel::parent(const QModelIndex& index) const { + if (!index.isValid()) + return {}; + + WaitTreeItem* parent_item = static_cast(index.internalPointer())->Parent(); + if (!parent_item) { + return QModelIndex(); + } + return createIndex(static_cast(parent_item->Row()), 0, parent_item); +} + +int WaitTreeModel::rowCount(const QModelIndex& parent) const { + if (!parent.isValid()) + return static_cast(thread_items.size()); + + WaitTreeItem* parent_item = static_cast(parent.internalPointer()); + parent_item->Expand(); + return static_cast(parent_item->Children().size()); +} + +int WaitTreeModel::columnCount(const QModelIndex&) const { + return 1; +} + +QVariant WaitTreeModel::data(const QModelIndex& index, int role) const { + if (!index.isValid()) + return {}; + + switch (role) { + case Qt::DisplayRole: + return static_cast(index.internalPointer())->GetText(); + case Qt::ForegroundRole: + return static_cast(index.internalPointer())->GetColor(); + default: + return {}; + } +} + +void WaitTreeModel::ClearItems() { + thread_items.clear(); +} + +void WaitTreeModel::InitItems() { + thread_items = WaitTreeItem::MakeThreadItemList(system); +} + +WaitTreeWidget::WaitTreeWidget(Core::System& system_, QWidget* parent) + : QDockWidget(tr("&Wait Tree"), parent), system{system_} { + setObjectName(QStringLiteral("WaitTreeWidget")); + view = new QTreeView(this); + view->setHeaderHidden(true); + setWidget(view); + setEnabled(false); +} + +WaitTreeWidget::~WaitTreeWidget() = default; + +void WaitTreeWidget::OnDebugModeEntered() { + if (!system.IsPoweredOn()) + return; + model->InitItems(); + view->setModel(model); + setEnabled(true); +} + +void WaitTreeWidget::OnDebugModeLeft() { + setEnabled(false); + view->setModel(nullptr); + model->ClearItems(); +} + +void WaitTreeWidget::OnEmulationStarting(EmuThread* emu_thread) { + model = new WaitTreeModel(system, this); + view->setModel(model); + setEnabled(false); +} + +void WaitTreeWidget::OnEmulationStopping() { + view->setModel(nullptr); + delete model; + setEnabled(false); +} diff --git a/src/sudachi/debugger/wait_tree.h b/src/sudachi/debugger/wait_tree.h new file mode 100644 index 0000000..23c329f --- /dev/null +++ b/src/sudachi/debugger/wait_tree.h @@ -0,0 +1,188 @@ +// SPDX-FileCopyrightText: 2016 Citra Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include +#include +#include + +#include +#include + +#include "common/common_types.h" +#include "core/hle/kernel/k_auto_object.h" +#include "core/hle/kernel/svc_common.h" + +class EmuThread; + +namespace Core { +class System; +} + +namespace Kernel { +class KHandleTable; +class KReadableEvent; +class KSynchronizationObject; +class KThread; +} // namespace Kernel + +class WaitTreeThread; + +class WaitTreeItem : public QObject { + Q_OBJECT +public: + WaitTreeItem(); + ~WaitTreeItem() override; + + virtual bool IsExpandable() const; + virtual std::vector> GetChildren() const; + virtual QString GetText() const = 0; + virtual QColor GetColor() const; + + void Expand(); + WaitTreeItem* Parent() const; + const std::vector>& Children() const; + std::size_t Row() const; + static std::vector> MakeThreadItemList(Core::System& system); + +private: + std::size_t row; + bool expanded = false; + WaitTreeItem* parent = nullptr; + std::vector> children; +}; + +class WaitTreeText : public WaitTreeItem { + Q_OBJECT +public: + explicit WaitTreeText(QString text); + ~WaitTreeText() override; + + QString GetText() const override; + +private: + QString text; +}; + +class WaitTreeExpandableItem : public WaitTreeItem { + Q_OBJECT +public: + WaitTreeExpandableItem(); + ~WaitTreeExpandableItem() override; + + bool IsExpandable() const override; +}; + +class WaitTreeCallstack : public WaitTreeExpandableItem { + Q_OBJECT +public: + explicit WaitTreeCallstack(const Kernel::KThread& thread_, Core::System& system_); + ~WaitTreeCallstack() override; + + QString GetText() const override; + std::vector> GetChildren() const override; + +private: + const Kernel::KThread& thread; + + Core::System& system; +}; + +class WaitTreeSynchronizationObject : public WaitTreeExpandableItem { + Q_OBJECT +public: + explicit WaitTreeSynchronizationObject(const Kernel::KSynchronizationObject& object_, + Core::System& system_); + ~WaitTreeSynchronizationObject() override; + + static std::unique_ptr make( + const Kernel::KSynchronizationObject& object, Core::System& system); + QString GetText() const override; + std::vector> GetChildren() const override; + +protected: + const Kernel::KSynchronizationObject& object; + +private: + Core::System& system; +}; + +class WaitTreeThread : public WaitTreeSynchronizationObject { + Q_OBJECT +public: + explicit WaitTreeThread(const Kernel::KThread& thread, Core::System& system_); + ~WaitTreeThread() override; + + QString GetText() const override; + QColor GetColor() const override; + std::vector> GetChildren() const override; + +private: + Core::System& system; +}; + +class WaitTreeEvent : public WaitTreeSynchronizationObject { + Q_OBJECT +public: + explicit WaitTreeEvent(const Kernel::KReadableEvent& object_, Core::System& system_); + ~WaitTreeEvent() override; +}; + +class WaitTreeThreadList : public WaitTreeExpandableItem { + Q_OBJECT +public: + explicit WaitTreeThreadList(std::vector&& list, Core::System& system_); + ~WaitTreeThreadList() override; + + QString GetText() const override; + std::vector> GetChildren() const override; + +private: + std::vector thread_list; + + Core::System& system; +}; + +class WaitTreeModel : public QAbstractItemModel { + Q_OBJECT + +public: + explicit WaitTreeModel(Core::System& system_, QObject* parent = nullptr); + ~WaitTreeModel() override; + + QVariant data(const QModelIndex& index, int role) const override; + QModelIndex index(int row, int column, const QModelIndex& parent) const override; + QModelIndex parent(const QModelIndex& index) const override; + int rowCount(const QModelIndex& parent) const override; + int columnCount(const QModelIndex& parent) const override; + + void ClearItems(); + void InitItems(); + +private: + std::vector> thread_items; + + Core::System& system; +}; + +class WaitTreeWidget : public QDockWidget { + Q_OBJECT + +public: + explicit WaitTreeWidget(Core::System& system_, QWidget* parent = nullptr); + ~WaitTreeWidget() override; + +public slots: + void OnDebugModeEntered(); + void OnDebugModeLeft(); + + void OnEmulationStarting(EmuThread* emu_thread); + void OnEmulationStopping(); + +private: + QTreeView* view; + WaitTreeModel* model; + + Core::System& system; +}; diff --git a/src/sudachi/discord.h b/src/sudachi/discord.h new file mode 100644 index 0000000..e087844 --- /dev/null +++ b/src/sudachi/discord.h @@ -0,0 +1,24 @@ +// SPDX-FileCopyrightText: 2018 Citra Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +namespace DiscordRPC { + +class DiscordInterface { +public: + virtual ~DiscordInterface() = default; + + virtual void Pause() = 0; + virtual void Update() = 0; +}; + +class NullImpl : public DiscordInterface { +public: + ~NullImpl() = default; + + void Pause() override {} + void Update() override {} +}; + +} // namespace DiscordRPC diff --git a/src/sudachi/discord_impl.cpp b/src/sudachi/discord_impl.cpp new file mode 100644 index 0000000..7aeb151 --- /dev/null +++ b/src/sudachi/discord_impl.cpp @@ -0,0 +1,117 @@ +// SPDX-FileCopyrightText: 2018 Citra Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include +#include + +#include +#include +#include + +#include +#include + +#include "common/common_types.h" +#include "common/string_util.h" +#include "core/core.h" +#include "core/loader/loader.h" +#include "sudachi/discord_impl.h" +#include "sudachi/uisettings.h" + +namespace DiscordRPC { + +DiscordImpl::DiscordImpl(Core::System& system_) : system{system_} { + DiscordEventHandlers handlers{}; + // The number is the client ID for sudachi, it's used for images and the + // application name + Discord_Initialize("712465656758665259", &handlers, 1, nullptr); +} + +DiscordImpl::~DiscordImpl() { + Discord_ClearPresence(); + Discord_Shutdown(); +} + +void DiscordImpl::Pause() { + Discord_ClearPresence(); +} + +std::string DiscordImpl::GetGameString(const std::string& title) { + // Convert to lowercase + std::string icon_name = Common::ToLower(title); + + // Replace spaces with dashes + std::replace(icon_name.begin(), icon_name.end(), ' ', '-'); + + // Remove non-alphanumeric characters but keep dashes + std::erase_if(icon_name, [](char c) { return !std::isalnum(c) && c != '-'; }); + + // Remove dashes from the start and end of the string + icon_name.erase(icon_name.begin(), std::find_if(icon_name.begin(), icon_name.end(), + [](int ch) { return ch != '-'; })); + icon_name.erase( + std::find_if(icon_name.rbegin(), icon_name.rend(), [](int ch) { return ch != '-'; }).base(), + icon_name.end()); + + // Remove double dashes + icon_name.erase(std::unique(icon_name.begin(), icon_name.end(), + [](char a, char b) { return a == '-' && b == '-'; }), + icon_name.end()); + + return icon_name; +} + +void DiscordImpl::UpdateGameStatus(bool use_default) { + const std::string default_text = "sudachi is an emulator for the Nintendo Switch"; + const std::string default_image = "sudachi_logo_ea"; + const std::string url = use_default ? default_image : game_url; + s64 start_time = std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()) + .count(); + DiscordRichPresence presence{}; + + presence.largeImageKey = url.c_str(); + presence.largeImageText = game_title.c_str(); + presence.smallImageKey = default_image.c_str(); + presence.smallImageText = default_text.c_str(); + presence.state = game_title.c_str(); + presence.details = "Currently in game"; + presence.startTimestamp = start_time; + Discord_UpdatePresence(&presence); +} + +void DiscordImpl::Update() { + const std::string default_text = "sudachi is an emulator for the Nintendo Switch"; + const std::string default_image = "sudachi_logo_ea"; + + if (system.IsPoweredOn()) { + system.GetAppLoader().ReadTitle(game_title); + + // Used to format Icon URL for sudachi website game compatibility page + std::string icon_name = GetGameString(game_title); + game_url = fmt::format("https://sudachi-emu.org/images/game/boxart/{}.png", icon_name); + + QNetworkAccessManager manager; + QNetworkRequest request; + request.setUrl(QUrl(QString::fromStdString(game_url))); + request.setTransferTimeout(3000); + QNetworkReply* reply = manager.head(request); + QEventLoop request_event_loop; + QObject::connect(reply, &QNetworkReply::finished, &request_event_loop, &QEventLoop::quit); + request_event_loop.exec(); + UpdateGameStatus(reply->error()); + return; + } + + s64 start_time = std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()) + .count(); + + DiscordRichPresence presence{}; + presence.largeImageKey = default_image.c_str(); + presence.largeImageText = default_text.c_str(); + presence.details = "Currently not in game"; + presence.startTimestamp = start_time; + Discord_UpdatePresence(&presence); +} +} // namespace DiscordRPC diff --git a/src/sudachi/discord_impl.h b/src/sudachi/discord_impl.h new file mode 100644 index 0000000..865fe8f --- /dev/null +++ b/src/sudachi/discord_impl.h @@ -0,0 +1,32 @@ +// SPDX-FileCopyrightText: 2018 Citra Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include "sudachi/discord.h" + +namespace Core { +class System; +} + +namespace DiscordRPC { + +class DiscordImpl : public DiscordInterface { +public: + DiscordImpl(Core::System& system_); + ~DiscordImpl() override; + + void Pause() override; + void Update() override; + +private: + std::string GetGameString(const std::string& title); + void UpdateGameStatus(bool use_default); + + std::string game_url{}; + std::string game_title{}; + + Core::System& system; +}; + +} // namespace DiscordRPC diff --git a/src/sudachi/game_list.cpp b/src/sudachi/game_list.cpp new file mode 100644 index 0000000..363e557 --- /dev/null +++ b/src/sudachi/game_list.cpp @@ -0,0 +1,970 @@ +// SPDX-FileCopyrightText: 2015 Citra Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "common/common_types.h" +#include "common/logging/log.h" +#include "core/core.h" +#include "core/file_sys/patch_manager.h" +#include "core/file_sys/registered_cache.h" +#include "sudachi/compatibility_list.h" +#include "sudachi/game_list.h" +#include "sudachi/game_list_p.h" +#include "sudachi/game_list_worker.h" +#include "sudachi/main.h" +#include "sudachi/uisettings.h" +#include "sudachi/util/controller_navigation.h" + +GameListSearchField::KeyReleaseEater::KeyReleaseEater(GameList* gamelist_, QObject* parent) + : QObject(parent), gamelist{gamelist_} {} + +// EventFilter in order to process systemkeys while editing the searchfield +bool GameListSearchField::KeyReleaseEater::eventFilter(QObject* obj, QEvent* event) { + // If it isn't a KeyRelease event then continue with standard event processing + if (event->type() != QEvent::KeyRelease) + return QObject::eventFilter(obj, event); + + QKeyEvent* keyEvent = static_cast(event); + QString edit_filter_text = gamelist->search_field->edit_filter->text().toLower(); + + // If the searchfield's text hasn't changed special function keys get checked + // If no function key changes the searchfield's text the filter doesn't need to get reloaded + if (edit_filter_text == edit_filter_text_old) { + switch (keyEvent->key()) { + // Escape: Resets the searchfield + case Qt::Key_Escape: { + if (edit_filter_text_old.isEmpty()) { + return QObject::eventFilter(obj, event); + } else { + gamelist->search_field->edit_filter->clear(); + edit_filter_text.clear(); + } + break; + } + // Return and Enter + // If the enter key gets pressed first checks how many and which entry is visible + // If there is only one result launch this game + case Qt::Key_Return: + case Qt::Key_Enter: { + if (gamelist->search_field->visible == 1) { + const QString file_path = gamelist->GetLastFilterResultItem(); + + // To avoid loading error dialog loops while confirming them using enter + // Also users usually want to run a different game after closing one + gamelist->search_field->edit_filter->clear(); + edit_filter_text.clear(); + emit gamelist->GameChosen(file_path); + } else { + return QObject::eventFilter(obj, event); + } + break; + } + default: + return QObject::eventFilter(obj, event); + } + } + edit_filter_text_old = edit_filter_text; + return QObject::eventFilter(obj, event); +} + +void GameListSearchField::setFilterResult(int visible_, int total_) { + visible = visible_; + total = total_; + + label_filter_result->setText(tr("%1 of %n result(s)", "", total).arg(visible)); +} + +QString GameListSearchField::filterText() const { + return edit_filter->text(); +} + +QString GameList::GetLastFilterResultItem() const { + QString file_path; + + for (int i = 1; i < item_model->rowCount() - 1; ++i) { + const QStandardItem* folder = item_model->item(i, 0); + const QModelIndex folder_index = folder->index(); + const int children_count = folder->rowCount(); + + for (int j = 0; j < children_count; ++j) { + if (tree_view->isRowHidden(j, folder_index)) { + continue; + } + + const QStandardItem* child = folder->child(j, 0); + file_path = child->data(GameListItemPath::FullPathRole).toString(); + } + } + + return file_path; +} + +void GameListSearchField::clear() { + edit_filter->clear(); +} + +void GameListSearchField::setFocus() { + if (edit_filter->isVisible()) { + edit_filter->setFocus(); + } +} + +GameListSearchField::GameListSearchField(GameList* parent) : QWidget{parent} { + auto* const key_release_eater = new KeyReleaseEater(parent, this); + layout_filter = new QHBoxLayout; + layout_filter->setContentsMargins(8, 8, 8, 8); + label_filter = new QLabel; + edit_filter = new QLineEdit; + edit_filter->clear(); + edit_filter->installEventFilter(key_release_eater); + edit_filter->setClearButtonEnabled(true); + connect(edit_filter, &QLineEdit::textChanged, parent, &GameList::OnTextChanged); + label_filter_result = new QLabel; + button_filter_close = new QToolButton(this); + button_filter_close->setText(QStringLiteral("X")); + button_filter_close->setCursor(Qt::ArrowCursor); + button_filter_close->setStyleSheet( + QStringLiteral("QToolButton{ border: none; padding: 0px; color: " + "#000000; font-weight: bold; background: #F0F0F0; }" + "QToolButton:hover{ border: none; padding: 0px; color: " + "#EEEEEE; font-weight: bold; background: #E81123}")); + connect(button_filter_close, &QToolButton::clicked, parent, &GameList::OnFilterCloseClicked); + layout_filter->setSpacing(10); + layout_filter->addWidget(label_filter); + layout_filter->addWidget(edit_filter); + layout_filter->addWidget(label_filter_result); + layout_filter->addWidget(button_filter_close); + setLayout(layout_filter); + RetranslateUI(); +} + +/** + * Checks if all words separated by spaces are contained in another string + * This offers a word order insensitive search function + * + * @param haystack String that gets checked if it contains all words of the userinput string + * @param userinput String containing all words getting checked + * @return true if the haystack contains all words of userinput + */ +static bool ContainsAllWords(const QString& haystack, const QString& userinput) { + const QStringList userinput_split = userinput.split(QLatin1Char{' '}, Qt::SkipEmptyParts); + + return std::all_of(userinput_split.begin(), userinput_split.end(), + [&haystack](const QString& s) { return haystack.contains(s); }); +} + +// Syncs the expanded state of Game Directories with settings to persist across sessions +void GameList::OnItemExpanded(const QModelIndex& item) { + const auto type = item.data(GameListItem::TypeRole).value(); + const bool is_dir = type == GameListItemType::CustomDir || type == GameListItemType::SdmcDir || + type == GameListItemType::UserNandDir || + type == GameListItemType::SysNandDir; + const bool is_fave = type == GameListItemType::Favorites; + if (!is_dir && !is_fave) { + return; + } + const bool is_expanded = tree_view->isExpanded(item); + if (is_fave) { + UISettings::values.favorites_expanded = is_expanded; + return; + } + const int item_dir_index = item.data(GameListDir::GameDirRole).toInt(); + UISettings::values.game_dirs[item_dir_index].expanded = is_expanded; +} + +// Event in order to filter the gamelist after editing the searchfield +void GameList::OnTextChanged(const QString& new_text) { + QString edit_filter_text = new_text.toLower(); + QStandardItem* folder; + int children_total = 0; + + // If the searchfield is empty every item is visible + // Otherwise the filter gets applied + if (edit_filter_text.isEmpty()) { + tree_view->setRowHidden(0, item_model->invisibleRootItem()->index(), + UISettings::values.favorited_ids.size() == 0); + for (int i = 1; i < item_model->rowCount() - 1; ++i) { + folder = item_model->item(i, 0); + const QModelIndex folder_index = folder->index(); + const int children_count = folder->rowCount(); + for (int j = 0; j < children_count; ++j) { + ++children_total; + tree_view->setRowHidden(j, folder_index, false); + } + } + search_field->setFilterResult(children_total, children_total); + } else { + tree_view->setRowHidden(0, item_model->invisibleRootItem()->index(), true); + int result_count = 0; + for (int i = 1; i < item_model->rowCount() - 1; ++i) { + folder = item_model->item(i, 0); + const QModelIndex folder_index = folder->index(); + const int children_count = folder->rowCount(); + for (int j = 0; j < children_count; ++j) { + ++children_total; + + const QStandardItem* child = folder->child(j, 0); + + const auto program_id = child->data(GameListItemPath::ProgramIdRole).toULongLong(); + + const QString file_path = + child->data(GameListItemPath::FullPathRole).toString().toLower(); + const QString file_title = + child->data(GameListItemPath::TitleRole).toString().toLower(); + const QString file_program_id = + QStringLiteral("%1").arg(program_id, 16, 16, QLatin1Char{'0'}); + + // Only items which filename in combination with its title contains all words + // that are in the searchfield will be visible in the gamelist + // The search is case insensitive because of toLower() + // I decided not to use Qt::CaseInsensitive in containsAllWords to prevent + // multiple conversions of edit_filter_text for each game in the gamelist + const QString file_name = + file_path.mid(file_path.lastIndexOf(QLatin1Char{'/'}) + 1) + QLatin1Char{' '} + + file_title; + if (ContainsAllWords(file_name, edit_filter_text) || + (file_program_id.count() == 16 && file_program_id.contains(edit_filter_text))) { + tree_view->setRowHidden(j, folder_index, false); + ++result_count; + } else { + tree_view->setRowHidden(j, folder_index, true); + } + } + } + search_field->setFilterResult(result_count, children_total); + } +} + +void GameList::OnUpdateThemedIcons() { + for (int i = 0; i < item_model->invisibleRootItem()->rowCount(); i++) { + QStandardItem* child = item_model->invisibleRootItem()->child(i); + + const int icon_size = UISettings::values.folder_icon_size.GetValue(); + + switch (child->data(GameListItem::TypeRole).value()) { + case GameListItemType::SdmcDir: + child->setData( + QIcon::fromTheme(QStringLiteral("sd_card")) + .pixmap(icon_size) + .scaled(icon_size, icon_size, Qt::IgnoreAspectRatio, Qt::SmoothTransformation), + Qt::DecorationRole); + break; + case GameListItemType::UserNandDir: + child->setData( + QIcon::fromTheme(QStringLiteral("chip")) + .pixmap(icon_size) + .scaled(icon_size, icon_size, Qt::IgnoreAspectRatio, Qt::SmoothTransformation), + Qt::DecorationRole); + break; + case GameListItemType::SysNandDir: + child->setData( + QIcon::fromTheme(QStringLiteral("chip")) + .pixmap(icon_size) + .scaled(icon_size, icon_size, Qt::IgnoreAspectRatio, Qt::SmoothTransformation), + Qt::DecorationRole); + break; + case GameListItemType::CustomDir: { + const UISettings::GameDir& game_dir = + UISettings::values.game_dirs[child->data(GameListDir::GameDirRole).toInt()]; + const QString icon_name = QFileInfo::exists(QString::fromStdString(game_dir.path)) + ? QStringLiteral("folder") + : QStringLiteral("bad_folder"); + child->setData( + QIcon::fromTheme(icon_name).pixmap(icon_size).scaled( + icon_size, icon_size, Qt::IgnoreAspectRatio, Qt::SmoothTransformation), + Qt::DecorationRole); + break; + } + case GameListItemType::AddDir: + child->setData( + QIcon::fromTheme(QStringLiteral("list-add")) + .pixmap(icon_size) + .scaled(icon_size, icon_size, Qt::IgnoreAspectRatio, Qt::SmoothTransformation), + Qt::DecorationRole); + break; + case GameListItemType::Favorites: + child->setData( + QIcon::fromTheme(QStringLiteral("star")) + .pixmap(icon_size) + .scaled(icon_size, icon_size, Qt::IgnoreAspectRatio, Qt::SmoothTransformation), + Qt::DecorationRole); + break; + default: + break; + } + } +} + +void GameList::OnFilterCloseClicked() { + main_window->filterBarSetChecked(false); +} + +GameList::GameList(FileSys::VirtualFilesystem vfs_, FileSys::ManualContentProvider* provider_, + PlayTime::PlayTimeManager& play_time_manager_, Core::System& system_, + GMainWindow* parent) + : QWidget{parent}, vfs{std::move(vfs_)}, provider{provider_}, + play_time_manager{play_time_manager_}, system{system_} { + watcher = new QFileSystemWatcher(this); + connect(watcher, &QFileSystemWatcher::directoryChanged, this, &GameList::RefreshGameDirectory); + + this->main_window = parent; + layout = new QVBoxLayout; + tree_view = new QTreeView; + controller_navigation = new ControllerNavigation(system.HIDCore(), this); + search_field = new GameListSearchField(this); + item_model = new QStandardItemModel(tree_view); + tree_view->setModel(item_model); + + tree_view->setAlternatingRowColors(true); + tree_view->setSelectionMode(QHeaderView::SingleSelection); + tree_view->setSelectionBehavior(QHeaderView::SelectRows); + tree_view->setVerticalScrollMode(QHeaderView::ScrollPerPixel); + tree_view->setHorizontalScrollMode(QHeaderView::ScrollPerPixel); + tree_view->setSortingEnabled(true); + tree_view->setEditTriggers(QHeaderView::NoEditTriggers); + tree_view->setContextMenuPolicy(Qt::CustomContextMenu); + tree_view->setStyleSheet(QStringLiteral("QTreeView{ border: none; }")); + + item_model->insertColumns(0, COLUMN_COUNT); + RetranslateUI(); + + tree_view->setColumnHidden(COLUMN_ADD_ONS, !UISettings::values.show_add_ons); + tree_view->setColumnHidden(COLUMN_COMPATIBILITY, !UISettings::values.show_compat); + tree_view->setColumnHidden(COLUMN_PLAY_TIME, !UISettings::values.show_play_time); + item_model->setSortRole(GameListItemPath::SortRole); + + connect(main_window, &GMainWindow::UpdateThemedIcons, this, &GameList::OnUpdateThemedIcons); + connect(tree_view, &QTreeView::activated, this, &GameList::ValidateEntry); + connect(tree_view, &QTreeView::customContextMenuRequested, this, &GameList::PopupContextMenu); + connect(tree_view, &QTreeView::expanded, this, &GameList::OnItemExpanded); + connect(tree_view, &QTreeView::collapsed, this, &GameList::OnItemExpanded); + connect(controller_navigation, &ControllerNavigation::TriggerKeyboardEvent, + [this](Qt::Key key) { + // Avoid pressing buttons while playing + if (system.IsPoweredOn()) { + return; + } + if (!this->isActiveWindow()) { + return; + } + QKeyEvent* event = new QKeyEvent(QEvent::KeyPress, key, Qt::NoModifier); + QCoreApplication::postEvent(tree_view, event); + }); + + // We must register all custom types with the Qt Automoc system so that we are able to use + // it with signals/slots. In this case, QList falls under the umbrells of custom types. + qRegisterMetaType>("QList"); + + layout->setContentsMargins(0, 0, 0, 0); + layout->setSpacing(0); + layout->addWidget(tree_view); + layout->addWidget(search_field); + setLayout(layout); +} + +void GameList::UnloadController() { + controller_navigation->UnloadController(); +} + +GameList::~GameList() { + UnloadController(); +} + +void GameList::SetFilterFocus() { + if (tree_view->model()->rowCount() > 0) { + search_field->setFocus(); + } +} + +void GameList::SetFilterVisible(bool visibility) { + search_field->setVisible(visibility); +} + +void GameList::ClearFilter() { + search_field->clear(); +} + +void GameList::WorkerEvent() { + current_worker->ProcessEvents(this); +} + +void GameList::AddDirEntry(GameListDir* entry_items) { + item_model->invisibleRootItem()->appendRow(entry_items); + tree_view->setExpanded( + entry_items->index(), + UISettings::values.game_dirs[entry_items->data(GameListDir::GameDirRole).toInt()].expanded); +} + +void GameList::AddEntry(const QList& entry_items, GameListDir* parent) { + parent->appendRow(entry_items); +} + +void GameList::ValidateEntry(const QModelIndex& item) { + const auto selected = item.sibling(item.row(), 0); + + switch (selected.data(GameListItem::TypeRole).value()) { + case GameListItemType::Game: { + const QString file_path = selected.data(GameListItemPath::FullPathRole).toString(); + if (file_path.isEmpty()) + return; + const QFileInfo file_info(file_path); + if (!file_info.exists()) + return; + + if (file_info.isDir()) { + const QDir dir{file_path}; + const QStringList matching_main = dir.entryList({QStringLiteral("main")}, QDir::Files); + if (matching_main.size() == 1) { + emit GameChosen(dir.path() + QDir::separator() + matching_main[0]); + } + return; + } + + const auto title_id = selected.data(GameListItemPath::ProgramIdRole).toULongLong(); + + // Users usually want to run a different game after closing one + search_field->clear(); + emit GameChosen(file_path, title_id); + break; + } + case GameListItemType::AddDir: + emit AddDirectory(); + break; + default: + break; + } +} + +bool GameList::IsEmpty() const { + for (int i = 0; i < item_model->rowCount(); i++) { + const QStandardItem* child = item_model->invisibleRootItem()->child(i); + const auto type = static_cast(child->type()); + + if (!child->hasChildren() && + (type == GameListItemType::SdmcDir || type == GameListItemType::UserNandDir || + type == GameListItemType::SysNandDir)) { + item_model->invisibleRootItem()->removeRow(child->row()); + i--; + } + } + + return !item_model->invisibleRootItem()->hasChildren(); +} + +void GameList::DonePopulating(const QStringList& watch_list) { + emit ShowList(!IsEmpty()); + + item_model->invisibleRootItem()->appendRow(new GameListAddDir()); + + // Add favorites row + item_model->invisibleRootItem()->insertRow(0, new GameListFavorites()); + tree_view->setRowHidden(0, item_model->invisibleRootItem()->index(), + UISettings::values.favorited_ids.size() == 0); + tree_view->setExpanded(item_model->invisibleRootItem()->child(0)->index(), + UISettings::values.favorites_expanded.GetValue()); + for (const auto id : UISettings::values.favorited_ids) { + AddFavorite(id); + } + + // Clear out the old directories to watch for changes and add the new ones + auto watch_dirs = watcher->directories(); + if (!watch_dirs.isEmpty()) { + watcher->removePaths(watch_dirs); + } + // Workaround: Add the watch paths in chunks to allow the gui to refresh + // This prevents the UI from stalling when a large number of watch paths are added + // Also artificially caps the watcher to a certain number of directories + constexpr int LIMIT_WATCH_DIRECTORIES = 5000; + constexpr int SLICE_SIZE = 25; + int len = std::min(static_cast(watch_list.size()), LIMIT_WATCH_DIRECTORIES); + for (int i = 0; i < len; i += SLICE_SIZE) { + watcher->addPaths(watch_list.mid(i, i + SLICE_SIZE)); + QCoreApplication::processEvents(); + } + tree_view->setEnabled(true); + int children_total = 0; + for (int i = 1; i < item_model->rowCount() - 1; ++i) { + children_total += item_model->item(i, 0)->rowCount(); + } + search_field->setFilterResult(children_total, children_total); + if (children_total > 0) { + search_field->setFocus(); + } + item_model->sort(tree_view->header()->sortIndicatorSection(), + tree_view->header()->sortIndicatorOrder()); + + emit PopulatingCompleted(); +} + +void GameList::PopupContextMenu(const QPoint& menu_location) { + QModelIndex item = tree_view->indexAt(menu_location); + if (!item.isValid()) + return; + + const auto selected = item.sibling(item.row(), 0); + QMenu context_menu; + switch (selected.data(GameListItem::TypeRole).value()) { + case GameListItemType::Game: + AddGamePopup(context_menu, selected.data(GameListItemPath::ProgramIdRole).toULongLong(), + selected.data(GameListItemPath::FullPathRole).toString().toStdString()); + break; + case GameListItemType::CustomDir: + AddPermDirPopup(context_menu, selected); + AddCustomDirPopup(context_menu, selected); + break; + case GameListItemType::SdmcDir: + case GameListItemType::UserNandDir: + case GameListItemType::SysNandDir: + AddPermDirPopup(context_menu, selected); + break; + case GameListItemType::Favorites: + AddFavoritesPopup(context_menu); + break; + default: + break; + } + context_menu.exec(tree_view->viewport()->mapToGlobal(menu_location)); +} + +void GameList::AddGamePopup(QMenu& context_menu, u64 program_id, const std::string& path) { + QAction* favorite = context_menu.addAction(tr("Favorite")); + context_menu.addSeparator(); + QAction* start_game = context_menu.addAction(tr("Start Game")); + QAction* start_game_global = + context_menu.addAction(tr("Start Game without Custom Configuration")); + context_menu.addSeparator(); + QAction* open_save_location = context_menu.addAction(tr("Open Save Data Location")); + QAction* open_mod_location = context_menu.addAction(tr("Open Mod Data Location")); + QAction* open_transferable_shader_cache = + context_menu.addAction(tr("Open Transferable Pipeline Cache")); + context_menu.addSeparator(); + QMenu* remove_menu = context_menu.addMenu(tr("Remove")); + QAction* remove_update = remove_menu->addAction(tr("Remove Installed Update")); + QAction* remove_dlc = remove_menu->addAction(tr("Remove All Installed DLC")); + QAction* remove_custom_config = remove_menu->addAction(tr("Remove Custom Configuration")); + QAction* remove_play_time_data = remove_menu->addAction(tr("Remove Play Time Data")); + QAction* remove_cache_storage = remove_menu->addAction(tr("Remove Cache Storage")); + QAction* remove_gl_shader_cache = remove_menu->addAction(tr("Remove OpenGL Pipeline Cache")); + QAction* remove_vk_shader_cache = remove_menu->addAction(tr("Remove Vulkan Pipeline Cache")); + remove_menu->addSeparator(); + QAction* remove_shader_cache = remove_menu->addAction(tr("Remove All Pipeline Caches")); + QAction* remove_all_content = remove_menu->addAction(tr("Remove All Installed Contents")); + QMenu* dump_romfs_menu = context_menu.addMenu(tr("Dump RomFS")); + QAction* dump_romfs = dump_romfs_menu->addAction(tr("Dump RomFS")); + QAction* dump_romfs_sdmc = dump_romfs_menu->addAction(tr("Dump RomFS to SDMC")); + QAction* verify_integrity = context_menu.addAction(tr("Verify Integrity")); + QAction* copy_tid = context_menu.addAction(tr("Copy Title ID to Clipboard")); + QAction* navigate_to_gamedb_entry = context_menu.addAction(tr("Navigate to GameDB entry")); +// TODO: Implement shortcut creation for macOS +#if !defined(__APPLE__) + QMenu* shortcut_menu = context_menu.addMenu(tr("Create Shortcut")); + QAction* create_desktop_shortcut = shortcut_menu->addAction(tr("Add to Desktop")); + QAction* create_applications_menu_shortcut = + shortcut_menu->addAction(tr("Add to Applications Menu")); +#endif + context_menu.addSeparator(); + QAction* properties = context_menu.addAction(tr("Properties")); + + favorite->setVisible(program_id != 0); + favorite->setCheckable(true); + favorite->setChecked(UISettings::values.favorited_ids.contains(program_id)); + open_save_location->setVisible(program_id != 0); + open_mod_location->setVisible(program_id != 0); + open_transferable_shader_cache->setVisible(program_id != 0); + remove_update->setVisible(program_id != 0); + remove_dlc->setVisible(program_id != 0); + remove_gl_shader_cache->setVisible(program_id != 0); + remove_vk_shader_cache->setVisible(program_id != 0); + remove_shader_cache->setVisible(program_id != 0); + remove_all_content->setVisible(program_id != 0); + auto it = FindMatchingCompatibilityEntry(compatibility_list, program_id); + navigate_to_gamedb_entry->setVisible(it != compatibility_list.end() && program_id != 0); + + connect(favorite, &QAction::triggered, [this, program_id]() { ToggleFavorite(program_id); }); + connect(open_save_location, &QAction::triggered, [this, program_id, path]() { + emit OpenFolderRequested(program_id, GameListOpenTarget::SaveData, path); + }); + connect(start_game, &QAction::triggered, + [this, path]() { emit BootGame(QString::fromStdString(path), StartGameType::Normal); }); + connect(start_game_global, &QAction::triggered, + [this, path]() { emit BootGame(QString::fromStdString(path), StartGameType::Global); }); + connect(open_mod_location, &QAction::triggered, [this, program_id, path]() { + emit OpenFolderRequested(program_id, GameListOpenTarget::ModData, path); + }); + connect(open_transferable_shader_cache, &QAction::triggered, + [this, program_id]() { emit OpenTransferableShaderCacheRequested(program_id); }); + connect(remove_all_content, &QAction::triggered, [this, program_id]() { + emit RemoveInstalledEntryRequested(program_id, InstalledEntryType::Game); + }); + connect(remove_update, &QAction::triggered, [this, program_id]() { + emit RemoveInstalledEntryRequested(program_id, InstalledEntryType::Update); + }); + connect(remove_dlc, &QAction::triggered, [this, program_id]() { + emit RemoveInstalledEntryRequested(program_id, InstalledEntryType::AddOnContent); + }); + connect(remove_gl_shader_cache, &QAction::triggered, [this, program_id, path]() { + emit RemoveFileRequested(program_id, GameListRemoveTarget::GlShaderCache, path); + }); + connect(remove_vk_shader_cache, &QAction::triggered, [this, program_id, path]() { + emit RemoveFileRequested(program_id, GameListRemoveTarget::VkShaderCache, path); + }); + connect(remove_shader_cache, &QAction::triggered, [this, program_id, path]() { + emit RemoveFileRequested(program_id, GameListRemoveTarget::AllShaderCache, path); + }); + connect(remove_custom_config, &QAction::triggered, [this, program_id, path]() { + emit RemoveFileRequested(program_id, GameListRemoveTarget::CustomConfiguration, path); + }); + connect(remove_play_time_data, &QAction::triggered, + [this, program_id]() { emit RemovePlayTimeRequested(program_id); }); + connect(remove_cache_storage, &QAction::triggered, [this, program_id, path] { + emit RemoveFileRequested(program_id, GameListRemoveTarget::CacheStorage, path); + }); + connect(dump_romfs, &QAction::triggered, [this, program_id, path]() { + emit DumpRomFSRequested(program_id, path, DumpRomFSTarget::Normal); + }); + connect(dump_romfs_sdmc, &QAction::triggered, [this, program_id, path]() { + emit DumpRomFSRequested(program_id, path, DumpRomFSTarget::SDMC); + }); + connect(verify_integrity, &QAction::triggered, + [this, path]() { emit VerifyIntegrityRequested(path); }); + connect(copy_tid, &QAction::triggered, + [this, program_id]() { emit CopyTIDRequested(program_id); }); + connect(navigate_to_gamedb_entry, &QAction::triggered, [this, program_id]() { + emit NavigateToGamedbEntryRequested(program_id, compatibility_list); + }); +// TODO: Implement shortcut creation for macOS +#if !defined(__APPLE__) + connect(create_desktop_shortcut, &QAction::triggered, [this, program_id, path]() { + emit CreateShortcut(program_id, path, GameListShortcutTarget::Desktop); + }); + connect(create_applications_menu_shortcut, &QAction::triggered, [this, program_id, path]() { + emit CreateShortcut(program_id, path, GameListShortcutTarget::Applications); + }); +#endif + connect(properties, &QAction::triggered, + [this, path]() { emit OpenPerGameGeneralRequested(path); }); +}; + +void GameList::AddCustomDirPopup(QMenu& context_menu, QModelIndex selected) { + UISettings::GameDir& game_dir = + UISettings::values.game_dirs[selected.data(GameListDir::GameDirRole).toInt()]; + + QAction* deep_scan = context_menu.addAction(tr("Scan Subfolders")); + QAction* delete_dir = context_menu.addAction(tr("Remove Game Directory")); + + deep_scan->setCheckable(true); + deep_scan->setChecked(game_dir.deep_scan); + + connect(deep_scan, &QAction::triggered, [this, &game_dir] { + game_dir.deep_scan = !game_dir.deep_scan; + PopulateAsync(UISettings::values.game_dirs); + }); + connect(delete_dir, &QAction::triggered, [this, &game_dir, selected] { + UISettings::values.game_dirs.removeOne(game_dir); + item_model->invisibleRootItem()->removeRow(selected.row()); + OnTextChanged(search_field->filterText()); + }); +} + +void GameList::AddPermDirPopup(QMenu& context_menu, QModelIndex selected) { + const int game_dir_index = selected.data(GameListDir::GameDirRole).toInt(); + + QAction* move_up = context_menu.addAction(tr("\u25B2 Move Up")); + QAction* move_down = context_menu.addAction(tr("\u25bc Move Down")); + QAction* open_directory_location = context_menu.addAction(tr("Open Directory Location")); + + const int row = selected.row(); + + move_up->setEnabled(row > 1); + move_down->setEnabled(row < item_model->rowCount() - 2); + + connect(move_up, &QAction::triggered, [this, selected, row, game_dir_index] { + const int other_index = selected.sibling(row - 1, 0).data(GameListDir::GameDirRole).toInt(); + // swap the items in the settings + std::swap(UISettings::values.game_dirs[game_dir_index], + UISettings::values.game_dirs[other_index]); + // swap the indexes held by the QVariants + item_model->setData(selected, QVariant(other_index), GameListDir::GameDirRole); + item_model->setData(selected.sibling(row - 1, 0), QVariant(game_dir_index), + GameListDir::GameDirRole); + // move the treeview items + QList item = item_model->takeRow(row); + item_model->invisibleRootItem()->insertRow(row - 1, item); + tree_view->setExpanded(selected.sibling(row - 1, 0), + UISettings::values.game_dirs[other_index].expanded); + }); + + connect(move_down, &QAction::triggered, [this, selected, row, game_dir_index] { + const int other_index = selected.sibling(row + 1, 0).data(GameListDir::GameDirRole).toInt(); + // swap the items in the settings + std::swap(UISettings::values.game_dirs[game_dir_index], + UISettings::values.game_dirs[other_index]); + // swap the indexes held by the QVariants + item_model->setData(selected, QVariant(other_index), GameListDir::GameDirRole); + item_model->setData(selected.sibling(row + 1, 0), QVariant(game_dir_index), + GameListDir::GameDirRole); + // move the treeview items + const QList item = item_model->takeRow(row); + item_model->invisibleRootItem()->insertRow(row + 1, item); + tree_view->setExpanded(selected.sibling(row + 1, 0), + UISettings::values.game_dirs[other_index].expanded); + }); + + connect(open_directory_location, &QAction::triggered, [this, game_dir_index] { + emit OpenDirectory( + QString::fromStdString(UISettings::values.game_dirs[game_dir_index].path)); + }); +} + +void GameList::AddFavoritesPopup(QMenu& context_menu) { + QAction* clear = context_menu.addAction(tr("Clear")); + + connect(clear, &QAction::triggered, [this] { + for (const auto id : UISettings::values.favorited_ids) { + RemoveFavorite(id); + } + UISettings::values.favorited_ids.clear(); + tree_view->setRowHidden(0, item_model->invisibleRootItem()->index(), true); + }); +} + +void GameList::LoadCompatibilityList() { + QFile compat_list{QStringLiteral(":compatibility_list/compatibility_list.json")}; + + if (!compat_list.open(QFile::ReadOnly | QFile::Text)) { + LOG_ERROR(Frontend, "Unable to open game compatibility list"); + return; + } + + if (compat_list.size() == 0) { + LOG_WARNING(Frontend, "Game compatibility list is empty"); + return; + } + + const QByteArray content = compat_list.readAll(); + if (content.isEmpty()) { + LOG_ERROR(Frontend, "Unable to completely read game compatibility list"); + return; + } + + const QJsonDocument json = QJsonDocument::fromJson(content); + const QJsonArray arr = json.array(); + + for (const QJsonValue value : arr) { + const QJsonObject game = value.toObject(); + const QString compatibility_key = QStringLiteral("compatibility"); + + if (!game.contains(compatibility_key) || !game[compatibility_key].isDouble()) { + continue; + } + + const int compatibility = game[compatibility_key].toInt(); + const QString directory = game[QStringLiteral("directory")].toString(); + const QJsonArray ids = game[QStringLiteral("releases")].toArray(); + + for (const QJsonValue id_ref : ids) { + const QJsonObject id_object = id_ref.toObject(); + const QString id = id_object[QStringLiteral("id")].toString(); + + compatibility_list.emplace(id.toUpper().toStdString(), + std::make_pair(QString::number(compatibility), directory)); + } + } +} + +void GameList::changeEvent(QEvent* event) { + if (event->type() == QEvent::LanguageChange) { + RetranslateUI(); + } + + QWidget::changeEvent(event); +} + +void GameList::RetranslateUI() { + item_model->setHeaderData(COLUMN_NAME, Qt::Horizontal, tr("Name")); + item_model->setHeaderData(COLUMN_COMPATIBILITY, Qt::Horizontal, tr("Compatibility")); + item_model->setHeaderData(COLUMN_ADD_ONS, Qt::Horizontal, tr("Add-ons")); + item_model->setHeaderData(COLUMN_FILE_TYPE, Qt::Horizontal, tr("File type")); + item_model->setHeaderData(COLUMN_SIZE, Qt::Horizontal, tr("Size")); + item_model->setHeaderData(COLUMN_PLAY_TIME, Qt::Horizontal, tr("Play time")); +} + +void GameListSearchField::changeEvent(QEvent* event) { + if (event->type() == QEvent::LanguageChange) { + RetranslateUI(); + } + + QWidget::changeEvent(event); +} + +void GameListSearchField::RetranslateUI() { + label_filter->setText(tr("Filter:")); + edit_filter->setPlaceholderText(tr("Enter pattern to filter")); +} + +QStandardItemModel* GameList::GetModel() const { + return item_model; +} + +void GameList::PopulateAsync(QVector& game_dirs) { + tree_view->setEnabled(false); + + // Update the columns in case UISettings has changed + tree_view->setColumnHidden(COLUMN_ADD_ONS, !UISettings::values.show_add_ons); + tree_view->setColumnHidden(COLUMN_COMPATIBILITY, !UISettings::values.show_compat); + tree_view->setColumnHidden(COLUMN_FILE_TYPE, !UISettings::values.show_types); + tree_view->setColumnHidden(COLUMN_SIZE, !UISettings::values.show_size); + tree_view->setColumnHidden(COLUMN_PLAY_TIME, !UISettings::values.show_play_time); + + // Cancel any existing worker. + current_worker.reset(); + + // Delete any rows that might already exist if we're repopulating + item_model->removeRows(0, item_model->rowCount()); + search_field->clear(); + + current_worker = std::make_unique(vfs, provider, game_dirs, compatibility_list, + play_time_manager, system); + + // Get events from the worker as data becomes available + connect(current_worker.get(), &GameListWorker::DataAvailable, this, &GameList::WorkerEvent, + Qt::QueuedConnection); + + QThreadPool::globalInstance()->start(current_worker.get()); +} + +void GameList::SaveInterfaceLayout() { + UISettings::values.gamelist_header_state = tree_view->header()->saveState(); +} + +void GameList::LoadInterfaceLayout() { + auto* header = tree_view->header(); + + if (header->restoreState(UISettings::values.gamelist_header_state)) { + return; + } + + // We are using the name column to display icons and titles + // so make it as large as possible as default. + header->resizeSection(COLUMN_NAME, header->width()); +} + +const QStringList GameList::supported_file_extensions = { + QStringLiteral("nso"), QStringLiteral("nro"), QStringLiteral("nca"), + QStringLiteral("xci"), QStringLiteral("nsp"), QStringLiteral("kip")}; + +void GameList::RefreshGameDirectory() { + if (!UISettings::values.game_dirs.empty() && current_worker != nullptr) { + LOG_INFO(Frontend, "Change detected in the games directory. Reloading game list."); + PopulateAsync(UISettings::values.game_dirs); + } +} + +void GameList::ToggleFavorite(u64 program_id) { + if (!UISettings::values.favorited_ids.contains(program_id)) { + tree_view->setRowHidden(0, item_model->invisibleRootItem()->index(), + !search_field->filterText().isEmpty()); + UISettings::values.favorited_ids.append(program_id); + AddFavorite(program_id); + item_model->sort(tree_view->header()->sortIndicatorSection(), + tree_view->header()->sortIndicatorOrder()); + } else { + UISettings::values.favorited_ids.removeOne(program_id); + RemoveFavorite(program_id); + if (UISettings::values.favorited_ids.size() == 0) { + tree_view->setRowHidden(0, item_model->invisibleRootItem()->index(), true); + } + } + SaveConfig(); +} + +void GameList::AddFavorite(u64 program_id) { + auto* favorites_row = item_model->item(0); + + for (int i = 1; i < item_model->rowCount() - 1; i++) { + const auto* folder = item_model->item(i); + for (int j = 0; j < folder->rowCount(); j++) { + if (folder->child(j)->data(GameListItemPath::ProgramIdRole).toULongLong() == + program_id) { + QList list; + for (int k = 0; k < COLUMN_COUNT; k++) { + list.append(folder->child(j, k)->clone()); + } + list[0]->setData(folder->child(j)->data(GameListItem::SortRole), + GameListItem::SortRole); + list[0]->setText(folder->child(j)->data(Qt::DisplayRole).toString()); + + favorites_row->appendRow(list); + return; + } + } + } +} + +void GameList::RemoveFavorite(u64 program_id) { + auto* favorites_row = item_model->item(0); + + for (int i = 0; i < favorites_row->rowCount(); i++) { + const auto* game = favorites_row->child(i); + if (game->data(GameListItemPath::ProgramIdRole).toULongLong() == program_id) { + favorites_row->removeRow(i); + return; + } + } +} + +GameListPlaceholder::GameListPlaceholder(GMainWindow* parent) : QWidget{parent} { + connect(parent, &GMainWindow::UpdateThemedIcons, this, + &GameListPlaceholder::onUpdateThemedIcons); + + layout = new QVBoxLayout; + image = new QLabel; + text = new QLabel; + layout->setAlignment(Qt::AlignCenter); + image->setPixmap(QIcon::fromTheme(QStringLiteral("plus_folder")).pixmap(200)); + + RetranslateUI(); + QFont font = text->font(); + font.setPointSize(20); + text->setFont(font); + text->setAlignment(Qt::AlignHCenter); + image->setAlignment(Qt::AlignHCenter); + + layout->addWidget(image); + layout->addWidget(text); + setLayout(layout); +} + +GameListPlaceholder::~GameListPlaceholder() = default; + +void GameListPlaceholder::onUpdateThemedIcons() { + image->setPixmap(QIcon::fromTheme(QStringLiteral("plus_folder")).pixmap(200)); +} + +void GameListPlaceholder::mouseDoubleClickEvent(QMouseEvent* event) { + emit GameListPlaceholder::AddDirectory(); +} + +void GameListPlaceholder::changeEvent(QEvent* event) { + if (event->type() == QEvent::LanguageChange) { + RetranslateUI(); + } + + QWidget::changeEvent(event); +} + +void GameListPlaceholder::RetranslateUI() { + text->setText(tr("Double-click to add a new folder to the game list")); +} diff --git a/src/sudachi/game_list.h b/src/sudachi/game_list.h new file mode 100644 index 0000000..3135e91 --- /dev/null +++ b/src/sudachi/game_list.h @@ -0,0 +1,204 @@ +// SPDX-FileCopyrightText: 2015 Citra Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "common/common_types.h" +#include "core/core.h" +#include "uisettings.h" +#include "sudachi/compatibility_list.h" +#include "sudachi/play_time_manager.h" + +namespace Core { +class System; +} + +class ControllerNavigation; +class GameListWorker; +class GameListSearchField; +class GameListDir; +class GMainWindow; +enum class AmLaunchType; +enum class StartGameType; + +namespace FileSys { +class ManualContentProvider; +class VfsFilesystem; +} // namespace FileSys + +enum class GameListOpenTarget { + SaveData, + ModData, +}; + +enum class GameListRemoveTarget { + GlShaderCache, + VkShaderCache, + AllShaderCache, + CustomConfiguration, + CacheStorage, +}; + +enum class DumpRomFSTarget { + Normal, + SDMC, +}; + +enum class GameListShortcutTarget { + Desktop, + Applications, +}; + +enum class InstalledEntryType { + Game, + Update, + AddOnContent, +}; + +class GameList : public QWidget { + Q_OBJECT + +public: + enum { + COLUMN_NAME, + COLUMN_COMPATIBILITY, + COLUMN_ADD_ONS, + COLUMN_FILE_TYPE, + COLUMN_SIZE, + COLUMN_PLAY_TIME, + COLUMN_COUNT, // Number of columns + }; + + explicit GameList(std::shared_ptr vfs_, + FileSys::ManualContentProvider* provider_, + PlayTime::PlayTimeManager& play_time_manager_, Core::System& system_, + GMainWindow* parent = nullptr); + ~GameList() override; + + QString GetLastFilterResultItem() const; + void ClearFilter(); + void SetFilterFocus(); + void SetFilterVisible(bool visibility); + bool IsEmpty() const; + + void LoadCompatibilityList(); + void PopulateAsync(QVector& game_dirs); + + void SaveInterfaceLayout(); + void LoadInterfaceLayout(); + + QStandardItemModel* GetModel() const; + + /// Disables events from the emulated controller + void UnloadController(); + + static const QStringList supported_file_extensions; + +signals: + void BootGame(const QString& game_path, StartGameType type); + void GameChosen(const QString& game_path, const u64 title_id = 0); + void OpenFolderRequested(u64 program_id, GameListOpenTarget target, + const std::string& game_path); + void OpenTransferableShaderCacheRequested(u64 program_id); + void RemoveInstalledEntryRequested(u64 program_id, InstalledEntryType type); + void RemoveFileRequested(u64 program_id, GameListRemoveTarget target, + const std::string& game_path); + void RemovePlayTimeRequested(u64 program_id); + void DumpRomFSRequested(u64 program_id, const std::string& game_path, DumpRomFSTarget target); + void VerifyIntegrityRequested(const std::string& game_path); + void CopyTIDRequested(u64 program_id); + void CreateShortcut(u64 program_id, const std::string& game_path, + GameListShortcutTarget target); + void NavigateToGamedbEntryRequested(u64 program_id, + const CompatibilityList& compatibility_list); + void OpenPerGameGeneralRequested(const std::string& file); + void OpenDirectory(const QString& directory); + void AddDirectory(); + void ShowList(bool show); + void PopulatingCompleted(); + void SaveConfig(); + +private slots: + void OnItemExpanded(const QModelIndex& item); + void OnTextChanged(const QString& new_text); + void OnFilterCloseClicked(); + void OnUpdateThemedIcons(); + +private: + friend class GameListWorker; + void WorkerEvent(); + + void AddDirEntry(GameListDir* entry_items); + void AddEntry(const QList& entry_items, GameListDir* parent); + void DonePopulating(const QStringList& watch_list); + +private: + void ValidateEntry(const QModelIndex& item); + + void RefreshGameDirectory(); + + void ToggleFavorite(u64 program_id); + void AddFavorite(u64 program_id); + void RemoveFavorite(u64 program_id); + + void PopupContextMenu(const QPoint& menu_location); + void AddGamePopup(QMenu& context_menu, u64 program_id, const std::string& path); + void AddCustomDirPopup(QMenu& context_menu, QModelIndex selected); + void AddPermDirPopup(QMenu& context_menu, QModelIndex selected); + void AddFavoritesPopup(QMenu& context_menu); + + void changeEvent(QEvent*) override; + void RetranslateUI(); + + std::shared_ptr vfs; + FileSys::ManualContentProvider* provider; + GameListSearchField* search_field; + GMainWindow* main_window = nullptr; + QVBoxLayout* layout = nullptr; + QTreeView* tree_view = nullptr; + QStandardItemModel* item_model = nullptr; + std::unique_ptr current_worker; + QFileSystemWatcher* watcher = nullptr; + ControllerNavigation* controller_navigation = nullptr; + CompatibilityList compatibility_list; + + friend class GameListSearchField; + + const PlayTime::PlayTimeManager& play_time_manager; + Core::System& system; +}; + +class GameListPlaceholder : public QWidget { + Q_OBJECT +public: + explicit GameListPlaceholder(GMainWindow* parent = nullptr); + ~GameListPlaceholder(); + +signals: + void AddDirectory(); + +private slots: + void onUpdateThemedIcons(); + +protected: + void mouseDoubleClickEvent(QMouseEvent* event) override; + +private: + void changeEvent(QEvent* event) override; + void RetranslateUI(); + + QVBoxLayout* layout = nullptr; + QLabel* image = nullptr; + QLabel* text = nullptr; +}; diff --git a/src/sudachi/game_list_p.h b/src/sudachi/game_list_p.h new file mode 100644 index 0000000..e57539a --- /dev/null +++ b/src/sudachi/game_list_p.h @@ -0,0 +1,408 @@ +// SPDX-FileCopyrightText: 2015 Citra Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +#include "common/common_types.h" +#include "common/logging/log.h" +#include "common/string_util.h" +#include "sudachi/play_time_manager.h" +#include "sudachi/uisettings.h" +#include "sudachi/util/util.h" + +enum class GameListItemType { + Game = QStandardItem::UserType + 1, + CustomDir = QStandardItem::UserType + 2, + SdmcDir = QStandardItem::UserType + 3, + UserNandDir = QStandardItem::UserType + 4, + SysNandDir = QStandardItem::UserType + 5, + AddDir = QStandardItem::UserType + 6, + Favorites = QStandardItem::UserType + 7, +}; + +Q_DECLARE_METATYPE(GameListItemType); + +/** + * Gets the default icon (for games without valid title metadata) + * @param size The desired width and height of the default icon. + * @return QPixmap default icon + */ +static QPixmap GetDefaultIcon(u32 size) { + QPixmap icon(size, size); + icon.fill(Qt::transparent); + return icon; +} + +class GameListItem : public QStandardItem { + +public: + // used to access type from item index + static constexpr int TypeRole = Qt::UserRole + 1; + static constexpr int SortRole = Qt::UserRole + 2; + GameListItem() = default; + explicit GameListItem(const QString& string) : QStandardItem(string) { + setData(string, SortRole); + } +}; + +/** + * A specialization of GameListItem for path values. + * This class ensures that for every full path value it holds, a correct string representation + * of just the filename (with no extension) will be displayed to the user. + * If this class receives valid title metadata, it will also display game icons and titles. + */ +class GameListItemPath : public GameListItem { +public: + static constexpr int TitleRole = SortRole + 1; + static constexpr int FullPathRole = SortRole + 2; + static constexpr int ProgramIdRole = SortRole + 3; + static constexpr int FileTypeRole = SortRole + 4; + + GameListItemPath() = default; + GameListItemPath(const QString& game_path, const std::vector& picture_data, + const QString& game_name, const QString& game_type, u64 program_id) { + setData(type(), TypeRole); + setData(game_path, FullPathRole); + setData(game_name, TitleRole); + setData(qulonglong(program_id), ProgramIdRole); + setData(game_type, FileTypeRole); + + const u32 size = UISettings::values.game_icon_size.GetValue(); + + QPixmap picture; + if (!picture.loadFromData(picture_data.data(), static_cast(picture_data.size()))) { + picture = GetDefaultIcon(size); + } + picture = picture.scaled(size, size, Qt::IgnoreAspectRatio, Qt::SmoothTransformation); + + setData(picture, Qt::DecorationRole); + } + + int type() const override { + return static_cast(GameListItemType::Game); + } + + QVariant data(int role) const override { + if (role == Qt::DisplayRole || role == SortRole) { + std::string filename; + Common::SplitPath(data(FullPathRole).toString().toStdString(), nullptr, &filename, + nullptr); + + const std::array row_data{{ + QString::fromStdString(filename), + data(FileTypeRole).toString(), + QString::fromStdString(fmt::format("0x{:016X}", data(ProgramIdRole).toULongLong())), + data(TitleRole).toString(), + }}; + + const auto& row1 = row_data.at(UISettings::values.row_1_text_id.GetValue()); + const int row2_id = UISettings::values.row_2_text_id.GetValue(); + + if (role == SortRole) { + return row1.toLower(); + } + + // None + if (row2_id == 4) { + return row1; + } + + const auto& row2 = row_data.at(row2_id); + + if (row1 == row2) { + return row1; + } + + return QStringLiteral("%1\n %2").arg(row1, row2); + } + + return GameListItem::data(role); + } +}; + +class GameListItemCompat : public GameListItem { + Q_DECLARE_TR_FUNCTIONS(GameListItemCompat) +public: + static constexpr int CompatNumberRole = SortRole; + GameListItemCompat() = default; + explicit GameListItemCompat(const QString& compatibility) { + setData(type(), TypeRole); + + struct CompatStatus { + QString color; + const char* text; + const char* tooltip; + }; + // clang-format off + const auto ingame_status = + CompatStatus{QStringLiteral("#f2d624"), QT_TR_NOOP("Ingame"), QT_TR_NOOP("Game starts, but crashes or major glitches prevent it from being completed.")}; + static const std::map status_data = { + {QStringLiteral("0"), {QStringLiteral("#5c93ed"), QT_TR_NOOP("Perfect"), QT_TR_NOOP("Game can be played without issues.")}}, + {QStringLiteral("1"), {QStringLiteral("#47d35c"), QT_TR_NOOP("Playable"), QT_TR_NOOP("Game functions with minor graphical or audio glitches and is playable from start to finish.")}}, + {QStringLiteral("2"), ingame_status}, + {QStringLiteral("3"), ingame_status}, // Fallback for the removed "Okay" category + {QStringLiteral("4"), {QStringLiteral("#FF0000"), QT_TR_NOOP("Intro/Menu"), QT_TR_NOOP("Game loads, but is unable to progress past the Start Screen.")}}, + {QStringLiteral("5"), {QStringLiteral("#828282"), QT_TR_NOOP("Won't Boot"), QT_TR_NOOP("The game crashes when attempting to startup.")}}, + {QStringLiteral("99"), {QStringLiteral("#000000"), QT_TR_NOOP("Not Tested"), QT_TR_NOOP("The game has not yet been tested.")}}, + }; + // clang-format on + + auto iterator = status_data.find(compatibility); + if (iterator == status_data.end()) { + LOG_WARNING(Frontend, "Invalid compatibility number {}", compatibility.toStdString()); + return; + } + const CompatStatus& status = iterator->second; + setData(compatibility, CompatNumberRole); + setText(tr(status.text)); + setToolTip(tr(status.tooltip)); + setData(CreateCirclePixmapFromColor(status.color), Qt::DecorationRole); + } + + int type() const override { + return static_cast(GameListItemType::Game); + } + + bool operator<(const QStandardItem& other) const override { + return data(CompatNumberRole).value() < + other.data(CompatNumberRole).value(); + } +}; + +/** + * A specialization of GameListItem for size values. + * This class ensures that for every numerical size value it holds (in bytes), a correct + * human-readable string representation will be displayed to the user. + */ +class GameListItemSize : public GameListItem { +public: + static constexpr int SizeRole = SortRole; + + GameListItemSize() = default; + explicit GameListItemSize(const qulonglong size_bytes) { + setData(type(), TypeRole); + setData(size_bytes, SizeRole); + } + + void setData(const QVariant& value, int role) override { + // By specializing setData for SizeRole, we can ensure that the numerical and string + // representations of the data are always accurate and in the correct format. + if (role == SizeRole) { + qulonglong size_bytes = value.toULongLong(); + GameListItem::setData(ReadableByteSize(size_bytes), Qt::DisplayRole); + GameListItem::setData(value, SizeRole); + } else { + GameListItem::setData(value, role); + } + } + + int type() const override { + return static_cast(GameListItemType::Game); + } + + /** + * This operator is, in practice, only used by the TreeView sorting systems. + * Override it so that it will correctly sort by numerical value instead of by string + * representation. + */ + bool operator<(const QStandardItem& other) const override { + return data(SizeRole).toULongLong() < other.data(SizeRole).toULongLong(); + } +}; + +/** + * GameListItem for Play Time values. + * This object stores the play time of a game in seconds, and its readable + * representation in minutes/hours + */ +class GameListItemPlayTime : public GameListItem { +public: + static constexpr int PlayTimeRole = SortRole; + + GameListItemPlayTime() = default; + explicit GameListItemPlayTime(const qulonglong time_seconds) { + setData(time_seconds, PlayTimeRole); + } + + void setData(const QVariant& value, int role) override { + qulonglong time_seconds = value.toULongLong(); + GameListItem::setData(PlayTime::ReadablePlayTime(time_seconds), Qt::DisplayRole); + GameListItem::setData(value, PlayTimeRole); + } + + bool operator<(const QStandardItem& other) const override { + return data(PlayTimeRole).toULongLong() < other.data(PlayTimeRole).toULongLong(); + } +}; + +class GameListDir : public GameListItem { +public: + static constexpr int GameDirRole = Qt::UserRole + 2; + + explicit GameListDir(UISettings::GameDir& directory, + GameListItemType dir_type_ = GameListItemType::CustomDir) + : dir_type{dir_type_} { + setData(type(), TypeRole); + + UISettings::GameDir* game_dir = &directory; + setData(QVariant(UISettings::values.game_dirs.indexOf(directory)), GameDirRole); + + const int icon_size = UISettings::values.folder_icon_size.GetValue(); + switch (dir_type) { + case GameListItemType::SdmcDir: + setData( + QIcon::fromTheme(QStringLiteral("sd_card")) + .pixmap(icon_size) + .scaled(icon_size, icon_size, Qt::IgnoreAspectRatio, Qt::SmoothTransformation), + Qt::DecorationRole); + setData(QObject::tr("Installed SD Titles"), Qt::DisplayRole); + break; + case GameListItemType::UserNandDir: + setData( + QIcon::fromTheme(QStringLiteral("chip")) + .pixmap(icon_size) + .scaled(icon_size, icon_size, Qt::IgnoreAspectRatio, Qt::SmoothTransformation), + Qt::DecorationRole); + setData(QObject::tr("Installed NAND Titles"), Qt::DisplayRole); + break; + case GameListItemType::SysNandDir: + setData( + QIcon::fromTheme(QStringLiteral("chip")) + .pixmap(icon_size) + .scaled(icon_size, icon_size, Qt::IgnoreAspectRatio, Qt::SmoothTransformation), + Qt::DecorationRole); + setData(QObject::tr("System Titles"), Qt::DisplayRole); + break; + case GameListItemType::CustomDir: { + const QString path = QString::fromStdString(game_dir->path); + const QString icon_name = + QFileInfo::exists(path) ? QStringLiteral("folder") : QStringLiteral("bad_folder"); + setData(QIcon::fromTheme(icon_name).pixmap(icon_size).scaled( + icon_size, icon_size, Qt::IgnoreAspectRatio, Qt::SmoothTransformation), + Qt::DecorationRole); + setData(path, Qt::DisplayRole); + break; + } + default: + break; + } + } + + int type() const override { + return static_cast(dir_type); + } + + /** + * Override to prevent automatic sorting between folders and the addDir button. + */ + bool operator<(const QStandardItem& other) const override { + return false; + } + +private: + GameListItemType dir_type; +}; + +class GameListAddDir : public GameListItem { +public: + explicit GameListAddDir() { + setData(type(), TypeRole); + + const int icon_size = UISettings::values.folder_icon_size.GetValue(); + + setData(QIcon::fromTheme(QStringLiteral("list-add")) + .pixmap(icon_size) + .scaled(icon_size, icon_size, Qt::IgnoreAspectRatio, Qt::SmoothTransformation), + Qt::DecorationRole); + setData(QObject::tr("Add New Game Directory"), Qt::DisplayRole); + } + + int type() const override { + return static_cast(GameListItemType::AddDir); + } + + bool operator<(const QStandardItem& other) const override { + return false; + } +}; + +class GameListFavorites : public GameListItem { +public: + explicit GameListFavorites() { + setData(type(), TypeRole); + + const int icon_size = UISettings::values.folder_icon_size.GetValue(); + + setData(QIcon::fromTheme(QStringLiteral("star")) + .pixmap(icon_size) + .scaled(icon_size, icon_size, Qt::IgnoreAspectRatio, Qt::SmoothTransformation), + Qt::DecorationRole); + setData(QObject::tr("Favorites"), Qt::DisplayRole); + } + + int type() const override { + return static_cast(GameListItemType::Favorites); + } + + bool operator<(const QStandardItem& other) const override { + return false; + } +}; + +class GameList; +class QHBoxLayout; +class QTreeView; +class QLabel; +class QLineEdit; +class QToolButton; + +class GameListSearchField : public QWidget { + Q_OBJECT + +public: + explicit GameListSearchField(GameList* parent = nullptr); + + QString filterText() const; + void setFilterResult(int visible_, int total_); + + void clear(); + void setFocus(); + +private: + void changeEvent(QEvent*) override; + void RetranslateUI(); + + class KeyReleaseEater : public QObject { + public: + explicit KeyReleaseEater(GameList* gamelist_, QObject* parent = nullptr); + + private: + GameList* gamelist = nullptr; + QString edit_filter_text_old; + + protected: + // EventFilter in order to process systemkeys while editing the searchfield + bool eventFilter(QObject* obj, QEvent* event) override; + }; + int visible; + int total; + + QHBoxLayout* layout_filter = nullptr; + QTreeView* tree_view = nullptr; + QLabel* label_filter = nullptr; + QLineEdit* edit_filter = nullptr; + QLabel* label_filter_result = nullptr; + QToolButton* button_filter_close = nullptr; +}; diff --git a/src/sudachi/game_list_worker.cpp b/src/sudachi/game_list_worker.cpp new file mode 100644 index 0000000..f127610 --- /dev/null +++ b/src/sudachi/game_list_worker.cpp @@ -0,0 +1,485 @@ +// SPDX-FileCopyrightText: Copyright 2018 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include +#include +#include +#include + +#include +#include +#include +#include + +#include "common/fs/fs.h" +#include "common/fs/path_util.h" +#include "core/core.h" +#include "core/file_sys/card_image.h" +#include "core/file_sys/content_archive.h" +#include "core/file_sys/control_metadata.h" +#include "core/file_sys/fs_filesystem.h" +#include "core/file_sys/nca_metadata.h" +#include "core/file_sys/patch_manager.h" +#include "core/file_sys/registered_cache.h" +#include "core/file_sys/submission_package.h" +#include "core/loader/loader.h" +#include "sudachi/compatibility_list.h" +#include "sudachi/game_list.h" +#include "sudachi/game_list_p.h" +#include "sudachi/game_list_worker.h" +#include "sudachi/uisettings.h" + +namespace { + +QString GetGameListCachedObject(const std::string& filename, const std::string& ext, + const std::function& generator) { + if (!UISettings::values.cache_game_list || filename == "0000000000000000") { + return generator(); + } + + const auto path = + Common::FS::PathToUTF8String(Common::FS::GetSudachiPath(Common::FS::SudachiPath::CacheDir) / + "game_list" / fmt::format("{}.{}", filename, ext)); + + void(Common::FS::CreateParentDirs(path)); + + if (!Common::FS::Exists(path)) { + const auto str = generator(); + + QFile file{QString::fromStdString(path)}; + if (file.open(QFile::WriteOnly)) { + file.write(str.toUtf8()); + } + + return str; + } + + QFile file{QString::fromStdString(path)}; + if (file.open(QFile::ReadOnly)) { + return QString::fromUtf8(file.readAll()); + } + + return generator(); +} + +std::pair, std::string> GetGameListCachedObject( + const std::string& filename, const std::string& ext, + const std::function, std::string>()>& generator) { + if (!UISettings::values.cache_game_list || filename == "0000000000000000") { + return generator(); + } + + const auto game_list_dir = + Common::FS::GetSudachiPath(Common::FS::SudachiPath::CacheDir) / "game_list"; + const auto jpeg_name = fmt::format("{}.jpeg", filename); + const auto app_name = fmt::format("{}.appname.txt", filename); + + const auto path1 = Common::FS::PathToUTF8String(game_list_dir / jpeg_name); + const auto path2 = Common::FS::PathToUTF8String(game_list_dir / app_name); + + void(Common::FS::CreateParentDirs(path1)); + + if (!Common::FS::Exists(path1) || !Common::FS::Exists(path2)) { + const auto [icon, nacp] = generator(); + + QFile file1{QString::fromStdString(path1)}; + if (!file1.open(QFile::WriteOnly)) { + LOG_ERROR(Frontend, "Failed to open cache file."); + return generator(); + } + + if (!file1.resize(icon.size())) { + LOG_ERROR(Frontend, "Failed to resize cache file to necessary size."); + return generator(); + } + + if (file1.write(reinterpret_cast(icon.data()), icon.size()) != + s64(icon.size())) { + LOG_ERROR(Frontend, "Failed to write data to cache file."); + return generator(); + } + + QFile file2{QString::fromStdString(path2)}; + if (file2.open(QFile::WriteOnly)) { + file2.write(nacp.data(), nacp.size()); + } + + return std::make_pair(icon, nacp); + } + + QFile file1(QString::fromStdString(path1)); + QFile file2(QString::fromStdString(path2)); + + if (!file1.open(QFile::ReadOnly)) { + LOG_ERROR(Frontend, "Failed to open cache file for reading."); + return generator(); + } + + if (!file2.open(QFile::ReadOnly)) { + LOG_ERROR(Frontend, "Failed to open cache file for reading."); + return generator(); + } + + std::vector vec(file1.size()); + if (file1.read(reinterpret_cast(vec.data()), vec.size()) != + static_cast(vec.size())) { + return generator(); + } + + const auto data = file2.readAll(); + return std::make_pair(vec, data.toStdString()); +} + +void GetMetadataFromControlNCA(const FileSys::PatchManager& patch_manager, const FileSys::NCA& nca, + std::vector& icon, std::string& name) { + std::tie(icon, name) = GetGameListCachedObject( + fmt::format("{:016X}", patch_manager.GetTitleID()), {}, [&patch_manager, &nca] { + const auto [nacp, icon_f] = patch_manager.ParseControlNCA(nca); + return std::make_pair(icon_f->ReadAllBytes(), nacp->GetApplicationName()); + }); +} + +bool HasSupportedFileExtension(const std::string& file_name) { + const QFileInfo file = QFileInfo(QString::fromStdString(file_name)); + return GameList::supported_file_extensions.contains(file.suffix(), Qt::CaseInsensitive); +} + +bool IsExtractedNCAMain(const std::string& file_name) { + return QFileInfo(QString::fromStdString(file_name)).fileName() == QStringLiteral("main"); +} + +QString FormatGameName(const std::string& physical_name) { + const QString physical_name_as_qstring = QString::fromStdString(physical_name); + const QFileInfo file_info(physical_name_as_qstring); + + if (IsExtractedNCAMain(physical_name)) { + return file_info.dir().path(); + } + + return physical_name_as_qstring; +} + +QString FormatPatchNameVersions(const FileSys::PatchManager& patch_manager, + Loader::AppLoader& loader, bool updatable = true) { + QString out; + FileSys::VirtualFile update_raw; + loader.ReadUpdateRaw(update_raw); + for (const auto& patch : patch_manager.GetPatches(update_raw)) { + const bool is_update = patch.name == "Update"; + if (!updatable && is_update) { + continue; + } + + const QString type = + QString::fromStdString(patch.enabled ? patch.name : "[D] " + patch.name); + + if (patch.version.empty()) { + out.append(QStringLiteral("%1\n").arg(type)); + } else { + auto ver = patch.version; + + // Display container name for packed updates + if (is_update && ver == "PACKED") { + ver = Loader::GetFileTypeString(loader.GetFileType()); + } + + out.append(QStringLiteral("%1 (%2)\n").arg(type, QString::fromStdString(ver))); + } + } + + out.chop(1); + return out; +} + +QList MakeGameListEntry(const std::string& path, const std::string& name, + const std::size_t size, const std::vector& icon, + Loader::AppLoader& loader, u64 program_id, + const CompatibilityList& compatibility_list, + const PlayTime::PlayTimeManager& play_time_manager, + const FileSys::PatchManager& patch) { + const auto it = FindMatchingCompatibilityEntry(compatibility_list, program_id); + + // The game list uses this as compatibility number for untested games + QString compatibility{QStringLiteral("99")}; + if (it != compatibility_list.end()) { + compatibility = it->second.first; + } + + const auto file_type = loader.GetFileType(); + const auto file_type_string = QString::fromStdString(Loader::GetFileTypeString(file_type)); + + QList list{ + new GameListItemPath(FormatGameName(path), icon, QString::fromStdString(name), + file_type_string, program_id), + new GameListItemCompat(compatibility), + new GameListItem(file_type_string), + new GameListItemSize(size), + new GameListItemPlayTime(play_time_manager.GetPlayTime(program_id)), + }; + + const auto patch_versions = GetGameListCachedObject( + fmt::format("{:016X}", patch.GetTitleID()), "pv.txt", [&patch, &loader] { + return FormatPatchNameVersions(patch, loader, loader.IsRomFSUpdatable()); + }); + list.insert(2, new GameListItem(patch_versions)); + + return list; +} +} // Anonymous namespace + +GameListWorker::GameListWorker(FileSys::VirtualFilesystem vfs_, + FileSys::ManualContentProvider* provider_, + QVector& game_dirs_, + const CompatibilityList& compatibility_list_, + const PlayTime::PlayTimeManager& play_time_manager_, + Core::System& system_) + : vfs{std::move(vfs_)}, provider{provider_}, game_dirs{game_dirs_}, + compatibility_list{compatibility_list_}, play_time_manager{play_time_manager_}, system{ + system_} { + // We want the game list to manage our lifetime. + setAutoDelete(false); +} + +GameListWorker::~GameListWorker() { + this->disconnect(); + stop_requested.store(true); + processing_completed.Wait(); +} + +void GameListWorker::ProcessEvents(GameList* game_list) { + while (true) { + std::function func; + { + // Lock queue to protect concurrent modification. + std::scoped_lock lk(lock); + + // If we can't pop a function, return. + if (queued_events.empty()) { + return; + } + + // Pop a function. + func = std::move(queued_events.back()); + queued_events.pop_back(); + } + + // Run the function. + func(game_list); + } +} + +template +void GameListWorker::RecordEvent(F&& func) { + { + // Lock queue to protect concurrent modification. + std::scoped_lock lk(lock); + + // Add the function into the front of the queue. + queued_events.emplace_front(std::move(func)); + } + + // Data now available. + emit DataAvailable(); +} + +void GameListWorker::AddTitlesToGameList(GameListDir* parent_dir) { + using namespace FileSys; + + const auto& cache = system.GetContentProviderUnion(); + + auto installed_games = cache.ListEntriesFilterOrigin(std::nullopt, TitleType::Application, + ContentRecordType::Program); + + if (parent_dir->type() == static_cast(GameListItemType::SdmcDir)) { + installed_games = cache.ListEntriesFilterOrigin( + ContentProviderUnionSlot::SDMC, TitleType::Application, ContentRecordType::Program); + } else if (parent_dir->type() == static_cast(GameListItemType::UserNandDir)) { + installed_games = cache.ListEntriesFilterOrigin( + ContentProviderUnionSlot::UserNAND, TitleType::Application, ContentRecordType::Program); + } else if (parent_dir->type() == static_cast(GameListItemType::SysNandDir)) { + installed_games = cache.ListEntriesFilterOrigin( + ContentProviderUnionSlot::SysNAND, TitleType::Application, ContentRecordType::Program); + } + + for (const auto& [slot, game] : installed_games) { + if (slot == ContentProviderUnionSlot::FrontendManual) { + continue; + } + + const auto file = cache.GetEntryUnparsed(game.title_id, game.type); + std::unique_ptr loader = Loader::GetLoader(system, file); + if (!loader) { + continue; + } + + std::vector icon; + std::string name; + u64 program_id = 0; + const auto result = loader->ReadProgramId(program_id); + + if (result != Loader::ResultStatus::Success) { + continue; + } + + const PatchManager patch{program_id, system.GetFileSystemController(), + system.GetContentProvider()}; + const auto control = cache.GetEntry(game.title_id, ContentRecordType::Control); + if (control != nullptr) { + GetMetadataFromControlNCA(patch, *control, icon, name); + } + + auto entry = MakeGameListEntry(file->GetFullPath(), name, file->GetSize(), icon, *loader, + program_id, compatibility_list, play_time_manager, patch); + RecordEvent([=](GameList* game_list) { game_list->AddEntry(entry, parent_dir); }); + } +} + +void GameListWorker::ScanFileSystem(ScanTarget target, const std::string& dir_path, bool deep_scan, + GameListDir* parent_dir) { + const auto callback = [this, target, parent_dir](const std::filesystem::path& path) -> bool { + if (stop_requested) { + // Breaks the callback loop. + return false; + } + + const auto physical_name = Common::FS::PathToUTF8String(path); + const auto is_dir = Common::FS::IsDir(path); + + if (!is_dir && + (HasSupportedFileExtension(physical_name) || IsExtractedNCAMain(physical_name))) { + const auto file = vfs->OpenFile(physical_name, FileSys::OpenMode::Read); + if (!file) { + return true; + } + + auto loader = Loader::GetLoader(system, file); + if (!loader) { + return true; + } + + const auto file_type = loader->GetFileType(); + if (file_type == Loader::FileType::Unknown || file_type == Loader::FileType::Error) { + return true; + } + + u64 program_id = 0; + const auto res2 = loader->ReadProgramId(program_id); + + if (target == ScanTarget::FillManualContentProvider) { + if (res2 == Loader::ResultStatus::Success && file_type == Loader::FileType::NCA) { + provider->AddEntry(FileSys::TitleType::Application, + FileSys::GetCRTypeFromNCAType(FileSys::NCA{file}.GetType()), + program_id, file); + } else if (res2 == Loader::ResultStatus::Success && + (file_type == Loader::FileType::XCI || + file_type == Loader::FileType::NSP)) { + const auto nsp = file_type == Loader::FileType::NSP + ? std::make_shared(file) + : FileSys::XCI{file}.GetSecurePartitionNSP(); + for (const auto& title : nsp->GetNCAs()) { + for (const auto& entry : title.second) { + provider->AddEntry(entry.first.first, entry.first.second, title.first, + entry.second->GetBaseFile()); + } + } + } + } else { + std::vector program_ids; + loader->ReadProgramIds(program_ids); + + if (res2 == Loader::ResultStatus::Success && program_ids.size() > 1 && + (file_type == Loader::FileType::XCI || file_type == Loader::FileType::NSP)) { + for (const auto id : program_ids) { + loader = Loader::GetLoader(system, file, id); + if (!loader) { + continue; + } + + std::vector icon; + [[maybe_unused]] const auto res1 = loader->ReadIcon(icon); + + std::string name = " "; + [[maybe_unused]] const auto res3 = loader->ReadTitle(name); + + const FileSys::PatchManager patch{id, system.GetFileSystemController(), + system.GetContentProvider()}; + + auto entry = MakeGameListEntry( + physical_name, name, Common::FS::GetSize(physical_name), icon, *loader, + id, compatibility_list, play_time_manager, patch); + + RecordEvent( + [=](GameList* game_list) { game_list->AddEntry(entry, parent_dir); }); + } + } else { + std::vector icon; + [[maybe_unused]] const auto res1 = loader->ReadIcon(icon); + + std::string name = " "; + [[maybe_unused]] const auto res3 = loader->ReadTitle(name); + + const FileSys::PatchManager patch{program_id, system.GetFileSystemController(), + system.GetContentProvider()}; + + auto entry = MakeGameListEntry( + physical_name, name, Common::FS::GetSize(physical_name), icon, *loader, + program_id, compatibility_list, play_time_manager, patch); + + RecordEvent( + [=](GameList* game_list) { game_list->AddEntry(entry, parent_dir); }); + } + } + } else if (is_dir) { + watch_list.append(QString::fromStdString(physical_name)); + } + + return true; + }; + + if (deep_scan) { + Common::FS::IterateDirEntriesRecursively(dir_path, callback, + Common::FS::DirEntryFilter::All); + } else { + Common::FS::IterateDirEntries(dir_path, callback, Common::FS::DirEntryFilter::File); + } +} + +void GameListWorker::run() { + watch_list.clear(); + provider->ClearAllEntries(); + + const auto DirEntryReady = [&](GameListDir* game_list_dir) { + RecordEvent([=](GameList* game_list) { game_list->AddDirEntry(game_list_dir); }); + }; + + for (UISettings::GameDir& game_dir : game_dirs) { + if (stop_requested) { + break; + } + + if (game_dir.path == std::string("SDMC")) { + auto* const game_list_dir = new GameListDir(game_dir, GameListItemType::SdmcDir); + DirEntryReady(game_list_dir); + AddTitlesToGameList(game_list_dir); + } else if (game_dir.path == std::string("UserNAND")) { + auto* const game_list_dir = new GameListDir(game_dir, GameListItemType::UserNandDir); + DirEntryReady(game_list_dir); + AddTitlesToGameList(game_list_dir); + } else if (game_dir.path == std::string("SysNAND")) { + auto* const game_list_dir = new GameListDir(game_dir, GameListItemType::SysNandDir); + DirEntryReady(game_list_dir); + AddTitlesToGameList(game_list_dir); + } else { + watch_list.append(QString::fromStdString(game_dir.path)); + auto* const game_list_dir = new GameListDir(game_dir); + DirEntryReady(game_list_dir); + ScanFileSystem(ScanTarget::FillManualContentProvider, game_dir.path, game_dir.deep_scan, + game_list_dir); + ScanFileSystem(ScanTarget::PopulateGameList, game_dir.path, game_dir.deep_scan, + game_list_dir); + } + } + + RecordEvent([this](GameList* game_list) { game_list->DonePopulating(watch_list); }); + processing_completed.Set(); +} diff --git a/src/sudachi/game_list_worker.h b/src/sudachi/game_list_worker.h new file mode 100644 index 0000000..b8dc5fd --- /dev/null +++ b/src/sudachi/game_list_worker.h @@ -0,0 +1,94 @@ +// SPDX-FileCopyrightText: Copyright 2018 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include +#include +#include +#include + +#include +#include +#include +#include + +#include "common/thread.h" +#include "sudachi/compatibility_list.h" +#include "sudachi/play_time_manager.h" + +namespace Core { +class System; +} + +class GameList; +class QStandardItem; + +namespace FileSys { +class NCA; +class VfsFilesystem; +} // namespace FileSys + +/** + * Asynchronous worker object for populating the game list. + * Communicates with other threads through Qt's signal/slot system. + */ +class GameListWorker : public QObject, public QRunnable { + Q_OBJECT + +public: + explicit GameListWorker(std::shared_ptr vfs_, + FileSys::ManualContentProvider* provider_, + QVector& game_dirs_, + const CompatibilityList& compatibility_list_, + const PlayTime::PlayTimeManager& play_time_manager_, + Core::System& system_); + ~GameListWorker() override; + + /// Starts the processing of directory tree information. + void run() override; + +public: + /** + * Synchronously processes any events queued by the worker. + * + * AddDirEntry is called on the game list for every discovered directory. + * AddEntry is called on the game list for every discovered program. + * DonePopulating is called on the game list when processing completes. + */ + void ProcessEvents(GameList* game_list); + +signals: + void DataAvailable(); + +private: + template + void RecordEvent(F&& func); + +private: + void AddTitlesToGameList(GameListDir* parent_dir); + + enum class ScanTarget { + FillManualContentProvider, + PopulateGameList, + }; + + void ScanFileSystem(ScanTarget target, const std::string& dir_path, bool deep_scan, + GameListDir* parent_dir); + + std::shared_ptr vfs; + FileSys::ManualContentProvider* provider; + QVector& game_dirs; + const CompatibilityList& compatibility_list; + const PlayTime::PlayTimeManager& play_time_manager; + + QStringList watch_list; + + std::mutex lock; + std::condition_variable cv; + std::deque> queued_events; + std::atomic_bool stop_requested = false; + Common::Event processing_completed; + + Core::System& system; +}; diff --git a/src/sudachi/hotkeys.cpp b/src/sudachi/hotkeys.cpp new file mode 100644 index 0000000..b117228 --- /dev/null +++ b/src/sudachi/hotkeys.cpp @@ -0,0 +1,214 @@ +// SPDX-FileCopyrightText: 2014 Citra Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include +#include +#include +#include + +#include "hid_core/frontend/emulated_controller.h" +#include "sudachi/hotkeys.h" +#include "sudachi/uisettings.h" + +HotkeyRegistry::HotkeyRegistry() = default; +HotkeyRegistry::~HotkeyRegistry() = default; + +void HotkeyRegistry::SaveHotkeys() { + UISettings::values.shortcuts.clear(); + for (const auto& group : hotkey_groups) { + for (const auto& hotkey : group.second) { + UISettings::values.shortcuts.push_back( + {hotkey.first, group.first, + UISettings::ContextualShortcut({hotkey.second.keyseq.toString().toStdString(), + hotkey.second.controller_keyseq, + hotkey.second.context, hotkey.second.repeat})}); + } + } +} + +void HotkeyRegistry::LoadHotkeys() { + // Make sure NOT to use a reference here because it would become invalid once we call + // beginGroup() + for (auto shortcut : UISettings::values.shortcuts) { + Hotkey& hk = hotkey_groups[shortcut.group][shortcut.name]; + if (!shortcut.shortcut.keyseq.empty()) { + hk.keyseq = QKeySequence::fromString(QString::fromStdString(shortcut.shortcut.keyseq), + QKeySequence::NativeText); + hk.context = static_cast(shortcut.shortcut.context); + } + if (!shortcut.shortcut.controller_keyseq.empty()) { + hk.controller_keyseq = shortcut.shortcut.controller_keyseq; + } + if (hk.shortcut) { + hk.shortcut->disconnect(); + hk.shortcut->setKey(hk.keyseq); + } + if (hk.controller_shortcut) { + hk.controller_shortcut->disconnect(); + hk.controller_shortcut->SetKey(hk.controller_keyseq); + } + hk.repeat = shortcut.shortcut.repeat; + } +} + +QShortcut* HotkeyRegistry::GetHotkey(const std::string& group, const std::string& action, + QWidget* widget) { + Hotkey& hk = hotkey_groups[group][action]; + + if (!hk.shortcut) { + hk.shortcut = new QShortcut(hk.keyseq, widget, nullptr, nullptr, hk.context); + } + + hk.shortcut->setAutoRepeat(hk.repeat); + return hk.shortcut; +} + +ControllerShortcut* HotkeyRegistry::GetControllerHotkey(const std::string& group, + const std::string& action, + Core::HID::EmulatedController* controller) { + Hotkey& hk = hotkey_groups[group][action]; + + if (!hk.controller_shortcut) { + hk.controller_shortcut = new ControllerShortcut(controller); + hk.controller_shortcut->SetKey(hk.controller_keyseq); + } + + return hk.controller_shortcut; +} + +QKeySequence HotkeyRegistry::GetKeySequence(const std::string& group, const std::string& action) { + return hotkey_groups[group][action].keyseq; +} + +Qt::ShortcutContext HotkeyRegistry::GetShortcutContext(const std::string& group, + const std::string& action) { + return hotkey_groups[group][action].context; +} + +ControllerShortcut::ControllerShortcut(Core::HID::EmulatedController* controller) { + emulated_controller = controller; + Core::HID::ControllerUpdateCallback engine_callback{ + .on_change = [this](Core::HID::ControllerTriggerType type) { ControllerUpdateEvent(type); }, + .is_npad_service = false, + }; + callback_key = emulated_controller->SetCallback(engine_callback); + is_enabled = true; +} + +ControllerShortcut::~ControllerShortcut() { + emulated_controller->DeleteCallback(callback_key); +} + +void ControllerShortcut::SetKey(const ControllerButtonSequence& buttons) { + button_sequence = buttons; +} + +void ControllerShortcut::SetKey(const std::string& buttons_shortcut) { + ControllerButtonSequence sequence{}; + name = buttons_shortcut; + std::istringstream command_line(buttons_shortcut); + std::string line; + while (std::getline(command_line, line, '+')) { + if (line.empty()) { + continue; + } + if (line == "A") { + sequence.npad.a.Assign(1); + } + if (line == "B") { + sequence.npad.b.Assign(1); + } + if (line == "X") { + sequence.npad.x.Assign(1); + } + if (line == "Y") { + sequence.npad.y.Assign(1); + } + if (line == "L") { + sequence.npad.l.Assign(1); + } + if (line == "R") { + sequence.npad.r.Assign(1); + } + if (line == "ZL") { + sequence.npad.zl.Assign(1); + } + if (line == "ZR") { + sequence.npad.zr.Assign(1); + } + if (line == "Dpad_Left") { + sequence.npad.left.Assign(1); + } + if (line == "Dpad_Right") { + sequence.npad.right.Assign(1); + } + if (line == "Dpad_Up") { + sequence.npad.up.Assign(1); + } + if (line == "Dpad_Down") { + sequence.npad.down.Assign(1); + } + if (line == "Left_Stick") { + sequence.npad.stick_l.Assign(1); + } + if (line == "Right_Stick") { + sequence.npad.stick_r.Assign(1); + } + if (line == "Minus") { + sequence.npad.minus.Assign(1); + } + if (line == "Plus") { + sequence.npad.plus.Assign(1); + } + if (line == "Home") { + sequence.home.home.Assign(1); + } + if (line == "Screenshot") { + sequence.capture.capture.Assign(1); + } + } + + button_sequence = sequence; +} + +ControllerButtonSequence ControllerShortcut::ButtonSequence() const { + return button_sequence; +} + +void ControllerShortcut::SetEnabled(bool enable) { + is_enabled = enable; +} + +bool ControllerShortcut::IsEnabled() const { + return is_enabled; +} + +void ControllerShortcut::ControllerUpdateEvent(Core::HID::ControllerTriggerType type) { + if (!is_enabled) { + return; + } + if (type != Core::HID::ControllerTriggerType::Button) { + return; + } + if (button_sequence.npad.raw == Core::HID::NpadButton::None && + button_sequence.capture.raw == 0 && button_sequence.home.raw == 0) { + return; + } + + const auto player_npad_buttons = + emulated_controller->GetNpadButtons().raw & button_sequence.npad.raw; + const u64 player_capture_buttons = + emulated_controller->GetCaptureButtons().raw & button_sequence.capture.raw; + const u64 player_home_buttons = + emulated_controller->GetHomeButtons().raw & button_sequence.home.raw; + + if (player_npad_buttons == button_sequence.npad.raw && + player_capture_buttons == button_sequence.capture.raw && + player_home_buttons == button_sequence.home.raw && !active) { + // Force user to press the home or capture button again + active = true; + emit Activated(); + return; + } + active = false; +} diff --git a/src/sudachi/hotkeys.h b/src/sudachi/hotkeys.h new file mode 100644 index 0000000..bdc0816 --- /dev/null +++ b/src/sudachi/hotkeys.h @@ -0,0 +1,127 @@ +// SPDX-FileCopyrightText: 2014 Citra Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include +#include +#include +#include +#include "hid_core/hid_types.h" + +class QDialog; +class QSettings; +class QShortcut; +class ControllerShortcut; + +namespace Core::HID { +enum class ControllerTriggerType; +class EmulatedController; +} // namespace Core::HID + +struct ControllerButtonSequence { + Core::HID::CaptureButtonState capture{}; + Core::HID::HomeButtonState home{}; + Core::HID::NpadButtonState npad{}; +}; + +class ControllerShortcut : public QObject { + Q_OBJECT + +public: + explicit ControllerShortcut(Core::HID::EmulatedController* controller); + ~ControllerShortcut(); + + void SetKey(const ControllerButtonSequence& buttons); + void SetKey(const std::string& buttons_shortcut); + + ControllerButtonSequence ButtonSequence() const; + + void SetEnabled(bool enable); + bool IsEnabled() const; + +Q_SIGNALS: + void Activated(); + +private: + void ControllerUpdateEvent(Core::HID::ControllerTriggerType type); + + bool is_enabled{}; + bool active{}; + int callback_key{}; + ControllerButtonSequence button_sequence{}; + std::string name{}; + Core::HID::EmulatedController* emulated_controller = nullptr; +}; + +class HotkeyRegistry final { +public: + friend class ConfigureHotkeys; + + explicit HotkeyRegistry(); + ~HotkeyRegistry(); + + /** + * Loads hotkeys from the settings file. + * + * @note Yet unregistered hotkeys which are present in the settings will automatically be + * registered. + */ + void LoadHotkeys(); + + /** + * Saves all registered hotkeys to the settings file. + * + * @note Each hotkey group will be stored a settings group; For each hotkey inside that group, a + * settings group will be created to store the key sequence and the hotkey context. + */ + void SaveHotkeys(); + + /** + * Returns a QShortcut object whose activated() signal can be connected to other QObjects' + * slots. + * + * @param group General group this hotkey belongs to (e.g. "Main Window", "Debugger"). + * @param action Name of the action (e.g. "Start Emulation", "Load Image"). + * @param widget Parent widget of the returned QShortcut. + * @warning If multiple QWidgets' call this function for the same action, the returned QShortcut + * will be the same. Thus, you shouldn't rely on the caller really being the + * QShortcut's parent. + */ + QShortcut* GetHotkey(const std::string& group, const std::string& action, QWidget* widget); + ControllerShortcut* GetControllerHotkey(const std::string& group, const std::string& action, + Core::HID::EmulatedController* controller); + + /** + * Returns a QKeySequence object whose signal can be connected to QAction::setShortcut. + * + * @param group General group this hotkey belongs to (e.g. "Main Window", "Debugger"). + * @param action Name of the action (e.g. "Start Emulation", "Load Image"). + */ + QKeySequence GetKeySequence(const std::string& group, const std::string& action); + + /** + * Returns a Qt::ShortcutContext object who can be connected to other + * QAction::setShortcutContext. + * + * @param group General group this shortcut context belongs to (e.g. "Main Window", + * "Debugger"). + * @param action Name of the action (e.g. "Start Emulation", "Load Image"). + */ + Qt::ShortcutContext GetShortcutContext(const std::string& group, const std::string& action); + +private: + struct Hotkey { + QKeySequence keyseq; + std::string controller_keyseq; + QShortcut* shortcut = nullptr; + ControllerShortcut* controller_shortcut = nullptr; + Qt::ShortcutContext context = Qt::WindowShortcut; + bool repeat; + }; + + using HotkeyMap = std::map; + using HotkeyGroupMap = std::map; + + HotkeyGroupMap hotkey_groups; +}; diff --git a/src/sudachi/install_dialog.cpp b/src/sudachi/install_dialog.cpp new file mode 100644 index 0000000..bf39e7e --- /dev/null +++ b/src/sudachi/install_dialog.cpp @@ -0,0 +1,69 @@ +// SPDX-FileCopyrightText: Copyright 2020 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include +#include +#include +#include +#include +#include +#include "sudachi/install_dialog.h" +#include "sudachi/uisettings.h" + +InstallDialog::InstallDialog(QWidget* parent, const QStringList& files) : QDialog(parent) { + file_list = new QListWidget(this); + + for (const QString& file : files) { + QListWidgetItem* item = new QListWidgetItem(QFileInfo(file).fileName(), file_list); + item->setData(Qt::UserRole, file); + item->setFlags(item->flags() | Qt::ItemIsUserCheckable); + item->setCheckState(Qt::Checked); + } + + file_list->setMinimumWidth((file_list->sizeHintForColumn(0) * 11) / 10); + + vbox_layout = new QVBoxLayout; + + hbox_layout = new QHBoxLayout; + + description = new QLabel(tr("Please confirm these are the files you wish to install.")); + + update_description = + new QLabel(tr("Installing an Update or DLC will overwrite the previously installed one.")); + + buttons = new QDialogButtonBox; + buttons->addButton(QDialogButtonBox::Cancel); + buttons->addButton(tr("Install"), QDialogButtonBox::AcceptRole); + + connect(buttons, &QDialogButtonBox::accepted, this, &InstallDialog::accept); + connect(buttons, &QDialogButtonBox::rejected, this, &InstallDialog::reject); + + hbox_layout->addWidget(buttons); + + vbox_layout->addWidget(description); + vbox_layout->addWidget(update_description); + vbox_layout->addWidget(file_list); + vbox_layout->addLayout(hbox_layout); + + setLayout(vbox_layout); + setWindowTitle(tr("Install Files to NAND")); +} + +InstallDialog::~InstallDialog() = default; + +QStringList InstallDialog::GetFiles() const { + QStringList files; + + for (int i = 0; i < file_list->count(); ++i) { + const QListWidgetItem* item = file_list->item(i); + if (item->checkState() == Qt::Checked) { + files.append(item->data(Qt::UserRole).toString()); + } + } + + return files; +} + +int InstallDialog::GetMinimumWidth() const { + return file_list->width(); +} diff --git a/src/sudachi/install_dialog.h b/src/sudachi/install_dialog.h new file mode 100644 index 0000000..474f2e4 --- /dev/null +++ b/src/sudachi/install_dialog.h @@ -0,0 +1,34 @@ +// SPDX-FileCopyrightText: Copyright 2020 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include + +class QCheckBox; +class QDialogButtonBox; +class QHBoxLayout; +class QLabel; +class QListWidget; +class QVBoxLayout; + +class InstallDialog : public QDialog { + Q_OBJECT + +public: + explicit InstallDialog(QWidget* parent, const QStringList& files); + ~InstallDialog() override; + + [[nodiscard]] QStringList GetFiles() const; + [[nodiscard]] int GetMinimumWidth() const; + +private: + QListWidget* file_list; + + QVBoxLayout* vbox_layout; + QHBoxLayout* hbox_layout; + + QLabel* description; + QLabel* update_description; + QDialogButtonBox* buttons; +}; diff --git a/src/sudachi/loading_screen.cpp b/src/sudachi/loading_screen.cpp new file mode 100644 index 0000000..9d061d5 --- /dev/null +++ b/src/sudachi/loading_screen.cpp @@ -0,0 +1,202 @@ +// SPDX-FileCopyrightText: Copyright 2019 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "core/frontend/framebuffer_layout.h" +#include "core/loader/loader.h" +#include "ui_loading_screen.h" +#include "video_core/rasterizer_interface.h" +#include "sudachi/loading_screen.h" + +// Mingw seems to not have QMovie at all. If QMovie is missing then use a single frame instead of an +// showing the full animation +#if !SUDACHI_QT_MOVIE_MISSING +#include +#endif + +constexpr char PROGRESSBAR_STYLE_PREPARE[] = R"( +QProgressBar {} +QProgressBar::chunk {})"; + +constexpr char PROGRESSBAR_STYLE_BUILD[] = R"( +QProgressBar { + background-color: black; + border: 2px solid white; + border-radius: 4px; + padding: 2px; +} +QProgressBar::chunk { + background-color: #ff3c28; + width: 1px; +})"; + +constexpr char PROGRESSBAR_STYLE_COMPLETE[] = R"( +QProgressBar { + background-color: #0ab9e6; + border: 2px solid white; + border-radius: 4px; + padding: 2px; +} +QProgressBar::chunk { + background-color: #ff3c28; +})"; + +LoadingScreen::LoadingScreen(QWidget* parent) + : QWidget(parent), ui(std::make_unique()), + previous_stage(VideoCore::LoadCallbackStage::Complete) { + ui->setupUi(this); + setMinimumSize(Layout::MinimumSize::Width, Layout::MinimumSize::Height); + + // Create a fade out effect to hide this loading screen widget. + // When fading opacity, it will fade to the parent widgets background color, which is why we + // create an internal widget named fade_widget that we use the effect on, while keeping the + // loading screen widget's background color black. This way we can create a fade to black effect + opacity_effect = new QGraphicsOpacityEffect(this); + opacity_effect->setOpacity(1); + ui->fade_parent->setGraphicsEffect(opacity_effect); + fadeout_animation = std::make_unique(opacity_effect, "opacity"); + fadeout_animation->setDuration(500); + fadeout_animation->setStartValue(1); + fadeout_animation->setEndValue(0); + fadeout_animation->setEasingCurve(QEasingCurve::OutBack); + + // After the fade completes, hide the widget and reset the opacity + connect(fadeout_animation.get(), &QPropertyAnimation::finished, [this] { + hide(); + opacity_effect->setOpacity(1); + emit Hidden(); + }); + connect(this, &LoadingScreen::LoadProgress, this, &LoadingScreen::OnLoadProgress, + Qt::QueuedConnection); + qRegisterMetaType(); + + stage_translations = { + {VideoCore::LoadCallbackStage::Prepare, tr("Loading...")}, + {VideoCore::LoadCallbackStage::Build, tr("Loading Shaders %1 / %2")}, + {VideoCore::LoadCallbackStage::Complete, tr("Launching...")}, + }; + progressbar_style = { + {VideoCore::LoadCallbackStage::Prepare, PROGRESSBAR_STYLE_PREPARE}, + {VideoCore::LoadCallbackStage::Build, PROGRESSBAR_STYLE_BUILD}, + {VideoCore::LoadCallbackStage::Complete, PROGRESSBAR_STYLE_COMPLETE}, + }; +} + +LoadingScreen::~LoadingScreen() = default; + +void LoadingScreen::Prepare(Loader::AppLoader& loader) { + std::vector buffer; + if (loader.ReadBanner(buffer) == Loader::ResultStatus::Success) { +#ifdef SUDACHI_QT_MOVIE_MISSING + QPixmap map; + map.loadFromData(buffer.data(), buffer.size()); + ui->banner->setPixmap(map); +#else + backing_mem = std::make_unique(reinterpret_cast(buffer.data()), + static_cast(buffer.size())); + backing_buf = std::make_unique(backing_mem.get()); + backing_buf->open(QIODevice::ReadOnly); + animation = std::make_unique(backing_buf.get(), QByteArray()); + animation->start(); + ui->banner->setMovie(animation.get()); +#endif + buffer.clear(); + } + if (loader.ReadLogo(buffer) == Loader::ResultStatus::Success) { + QPixmap map; + map.loadFromData(buffer.data(), static_cast(buffer.size())); + ui->logo->setPixmap(map); + } + + slow_shader_compile_start = false; + OnLoadProgress(VideoCore::LoadCallbackStage::Prepare, 0, 0); +} + +void LoadingScreen::OnLoadComplete() { + fadeout_animation->start(QPropertyAnimation::KeepWhenStopped); +} + +void LoadingScreen::OnLoadProgress(VideoCore::LoadCallbackStage stage, std::size_t value, + std::size_t total) { + using namespace std::chrono; + const auto now = steady_clock::now(); + // reset the timer if the stage changes + if (stage != previous_stage) { + ui->progress_bar->setStyleSheet(QString::fromUtf8(progressbar_style[stage])); + // Hide the progress bar during the prepare stage + if (stage == VideoCore::LoadCallbackStage::Prepare) { + ui->progress_bar->hide(); + } else { + ui->progress_bar->show(); + } + previous_stage = stage; + // reset back to fast shader compiling since the stage changed + slow_shader_compile_start = false; + } + // update the max of the progress bar if the number of shaders change + if (total != previous_total) { + ui->progress_bar->setMaximum(static_cast(total)); + previous_total = total; + } + // Reset the progress bar ranges if compilation is done + if (stage == VideoCore::LoadCallbackStage::Complete) { + ui->progress_bar->setRange(0, 0); + } + + QString estimate; + // If there's a drastic slowdown in the rate, then display an estimate + if (now - previous_time > milliseconds{50} || slow_shader_compile_start) { + if (!slow_shader_compile_start) { + slow_shader_start = steady_clock::now(); + slow_shader_compile_start = true; + slow_shader_first_value = value; + } + // only calculate an estimate time after a second has passed since stage change + const auto diff = duration_cast(now - slow_shader_start); + if (diff > seconds{1}) { + const auto eta_mseconds = + static_cast(static_cast(total - slow_shader_first_value) / + (value - slow_shader_first_value) * diff.count()); + estimate = + tr("Estimated Time %1") + .arg(QTime(0, 0, 0, 0) + .addMSecs(std::max(eta_mseconds - diff.count() + 1000, 1000)) + .toString(QStringLiteral("mm:ss"))); + } + } + + // update labels and progress bar + if (stage == VideoCore::LoadCallbackStage::Build) { + ui->stage->setText(stage_translations[stage].arg(value).arg(total)); + } else { + ui->stage->setText(stage_translations[stage]); + } + ui->value->setText(estimate); + ui->progress_bar->setValue(static_cast(value)); + previous_time = now; +} + +void LoadingScreen::paintEvent(QPaintEvent* event) { + QStyleOption opt; + opt.initFrom(this); + QPainter p(this); + style()->drawPrimitive(QStyle::PE_Widget, &opt, &p, this); + QWidget::paintEvent(event); +} + +void LoadingScreen::Clear() { +#ifndef SUDACHI_QT_MOVIE_MISSING + animation.reset(); + backing_buf.reset(); + backing_mem.reset(); +#endif +} diff --git a/src/sudachi/loading_screen.h b/src/sudachi/loading_screen.h new file mode 100644 index 0000000..fa06185 --- /dev/null +++ b/src/sudachi/loading_screen.h @@ -0,0 +1,94 @@ +// SPDX-FileCopyrightText: Copyright 2019 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include +#include +#include +#include +#include + +#if !QT_CONFIG(movie) +#define SUDACHI_QT_MOVIE_MISSING 1 +#endif + +namespace Loader { +class AppLoader; +} + +namespace Ui { +class LoadingScreen; +} + +namespace VideoCore { +enum class LoadCallbackStage; +} + +class QBuffer; +class QByteArray; +class QGraphicsOpacityEffect; +class QMovie; +class QPropertyAnimation; + +class LoadingScreen : public QWidget { + Q_OBJECT + +public: + explicit LoadingScreen(QWidget* parent = nullptr); + + ~LoadingScreen(); + + /// Call before showing the loading screen to load the widgets with the logo and banner for the + /// currently loaded application. + void Prepare(Loader::AppLoader& loader); + + /// After the loading screen is hidden, the owner of this class can call this to clean up any + /// used resources such as the logo and banner. + void Clear(); + + /// Slot used to update the status of the progress bar + void OnLoadProgress(VideoCore::LoadCallbackStage stage, std::size_t value, std::size_t total); + + /// Hides the LoadingScreen with a fade out effect + void OnLoadComplete(); + + // In order to use a custom widget with a stylesheet, you need to override the paintEvent + // See https://wiki.qt.io/How_to_Change_the_Background_Color_of_QWidget + void paintEvent(QPaintEvent* event) override; + +signals: + void LoadProgress(VideoCore::LoadCallbackStage stage, std::size_t value, std::size_t total); + /// Signals that this widget is completely hidden now and should be replaced with the other + /// widget + void Hidden(); + +private: +#ifndef SUDACHI_QT_MOVIE_MISSING + std::unique_ptr animation; + std::unique_ptr backing_buf; + std::unique_ptr backing_mem; +#endif + std::unique_ptr ui; + std::size_t previous_total = 0; + VideoCore::LoadCallbackStage previous_stage; + + QGraphicsOpacityEffect* opacity_effect = nullptr; + std::unique_ptr fadeout_animation; + + // Definitions for the differences in text and styling for each stage + std::unordered_map progressbar_style; + std::unordered_map stage_translations; + + // newly generated shaders are added to the end of the file, so when loading and compiling + // shaders, it will start quickly but end slow if new shaders were added since previous launch. + // These variables are used to detect the change in speed so we can generate an ETA + bool slow_shader_compile_start = false; + std::chrono::steady_clock::time_point slow_shader_start; + std::chrono::steady_clock::time_point previous_time; + std::size_t slow_shader_first_value = 0; +}; + +#if QT_VERSION < QT_VERSION_CHECK(6, 0, 0) +Q_DECLARE_METATYPE(VideoCore::LoadCallbackStage); +#endif diff --git a/src/sudachi/loading_screen.ui b/src/sudachi/loading_screen.ui new file mode 100644 index 0000000..820b475 --- /dev/null +++ b/src/sudachi/loading_screen.ui @@ -0,0 +1,164 @@ + + + LoadingScreen + + + + 0 + 0 + 746 + 495 + + + + background-color: rgb(0, 0, 0); + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + + + Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop + + + 30 + + + + + + + 15 + + + QLayout::SetNoConstraint + + + + + + 0 + 0 + + + + background-color: black; color: white; +font: 75 20pt "Arial"; + + + Loading Shaders 387 / 1628 + + + + + + + + 0 + 0 + + + + + 500 + 40 + + + + QProgressBar { +color: white; +border: 2px solid white; +outline-color: black; +border-radius: 20px; +} +QProgressBar::chunk { +background-color: white; +border-radius: 15px; +} + + + 50 + + + false + + + Loading Shaders %v out of %m + + + + + + + + + + background-color: black; color: white; +font: 75 15pt "Arial"; + + + Estimated Time 5m 4s + + + + + + + + + background-color: black; + + + + + + Qt::AlignCenter + + + 30 + + + + + + + + + + + diff --git a/src/sudachi/main.cpp b/src/sudachi/main.cpp new file mode 100644 index 0000000..8e31a84 --- /dev/null +++ b/src/sudachi/main.cpp @@ -0,0 +1,5343 @@ +// SPDX-FileCopyrightText: 2014 Citra Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include +#include +#include +#include +#include +#include +#include +#include "core/hle/service/am/applet_manager.h" +#include "core/loader/nca.h" +#include "core/tools/renderdoc.h" + +#ifdef __APPLE__ +#include // for chdir +#endif +#ifdef __unix__ +#include +#include +#include "common/linux/gamemode.h" +#endif + +#include + +// VFS includes must be before glad as they will conflict with Windows file api, which uses defines. +#include "applets/qt_amiibo_settings.h" +#include "applets/qt_controller.h" +#include "applets/qt_error.h" +#include "applets/qt_profile_select.h" +#include "applets/qt_software_keyboard.h" +#include "applets/qt_web_browser.h" +#include "common/nvidia_flags.h" +#include "common/settings_enums.h" +#include "configuration/configure_input.h" +#include "configuration/configure_per_game.h" +#include "configuration/configure_tas.h" +#include "core/file_sys/romfs_factory.h" +#include "core/file_sys/vfs/vfs.h" +#include "core/file_sys/vfs/vfs_real.h" +#include "core/frontend/applets/cabinet.h" +#include "core/frontend/applets/controller.h" +#include "core/frontend/applets/general.h" +#include "core/frontend/applets/mii_edit.h" +#include "core/frontend/applets/software_keyboard.h" +#include "core/hle/service/acc/profile_manager.h" +#include "core/hle/service/am/frontend/applets.h" +#include "core/hle/service/set/system_settings_server.h" +#include "frontend_common/content_manager.h" +#include "hid_core/frontend/emulated_controller.h" +#include "hid_core/hid_core.h" +#include "sudachi/multiplayer/state.h" +#include "sudachi/util/controller_navigation.h" + +// These are wrappers to avoid the calls to CreateDirectory and CreateFile because of the Windows +// defines. +static FileSys::VirtualDir VfsFilesystemCreateDirectoryWrapper( + const FileSys::VirtualFilesystem& vfs, const std::string& path, FileSys::OpenMode mode) { + return vfs->CreateDirectory(path, mode); +} + +static FileSys::VirtualFile VfsDirectoryCreateFileWrapper(const FileSys::VirtualDir& dir, + const std::string& path) { + return dir->CreateFile(path); +} + +#include +#include + +#define QT_NO_OPENGL +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifdef HAVE_SDL2 +#include // For SDL ScreenSaver functions +#endif + +#include +#include "common/detached_tasks.h" +#include "common/fs/fs.h" +#include "common/fs/path_util.h" +#include "common/literals.h" +#include "common/logging/backend.h" +#include "common/logging/log.h" +#include "common/memory_detect.h" +#include "common/microprofile.h" +#include "common/scm_rev.h" +#include "common/scope_exit.h" +#ifdef _WIN32 +#include +#include "common/windows/timer_resolution.h" +#endif +#ifdef ARCHITECTURE_x86_64 +#include "common/x64/cpu_detect.h" +#endif +#include "common/settings.h" +#include "common/telemetry.h" +#include "core/core.h" +#include "core/core_timing.h" +#include "core/crypto/key_manager.h" +#include "core/file_sys/card_image.h" +#include "core/file_sys/common_funcs.h" +#include "core/file_sys/content_archive.h" +#include "core/file_sys/control_metadata.h" +#include "core/file_sys/patch_manager.h" +#include "core/file_sys/registered_cache.h" +#include "core/file_sys/romfs.h" +#include "core/file_sys/savedata_factory.h" +#include "core/file_sys/submission_package.h" +#include "core/hle/kernel/k_process.h" +#include "core/hle/service/am/am.h" +#include "core/hle/service/filesystem/filesystem.h" +#include "core/hle/service/sm/sm.h" +#include "core/loader/loader.h" +#include "core/perf_stats.h" +#include "core/telemetry_session.h" +#include "frontend_common/config.h" +#include "input_common/drivers/tas_input.h" +#include "input_common/drivers/virtual_amiibo.h" +#include "input_common/main.h" +#include "ui_main.h" +#include "util/overlay_dialog.h" +#include "video_core/gpu.h" +#include "video_core/renderer_base.h" +#include "video_core/shader_notify.h" +#include "sudachi/about_dialog.h" +#include "sudachi/bootmanager.h" +#include "sudachi/compatdb.h" +#include "sudachi/compatibility_list.h" +#include "sudachi/configuration/configure_dialog.h" +#include "sudachi/configuration/configure_input_per_game.h" +#include "sudachi/configuration/qt_config.h" +#include "sudachi/debugger/console.h" +#include "sudachi/debugger/controller.h" +#include "sudachi/debugger/profiler.h" +#include "sudachi/debugger/wait_tree.h" +#include "sudachi/discord.h" +#include "sudachi/game_list.h" +#include "sudachi/game_list_p.h" +#include "sudachi/hotkeys.h" +#include "sudachi/install_dialog.h" +#include "sudachi/loading_screen.h" +#include "sudachi/main.h" +#include "sudachi/play_time_manager.h" +#include "sudachi/startup_checks.h" +#include "sudachi/uisettings.h" +#include "sudachi/util/clickable_label.h" +#include "sudachi/vk_device_info.h" + +#ifdef SUDACHI_CRASH_DUMPS +#include "sudachi/breakpad.h" +#endif + +using namespace Common::Literals; + +#ifdef USE_DISCORD_PRESENCE +#include "sudachi/discord_impl.h" +#endif + +#ifdef QT_STATICPLUGIN +Q_IMPORT_PLUGIN(QWindowsIntegrationPlugin); +#endif + +#ifdef _WIN32 +#include +extern "C" { +// tells Nvidia and AMD drivers to use the dedicated GPU by default on laptops with switchable +// graphics +__declspec(dllexport) unsigned long NvOptimusEnablement = 0x00000001; +__declspec(dllexport) int AmdPowerXpressRequestHighPerformance = 1; +} +#endif + +constexpr int default_mouse_hide_timeout = 2500; +constexpr int default_input_update_timeout = 1; + +constexpr size_t CopyBufferSize = 1_MiB; + +/** + * "Callouts" are one-time instructional messages shown to the user. In the config settings, there + * is a bitfield "callout_flags" options, used to track if a message has already been shown to the + * user. This is 32-bits - if we have more than 32 callouts, we should retire and recycle old ones. + */ +enum class CalloutFlag : uint32_t { + Telemetry = 0x1, + DRDDeprecation = 0x2, +}; + +void GMainWindow::ShowTelemetryCallout() { + if (UISettings::values.callout_flags.GetValue() & + static_cast(CalloutFlag::Telemetry)) { + return; + } + + UISettings::values.callout_flags = + UISettings::values.callout_flags.GetValue() | static_cast(CalloutFlag::Telemetry); + const QString telemetry_message = + tr("Anonymous " + "data is collected to help improve sudachi. " + "

Would you like to share your usage data with us?"); + if (!question(this, tr("Telemetry"), telemetry_message)) { + Settings::values.enable_telemetry = false; + system->ApplySettings(); + } +} + +const int GMainWindow::max_recent_files_item; + +static void RemoveCachedContents() { + const auto cache_dir = Common::FS::GetSudachiPath(Common::FS::SudachiPath::CacheDir); + const auto offline_fonts = cache_dir / "fonts"; + const auto offline_manual = cache_dir / "offline_web_applet_manual"; + const auto offline_legal_information = cache_dir / "offline_web_applet_legal_information"; + const auto offline_system_data = cache_dir / "offline_web_applet_system_data"; + + Common::FS::RemoveDirRecursively(offline_fonts); + Common::FS::RemoveDirRecursively(offline_manual); + Common::FS::RemoveDirRecursively(offline_legal_information); + Common::FS::RemoveDirRecursively(offline_system_data); +} + +static void LogRuntimes() { +#ifdef _MSC_VER + // It is possible that the name of the dll will change. + // vcruntime140.dll is for 2015 and onwards + static constexpr char runtime_dll_name[] = "vcruntime140.dll"; + UINT sz = GetFileVersionInfoSizeA(runtime_dll_name, nullptr); + bool runtime_version_inspection_worked = false; + if (sz > 0) { + std::vector buf(sz); + if (GetFileVersionInfoA(runtime_dll_name, 0, sz, buf.data())) { + VS_FIXEDFILEINFO* pvi; + sz = sizeof(VS_FIXEDFILEINFO); + if (VerQueryValueA(buf.data(), "\\", reinterpret_cast(&pvi), &sz)) { + if (pvi->dwSignature == VS_FFI_SIGNATURE) { + runtime_version_inspection_worked = true; + LOG_INFO(Frontend, "MSVC Compiler: {} Runtime: {}.{}.{}.{}", _MSC_VER, + pvi->dwProductVersionMS >> 16, pvi->dwProductVersionMS & 0xFFFF, + pvi->dwProductVersionLS >> 16, pvi->dwProductVersionLS & 0xFFFF); + } + } + } + } + if (!runtime_version_inspection_worked) { + LOG_INFO(Frontend, "Unable to inspect {}", runtime_dll_name); + } +#endif + LOG_INFO(Frontend, "Qt Compile: {} Runtime: {}", QT_VERSION_STR, qVersion()); +} + +static QString PrettyProductName() { +#ifdef _WIN32 + // After Windows 10 Version 2004, Microsoft decided to switch to a different notation: 20H2 + // With that notation change they changed the registry key used to denote the current version + QSettings windows_registry( + QStringLiteral("HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion"), + QSettings::NativeFormat); + const QString release_id = windows_registry.value(QStringLiteral("ReleaseId")).toString(); + if (release_id == QStringLiteral("2009")) { + const u32 current_build = windows_registry.value(QStringLiteral("CurrentBuild")).toUInt(); + const QString display_version = + windows_registry.value(QStringLiteral("DisplayVersion")).toString(); + const u32 ubr = windows_registry.value(QStringLiteral("UBR")).toUInt(); + u32 version = 10; + if (current_build >= 22000) { + version = 11; + } + return QStringLiteral("Windows %1 Version %2 (Build %3.%4)") + .arg(QString::number(version), display_version, QString::number(current_build), + QString::number(ubr)); + } +#endif + return QSysInfo::prettyProductName(); +} + +#ifdef _WIN32 +static void OverrideWindowsFont() { + // Qt5 chooses these fonts on Windows and they have fairly ugly alphanumeric/cyrillic characters + // Asking to use "MS Shell Dlg 2" gives better other chars while leaving the Chinese Characters. + const QString startup_font = QApplication::font().family(); + const QStringList ugly_fonts = {QStringLiteral("SimSun"), QStringLiteral("PMingLiU")}; + if (ugly_fonts.contains(startup_font)) { + QApplication::setFont(QFont(QStringLiteral("MS Shell Dlg 2"), 9, QFont::Normal)); + } +} +#endif + +bool GMainWindow::CheckDarkMode() { +#ifdef __unix__ + const QPalette test_palette(qApp->palette()); + const QColor text_color = test_palette.color(QPalette::Active, QPalette::Text); + const QColor window_color = test_palette.color(QPalette::Active, QPalette::Window); + return (text_color.value() > window_color.value()); +#else + // TODO: Windows + return false; +#endif // __unix__ +} + +GMainWindow::GMainWindow(std::unique_ptr config_, bool has_broken_vulkan) + : ui{std::make_unique()}, system{std::make_unique()}, + input_subsystem{std::make_shared()}, config{std::move(config_)}, + vfs{std::make_shared()}, + provider{std::make_unique()} { +#ifdef __unix__ + SetupSigInterrupts(); + SetGamemodeEnabled(Settings::values.enable_gamemode.GetValue()); +#endif + system->Initialize(); + + Common::Log::Initialize(); + Common::Log::Start(); + + LoadTranslation(); + + setAcceptDrops(true); + ui->setupUi(this); + statusBar()->hide(); + + // Check dark mode before a theme is loaded + os_dark_mode = CheckDarkMode(); + startup_icon_theme = QIcon::themeName(); + // fallback can only be set once, colorful theme icons are okay on both light/dark + QIcon::setFallbackThemeName(QStringLiteral("colorful")); + QIcon::setFallbackSearchPaths(QStringList(QStringLiteral(":/icons"))); + + default_theme_paths = QIcon::themeSearchPaths(); + UpdateUITheme(); + + SetDiscordEnabled(UISettings::values.enable_discord_presence.GetValue()); + discord_rpc->Update(); + + play_time_manager = std::make_unique(system->GetProfileManager()); + + system->GetRoomNetwork().Init(); + + RegisterMetaTypes(); + + InitializeWidgets(); + InitializeDebugWidgets(); + InitializeRecentFileMenuActions(); + InitializeHotkeys(); + + SetDefaultUIGeometry(); + RestoreUIState(); + + ConnectMenuEvents(); + ConnectWidgetEvents(); + + system->HIDCore().ReloadInputDevices(); + controller_dialog->refreshConfiguration(); + + const auto branch_name = std::string(Common::g_scm_branch); + const auto description = std::string(Common::g_scm_desc); + const auto build_id = std::string(Common::g_build_id); + + const auto sudachi_build = fmt::format("sudachi Development Build | {}-{}", branch_name, description); + const auto override_build = + fmt::format(fmt::runtime(std::string(Common::g_title_bar_format_idle)), build_id); + const auto sudachi_build_version = override_build.empty() ? sudachi_build : override_build; + const auto processor_count = std::thread::hardware_concurrency(); + + LOG_INFO(Frontend, "sudachi Version: {}", sudachi_build_version); + LogRuntimes(); +#ifdef ARCHITECTURE_x86_64 + const auto& caps = Common::GetCPUCaps(); + std::string cpu_string = caps.cpu_string; + if (caps.avx || caps.avx2 || caps.avx512f) { + cpu_string += " | AVX"; + if (caps.avx512f) { + cpu_string += "512"; + } else if (caps.avx2) { + cpu_string += '2'; + } + if (caps.fma || caps.fma4) { + cpu_string += " | FMA"; + } + } + LOG_INFO(Frontend, "Host CPU: {}", cpu_string); + if (std::optional processor_core = Common::GetProcessorCount()) { + LOG_INFO(Frontend, "Host CPU Cores: {}", *processor_core); + } +#endif + LOG_INFO(Frontend, "Host CPU Threads: {}", processor_count); + LOG_INFO(Frontend, "Host OS: {}", PrettyProductName().toStdString()); + LOG_INFO(Frontend, "Host RAM: {:.2f} GiB", + Common::GetMemInfo().TotalPhysicalMemory / f64{1_GiB}); + LOG_INFO(Frontend, "Host Swap: {:.2f} GiB", Common::GetMemInfo().TotalSwapMemory / f64{1_GiB}); +#ifdef _WIN32 + LOG_INFO(Frontend, "Host Timer Resolution: {:.4f} ms", + std::chrono::duration_cast>( + Common::Windows::SetCurrentTimerResolutionToMaximum()) + .count()); + system->CoreTiming().SetTimerResolutionNs(Common::Windows::GetCurrentTimerResolution()); +#endif + UpdateWindowTitle(); + + show(); + + system->SetContentProvider(std::make_unique()); + system->RegisterContentProvider(FileSys::ContentProviderUnionSlot::FrontendManual, + provider.get()); + system->GetFileSystemController().CreateFactories(*vfs); + + // Remove cached contents generated during the previous session + RemoveCachedContents(); + + // Gen keys if necessary + OnCheckFirmwareDecryption(); + + game_list->LoadCompatibilityList(); + game_list->PopulateAsync(UISettings::values.game_dirs); + + // Show one-time "callout" messages to the user + ShowTelemetryCallout(); + + // make sure menubar has the arrow cursor instead of inheriting from this + ui->menubar->setCursor(QCursor()); + statusBar()->setCursor(QCursor()); + + mouse_hide_timer.setInterval(default_mouse_hide_timeout); + connect(&mouse_hide_timer, &QTimer::timeout, this, &GMainWindow::HideMouseCursor); + connect(ui->menubar, &QMenuBar::hovered, this, &GMainWindow::ShowMouseCursor); + + update_input_timer.setInterval(default_input_update_timeout); + connect(&update_input_timer, &QTimer::timeout, this, &GMainWindow::UpdateInputDrivers); + update_input_timer.start(); + + MigrateConfigFiles(); + + if (has_broken_vulkan) { + UISettings::values.has_broken_vulkan = true; + + QMessageBox::warning(this, tr("Broken Vulkan Installation Detected"), + tr("Vulkan initialization failed during boot.

Click " + "here for instructions to fix the issue.")); + +#ifdef HAS_OPENGL + Settings::values.renderer_backend = Settings::RendererBackend::OpenGL; +#else + Settings::values.renderer_backend = Settings::RendererBackend::Null; +#endif + + UpdateAPIText(); + renderer_status_button->setDisabled(true); + renderer_status_button->setChecked(false); + } else { + VkDeviceInfo::PopulateRecords(vk_device_records, this->window()->windowHandle()); + } + +#if defined(HAVE_SDL2) && !defined(_WIN32) + SDL_InitSubSystem(SDL_INIT_VIDEO); + + // Set a screensaver inhibition reason string. Currently passed to DBus by SDL and visible to + // the user through their desktop environment. + //: TRANSLATORS: This string is shown to the user to explain why sudachi needs to prevent the + //: computer from sleeping + QByteArray wakelock_reason = tr("Running a game").toUtf8(); + SDL_SetHint(SDL_HINT_SCREENSAVER_INHIBIT_ACTIVITY_NAME, wakelock_reason.data()); + + // SDL disables the screen saver by default, and setting the hint + // SDL_HINT_VIDEO_ALLOW_SCREENSAVER doesn't seem to work, so we just enable the screen saver + // for now. + SDL_EnableScreenSaver(); +#endif + + SetupPrepareForSleep(); + + QStringList args = QApplication::arguments(); + + if (args.size() < 2) { + return; + } + + QString game_path; + bool has_gamepath = false; + bool is_fullscreen = false; + + for (int i = 1; i < args.size(); ++i) { + // Preserves drag/drop functionality + if (args.size() == 2 && !args[1].startsWith(QChar::fromLatin1('-'))) { + game_path = args[1]; + has_gamepath = true; + break; + } + + // Launch game in fullscreen mode + if (args[i] == QStringLiteral("-f")) { + is_fullscreen = true; + continue; + } + + // Launch game with a specific user + if (args[i] == QStringLiteral("-u")) { + if (i >= args.size() - 1) { + continue; + } + + if (args[i + 1].startsWith(QChar::fromLatin1('-'))) { + continue; + } + + int user_arg_idx = ++i; + bool argument_ok; + std::size_t selected_user = args[user_arg_idx].toUInt(&argument_ok); + + if (!argument_ok) { + // try to look it up by username, only finds the first username that matches. + const std::string user_arg_str = args[user_arg_idx].toStdString(); + const auto user_idx = system->GetProfileManager().GetUserIndex(user_arg_str); + + if (user_idx == std::nullopt) { + LOG_ERROR(Frontend, "Invalid user argument"); + continue; + } + + selected_user = user_idx.value(); + } + + if (!system->GetProfileManager().UserExistsIndex(selected_user)) { + LOG_ERROR(Frontend, "Selected user doesn't exist"); + continue; + } + + Settings::values.current_user = static_cast(selected_user); + + user_flag_cmd_line = true; + continue; + } + + // Launch game at path + if (args[i] == QStringLiteral("-g")) { + if (i >= args.size() - 1) { + continue; + } + + if (args[i + 1].startsWith(QChar::fromLatin1('-'))) { + continue; + } + + game_path = args[++i]; + has_gamepath = true; + } + } + + // Override fullscreen setting if gamepath or argument is provided + if (has_gamepath || is_fullscreen) { + ui->action_Fullscreen->setChecked(is_fullscreen); + } + + if (!game_path.isEmpty()) { + BootGame(game_path, ApplicationAppletParameters()); + } +} + +GMainWindow::~GMainWindow() { + // will get automatically deleted otherwise + if (render_window->parent() == nullptr) { + delete render_window; + } + +#ifdef __unix__ + ::close(sig_interrupt_fds[0]); + ::close(sig_interrupt_fds[1]); +#endif +} + +void GMainWindow::RegisterMetaTypes() { + // Register integral and floating point types + qRegisterMetaType("u8"); + qRegisterMetaType("u16"); + qRegisterMetaType("u32"); + qRegisterMetaType("u64"); + qRegisterMetaType("u128"); + qRegisterMetaType("s8"); + qRegisterMetaType("s16"); + qRegisterMetaType("s32"); + qRegisterMetaType("s64"); + qRegisterMetaType("f32"); + qRegisterMetaType("f64"); + + // Register string types + qRegisterMetaType("std::string"); + qRegisterMetaType("std::wstring"); + qRegisterMetaType("std::u8string"); + qRegisterMetaType("std::u16string"); + qRegisterMetaType("std::u32string"); + qRegisterMetaType("std::string_view"); + qRegisterMetaType("std::wstring_view"); + qRegisterMetaType("std::u8string_view"); + qRegisterMetaType("std::u16string_view"); + qRegisterMetaType("std::u32string_view"); + + // Register applet types + + // Cabinet Applet + qRegisterMetaType("Core::Frontend::CabinetParameters"); + qRegisterMetaType>( + "std::shared_ptr"); + + // Controller Applet + qRegisterMetaType("Core::Frontend::ControllerParameters"); + + // Profile Select Applet + qRegisterMetaType( + "Core::Frontend::ProfileSelectParameters"); + + // Software Keyboard Applet + qRegisterMetaType( + "Core::Frontend::KeyboardInitializeParameters"); + qRegisterMetaType( + "Core::Frontend::InlineAppearParameters"); + qRegisterMetaType("Core::Frontend::InlineTextParameters"); + qRegisterMetaType("Service::AM::Frontend::SwkbdResult"); + qRegisterMetaType( + "Service::AM::Frontend::SwkbdTextCheckResult"); + qRegisterMetaType( + "Service::AM::Frontend::SwkbdReplyType"); + + // Web Browser Applet + qRegisterMetaType("Service::AM::Frontend::WebExitReason"); + + // Register loader types + qRegisterMetaType("Core::SystemResultStatus"); +} + +void GMainWindow::AmiiboSettingsShowDialog(const Core::Frontend::CabinetParameters& parameters, + std::shared_ptr nfp_device) { + cabinet_applet = + new QtAmiiboSettingsDialog(this, parameters, input_subsystem.get(), nfp_device); + SCOPE_EXIT { + cabinet_applet->deleteLater(); + cabinet_applet = nullptr; + }; + + cabinet_applet->setWindowFlags(Qt::Dialog | Qt::CustomizeWindowHint | Qt::WindowStaysOnTopHint | + Qt::WindowTitleHint | Qt::WindowSystemMenuHint); + cabinet_applet->setWindowModality(Qt::WindowModal); + + if (cabinet_applet->exec() == QDialog::Rejected) { + emit AmiiboSettingsFinished(false, {}); + return; + } + + emit AmiiboSettingsFinished(true, cabinet_applet->GetName()); +} + +void GMainWindow::AmiiboSettingsRequestExit() { + if (cabinet_applet) { + cabinet_applet->reject(); + } +} + +void GMainWindow::ControllerSelectorReconfigureControllers( + const Core::Frontend::ControllerParameters& parameters) { + controller_applet = + new QtControllerSelectorDialog(this, parameters, input_subsystem.get(), *system); + SCOPE_EXIT { + controller_applet->deleteLater(); + controller_applet = nullptr; + }; + + controller_applet->setWindowFlags(Qt::Dialog | Qt::CustomizeWindowHint | + Qt::WindowStaysOnTopHint | Qt::WindowTitleHint | + Qt::WindowSystemMenuHint); + controller_applet->setWindowModality(Qt::WindowModal); + bool is_success = controller_applet->exec() != QDialog::Rejected; + + // Don't forget to apply settings. + system->HIDCore().DisableAllControllerConfiguration(); + system->ApplySettings(); + config->SaveAllValues(); + + UpdateStatusButtons(); + + emit ControllerSelectorReconfigureFinished(is_success); +} + +void GMainWindow::ControllerSelectorRequestExit() { + if (controller_applet) { + controller_applet->reject(); + } +} + +void GMainWindow::ProfileSelectorSelectProfile( + const Core::Frontend::ProfileSelectParameters& parameters) { + profile_select_applet = new QtProfileSelectionDialog(*system, this, parameters); + SCOPE_EXIT { + profile_select_applet->deleteLater(); + profile_select_applet = nullptr; + }; + + profile_select_applet->setWindowFlags(Qt::Dialog | Qt::CustomizeWindowHint | + Qt::WindowStaysOnTopHint | Qt::WindowTitleHint | + Qt::WindowSystemMenuHint | Qt::WindowCloseButtonHint); + profile_select_applet->setWindowModality(Qt::WindowModal); + if (profile_select_applet->exec() == QDialog::Rejected) { + emit ProfileSelectorFinishedSelection(std::nullopt); + return; + } + + const auto uuid = system->GetProfileManager().GetUser( + static_cast(profile_select_applet->GetIndex())); + if (!uuid.has_value()) { + emit ProfileSelectorFinishedSelection(std::nullopt); + return; + } + + emit ProfileSelectorFinishedSelection(uuid); +} + +void GMainWindow::ProfileSelectorRequestExit() { + if (profile_select_applet) { + profile_select_applet->reject(); + } +} + +void GMainWindow::SoftwareKeyboardInitialize( + bool is_inline, Core::Frontend::KeyboardInitializeParameters initialize_parameters) { + if (software_keyboard) { + LOG_ERROR(Frontend, "The software keyboard is already initialized!"); + return; + } + + software_keyboard = new QtSoftwareKeyboardDialog(render_window, *system, is_inline, + std::move(initialize_parameters)); + + if (is_inline) { + connect( + software_keyboard, &QtSoftwareKeyboardDialog::SubmitInlineText, this, + [this](Service::AM::Frontend::SwkbdReplyType reply_type, std::u16string submitted_text, + s32 cursor_position) { + emit SoftwareKeyboardSubmitInlineText(reply_type, submitted_text, cursor_position); + }, + Qt::QueuedConnection); + } else { + connect( + software_keyboard, &QtSoftwareKeyboardDialog::SubmitNormalText, this, + [this](Service::AM::Frontend::SwkbdResult result, std::u16string submitted_text, + bool confirmed) { + emit SoftwareKeyboardSubmitNormalText(result, submitted_text, confirmed); + }, + Qt::QueuedConnection); + } +} + +void GMainWindow::SoftwareKeyboardShowNormal() { + if (!software_keyboard) { + LOG_ERROR(Frontend, "The software keyboard is not initialized!"); + return; + } + + const auto& layout = render_window->GetFramebufferLayout(); + + const auto x = layout.screen.left; + const auto y = layout.screen.top; + const auto w = layout.screen.GetWidth(); + const auto h = layout.screen.GetHeight(); + const auto scale_ratio = devicePixelRatioF(); + + software_keyboard->ShowNormalKeyboard(render_window->mapToGlobal(QPoint(x, y) / scale_ratio), + QSize(w, h) / scale_ratio); +} + +void GMainWindow::SoftwareKeyboardShowTextCheck( + Service::AM::Frontend::SwkbdTextCheckResult text_check_result, + std::u16string text_check_message) { + if (!software_keyboard) { + LOG_ERROR(Frontend, "The software keyboard is not initialized!"); + return; + } + + software_keyboard->ShowTextCheckDialog(text_check_result, text_check_message); +} + +void GMainWindow::SoftwareKeyboardShowInline( + Core::Frontend::InlineAppearParameters appear_parameters) { + if (!software_keyboard) { + LOG_ERROR(Frontend, "The software keyboard is not initialized!"); + return; + } + + const auto& layout = render_window->GetFramebufferLayout(); + + const auto x = + static_cast(layout.screen.left + (0.5f * layout.screen.GetWidth() * + ((2.0f * appear_parameters.key_top_translate_x) + + (1.0f - appear_parameters.key_top_scale_x)))); + const auto y = + static_cast(layout.screen.top + (layout.screen.GetHeight() * + ((2.0f * appear_parameters.key_top_translate_y) + + (1.0f - appear_parameters.key_top_scale_y)))); + const auto w = static_cast(layout.screen.GetWidth() * appear_parameters.key_top_scale_x); + const auto h = static_cast(layout.screen.GetHeight() * appear_parameters.key_top_scale_y); + const auto scale_ratio = devicePixelRatioF(); + + software_keyboard->ShowInlineKeyboard(std::move(appear_parameters), + render_window->mapToGlobal(QPoint(x, y) / scale_ratio), + QSize(w, h) / scale_ratio); +} + +void GMainWindow::SoftwareKeyboardHideInline() { + if (!software_keyboard) { + LOG_ERROR(Frontend, "The software keyboard is not initialized!"); + return; + } + + software_keyboard->HideInlineKeyboard(); +} + +void GMainWindow::SoftwareKeyboardInlineTextChanged( + Core::Frontend::InlineTextParameters text_parameters) { + if (!software_keyboard) { + LOG_ERROR(Frontend, "The software keyboard is not initialized!"); + return; + } + + software_keyboard->InlineTextChanged(std::move(text_parameters)); +} + +void GMainWindow::SoftwareKeyboardExit() { + if (!software_keyboard) { + return; + } + + software_keyboard->ExitKeyboard(); + + software_keyboard = nullptr; +} + +void GMainWindow::WebBrowserOpenWebPage(const std::string& main_url, + const std::string& additional_args, bool is_local) { +#ifdef SUDACHI_USE_QT_WEB_ENGINE + + // Raw input breaks with the web applet, Disable web applets if enabled + if (UISettings::values.disable_web_applet || Settings::values.enable_raw_input) { + emit WebBrowserClosed(Service::AM::Frontend::WebExitReason::WindowClosed, + "http://localhost/"); + return; + } + + web_applet = new QtNXWebEngineView(this, *system, input_subsystem.get()); + + ui->action_Pause->setEnabled(false); + ui->action_Restart->setEnabled(false); + ui->action_Stop->setEnabled(false); + + { + QProgressDialog loading_progress(this); + loading_progress.setLabelText(tr("Loading Web Applet...")); + loading_progress.setRange(0, 3); + loading_progress.setValue(0); + + if (is_local && !Common::FS::Exists(main_url)) { + loading_progress.show(); + + auto future = QtConcurrent::run([this] { emit WebBrowserExtractOfflineRomFS(); }); + + while (!future.isFinished()) { + QCoreApplication::processEvents(); + + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + } + } + + loading_progress.setValue(1); + + if (is_local) { + web_applet->LoadLocalWebPage(main_url, additional_args); + } else { + web_applet->LoadExternalWebPage(main_url, additional_args); + } + + if (render_window->IsLoadingComplete()) { + render_window->hide(); + } + + const auto& layout = render_window->GetFramebufferLayout(); + const auto scale_ratio = devicePixelRatioF(); + web_applet->resize(layout.screen.GetWidth() / scale_ratio, + layout.screen.GetHeight() / scale_ratio); + web_applet->move(layout.screen.left / scale_ratio, + (layout.screen.top / scale_ratio) + menuBar()->height()); + web_applet->setZoomFactor(static_cast(layout.screen.GetWidth() / scale_ratio) / + static_cast(Layout::ScreenUndocked::Width)); + + web_applet->setFocus(); + web_applet->show(); + + loading_progress.setValue(2); + + QCoreApplication::processEvents(); + + loading_progress.setValue(3); + } + + bool exit_check = false; + + // TODO (Morph): Remove this + QAction* exit_action = new QAction(tr("Disable Web Applet"), this); + connect(exit_action, &QAction::triggered, this, [this] { + const auto result = QMessageBox::warning( + this, tr("Disable Web Applet"), + tr("Disabling the web applet can lead to undefined behavior and should only be used " + "with Super Mario 3D All-Stars. Are you sure you want to disable the web " + "applet?\n(This can be re-enabled in the Debug settings.)"), + QMessageBox::Yes | QMessageBox::No); + if (result == QMessageBox::Yes) { + UISettings::values.disable_web_applet = true; + web_applet->SetFinished(true); + } + }); + ui->menubar->addAction(exit_action); + + while (!web_applet->IsFinished()) { + QCoreApplication::processEvents(); + + if (!exit_check) { + web_applet->page()->runJavaScript( + QStringLiteral("end_applet;"), [&](const QVariant& variant) { + exit_check = false; + if (variant.toBool()) { + web_applet->SetFinished(true); + web_applet->SetExitReason( + Service::AM::Frontend::WebExitReason::EndButtonPressed); + } + }); + + exit_check = true; + } + + if (web_applet->GetCurrentURL().contains(QStringLiteral("localhost"))) { + if (!web_applet->IsFinished()) { + web_applet->SetFinished(true); + web_applet->SetExitReason(Service::AM::Frontend::WebExitReason::CallbackURL); + } + + web_applet->SetLastURL(web_applet->GetCurrentURL().toStdString()); + } + + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + } + + const auto exit_reason = web_applet->GetExitReason(); + const auto last_url = web_applet->GetLastURL(); + + web_applet->hide(); + + render_window->setFocus(); + + if (render_window->IsLoadingComplete()) { + render_window->show(); + } + + ui->action_Pause->setEnabled(true); + ui->action_Restart->setEnabled(true); + ui->action_Stop->setEnabled(true); + + ui->menubar->removeAction(exit_action); + + QCoreApplication::processEvents(); + + emit WebBrowserClosed(exit_reason, last_url); + +#else + + // Utilize the same fallback as the default web browser applet. + emit WebBrowserClosed(Service::AM::Frontend::WebExitReason::WindowClosed, "http://localhost/"); + +#endif +} + +void GMainWindow::WebBrowserRequestExit() { +#ifdef SUDACHI_USE_QT_WEB_ENGINE + if (web_applet) { + web_applet->SetExitReason(Service::AM::Frontend::WebExitReason::ExitRequested); + web_applet->SetFinished(true); + } +#endif +} + +void GMainWindow::InitializeWidgets() { +#ifdef SUDACHI_ENABLE_COMPATIBILITY_REPORTING + ui->action_Report_Compatibility->setVisible(true); +#endif + render_window = new GRenderWindow(this, emu_thread.get(), input_subsystem, *system); + render_window->hide(); + + game_list = new GameList(vfs, provider.get(), *play_time_manager, *system, this); + ui->horizontalLayout->addWidget(game_list); + + game_list_placeholder = new GameListPlaceholder(this); + ui->horizontalLayout->addWidget(game_list_placeholder); + game_list_placeholder->setVisible(false); + + loading_screen = new LoadingScreen(this); + loading_screen->hide(); + ui->horizontalLayout->addWidget(loading_screen); + connect(loading_screen, &LoadingScreen::Hidden, [&] { + loading_screen->Clear(); + if (emulation_running) { + render_window->show(); + render_window->setFocus(); + } + }); + + multiplayer_state = new MultiplayerState(this, game_list->GetModel(), ui->action_Leave_Room, + ui->action_Show_Room, *system); + multiplayer_state->setVisible(false); + + // Create status bar + message_label = new QLabel(); + // Configured separately for left alignment + message_label->setFrameStyle(QFrame::NoFrame); + message_label->setContentsMargins(4, 0, 4, 0); + message_label->setAlignment(Qt::AlignLeft); + statusBar()->addPermanentWidget(message_label, 1); + + shader_building_label = new QLabel(); + shader_building_label->setToolTip(tr("The amount of shaders currently being built")); + res_scale_label = new QLabel(); + res_scale_label->setToolTip(tr("The current selected resolution scaling multiplier.")); + emu_speed_label = new QLabel(); + emu_speed_label->setToolTip( + tr("Current emulation speed. Values higher or lower than 100% " + "indicate emulation is running faster or slower than a Switch.")); + game_fps_label = new QLabel(); + game_fps_label->setToolTip(tr("How many frames per second the game is currently displaying. " + "This will vary from game to game and scene to scene.")); + emu_frametime_label = new QLabel(); + emu_frametime_label->setToolTip( + tr("Time taken to emulate a Switch frame, not counting framelimiting or v-sync. For " + "full-speed emulation this should be at most 16.67 ms.")); + + for (auto& label : {shader_building_label, res_scale_label, emu_speed_label, game_fps_label, + emu_frametime_label}) { + label->setVisible(false); + label->setFrameStyle(QFrame::NoFrame); + label->setContentsMargins(4, 0, 4, 0); + statusBar()->addPermanentWidget(label); + } + + firmware_label = new QLabel(); + firmware_label->setObjectName(QStringLiteral("FirmwareLabel")); + firmware_label->setVisible(false); + firmware_label->setFocusPolicy(Qt::NoFocus); + statusBar()->addPermanentWidget(firmware_label); + + statusBar()->addPermanentWidget(multiplayer_state->GetStatusText(), 0); + statusBar()->addPermanentWidget(multiplayer_state->GetStatusIcon(), 0); + + tas_label = new QLabel(); + tas_label->setObjectName(QStringLiteral("TASlabel")); + tas_label->setFocusPolicy(Qt::NoFocus); + statusBar()->insertPermanentWidget(0, tas_label); + + volume_popup = new QWidget(this); + volume_popup->setWindowFlags(Qt::FramelessWindowHint | Qt::NoDropShadowWindowHint | Qt::Popup); + volume_popup->setLayout(new QVBoxLayout()); + volume_popup->setMinimumWidth(200); + + volume_slider = new QSlider(Qt::Horizontal); + volume_slider->setObjectName(QStringLiteral("volume_slider")); + volume_slider->setMaximum(200); + volume_slider->setPageStep(5); + volume_popup->layout()->addWidget(volume_slider); + + volume_button = new VolumeButton(); + volume_button->setObjectName(QStringLiteral("TogglableStatusBarButton")); + volume_button->setFocusPolicy(Qt::NoFocus); + volume_button->setCheckable(true); + UpdateVolumeUI(); + connect(volume_slider, &QSlider::valueChanged, this, [this](int percentage) { + Settings::values.audio_muted = false; + const auto volume = static_cast(percentage); + Settings::values.volume.SetValue(volume); + UpdateVolumeUI(); + }); + connect(volume_button, &QPushButton::clicked, this, [&] { + UpdateVolumeUI(); + volume_popup->setVisible(!volume_popup->isVisible()); + QRect rect = volume_button->geometry(); + QPoint bottomLeft = statusBar()->mapToGlobal(rect.topLeft()); + bottomLeft.setY(bottomLeft.y() - volume_popup->geometry().height()); + volume_popup->setGeometry(QRect(bottomLeft, QSize(rect.width(), rect.height()))); + }); + volume_button->setContextMenuPolicy(Qt::CustomContextMenu); + connect(volume_button, &QPushButton::customContextMenuRequested, + [this](const QPoint& menu_location) { + QMenu context_menu; + context_menu.addAction( + Settings::values.audio_muted ? tr("Unmute") : tr("Mute"), [this] { + Settings::values.audio_muted = !Settings::values.audio_muted; + UpdateVolumeUI(); + }); + + context_menu.addAction(tr("Reset Volume"), [this] { + Settings::values.volume.SetValue(100); + UpdateVolumeUI(); + }); + + context_menu.exec(volume_button->mapToGlobal(menu_location)); + volume_button->repaint(); + }); + connect(volume_button, &VolumeButton::VolumeChanged, this, &GMainWindow::UpdateVolumeUI); + + statusBar()->insertPermanentWidget(0, volume_button); + + // setup AA button + aa_status_button = new QPushButton(); + aa_status_button->setObjectName(QStringLiteral("TogglableStatusBarButton")); + aa_status_button->setFocusPolicy(Qt::NoFocus); + connect(aa_status_button, &QPushButton::clicked, [&] { + auto aa_mode = Settings::values.anti_aliasing.GetValue(); + aa_mode = static_cast(static_cast(aa_mode) + 1); + if (aa_mode == Settings::AntiAliasing::MaxEnum) { + aa_mode = Settings::AntiAliasing::None; + } + Settings::values.anti_aliasing.SetValue(aa_mode); + aa_status_button->setChecked(true); + UpdateAAText(); + }); + UpdateAAText(); + aa_status_button->setCheckable(true); + aa_status_button->setChecked(true); + aa_status_button->setContextMenuPolicy(Qt::CustomContextMenu); + connect(aa_status_button, &QPushButton::customContextMenuRequested, + [this](const QPoint& menu_location) { + QMenu context_menu; + for (auto const& aa_text_pair : ConfigurationShared::anti_aliasing_texts_map) { + context_menu.addAction(aa_text_pair.second, [this, aa_text_pair] { + Settings::values.anti_aliasing.SetValue(aa_text_pair.first); + UpdateAAText(); + }); + } + context_menu.exec(aa_status_button->mapToGlobal(menu_location)); + aa_status_button->repaint(); + }); + statusBar()->insertPermanentWidget(0, aa_status_button); + + // Setup Filter button + filter_status_button = new QPushButton(); + filter_status_button->setObjectName(QStringLiteral("TogglableStatusBarButton")); + filter_status_button->setFocusPolicy(Qt::NoFocus); + connect(filter_status_button, &QPushButton::clicked, this, + &GMainWindow::OnToggleAdaptingFilter); + UpdateFilterText(); + filter_status_button->setCheckable(true); + filter_status_button->setChecked(true); + filter_status_button->setContextMenuPolicy(Qt::CustomContextMenu); + connect(filter_status_button, &QPushButton::customContextMenuRequested, + [this](const QPoint& menu_location) { + QMenu context_menu; + for (auto const& filter_text_pair : ConfigurationShared::scaling_filter_texts_map) { + context_menu.addAction(filter_text_pair.second, [this, filter_text_pair] { + Settings::values.scaling_filter.SetValue(filter_text_pair.first); + UpdateFilterText(); + }); + } + context_menu.exec(filter_status_button->mapToGlobal(menu_location)); + filter_status_button->repaint(); + }); + statusBar()->insertPermanentWidget(0, filter_status_button); + + // Setup Dock button + dock_status_button = new QPushButton(); + dock_status_button->setObjectName(QStringLiteral("DockingStatusBarButton")); + dock_status_button->setFocusPolicy(Qt::NoFocus); + connect(dock_status_button, &QPushButton::clicked, this, &GMainWindow::OnToggleDockedMode); + dock_status_button->setCheckable(true); + UpdateDockedButton(); + dock_status_button->setContextMenuPolicy(Qt::CustomContextMenu); + connect(dock_status_button, &QPushButton::customContextMenuRequested, + [this](const QPoint& menu_location) { + QMenu context_menu; + + for (auto const& pair : ConfigurationShared::use_docked_mode_texts_map) { + context_menu.addAction(pair.second, [this, &pair] { + if (pair.first != Settings::values.use_docked_mode.GetValue()) { + OnToggleDockedMode(); + } + }); + } + context_menu.exec(dock_status_button->mapToGlobal(menu_location)); + dock_status_button->repaint(); + }); + statusBar()->insertPermanentWidget(0, dock_status_button); + + // Setup GPU Accuracy button + gpu_accuracy_button = new QPushButton(); + gpu_accuracy_button->setObjectName(QStringLiteral("GPUStatusBarButton")); + gpu_accuracy_button->setCheckable(true); + gpu_accuracy_button->setFocusPolicy(Qt::NoFocus); + connect(gpu_accuracy_button, &QPushButton::clicked, this, &GMainWindow::OnToggleGpuAccuracy); + UpdateGPUAccuracyButton(); + gpu_accuracy_button->setContextMenuPolicy(Qt::CustomContextMenu); + connect(gpu_accuracy_button, &QPushButton::customContextMenuRequested, + [this](const QPoint& menu_location) { + QMenu context_menu; + + for (auto const& gpu_accuracy_pair : ConfigurationShared::gpu_accuracy_texts_map) { + if (gpu_accuracy_pair.first == Settings::GpuAccuracy::Extreme) { + continue; + } + context_menu.addAction(gpu_accuracy_pair.second, [this, gpu_accuracy_pair] { + Settings::values.gpu_accuracy.SetValue(gpu_accuracy_pair.first); + UpdateGPUAccuracyButton(); + }); + } + context_menu.exec(gpu_accuracy_button->mapToGlobal(menu_location)); + gpu_accuracy_button->repaint(); + }); + statusBar()->insertPermanentWidget(0, gpu_accuracy_button); + + // Setup Renderer API button + renderer_status_button = new QPushButton(); + renderer_status_button->setObjectName(QStringLiteral("RendererStatusBarButton")); + renderer_status_button->setCheckable(true); + renderer_status_button->setFocusPolicy(Qt::NoFocus); + connect(renderer_status_button, &QPushButton::clicked, this, &GMainWindow::OnToggleGraphicsAPI); + UpdateAPIText(); + renderer_status_button->setCheckable(true); + renderer_status_button->setChecked(Settings::values.renderer_backend.GetValue() == + Settings::RendererBackend::Vulkan); + renderer_status_button->setContextMenuPolicy(Qt::CustomContextMenu); + connect(renderer_status_button, &QPushButton::customContextMenuRequested, + [this](const QPoint& menu_location) { + QMenu context_menu; + + for (auto const& renderer_backend_pair : + ConfigurationShared::renderer_backend_texts_map) { + if (renderer_backend_pair.first == Settings::RendererBackend::Null) { + continue; + } + context_menu.addAction( + renderer_backend_pair.second, [this, renderer_backend_pair] { + Settings::values.renderer_backend.SetValue(renderer_backend_pair.first); + UpdateAPIText(); + }); + } + context_menu.exec(renderer_status_button->mapToGlobal(menu_location)); + renderer_status_button->repaint(); + }); + statusBar()->insertPermanentWidget(0, renderer_status_button); + + statusBar()->setVisible(true); + setStyleSheet(QStringLiteral("QStatusBar::item{border: none;}")); +} + +void GMainWindow::InitializeDebugWidgets() { + QMenu* debug_menu = ui->menu_View_Debugging; + +#if MICROPROFILE_ENABLED + microProfileDialog = new MicroProfileDialog(this); + microProfileDialog->hide(); + debug_menu->addAction(microProfileDialog->toggleViewAction()); +#endif + + waitTreeWidget = new WaitTreeWidget(*system, this); + addDockWidget(Qt::LeftDockWidgetArea, waitTreeWidget); + waitTreeWidget->hide(); + debug_menu->addAction(waitTreeWidget->toggleViewAction()); + + controller_dialog = new ControllerDialog(system->HIDCore(), input_subsystem, this); + controller_dialog->hide(); + debug_menu->addAction(controller_dialog->toggleViewAction()); + + connect(this, &GMainWindow::EmulationStarting, waitTreeWidget, + &WaitTreeWidget::OnEmulationStarting); + connect(this, &GMainWindow::EmulationStopping, waitTreeWidget, + &WaitTreeWidget::OnEmulationStopping); +} + +void GMainWindow::InitializeRecentFileMenuActions() { + for (int i = 0; i < max_recent_files_item; ++i) { + actions_recent_files[i] = new QAction(this); + actions_recent_files[i]->setVisible(false); + connect(actions_recent_files[i], &QAction::triggered, this, &GMainWindow::OnMenuRecentFile); + + ui->menu_recent_files->addAction(actions_recent_files[i]); + } + ui->menu_recent_files->addSeparator(); + QAction* action_clear_recent_files = new QAction(this); + action_clear_recent_files->setText(tr("&Clear Recent Files")); + connect(action_clear_recent_files, &QAction::triggered, this, [this] { + UISettings::values.recent_files.clear(); + UpdateRecentFiles(); + }); + ui->menu_recent_files->addAction(action_clear_recent_files); + + UpdateRecentFiles(); +} + +void GMainWindow::LinkActionShortcut(QAction* action, const QString& action_name, + const bool tas_allowed) { + static const auto main_window = std::string("Main Window"); + action->setShortcut(hotkey_registry.GetKeySequence(main_window, action_name.toStdString())); + action->setShortcutContext( + hotkey_registry.GetShortcutContext(main_window, action_name.toStdString())); + action->setAutoRepeat(false); + + this->addAction(action); + + auto* controller = system->HIDCore().GetEmulatedController(Core::HID::NpadIdType::Player1); + const auto* controller_hotkey = + hotkey_registry.GetControllerHotkey(main_window, action_name.toStdString(), controller); + connect( + controller_hotkey, &ControllerShortcut::Activated, this, + [action, tas_allowed, this] { + auto [tas_status, current_tas_frame, total_tas_frames] = + input_subsystem->GetTas()->GetStatus(); + if (tas_allowed || tas_status == InputCommon::TasInput::TasState::Stopped) { + action->trigger(); + } + }, + Qt::QueuedConnection); +} + +void GMainWindow::InitializeHotkeys() { + hotkey_registry.LoadHotkeys(); + + LinkActionShortcut(ui->action_Load_File, QStringLiteral("Load File")); + LinkActionShortcut(ui->action_Load_Amiibo, QStringLiteral("Load/Remove Amiibo")); + LinkActionShortcut(ui->action_Exit, QStringLiteral("Exit sudachi")); + LinkActionShortcut(ui->action_Restart, QStringLiteral("Restart Emulation")); + LinkActionShortcut(ui->action_Pause, QStringLiteral("Continue/Pause Emulation")); + LinkActionShortcut(ui->action_Stop, QStringLiteral("Stop Emulation")); + LinkActionShortcut(ui->action_Show_Filter_Bar, QStringLiteral("Toggle Filter Bar")); + LinkActionShortcut(ui->action_Show_Status_Bar, QStringLiteral("Toggle Status Bar")); + LinkActionShortcut(ui->action_Fullscreen, QStringLiteral("Fullscreen")); + LinkActionShortcut(ui->action_Capture_Screenshot, QStringLiteral("Capture Screenshot")); + LinkActionShortcut(ui->action_TAS_Start, QStringLiteral("TAS Start/Stop"), true); + LinkActionShortcut(ui->action_TAS_Record, QStringLiteral("TAS Record"), true); + LinkActionShortcut(ui->action_TAS_Reset, QStringLiteral("TAS Reset"), true); + LinkActionShortcut(ui->action_View_Lobby, + QStringLiteral("Multiplayer Browse Public Game Lobby")); + LinkActionShortcut(ui->action_Start_Room, QStringLiteral("Multiplayer Create Room")); + LinkActionShortcut(ui->action_Connect_To_Room, + QStringLiteral("Multiplayer Direct Connect to Room")); + LinkActionShortcut(ui->action_Show_Room, QStringLiteral("Multiplayer Show Current Room")); + LinkActionShortcut(ui->action_Leave_Room, QStringLiteral("Multiplayer Leave Room")); + + static const QString main_window = QStringLiteral("Main Window"); + const auto connect_shortcut = [&](const QString& action_name, const Fn& function) { + const auto* hotkey = + hotkey_registry.GetHotkey(main_window.toStdString(), action_name.toStdString(), this); + auto* controller = system->HIDCore().GetEmulatedController(Core::HID::NpadIdType::Player1); + const auto* controller_hotkey = hotkey_registry.GetControllerHotkey( + main_window.toStdString(), action_name.toStdString(), controller); + connect(hotkey, &QShortcut::activated, this, function); + connect(controller_hotkey, &ControllerShortcut::Activated, this, function, + Qt::QueuedConnection); + }; + + connect_shortcut(QStringLiteral("Exit Fullscreen"), [&] { + if (emulation_running && ui->action_Fullscreen->isChecked()) { + ui->action_Fullscreen->setChecked(false); + ToggleFullscreen(); + } + }); + connect_shortcut(QStringLiteral("Change Adapting Filter"), + &GMainWindow::OnToggleAdaptingFilter); + connect_shortcut(QStringLiteral("Change Docked Mode"), &GMainWindow::OnToggleDockedMode); + connect_shortcut(QStringLiteral("Change GPU Accuracy"), &GMainWindow::OnToggleGpuAccuracy); + connect_shortcut(QStringLiteral("Audio Mute/Unmute"), &GMainWindow::OnMute); + connect_shortcut(QStringLiteral("Audio Volume Down"), &GMainWindow::OnDecreaseVolume); + connect_shortcut(QStringLiteral("Audio Volume Up"), &GMainWindow::OnIncreaseVolume); + connect_shortcut(QStringLiteral("Toggle Framerate Limit"), [] { + Settings::values.use_speed_limit.SetValue(!Settings::values.use_speed_limit.GetValue()); + }); + connect_shortcut(QStringLiteral("Toggle Renderdoc Capture"), [this] { + if (Settings::values.enable_renderdoc_hotkey) { + system->GetRenderdocAPI().ToggleCapture(); + } + }); + connect_shortcut(QStringLiteral("Toggle Mouse Panning"), [&] { + Settings::values.mouse_panning = !Settings::values.mouse_panning; + if (Settings::values.mouse_panning) { + render_window->installEventFilter(render_window); + render_window->setAttribute(Qt::WA_Hover, true); + } + }); +} + +void GMainWindow::SetDefaultUIGeometry() { + // geometry: 53% of the window contents are in the upper screen half, 47% in the lower half + const QRect screenRect = QGuiApplication::primaryScreen()->geometry(); + + const int w = screenRect.width() * 2 / 3; + const int h = screenRect.height() * 2 / 3; + const int x = (screenRect.x() + screenRect.width()) / 2 - w / 2; + const int y = (screenRect.y() + screenRect.height()) / 2 - h * 53 / 100; + + setGeometry(x, y, w, h); +} + +void GMainWindow::RestoreUIState() { + setWindowFlags(windowFlags() & ~Qt::FramelessWindowHint); + restoreGeometry(UISettings::values.geometry); + // Work-around because the games list isn't supposed to be full screen + if (isFullScreen()) { + showNormal(); + } + restoreState(UISettings::values.state); + render_window->setWindowFlags(render_window->windowFlags() & ~Qt::FramelessWindowHint); + render_window->restoreGeometry(UISettings::values.renderwindow_geometry); +#if MICROPROFILE_ENABLED + microProfileDialog->restoreGeometry(UISettings::values.microprofile_geometry); + microProfileDialog->setVisible(UISettings::values.microprofile_visible.GetValue()); +#endif + + game_list->LoadInterfaceLayout(); + + ui->action_Single_Window_Mode->setChecked(UISettings::values.single_window_mode.GetValue()); + ToggleWindowMode(); + + ui->action_Fullscreen->setChecked(UISettings::values.fullscreen.GetValue()); + + ui->action_Display_Dock_Widget_Headers->setChecked( + UISettings::values.display_titlebar.GetValue()); + OnDisplayTitleBars(ui->action_Display_Dock_Widget_Headers->isChecked()); + + ui->action_Show_Filter_Bar->setChecked(UISettings::values.show_filter_bar.GetValue()); + game_list->SetFilterVisible(ui->action_Show_Filter_Bar->isChecked()); + + ui->action_Show_Status_Bar->setChecked(UISettings::values.show_status_bar.GetValue()); + statusBar()->setVisible(ui->action_Show_Status_Bar->isChecked()); + Debugger::ToggleConsole(); +} + +void GMainWindow::OnAppFocusStateChanged(Qt::ApplicationState state) { + if (state != Qt::ApplicationHidden && state != Qt::ApplicationInactive && + state != Qt::ApplicationActive) { + LOG_DEBUG(Frontend, "ApplicationState unusual flag: {} ", state); + } + if (!emulation_running) { + return; + } + if (UISettings::values.pause_when_in_background) { + if (emu_thread->IsRunning() && + (state & (Qt::ApplicationHidden | Qt::ApplicationInactive))) { + auto_paused = true; + OnPauseGame(); + } else if (!emu_thread->IsRunning() && auto_paused && state == Qt::ApplicationActive) { + auto_paused = false; + OnStartGame(); + } + } + if (UISettings::values.mute_when_in_background) { + if (!Settings::values.audio_muted && + (state & (Qt::ApplicationHidden | Qt::ApplicationInactive))) { + Settings::values.audio_muted = true; + auto_muted = true; + } else if (auto_muted && state == Qt::ApplicationActive) { + Settings::values.audio_muted = false; + auto_muted = false; + } + UpdateVolumeUI(); + } +} + +void GMainWindow::ConnectWidgetEvents() { + connect(game_list, &GameList::BootGame, this, &GMainWindow::BootGameFromList); + connect(game_list, &GameList::GameChosen, this, &GMainWindow::OnGameListLoadFile); + connect(game_list, &GameList::OpenDirectory, this, &GMainWindow::OnGameListOpenDirectory); + connect(game_list, &GameList::OpenFolderRequested, this, &GMainWindow::OnGameListOpenFolder); + connect(game_list, &GameList::OpenTransferableShaderCacheRequested, this, + &GMainWindow::OnTransferableShaderCacheOpenFile); + connect(game_list, &GameList::RemoveInstalledEntryRequested, this, + &GMainWindow::OnGameListRemoveInstalledEntry); + connect(game_list, &GameList::RemoveFileRequested, this, &GMainWindow::OnGameListRemoveFile); + connect(game_list, &GameList::RemovePlayTimeRequested, this, + &GMainWindow::OnGameListRemovePlayTimeData); + connect(game_list, &GameList::DumpRomFSRequested, this, &GMainWindow::OnGameListDumpRomFS); + connect(game_list, &GameList::VerifyIntegrityRequested, this, + &GMainWindow::OnGameListVerifyIntegrity); + connect(game_list, &GameList::CopyTIDRequested, this, &GMainWindow::OnGameListCopyTID); + connect(game_list, &GameList::NavigateToGamedbEntryRequested, this, + &GMainWindow::OnGameListNavigateToGamedbEntry); + connect(game_list, &GameList::CreateShortcut, this, &GMainWindow::OnGameListCreateShortcut); + connect(game_list, &GameList::AddDirectory, this, &GMainWindow::OnGameListAddDirectory); + connect(game_list_placeholder, &GameListPlaceholder::AddDirectory, this, + &GMainWindow::OnGameListAddDirectory); + connect(game_list, &GameList::ShowList, this, &GMainWindow::OnGameListShowList); + connect(game_list, &GameList::PopulatingCompleted, + [this] { multiplayer_state->UpdateGameList(game_list->GetModel()); }); + connect(game_list, &GameList::SaveConfig, this, &GMainWindow::OnSaveConfig); + + connect(game_list, &GameList::OpenPerGameGeneralRequested, this, + &GMainWindow::OnGameListOpenPerGameProperties); + + connect(this, &GMainWindow::UpdateInstallProgress, this, + &GMainWindow::IncrementInstallProgress); + + connect(this, &GMainWindow::EmulationStarting, render_window, + &GRenderWindow::OnEmulationStarting); + connect(this, &GMainWindow::EmulationStopping, render_window, + &GRenderWindow::OnEmulationStopping); + + // Software Keyboard Applet + connect(this, &GMainWindow::EmulationStarting, this, &GMainWindow::SoftwareKeyboardExit); + connect(this, &GMainWindow::EmulationStopping, this, &GMainWindow::SoftwareKeyboardExit); + + connect(&status_bar_update_timer, &QTimer::timeout, this, &GMainWindow::UpdateStatusBar); + + connect(this, &GMainWindow::UpdateThemedIcons, multiplayer_state, + &MultiplayerState::UpdateThemedIcons); +} + +void GMainWindow::ConnectMenuEvents() { + const auto connect_menu = [&](QAction* action, const Fn& event_fn) { + connect(action, &QAction::triggered, this, event_fn); + // Add actions to this window so that hiding menus in fullscreen won't disable them + addAction(action); + // Add actions to the render window so that they work outside of single window mode + render_window->addAction(action); + }; + + // File + connect_menu(ui->action_Load_File, &GMainWindow::OnMenuLoadFile); + connect_menu(ui->action_Load_Folder, &GMainWindow::OnMenuLoadFolder); + connect_menu(ui->action_Install_File_NAND, &GMainWindow::OnMenuInstallToNAND); + connect_menu(ui->action_Exit, &QMainWindow::close); + connect_menu(ui->action_Load_Amiibo, &GMainWindow::OnLoadAmiibo); + + // Emulation + connect_menu(ui->action_Pause, &GMainWindow::OnPauseContinueGame); + connect_menu(ui->action_Stop, &GMainWindow::OnStopGame); + connect_menu(ui->action_Report_Compatibility, &GMainWindow::OnMenuReportCompatibility); + connect_menu(ui->action_Open_Mods_Page, &GMainWindow::OnOpenModsPage); + connect_menu(ui->action_Open_Quickstart_Guide, &GMainWindow::OnOpenQuickstartGuide); + connect_menu(ui->action_Open_FAQ, &GMainWindow::OnOpenFAQ); + connect_menu(ui->action_Restart, &GMainWindow::OnRestartGame); + connect_menu(ui->action_Configure, &GMainWindow::OnConfigure); + connect_menu(ui->action_Configure_Current_Game, &GMainWindow::OnConfigurePerGame); + + // View + connect_menu(ui->action_Fullscreen, &GMainWindow::ToggleFullscreen); + connect_menu(ui->action_Single_Window_Mode, &GMainWindow::ToggleWindowMode); + connect_menu(ui->action_Display_Dock_Widget_Headers, &GMainWindow::OnDisplayTitleBars); + connect_menu(ui->action_Show_Filter_Bar, &GMainWindow::OnToggleFilterBar); + connect_menu(ui->action_Show_Status_Bar, &GMainWindow::OnToggleStatusBar); + + connect_menu(ui->action_Reset_Window_Size_720, &GMainWindow::ResetWindowSize720); + connect_menu(ui->action_Reset_Window_Size_900, &GMainWindow::ResetWindowSize900); + connect_menu(ui->action_Reset_Window_Size_1080, &GMainWindow::ResetWindowSize1080); + ui->menu_Reset_Window_Size->addActions({ui->action_Reset_Window_Size_720, + ui->action_Reset_Window_Size_900, + ui->action_Reset_Window_Size_1080}); + + // Multiplayer + connect(ui->action_View_Lobby, &QAction::triggered, multiplayer_state, + &MultiplayerState::OnViewLobby); + connect(ui->action_Start_Room, &QAction::triggered, multiplayer_state, + &MultiplayerState::OnCreateRoom); + connect(ui->action_Leave_Room, &QAction::triggered, multiplayer_state, + &MultiplayerState::OnCloseRoom); + connect(ui->action_Connect_To_Room, &QAction::triggered, multiplayer_state, + &MultiplayerState::OnDirectConnectToRoom); + connect(ui->action_Show_Room, &QAction::triggered, multiplayer_state, + &MultiplayerState::OnOpenNetworkRoom); + connect(multiplayer_state, &MultiplayerState::SaveConfig, this, &GMainWindow::OnSaveConfig); + + // Tools + connect_menu(ui->action_Load_Album, &GMainWindow::OnAlbum); + connect_menu(ui->action_Load_Cabinet_Nickname_Owner, + [this]() { OnCabinet(Service::NFP::CabinetMode::StartNicknameAndOwnerSettings); }); + connect_menu(ui->action_Load_Cabinet_Eraser, + [this]() { OnCabinet(Service::NFP::CabinetMode::StartGameDataEraser); }); + connect_menu(ui->action_Load_Cabinet_Restorer, + [this]() { OnCabinet(Service::NFP::CabinetMode::StartRestorer); }); + connect_menu(ui->action_Load_Cabinet_Formatter, + [this]() { OnCabinet(Service::NFP::CabinetMode::StartFormatter); }); + connect_menu(ui->action_Load_Mii_Edit, &GMainWindow::OnMiiEdit); + connect_menu(ui->action_Open_Controller_Menu, &GMainWindow::OnOpenControllerMenu); + connect_menu(ui->action_Capture_Screenshot, &GMainWindow::OnCaptureScreenshot); + + // TAS + connect_menu(ui->action_TAS_Start, &GMainWindow::OnTasStartStop); + connect_menu(ui->action_TAS_Record, &GMainWindow::OnTasRecord); + connect_menu(ui->action_TAS_Reset, &GMainWindow::OnTasReset); + connect_menu(ui->action_Configure_Tas, &GMainWindow::OnConfigureTas); + + // Help + connect_menu(ui->action_Open_sudachi_Folder, &GMainWindow::OnOpenSudachiFolder); + connect_menu(ui->action_Verify_installed_contents, &GMainWindow::OnVerifyInstalledContents); + connect_menu(ui->action_Install_Firmware, &GMainWindow::OnInstallFirmware); + connect_menu(ui->action_Install_Keys, &GMainWindow::OnInstallDecryptionKeys); + connect_menu(ui->action_About, &GMainWindow::OnAbout); +} + +void GMainWindow::UpdateMenuState() { + const bool is_paused = emu_thread == nullptr || !emu_thread->IsRunning(); + const bool is_firmware_available = CheckFirmwarePresence(); + + const std::array running_actions{ + ui->action_Stop, + ui->action_Restart, + ui->action_Configure_Current_Game, + ui->action_Report_Compatibility, + ui->action_Load_Amiibo, + ui->action_Pause, + }; + + const std::array applet_actions{ui->action_Load_Album, + ui->action_Load_Cabinet_Nickname_Owner, + ui->action_Load_Cabinet_Eraser, + ui->action_Load_Cabinet_Restorer, + ui->action_Load_Cabinet_Formatter, + ui->action_Load_Mii_Edit, + ui->action_Open_Controller_Menu}; + + for (QAction* action : running_actions) { + action->setEnabled(emulation_running); + } + + ui->action_Install_Firmware->setEnabled(!emulation_running); + ui->action_Install_Keys->setEnabled(!emulation_running); + + for (QAction* action : applet_actions) { + action->setEnabled(is_firmware_available && !emulation_running); + } + + ui->action_Capture_Screenshot->setEnabled(emulation_running && !is_paused); + + if (emulation_running && is_paused) { + ui->action_Pause->setText(tr("&Continue")); + } else { + ui->action_Pause->setText(tr("&Pause")); + } + + multiplayer_state->UpdateNotificationStatus(); +} + +void GMainWindow::OnDisplayTitleBars(bool show) { + QList widgets = findChildren(); + + if (show) { + for (QDockWidget* widget : widgets) { + QWidget* old = widget->titleBarWidget(); + widget->setTitleBarWidget(nullptr); + if (old != nullptr) + delete old; + } + } else { + for (QDockWidget* widget : widgets) { + QWidget* old = widget->titleBarWidget(); + widget->setTitleBarWidget(new QWidget()); + if (old != nullptr) + delete old; + } + } +} + +void GMainWindow::SetupPrepareForSleep() { +#ifdef __unix__ + auto bus = QDBusConnection::systemBus(); + if (bus.isConnected()) { + const bool success = bus.connect( + QStringLiteral("org.freedesktop.login1"), QStringLiteral("/org/freedesktop/login1"), + QStringLiteral("org.freedesktop.login1.Manager"), QStringLiteral("PrepareForSleep"), + QStringLiteral("b"), this, SLOT(OnPrepareForSleep(bool))); + + if (!success) { + LOG_WARNING(Frontend, "Couldn't register PrepareForSleep signal"); + } + } else { + LOG_WARNING(Frontend, "QDBusConnection system bus is not connected"); + } +#endif // __unix__ +} + +void GMainWindow::OnPrepareForSleep(bool prepare_sleep) { + if (emu_thread == nullptr) { + return; + } + + if (prepare_sleep) { + if (emu_thread->IsRunning()) { + auto_paused = true; + OnPauseGame(); + } + } else { + if (!emu_thread->IsRunning() && auto_paused) { + auto_paused = false; + OnStartGame(); + } + } +} + +#ifdef __unix__ +std::array GMainWindow::sig_interrupt_fds{0, 0, 0}; + +void GMainWindow::SetupSigInterrupts() { + if (sig_interrupt_fds[2] == 1) { + return; + } + socketpair(AF_UNIX, SOCK_STREAM, 0, sig_interrupt_fds.data()); + sig_interrupt_fds[2] = 1; + + struct sigaction sa; + sa.sa_handler = &GMainWindow::HandleSigInterrupt; + sigemptyset(&sa.sa_mask); + sa.sa_flags = SA_RESETHAND; + sigaction(SIGINT, &sa, nullptr); + sigaction(SIGTERM, &sa, nullptr); + + sig_interrupt_notifier = new QSocketNotifier(sig_interrupt_fds[1], QSocketNotifier::Read, this); + connect(sig_interrupt_notifier, &QSocketNotifier::activated, this, + &GMainWindow::OnSigInterruptNotifierActivated); + connect(this, &GMainWindow::SigInterrupt, this, &GMainWindow::close); +} + +void GMainWindow::HandleSigInterrupt(int sig) { + if (sig == SIGINT) { + _exit(1); + } + + // Calling into Qt directly from a signal handler is not safe, + // so wake up a QSocketNotifier with this hacky write call instead. + char a = 1; + int ret = write(sig_interrupt_fds[0], &a, sizeof(a)); + (void)ret; +} + +void GMainWindow::OnSigInterruptNotifierActivated() { + sig_interrupt_notifier->setEnabled(false); + + char a; + int ret = read(sig_interrupt_fds[1], &a, sizeof(a)); + (void)ret; + + sig_interrupt_notifier->setEnabled(true); + + emit SigInterrupt(); +} +#endif // __unix__ + +void GMainWindow::PreventOSSleep() { +#ifdef _WIN32 + SetThreadExecutionState(ES_CONTINUOUS | ES_SYSTEM_REQUIRED | ES_DISPLAY_REQUIRED); +#elif defined(HAVE_SDL2) + SDL_DisableScreenSaver(); +#endif +} + +void GMainWindow::AllowOSSleep() { +#ifdef _WIN32 + SetThreadExecutionState(ES_CONTINUOUS); +#elif defined(HAVE_SDL2) + SDL_EnableScreenSaver(); +#endif +} + +bool GMainWindow::LoadROM(const QString& filename, Service::AM::FrontendAppletParameters params) { + // Shutdown previous session if the emu thread is still active... + if (emu_thread != nullptr) { + ShutdownGame(); + } + + if (!render_window->InitRenderTarget()) { + return false; + } + + system->SetFilesystem(vfs); + + if (params.launch_type == Service::AM::LaunchType::FrontendInitiated) { + system->GetUserChannel().clear(); + } + + system->SetFrontendAppletSet({ + std::make_unique(*this), // Amiibo Settings + (UISettings::values.controller_applet_disabled.GetValue() == true) + ? nullptr + : std::make_unique(*this), // Controller Selector + std::make_unique(*this), // Error Display + nullptr, // Mii Editor + nullptr, // Parental Controls + nullptr, // Photo Viewer + std::make_unique(*this), // Profile Selector + std::make_unique(*this), // Software Keyboard + std::make_unique(*this), // Web Browser + }); + + const Core::SystemResultStatus result{ + system->Load(*render_window, filename.toStdString(), params)}; + + const auto drd_callout = (UISettings::values.callout_flags.GetValue() & + static_cast(CalloutFlag::DRDDeprecation)) == 0; + + if (result == Core::SystemResultStatus::Success && + system->GetAppLoader().GetFileType() == Loader::FileType::DeconstructedRomDirectory && + drd_callout) { + UISettings::values.callout_flags = UISettings::values.callout_flags.GetValue() | + static_cast(CalloutFlag::DRDDeprecation); + QMessageBox::warning( + this, tr("Warning Outdated Game Format"), + tr("You are using the deconstructed ROM directory format for this game, which is an " + "outdated format that has been superseded by others such as NCA, NAX, XCI, or " + "NSP. Deconstructed ROM directories lack icons, metadata, and update " + "support.

For an explanation of the various Switch formats sudachi supports, check out our " + "wiki. This message will not be shown again.")); + } + + if (result != Core::SystemResultStatus::Success) { + switch (result) { + case Core::SystemResultStatus::ErrorGetLoader: + LOG_CRITICAL(Frontend, "Failed to obtain loader for {}!", filename.toStdString()); + QMessageBox::critical(this, tr("Error while loading ROM!"), + tr("The ROM format is not supported.")); + break; + case Core::SystemResultStatus::ErrorVideoCore: + QMessageBox::critical( + this, tr("An error occurred initializing the video core."), + tr("sudachi has encountered an error while running the video core. " + "This is usually caused by outdated GPU drivers, including integrated ones. " + "Please see the log for more details. " + "For more information on accessing the log, please see the following page: " + "" + "How to Upload the Log File. ")); + break; + default: + if (result > Core::SystemResultStatus::ErrorLoader) { + const u16 loader_id = static_cast(Core::SystemResultStatus::ErrorLoader); + const u16 error_id = static_cast(result) - loader_id; + const std::string error_code = fmt::format("({:04X}-{:04X})", loader_id, error_id); + LOG_CRITICAL(Frontend, "Failed to load ROM! {}", error_code); + + const auto title = + tr("Error while loading ROM! %1", "%1 signifies a numeric error code.") + .arg(QString::fromStdString(error_code)); + const auto description = + tr("%1
Please follow the " + "sudachi quickstart guide to redump your files.
You can refer " + "to the sudachi wiki or the sudachi Discord for help.", + "%1 signifies an error string.") + .arg(QString::fromStdString( + GetResultStatusString(static_cast(error_id)))); + + QMessageBox::critical(this, title, description); + } else { + QMessageBox::critical( + this, tr("Error while loading ROM!"), + tr("An unknown error occurred. Please see the log for more details.")); + } + break; + } + return false; + } + current_game_path = filename; + + system->TelemetrySession().AddField(Common::Telemetry::FieldType::App, "Frontend", "Qt"); + return true; +} + +bool GMainWindow::SelectAndSetCurrentUser( + const Core::Frontend::ProfileSelectParameters& parameters) { + QtProfileSelectionDialog dialog(*system, this, parameters); + dialog.setWindowFlags(Qt::Dialog | Qt::CustomizeWindowHint | Qt::WindowTitleHint | + Qt::WindowSystemMenuHint | Qt::WindowCloseButtonHint); + dialog.setWindowModality(Qt::WindowModal); + + if (dialog.exec() == QDialog::Rejected) { + return false; + } + + Settings::values.current_user = dialog.GetIndex(); + return true; +} + +void GMainWindow::ConfigureFilesystemProvider(const std::string& filepath) { + // Ensure all NCAs are registered before launching the game + const auto file = vfs->OpenFile(filepath, FileSys::OpenMode::Read); + if (!file) { + return; + } + + auto loader = Loader::GetLoader(*system, file); + if (!loader) { + return; + } + + const auto file_type = loader->GetFileType(); + if (file_type == Loader::FileType::Unknown || file_type == Loader::FileType::Error) { + return; + } + + u64 program_id = 0; + const auto res2 = loader->ReadProgramId(program_id); + if (res2 == Loader::ResultStatus::Success && file_type == Loader::FileType::NCA) { + provider->AddEntry(FileSys::TitleType::Application, + FileSys::GetCRTypeFromNCAType(FileSys::NCA{file}.GetType()), program_id, + file); + } else if (res2 == Loader::ResultStatus::Success && + (file_type == Loader::FileType::XCI || file_type == Loader::FileType::NSP)) { + const auto nsp = file_type == Loader::FileType::NSP + ? std::make_shared(file) + : FileSys::XCI{file}.GetSecurePartitionNSP(); + for (const auto& title : nsp->GetNCAs()) { + for (const auto& entry : title.second) { + provider->AddEntry(entry.first.first, entry.first.second, title.first, + entry.second->GetBaseFile()); + } + } + } +} + +void GMainWindow::BootGame(const QString& filename, Service::AM::FrontendAppletParameters params, + StartGameType type) { + LOG_INFO(Frontend, "sudachi starting..."); + + if (params.program_id == 0 || + params.program_id > static_cast(Service::AM::AppletProgramId::MaxProgramId)) { + StoreRecentFile(filename); // Put the filename on top of the list + } + + // Save configurations + UpdateUISettings(); + game_list->SaveInterfaceLayout(); + config->SaveAllValues(); + + u64 title_id{0}; + + last_filename_booted = filename; + + ConfigureFilesystemProvider(filename.toStdString()); + const auto v_file = Core::GetGameFileFromPath(vfs, filename.toUtf8().constData()); + const auto loader = Loader::GetLoader(*system, v_file, params.program_id, params.program_index); + + if (loader != nullptr && loader->ReadProgramId(title_id) == Loader::ResultStatus::Success && + type == StartGameType::Normal) { + // Load per game settings + const auto file_path = + std::filesystem::path{Common::U16StringFromBuffer(filename.utf16(), filename.size())}; + const auto config_file_name = title_id == 0 + ? Common::FS::PathToUTF8String(file_path.filename()) + : fmt::format("{:016X}", title_id); + QtConfig per_game_config(config_file_name, Config::ConfigType::PerGameConfig); + system->HIDCore().ReloadInputDevices(); + system->ApplySettings(); + } + + Settings::LogSettings(); + + if (UISettings::values.select_user_on_boot && !user_flag_cmd_line) { + const Core::Frontend::ProfileSelectParameters parameters{ + .mode = Service::AM::Frontend::UiMode::UserSelector, + .invalid_uid_list = {}, + .display_options = {}, + .purpose = Service::AM::Frontend::UserSelectionPurpose::General, + }; + if (SelectAndSetCurrentUser(parameters) == false) { + return; + } + } + + // If the user specifies -u (successfully) on the cmd line, don't prompt for a user on first + // game startup only. If the user stops emulation and starts a new one, go back to the expected + // behavior of asking. + user_flag_cmd_line = false; + + if (!LoadROM(filename, params)) { + return; + } + + system->SetShuttingDown(false); + game_list->setDisabled(true); + + // Create and start the emulation thread + emu_thread = std::make_unique(*system); + emit EmulationStarting(emu_thread.get()); + emu_thread->start(); + + // Register an ExecuteProgram callback such that Core can execute a sub-program + system->RegisterExecuteProgramCallback( + [this](std::size_t program_index_) { render_window->ExecuteProgram(program_index_); }); + + system->RegisterExitCallback([this] { + emu_thread->ForceStop(); + render_window->Exit(); + }); + + connect(render_window, &GRenderWindow::Closed, this, &GMainWindow::OnStopGame); + connect(render_window, &GRenderWindow::MouseActivity, this, &GMainWindow::OnMouseActivity); + // BlockingQueuedConnection is important here, it makes sure we've finished refreshing our views + // before the CPU continues + connect(emu_thread.get(), &EmuThread::DebugModeEntered, waitTreeWidget, + &WaitTreeWidget::OnDebugModeEntered, Qt::BlockingQueuedConnection); + connect(emu_thread.get(), &EmuThread::DebugModeLeft, waitTreeWidget, + &WaitTreeWidget::OnDebugModeLeft, Qt::BlockingQueuedConnection); + + connect(emu_thread.get(), &EmuThread::LoadProgress, loading_screen, + &LoadingScreen::OnLoadProgress, Qt::QueuedConnection); + + // Update the GUI + UpdateStatusButtons(); + if (ui->action_Single_Window_Mode->isChecked()) { + game_list->hide(); + game_list_placeholder->hide(); + } + status_bar_update_timer.start(500); + renderer_status_button->setDisabled(true); + + if (UISettings::values.hide_mouse || Settings::values.mouse_panning) { + render_window->installEventFilter(render_window); + render_window->setAttribute(Qt::WA_Hover, true); + } + + if (UISettings::values.hide_mouse) { + mouse_hide_timer.start(); + } + + render_window->InitializeCamera(); + + std::string title_name; + std::string title_version; + const auto res = system->GetGameName(title_name); + + const auto metadata = [this, title_id] { + const FileSys::PatchManager pm(title_id, system->GetFileSystemController(), + system->GetContentProvider()); + return pm.GetControlMetadata(); + }(); + if (metadata.first != nullptr) { + title_version = metadata.first->GetVersionString(); + title_name = metadata.first->GetApplicationName(); + } + if (res != Loader::ResultStatus::Success || title_name.empty()) { + title_name = Common::FS::PathToUTF8String( + std::filesystem::path{Common::U16StringFromBuffer(filename.utf16(), filename.size())} + .filename()); + } + const bool is_64bit = system->Kernel().ApplicationProcess()->Is64Bit(); + const auto instruction_set_suffix = is_64bit ? tr("(64-bit)") : tr("(32-bit)"); + title_name = tr("%1 %2", "%1 is the title name. %2 indicates if the title is 64-bit or 32-bit") + .arg(QString::fromStdString(title_name), instruction_set_suffix) + .toStdString(); + LOG_INFO(Frontend, "Booting game: {:016X} | {} | {}", title_id, title_name, title_version); + const auto gpu_vendor = system->GPU().Renderer().GetDeviceVendor(); + UpdateWindowTitle(title_name, title_version, gpu_vendor); + + loading_screen->Prepare(system->GetAppLoader()); + loading_screen->show(); + + emulation_running = true; + if (ui->action_Fullscreen->isChecked()) { + ShowFullscreen(); + } + OnStartGame(); +} + +void GMainWindow::BootGameFromList(const QString& filename, StartGameType with_config) { + BootGame(filename, ApplicationAppletParameters(), with_config); +} + +bool GMainWindow::OnShutdownBegin() { + if (!emulation_running) { + return false; + } + + if (ui->action_Fullscreen->isChecked()) { + HideFullscreen(); + } + + AllowOSSleep(); + + // Disable unlimited frame rate + Settings::values.use_speed_limit.SetValue(true); + + if (system->IsShuttingDown()) { + return false; + } + + system->SetShuttingDown(true); + discord_rpc->Pause(); + + RequestGameExit(); + emu_thread->disconnect(); + emu_thread->SetRunning(true); + + emit EmulationStopping(); + + int shutdown_time = 1000; + + if (system->DebuggerEnabled()) { + shutdown_time = 0; + } else if (system->GetExitLocked()) { + shutdown_time = 5000; + } + + shutdown_timer.setSingleShot(true); + shutdown_timer.start(shutdown_time); + connect(&shutdown_timer, &QTimer::timeout, this, &GMainWindow::OnEmulationStopTimeExpired); + connect(emu_thread.get(), &QThread::finished, this, &GMainWindow::OnEmulationStopped); + + // Disable everything to prevent anything from being triggered here + ui->action_Pause->setEnabled(false); + ui->action_Restart->setEnabled(false); + ui->action_Stop->setEnabled(false); + + return true; +} + +void GMainWindow::OnShutdownBeginDialog() { + shutdown_dialog = new OverlayDialog(this, *system, QString{}, tr("Closing software..."), + QString{}, QString{}, Qt::AlignHCenter | Qt::AlignVCenter); + shutdown_dialog->open(); +} + +void GMainWindow::OnEmulationStopTimeExpired() { + if (emu_thread) { + emu_thread->ForceStop(); + } +} + +void GMainWindow::OnEmulationStopped() { + shutdown_timer.stop(); + if (emu_thread) { + emu_thread->disconnect(); + emu_thread->wait(); + emu_thread.reset(); + } + + if (shutdown_dialog) { + shutdown_dialog->deleteLater(); + shutdown_dialog = nullptr; + } + + emulation_running = false; + + discord_rpc->Update(); + +#ifdef __unix__ + Common::Linux::StopGamemode(); +#endif + + // The emulation is stopped, so closing the window or not does not matter anymore + disconnect(render_window, &GRenderWindow::Closed, this, &GMainWindow::OnStopGame); + + // Update the GUI + UpdateMenuState(); + + render_window->hide(); + loading_screen->hide(); + loading_screen->Clear(); + if (game_list->IsEmpty()) { + game_list_placeholder->show(); + } else { + game_list->show(); + } + game_list->SetFilterFocus(); + tas_label->clear(); + input_subsystem->GetTas()->Stop(); + OnTasStateChanged(); + render_window->FinalizeCamera(); + + system->GetFrontendAppletHolder().SetCurrentAppletId(Service::AM::AppletId::None); + + // Enable all controllers + system->HIDCore().SetSupportedStyleTag({Core::HID::NpadStyleSet::All}); + + render_window->removeEventFilter(render_window); + render_window->setAttribute(Qt::WA_Hover, false); + + UpdateWindowTitle(); + + // Disable status bar updates + status_bar_update_timer.stop(); + shader_building_label->setVisible(false); + res_scale_label->setVisible(false); + emu_speed_label->setVisible(false); + game_fps_label->setVisible(false); + emu_frametime_label->setVisible(false); + renderer_status_button->setEnabled(!UISettings::values.has_broken_vulkan); + + if (!firmware_label->text().isEmpty()) { + firmware_label->setVisible(true); + } + + current_game_path.clear(); + + // When closing the game, destroy the GLWindow to clear the context after the game is closed + render_window->ReleaseRenderTarget(); + + // Enable game list + game_list->setEnabled(true); + + Settings::RestoreGlobalState(system->IsPoweredOn()); + system->HIDCore().ReloadInputDevices(); + UpdateStatusButtons(); +} + +void GMainWindow::ShutdownGame() { + if (!emulation_running) { + return; + } + + play_time_manager->Stop(); + OnShutdownBegin(); + OnEmulationStopTimeExpired(); + OnEmulationStopped(); +} + +void GMainWindow::StoreRecentFile(const QString& filename) { + UISettings::values.recent_files.prepend(filename); + UISettings::values.recent_files.removeDuplicates(); + while (UISettings::values.recent_files.size() > max_recent_files_item) { + UISettings::values.recent_files.removeLast(); + } + + UpdateRecentFiles(); +} + +void GMainWindow::UpdateRecentFiles() { + const int num_recent_files = + std::min(static_cast(UISettings::values.recent_files.size()), max_recent_files_item); + + for (int i = 0; i < num_recent_files; i++) { + const QString text = QStringLiteral("&%1. %2").arg(i + 1).arg( + QFileInfo(UISettings::values.recent_files[i]).fileName()); + actions_recent_files[i]->setText(text); + actions_recent_files[i]->setData(UISettings::values.recent_files[i]); + actions_recent_files[i]->setToolTip(UISettings::values.recent_files[i]); + actions_recent_files[i]->setVisible(true); + } + + for (int j = num_recent_files; j < max_recent_files_item; ++j) { + actions_recent_files[j]->setVisible(false); + } + + // Enable the recent files menu if the list isn't empty + ui->menu_recent_files->setEnabled(num_recent_files != 0); +} + +void GMainWindow::OnGameListLoadFile(QString game_path, u64 program_id) { + auto params = ApplicationAppletParameters(); + params.program_id = program_id; + + BootGame(game_path, params); +} + +void GMainWindow::OnGameListOpenFolder(u64 program_id, GameListOpenTarget target, + const std::string& game_path) { + std::filesystem::path path; + QString open_target; + + const auto [user_save_size, device_save_size] = [this, &game_path, &program_id] { + const FileSys::PatchManager pm{program_id, system->GetFileSystemController(), + system->GetContentProvider()}; + const auto control = pm.GetControlMetadata().first; + if (control != nullptr) { + return std::make_pair(control->GetDefaultNormalSaveSize(), + control->GetDeviceSaveDataSize()); + } else { + const auto file = Core::GetGameFileFromPath(vfs, game_path); + const auto loader = Loader::GetLoader(*system, file); + + FileSys::NACP nacp{}; + loader->ReadControlData(nacp); + return std::make_pair(nacp.GetDefaultNormalSaveSize(), nacp.GetDeviceSaveDataSize()); + } + }(); + + const bool has_user_save{user_save_size > 0}; + const bool has_device_save{device_save_size > 0}; + + ASSERT_MSG(has_user_save != has_device_save, "Game uses both user and device savedata?"); + + switch (target) { + case GameListOpenTarget::SaveData: { + open_target = tr("Save Data"); + const auto nand_dir = Common::FS::GetSudachiPath(Common::FS::SudachiPath::NANDDir); + auto vfs_nand_dir = + vfs->OpenDirectory(Common::FS::PathToUTF8String(nand_dir), FileSys::OpenMode::Read); + + if (has_user_save) { + // User save data + const auto select_profile = [this] { + const Core::Frontend::ProfileSelectParameters parameters{ + .mode = Service::AM::Frontend::UiMode::UserSelector, + .invalid_uid_list = {}, + .display_options = {}, + .purpose = Service::AM::Frontend::UserSelectionPurpose::General, + }; + QtProfileSelectionDialog dialog(*system, this, parameters); + dialog.setWindowFlags(Qt::Dialog | Qt::CustomizeWindowHint | Qt::WindowTitleHint | + Qt::WindowSystemMenuHint | Qt::WindowCloseButtonHint); + dialog.setWindowModality(Qt::WindowModal); + + if (dialog.exec() == QDialog::Rejected) { + return -1; + } + + return dialog.GetIndex(); + }; + + const auto index = select_profile(); + if (index == -1) { + return; + } + + const auto user_id = + system->GetProfileManager().GetUser(static_cast(index)); + ASSERT(user_id); + + const auto user_save_data_path = FileSys::SaveDataFactory::GetFullPath( + {}, vfs_nand_dir, FileSys::SaveDataSpaceId::User, FileSys::SaveDataType::Account, + program_id, user_id->AsU128(), 0); + + path = Common::FS::ConcatPathSafe(nand_dir, user_save_data_path); + } else { + // Device save data + const auto device_save_data_path = FileSys::SaveDataFactory::GetFullPath( + {}, vfs_nand_dir, FileSys::SaveDataSpaceId::User, FileSys::SaveDataType::Account, + program_id, {}, 0); + + path = Common::FS::ConcatPathSafe(nand_dir, device_save_data_path); + } + + if (!Common::FS::CreateDirs(path)) { + LOG_ERROR(Frontend, "Unable to create the directories for save data"); + } + + break; + } + case GameListOpenTarget::ModData: { + open_target = tr("Mod Data"); + path = Common::FS::GetSudachiPath(Common::FS::SudachiPath::LoadDir) / + fmt::format("{:016X}", program_id); + break; + } + default: + UNIMPLEMENTED(); + break; + } + + const QString qpath = QString::fromStdString(Common::FS::PathToUTF8String(path)); + const QDir dir(qpath); + if (!dir.exists()) { + QMessageBox::warning(this, tr("Error Opening %1 Folder").arg(open_target), + tr("Folder does not exist!")); + return; + } + LOG_INFO(Frontend, "Opening {} path for program_id={:016x}", open_target.toStdString(), + program_id); + QDesktopServices::openUrl(QUrl::fromLocalFile(qpath)); +} + +void GMainWindow::OnTransferableShaderCacheOpenFile(u64 program_id) { + const auto shader_cache_dir = Common::FS::GetSudachiPath(Common::FS::SudachiPath::ShaderDir); + const auto shader_cache_folder_path{shader_cache_dir / fmt::format("{:016x}", program_id)}; + if (!Common::FS::CreateDirs(shader_cache_folder_path)) { + QMessageBox::warning(this, tr("Error Opening Transferable Shader Cache"), + tr("Failed to create the shader cache directory for this title.")); + return; + } + const auto shader_path_string{Common::FS::PathToUTF8String(shader_cache_folder_path)}; + const auto qt_shader_cache_path = QString::fromStdString(shader_path_string); + QDesktopServices::openUrl(QUrl::fromLocalFile(qt_shader_cache_path)); +} + +static bool RomFSRawCopy(size_t total_size, size_t& read_size, QProgressDialog& dialog, + const FileSys::VirtualDir& src, const FileSys::VirtualDir& dest, + bool full) { + if (src == nullptr || dest == nullptr || !src->IsReadable() || !dest->IsWritable()) + return false; + if (dialog.wasCanceled()) + return false; + + std::vector buffer(CopyBufferSize); + auto last_timestamp = std::chrono::steady_clock::now(); + + const auto QtRawCopy = [&](const FileSys::VirtualFile& src_file, + const FileSys::VirtualFile& dest_file) { + if (src_file == nullptr || dest_file == nullptr) { + return false; + } + if (!dest_file->Resize(src_file->GetSize())) { + return false; + } + + for (std::size_t i = 0; i < src_file->GetSize(); i += buffer.size()) { + if (dialog.wasCanceled()) { + dest_file->Resize(0); + return false; + } + + using namespace std::literals::chrono_literals; + const auto new_timestamp = std::chrono::steady_clock::now(); + + if ((new_timestamp - last_timestamp) > 33ms) { + last_timestamp = new_timestamp; + dialog.setValue( + static_cast(std::min(read_size, total_size) * 100 / total_size)); + QCoreApplication::processEvents(); + } + + const auto read = src_file->Read(buffer.data(), buffer.size(), i); + dest_file->Write(buffer.data(), read, i); + + read_size += read; + } + + return true; + }; + + if (full) { + for (const auto& file : src->GetFiles()) { + const auto out = VfsDirectoryCreateFileWrapper(dest, file->GetName()); + if (!QtRawCopy(file, out)) + return false; + } + } + + for (const auto& dir : src->GetSubdirectories()) { + const auto out = dest->CreateSubdirectory(dir->GetName()); + if (!RomFSRawCopy(total_size, read_size, dialog, dir, out, full)) + return false; + } + + return true; +} + +QString GMainWindow::GetGameListErrorRemoving(InstalledEntryType type) const { + switch (type) { + case InstalledEntryType::Game: + return tr("Error Removing Contents"); + case InstalledEntryType::Update: + return tr("Error Removing Update"); + case InstalledEntryType::AddOnContent: + return tr("Error Removing DLC"); + default: + return QStringLiteral("Error Removing "); + } +} +void GMainWindow::OnGameListRemoveInstalledEntry(u64 program_id, InstalledEntryType type) { + const QString entry_question = [type] { + switch (type) { + case InstalledEntryType::Game: + return tr("Remove Installed Game Contents?"); + case InstalledEntryType::Update: + return tr("Remove Installed Game Update?"); + case InstalledEntryType::AddOnContent: + return tr("Remove Installed Game DLC?"); + default: + return QStringLiteral("Remove Installed Game ?"); + } + }(); + + if (!question(this, tr("Remove Entry"), entry_question, QMessageBox::Yes | QMessageBox::No, + QMessageBox::No)) { + return; + } + + switch (type) { + case InstalledEntryType::Game: + RemoveBaseContent(program_id, type); + [[fallthrough]]; + case InstalledEntryType::Update: + RemoveUpdateContent(program_id, type); + if (type != InstalledEntryType::Game) { + break; + } + [[fallthrough]]; + case InstalledEntryType::AddOnContent: + RemoveAddOnContent(program_id, type); + break; + } + Common::FS::RemoveDirRecursively(Common::FS::GetSudachiPath(Common::FS::SudachiPath::CacheDir) / + "game_list"); + game_list->PopulateAsync(UISettings::values.game_dirs); +} + +void GMainWindow::RemoveBaseContent(u64 program_id, InstalledEntryType type) { + const auto res = + ContentManager::RemoveBaseContent(system->GetFileSystemController(), program_id); + if (res) { + QMessageBox::information(this, tr("Successfully Removed"), + tr("Successfully removed the installed base game.")); + } else { + QMessageBox::warning( + this, GetGameListErrorRemoving(type), + tr("The base game is not installed in the NAND and cannot be removed.")); + } +} + +void GMainWindow::RemoveUpdateContent(u64 program_id, InstalledEntryType type) { + const auto res = ContentManager::RemoveUpdate(system->GetFileSystemController(), program_id); + if (res) { + QMessageBox::information(this, tr("Successfully Removed"), + tr("Successfully removed the installed update.")); + } else { + QMessageBox::warning(this, GetGameListErrorRemoving(type), + tr("There is no update installed for this title.")); + } +} + +void GMainWindow::RemoveAddOnContent(u64 program_id, InstalledEntryType type) { + const size_t count = ContentManager::RemoveAllDLC(*system, program_id); + if (count == 0) { + QMessageBox::warning(this, GetGameListErrorRemoving(type), + tr("There are no DLC installed for this title.")); + return; + } + + QMessageBox::information(this, tr("Successfully Removed"), + tr("Successfully removed %1 installed DLC.").arg(count)); +} + +void GMainWindow::OnGameListRemoveFile(u64 program_id, GameListRemoveTarget target, + const std::string& game_path) { + const QString question = [target] { + switch (target) { + case GameListRemoveTarget::GlShaderCache: + return tr("Delete OpenGL Transferable Shader Cache?"); + case GameListRemoveTarget::VkShaderCache: + return tr("Delete Vulkan Transferable Shader Cache?"); + case GameListRemoveTarget::AllShaderCache: + return tr("Delete All Transferable Shader Caches?"); + case GameListRemoveTarget::CustomConfiguration: + return tr("Remove Custom Game Configuration?"); + case GameListRemoveTarget::CacheStorage: + return tr("Remove Cache Storage?"); + default: + return QString{}; + } + }(); + + if (!GMainWindow::question(this, tr("Remove File"), question, + QMessageBox::Yes | QMessageBox::No, QMessageBox::No)) { + return; + } + + switch (target) { + case GameListRemoveTarget::VkShaderCache: + RemoveVulkanDriverPipelineCache(program_id); + [[fallthrough]]; + case GameListRemoveTarget::GlShaderCache: + RemoveTransferableShaderCache(program_id, target); + break; + case GameListRemoveTarget::AllShaderCache: + RemoveAllTransferableShaderCaches(program_id); + break; + case GameListRemoveTarget::CustomConfiguration: + RemoveCustomConfiguration(program_id, game_path); + break; + case GameListRemoveTarget::CacheStorage: + RemoveCacheStorage(program_id); + break; + } +} + +void GMainWindow::OnGameListRemovePlayTimeData(u64 program_id) { + if (QMessageBox::question(this, tr("Remove Play Time Data"), tr("Reset play time?"), + QMessageBox::Yes | QMessageBox::No, + QMessageBox::No) != QMessageBox::Yes) { + return; + } + + play_time_manager->ResetProgramPlayTime(program_id); + game_list->PopulateAsync(UISettings::values.game_dirs); +} + +void GMainWindow::RemoveTransferableShaderCache(u64 program_id, GameListRemoveTarget target) { + const auto target_file_name = [target] { + switch (target) { + case GameListRemoveTarget::GlShaderCache: + return "opengl.bin"; + case GameListRemoveTarget::VkShaderCache: + return "vulkan.bin"; + default: + return ""; + } + }(); + const auto shader_cache_dir = Common::FS::GetSudachiPath(Common::FS::SudachiPath::ShaderDir); + const auto shader_cache_folder_path = shader_cache_dir / fmt::format("{:016x}", program_id); + const auto target_file = shader_cache_folder_path / target_file_name; + + if (!Common::FS::Exists(target_file)) { + QMessageBox::warning(this, tr("Error Removing Transferable Shader Cache"), + tr("A shader cache for this title does not exist.")); + return; + } + if (Common::FS::RemoveFile(target_file)) { + QMessageBox::information(this, tr("Successfully Removed"), + tr("Successfully removed the transferable shader cache.")); + } else { + QMessageBox::warning(this, tr("Error Removing Transferable Shader Cache"), + tr("Failed to remove the transferable shader cache.")); + } +} + +void GMainWindow::RemoveVulkanDriverPipelineCache(u64 program_id) { + static constexpr std::string_view target_file_name = "vulkan_pipelines.bin"; + + const auto shader_cache_dir = Common::FS::GetSudachiPath(Common::FS::SudachiPath::ShaderDir); + const auto shader_cache_folder_path = shader_cache_dir / fmt::format("{:016x}", program_id); + const auto target_file = shader_cache_folder_path / target_file_name; + + if (!Common::FS::Exists(target_file)) { + return; + } + if (!Common::FS::RemoveFile(target_file)) { + QMessageBox::warning(this, tr("Error Removing Vulkan Driver Pipeline Cache"), + tr("Failed to remove the driver pipeline cache.")); + } +} + +void GMainWindow::RemoveAllTransferableShaderCaches(u64 program_id) { + const auto shader_cache_dir = Common::FS::GetSudachiPath(Common::FS::SudachiPath::ShaderDir); + const auto program_shader_cache_dir = shader_cache_dir / fmt::format("{:016x}", program_id); + + if (!Common::FS::Exists(program_shader_cache_dir)) { + QMessageBox::warning(this, tr("Error Removing Transferable Shader Caches"), + tr("A shader cache for this title does not exist.")); + return; + } + if (Common::FS::RemoveDirRecursively(program_shader_cache_dir)) { + QMessageBox::information(this, tr("Successfully Removed"), + tr("Successfully removed the transferable shader caches.")); + } else { + QMessageBox::warning(this, tr("Error Removing Transferable Shader Caches"), + tr("Failed to remove the transferable shader cache directory.")); + } +} + +void GMainWindow::RemoveCustomConfiguration(u64 program_id, const std::string& game_path) { + const auto file_path = std::filesystem::path(Common::FS::ToU8String(game_path)); + const auto config_file_name = + program_id == 0 ? Common::FS::PathToUTF8String(file_path.filename()).append(".ini") + : fmt::format("{:016X}.ini", program_id); + const auto custom_config_file_path = + Common::FS::GetSudachiPath(Common::FS::SudachiPath::ConfigDir) / "custom" / config_file_name; + + if (!Common::FS::Exists(custom_config_file_path)) { + QMessageBox::warning(this, tr("Error Removing Custom Configuration"), + tr("A custom configuration for this title does not exist.")); + return; + } + + if (Common::FS::RemoveFile(custom_config_file_path)) { + QMessageBox::information(this, tr("Successfully Removed"), + tr("Successfully removed the custom game configuration.")); + } else { + QMessageBox::warning(this, tr("Error Removing Custom Configuration"), + tr("Failed to remove the custom game configuration.")); + } +} + +void GMainWindow::RemoveCacheStorage(u64 program_id) { + const auto nand_dir = Common::FS::GetSudachiPath(Common::FS::SudachiPath::NANDDir); + auto vfs_nand_dir = + vfs->OpenDirectory(Common::FS::PathToUTF8String(nand_dir), FileSys::OpenMode::Read); + + const auto cache_storage_path = FileSys::SaveDataFactory::GetFullPath( + {}, vfs_nand_dir, FileSys::SaveDataSpaceId::User, FileSys::SaveDataType::Cache, + 0 /* program_id */, {}, 0); + + const auto path = Common::FS::ConcatPathSafe(nand_dir, cache_storage_path); + + // Not an error if it wasn't cleared. + Common::FS::RemoveDirRecursively(path); +} + +void GMainWindow::OnGameListDumpRomFS(u64 program_id, const std::string& game_path, + DumpRomFSTarget target) { + const auto failed = [this] { + QMessageBox::warning(this, tr("RomFS Extraction Failed!"), + tr("There was an error copying the RomFS files or the user " + "cancelled the operation.")); + }; + + const auto loader = + Loader::GetLoader(*system, vfs->OpenFile(game_path, FileSys::OpenMode::Read)); + if (loader == nullptr) { + failed(); + return; + } + + FileSys::VirtualFile packed_update_raw{}; + loader->ReadUpdateRaw(packed_update_raw); + + const auto& installed = system->GetContentProvider(); + + u64 title_id{}; + u8 raw_type{}; + if (!SelectRomFSDumpTarget(installed, program_id, &title_id, &raw_type)) { + failed(); + return; + } + + const auto type = static_cast(raw_type); + const auto base_nca = installed.GetEntry(title_id, type); + if (!base_nca) { + failed(); + return; + } + + const FileSys::NCA update_nca{packed_update_raw, nullptr}; + if (type != FileSys::ContentRecordType::Program || + update_nca.GetStatus() != Loader::ResultStatus::ErrorMissingBKTRBaseRomFS || + update_nca.GetTitleId() != FileSys::GetUpdateTitleID(title_id)) { + packed_update_raw = {}; + } + + const auto base_romfs = base_nca->GetRomFS(); + const auto dump_dir = + target == DumpRomFSTarget::Normal + ? Common::FS::GetSudachiPath(Common::FS::SudachiPath::DumpDir) + : Common::FS::GetSudachiPath(Common::FS::SudachiPath::SDMCDir) / "atmosphere" / "contents"; + const auto romfs_dir = fmt::format("{:016X}/romfs", title_id); + + const auto path = Common::FS::PathToUTF8String(dump_dir / romfs_dir); + + const FileSys::PatchManager pm{title_id, system->GetFileSystemController(), installed}; + auto romfs = pm.PatchRomFS(base_nca.get(), base_romfs, type, packed_update_raw, false); + + const auto out = VfsFilesystemCreateDirectoryWrapper(vfs, path, FileSys::OpenMode::ReadWrite); + + if (out == nullptr) { + failed(); + vfs->DeleteDirectory(path); + return; + } + + bool ok = false; + const QStringList selections{tr("Full"), tr("Skeleton")}; + const auto res = QInputDialog::getItem( + this, tr("Select RomFS Dump Mode"), + tr("Please select the how you would like the RomFS dumped.
Full will copy all of the " + "files into the new directory while
skeleton will only create the directory " + "structure."), + selections, 0, false, &ok); + if (!ok) { + failed(); + vfs->DeleteDirectory(path); + return; + } + + const auto extracted = FileSys::ExtractRomFS(romfs); + if (extracted == nullptr) { + failed(); + return; + } + + const auto full = res == selections.constFirst(); + + // The expected required space is the size of the RomFS + 1 GiB + const auto minimum_free_space = romfs->GetSize() + 0x40000000; + + if (full && Common::FS::GetFreeSpaceSize(path) < minimum_free_space) { + QMessageBox::warning(this, tr("RomFS Extraction Failed!"), + tr("There is not enough free space at %1 to extract the RomFS. Please " + "free up space or select a different dump directory at " + "Emulation > Configure > System > Filesystem > Dump Root") + .arg(QString::fromStdString(path))); + return; + } + + QProgressDialog progress(tr("Extracting RomFS..."), tr("Cancel"), 0, 100, this); + progress.setWindowModality(Qt::WindowModal); + progress.setMinimumDuration(100); + progress.setAutoClose(false); + progress.setAutoReset(false); + + size_t read_size = 0; + + if (RomFSRawCopy(romfs->GetSize(), read_size, progress, extracted, out, full)) { + progress.close(); + QMessageBox::information(this, tr("RomFS Extraction Succeeded!"), + tr("The operation completed successfully.")); + QDesktopServices::openUrl(QUrl::fromLocalFile(QString::fromStdString(path))); + } else { + progress.close(); + failed(); + vfs->DeleteDirectory(path); + } +} + +void GMainWindow::OnGameListVerifyIntegrity(const std::string& game_path) { + const auto NotImplemented = [this] { + QMessageBox::warning(this, tr("Integrity verification couldn't be performed!"), + tr("File contents were not checked for validity.")); + }; + + QProgressDialog progress(tr("Verifying integrity..."), tr("Cancel"), 0, 100, this); + progress.setWindowModality(Qt::WindowModal); + progress.setMinimumDuration(100); + progress.setAutoClose(false); + progress.setAutoReset(false); + + const auto QtProgressCallback = [&](size_t total_size, size_t processed_size) { + progress.setValue(static_cast((processed_size * 100) / total_size)); + return progress.wasCanceled(); + }; + + const auto result = ContentManager::VerifyGameContents(*system, game_path, QtProgressCallback); + progress.close(); + switch (result) { + case ContentManager::GameVerificationResult::Success: + QMessageBox::information(this, tr("Integrity verification succeeded!"), + tr("The operation completed successfully.")); + break; + case ContentManager::GameVerificationResult::Failed: + QMessageBox::critical(this, tr("Integrity verification failed!"), + tr("File contents may be corrupt.")); + break; + case ContentManager::GameVerificationResult::NotImplemented: + NotImplemented(); + } +} + +void GMainWindow::OnGameListCopyTID(u64 program_id) { + QClipboard* clipboard = QGuiApplication::clipboard(); + clipboard->setText(QString::fromStdString(fmt::format("{:016X}", program_id))); +} + +void GMainWindow::OnGameListNavigateToGamedbEntry(u64 program_id, + const CompatibilityList& compatibility_list) { + const auto it = FindMatchingCompatibilityEntry(compatibility_list, program_id); + + QString directory; + if (it != compatibility_list.end()) { + directory = it->second.second; + } + + QDesktopServices::openUrl(QUrl(QStringLiteral("https://sudachi-emu.org/game/") + directory)); +} + +bool GMainWindow::CreateShortcutLink(const std::filesystem::path& shortcut_path, + const std::string& comment, + const std::filesystem::path& icon_path, + const std::filesystem::path& command, + const std::string& arguments, const std::string& categories, + const std::string& keywords, const std::string& name) try { +#if defined(__linux__) || defined(__FreeBSD__) // Linux and FreeBSD + std::filesystem::path shortcut_path_full = shortcut_path / (name + ".desktop"); + std::ofstream shortcut_stream(shortcut_path_full, std::ios::binary | std::ios::trunc); + if (!shortcut_stream.is_open()) { + LOG_ERROR(Frontend, "Failed to create shortcut"); + return false; + } + // TODO: Migrate fmt::print to std::print in futures STD C++ 23. + fmt::print(shortcut_stream, "[Desktop Entry]\n"); + fmt::print(shortcut_stream, "Type=Application\n"); + fmt::print(shortcut_stream, "Version=1.0\n"); + fmt::print(shortcut_stream, "Name={}\n", name); + if (!comment.empty()) { + fmt::print(shortcut_stream, "Comment={}\n", comment); + } + if (std::filesystem::is_regular_file(icon_path)) { + fmt::print(shortcut_stream, "Icon={}\n", icon_path.string()); + } + fmt::print(shortcut_stream, "TryExec={}\n", command.string()); + fmt::print(shortcut_stream, "Exec={} {}\n", command.string(), arguments); + if (!categories.empty()) { + fmt::print(shortcut_stream, "Categories={}\n", categories); + } + if (!keywords.empty()) { + fmt::print(shortcut_stream, "Keywords={}\n", keywords); + } + return true; +#elif defined(_WIN32) // Windows + HRESULT hr = CoInitialize(nullptr); + if (FAILED(hr)) { + LOG_ERROR(Frontend, "CoInitialize failed"); + return false; + } + SCOPE_EXIT { + CoUninitialize(); + }; + IShellLinkW* ps1 = nullptr; + IPersistFile* persist_file = nullptr; + SCOPE_EXIT { + if (persist_file != nullptr) { + persist_file->Release(); + } + if (ps1 != nullptr) { + ps1->Release(); + } + }; + HRESULT hres = CoCreateInstance(CLSID_ShellLink, nullptr, CLSCTX_INPROC_SERVER, IID_IShellLinkW, + reinterpret_cast(&ps1)); + if (FAILED(hres)) { + LOG_ERROR(Frontend, "Failed to create IShellLinkW instance"); + return false; + } + hres = ps1->SetPath(command.c_str()); + if (FAILED(hres)) { + LOG_ERROR(Frontend, "Failed to set path"); + return false; + } + if (!arguments.empty()) { + hres = ps1->SetArguments(Common::UTF8ToUTF16W(arguments).data()); + if (FAILED(hres)) { + LOG_ERROR(Frontend, "Failed to set arguments"); + return false; + } + } + if (!comment.empty()) { + hres = ps1->SetDescription(Common::UTF8ToUTF16W(comment).data()); + if (FAILED(hres)) { + LOG_ERROR(Frontend, "Failed to set description"); + return false; + } + } + if (std::filesystem::is_regular_file(icon_path)) { + hres = ps1->SetIconLocation(icon_path.c_str(), 0); + if (FAILED(hres)) { + LOG_ERROR(Frontend, "Failed to set icon location"); + return false; + } + } + hres = ps1->QueryInterface(IID_IPersistFile, reinterpret_cast(&persist_file)); + if (FAILED(hres)) { + LOG_ERROR(Frontend, "Failed to get IPersistFile interface"); + return false; + } + hres = persist_file->Save(std::filesystem::path{shortcut_path / (name + ".lnk")}.c_str(), TRUE); + if (FAILED(hres)) { + LOG_ERROR(Frontend, "Failed to save shortcut"); + return false; + } + return true; +#else // Unsupported platform + return false; +#endif +} catch (const std::exception& e) { + LOG_ERROR(Frontend, "Failed to create shortcut: {}", e.what()); + return false; +} +// Messages in pre-defined message boxes for less code spaghetti +bool GMainWindow::CreateShortcutMessagesGUI(QWidget* parent, int imsg, const QString& game_title) { + int result = 0; + QMessageBox::StandardButtons buttons; + switch (imsg) { + case GMainWindow::CREATE_SHORTCUT_MSGBOX_FULLSCREEN_YES: + buttons = QMessageBox::Yes | QMessageBox::No; + result = + QMessageBox::information(parent, tr("Create Shortcut"), + tr("Do you want to launch the game in fullscreen?"), buttons); + return result == QMessageBox::Yes; + case GMainWindow::CREATE_SHORTCUT_MSGBOX_SUCCESS: + QMessageBox::information(parent, tr("Create Shortcut"), + tr("Successfully created a shortcut to %1").arg(game_title)); + return false; + case GMainWindow::CREATE_SHORTCUT_MSGBOX_APPVOLATILE_WARNING: + buttons = QMessageBox::StandardButton::Ok | QMessageBox::StandardButton::Cancel; + result = + QMessageBox::warning(this, tr("Create Shortcut"), + tr("This will create a shortcut to the current AppImage. This may " + "not work well if you update. Continue?"), + buttons); + return result == QMessageBox::Ok; + default: + buttons = QMessageBox::Ok; + QMessageBox::critical(parent, tr("Create Shortcut"), + tr("Failed to create a shortcut to %1").arg(game_title), buttons); + return false; + } +} + +bool GMainWindow::MakeShortcutIcoPath(const u64 program_id, const std::string_view game_file_name, + std::filesystem::path& out_icon_path) { + // Get path to Sudachi icons directory & icon extension + std::string ico_extension = "png"; +#if defined(_WIN32) + out_icon_path = Common::FS::GetSudachiPath(Common::FS::SudachiPath::IconsDir); + ico_extension = "ico"; +#elif defined(__linux__) || defined(__FreeBSD__) + out_icon_path = Common::FS::GetDataDirectory("XDG_DATA_HOME") / "icons/hicolor/256x256"; +#endif + // Create icons directory if it doesn't exist + if (!Common::FS::CreateDirs(out_icon_path)) { + QMessageBox::critical( + this, tr("Create Icon"), + tr("Cannot create icon file. Path \"%1\" does not exist and cannot be created.") + .arg(QString::fromStdString(out_icon_path.string())), + QMessageBox::StandardButton::Ok); + out_icon_path.clear(); + return false; + } + + // Create icon file path + out_icon_path /= (program_id == 0 ? fmt::format("sudachi-{}.{}", game_file_name, ico_extension) + : fmt::format("sudachi-{:016X}.{}", program_id, ico_extension)); + return true; +} + +void GMainWindow::OnGameListCreateShortcut(u64 program_id, const std::string& game_path, + GameListShortcutTarget target) { + // Get path to sudachi executable + const QStringList args = QApplication::arguments(); + std::filesystem::path sudachi_command = args[0].toStdString(); + // If relative path, make it an absolute path + if (sudachi_command.c_str()[0] == '.') { + sudachi_command = Common::FS::GetCurrentDir() / sudachi_command; + } + // Shortcut path + std::filesystem::path shortcut_path{}; + if (target == GameListShortcutTarget::Desktop) { + shortcut_path = + QStandardPaths::writableLocation(QStandardPaths::DesktopLocation).toStdString(); + } else if (target == GameListShortcutTarget::Applications) { + shortcut_path = + QStandardPaths::writableLocation(QStandardPaths::ApplicationsLocation).toStdString(); + } + + if (!std::filesystem::exists(shortcut_path)) { + GMainWindow::CreateShortcutMessagesGUI( + this, GMainWindow::CREATE_SHORTCUT_MSGBOX_ERROR, + QString::fromStdString(shortcut_path.generic_string())); + LOG_ERROR(Frontend, "Invalid shortcut target {}", shortcut_path.generic_string()); + return; + } + + // Get title from game file + const FileSys::PatchManager pm{program_id, system->GetFileSystemController(), + system->GetContentProvider()}; + const auto control = pm.GetControlMetadata(); + const auto loader = + Loader::GetLoader(*system, vfs->OpenFile(game_path, FileSys::OpenMode::Read)); + std::string game_title = fmt::format("{:016X}", program_id); + if (control.first != nullptr) { + game_title = control.first->GetApplicationName(); + } else { + loader->ReadTitle(game_title); + } + // Delete illegal characters from title + const std::string illegal_chars = "<>:\"/\\|?*."; + for (auto it = game_title.rbegin(); it != game_title.rend(); ++it) { + if (illegal_chars.find(*it) != std::string::npos) { + game_title.erase(it.base() - 1); + } + } + const QString qt_game_title = QString::fromStdString(game_title); + // Get icon from game file + std::vector icon_image_file{}; + if (control.second != nullptr) { + icon_image_file = control.second->ReadAllBytes(); + } else if (loader->ReadIcon(icon_image_file) != Loader::ResultStatus::Success) { + LOG_WARNING(Frontend, "Could not read icon from {:s}", game_path); + } + QImage icon_data = + QImage::fromData(icon_image_file.data(), static_cast(icon_image_file.size())); + std::filesystem::path out_icon_path; + if (GMainWindow::MakeShortcutIcoPath(program_id, game_title, out_icon_path)) { + if (!SaveIconToFile(out_icon_path, icon_data)) { + LOG_ERROR(Frontend, "Could not write icon to file"); + } + } + +#if defined(__linux__) + // Special case for AppImages + // Warn once if we are making a shortcut to a volatile AppImage + const std::string appimage_ending = + std::string(Common::g_scm_rev).substr(0, 9).append(".AppImage"); + if (sudachi_command.string().ends_with(appimage_ending) && + !UISettings::values.shortcut_already_warned) { + if (GMainWindow::CreateShortcutMessagesGUI( + this, GMainWindow::CREATE_SHORTCUT_MSGBOX_APPVOLATILE_WARNING, qt_game_title)) { + return; + } + UISettings::values.shortcut_already_warned = true; + } +#endif // __linux__ + // Create shortcut + std::string arguments = fmt::format("-g \"{:s}\"", game_path); + if (GMainWindow::CreateShortcutMessagesGUI( + this, GMainWindow::CREATE_SHORTCUT_MSGBOX_FULLSCREEN_YES, qt_game_title)) { + arguments = "-f " + arguments; + } + const std::string comment = fmt::format("Start {:s} with the sudachi Emulator", game_title); + const std::string categories = "Game;Emulator;Qt;"; + const std::string keywords = "Switch;Nintendo;"; + + if (GMainWindow::CreateShortcutLink(shortcut_path, comment, out_icon_path, sudachi_command, + arguments, categories, keywords, game_title)) { + GMainWindow::CreateShortcutMessagesGUI(this, GMainWindow::CREATE_SHORTCUT_MSGBOX_SUCCESS, + qt_game_title); + return; + } + GMainWindow::CreateShortcutMessagesGUI(this, GMainWindow::CREATE_SHORTCUT_MSGBOX_ERROR, + qt_game_title); +} + +void GMainWindow::OnGameListOpenDirectory(const QString& directory) { + std::filesystem::path fs_path; + if (directory == QStringLiteral("SDMC")) { + fs_path = + Common::FS::GetSudachiPath(Common::FS::SudachiPath::SDMCDir) / "Nintendo/Contents/registered"; + } else if (directory == QStringLiteral("UserNAND")) { + fs_path = + Common::FS::GetSudachiPath(Common::FS::SudachiPath::NANDDir) / "user/Contents/registered"; + } else if (directory == QStringLiteral("SysNAND")) { + fs_path = + Common::FS::GetSudachiPath(Common::FS::SudachiPath::NANDDir) / "system/Contents/registered"; + } else { + fs_path = directory.toStdString(); + } + + const auto qt_path = QString::fromStdString(Common::FS::PathToUTF8String(fs_path)); + + if (!Common::FS::IsDir(fs_path)) { + QMessageBox::critical(this, tr("Error Opening %1").arg(qt_path), + tr("Folder does not exist!")); + return; + } + + QDesktopServices::openUrl(QUrl::fromLocalFile(qt_path)); +} + +void GMainWindow::OnGameListAddDirectory() { + const QString dir_path = QFileDialog::getExistingDirectory(this, tr("Select Directory")); + if (dir_path.isEmpty()) { + return; + } + + UISettings::GameDir game_dir{dir_path.toStdString(), false, true}; + if (!UISettings::values.game_dirs.contains(game_dir)) { + UISettings::values.game_dirs.append(game_dir); + game_list->PopulateAsync(UISettings::values.game_dirs); + } else { + LOG_WARNING(Frontend, "Selected directory is already in the game list"); + } + + OnSaveConfig(); +} + +void GMainWindow::OnGameListShowList(bool show) { + if (emulation_running && ui->action_Single_Window_Mode->isChecked()) + return; + game_list->setVisible(show); + game_list_placeholder->setVisible(!show); +}; + +void GMainWindow::OnGameListOpenPerGameProperties(const std::string& file) { + u64 title_id{}; + const auto v_file = Core::GetGameFileFromPath(vfs, file); + const auto loader = Loader::GetLoader(*system, v_file); + + if (loader == nullptr || loader->ReadProgramId(title_id) != Loader::ResultStatus::Success) { + QMessageBox::information(this, tr("Properties"), + tr("The game properties could not be loaded.")); + return; + } + + OpenPerGameConfiguration(title_id, file); +} + +void GMainWindow::OnMenuLoadFile() { + if (is_load_file_select_active) { + return; + } + + is_load_file_select_active = true; + const QString extensions = + QStringLiteral("*.") + .append(GameList::supported_file_extensions.join(QStringLiteral(" *."))) + .append(QStringLiteral(" main")); + const QString file_filter = tr("Switch Executable (%1);;All Files (*.*)", + "%1 is an identifier for the Switch executable file extensions.") + .arg(extensions); + const QString filename = QFileDialog::getOpenFileName( + this, tr("Load File"), QString::fromStdString(UISettings::values.roms_path), file_filter); + is_load_file_select_active = false; + + if (filename.isEmpty()) { + return; + } + + UISettings::values.roms_path = QFileInfo(filename).path().toStdString(); + BootGame(filename, ApplicationAppletParameters()); +} + +void GMainWindow::OnMenuLoadFolder() { + const QString dir_path = + QFileDialog::getExistingDirectory(this, tr("Open Extracted ROM Directory")); + + if (dir_path.isNull()) { + return; + } + + const QDir dir{dir_path}; + const QStringList matching_main = dir.entryList({QStringLiteral("main")}, QDir::Files); + if (matching_main.size() == 1) { + BootGame(dir.path() + QDir::separator() + matching_main[0], ApplicationAppletParameters()); + } else { + QMessageBox::warning(this, tr("Invalid Directory Selected"), + tr("The directory you have selected does not contain a 'main' file.")); + } +} + +void GMainWindow::IncrementInstallProgress() { + install_progress->setValue(install_progress->value() + 1); +} + +void GMainWindow::OnMenuInstallToNAND() { + const QString file_filter = + tr("Installable Switch File (*.nca *.nsp *.xci);;Nintendo Content Archive " + "(*.nca);;Nintendo Submission Package (*.nsp);;NX Cartridge " + "Image (*.xci)"); + + QStringList filenames = QFileDialog::getOpenFileNames( + this, tr("Install Files"), QString::fromStdString(UISettings::values.roms_path), + file_filter); + + if (filenames.isEmpty()) { + return; + } + + InstallDialog installDialog(this, filenames); + if (installDialog.exec() == QDialog::Rejected) { + return; + } + + const QStringList files = installDialog.GetFiles(); + + if (files.isEmpty()) { + return; + } + + // Save folder location of the first selected file + UISettings::values.roms_path = QFileInfo(filenames[0]).path().toStdString(); + + int remaining = filenames.size(); + + // This would only overflow above 2^51 bytes (2.252 PB) + int total_size = 0; + for (const QString& file : files) { + total_size += static_cast(QFile(file).size() / CopyBufferSize); + } + if (total_size < 0) { + LOG_CRITICAL(Frontend, "Attempting to install too many files, aborting."); + return; + } + + QStringList new_files{}; // Newly installed files that do not yet exist in the NAND + QStringList overwritten_files{}; // Files that overwrote those existing in the NAND + QStringList failed_files{}; // Files that failed to install due to errors + bool detected_base_install{}; // Whether a base game was attempted to be installed + + ui->action_Install_File_NAND->setEnabled(false); + + install_progress = new QProgressDialog(QString{}, tr("Cancel"), 0, total_size, this); + install_progress->setWindowFlags(windowFlags() & ~Qt::WindowMaximizeButtonHint); + install_progress->setAttribute(Qt::WA_DeleteOnClose, true); + install_progress->setFixedWidth(installDialog.GetMinimumWidth() + 40); + install_progress->show(); + + for (const QString& file : files) { + install_progress->setWindowTitle(tr("%n file(s) remaining", "", remaining)); + install_progress->setLabelText( + tr("Installing file \"%1\"...").arg(QFileInfo(file).fileName())); + + QFuture future; + ContentManager::InstallResult result; + + if (file.endsWith(QStringLiteral("nsp"), Qt::CaseInsensitive)) { + const auto progress_callback = [this](size_t size, size_t progress) { + emit UpdateInstallProgress(); + if (install_progress->wasCanceled()) { + return true; + } + return false; + }; + future = QtConcurrent::run([this, &file, progress_callback] { + return ContentManager::InstallNSP(*system, *vfs, file.toStdString(), + progress_callback); + }); + + while (!future.isFinished()) { + QCoreApplication::processEvents(); + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + } + + result = future.result(); + + } else { + result = InstallNCA(file); + } + + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + + switch (result) { + case ContentManager::InstallResult::Success: + new_files.append(QFileInfo(file).fileName()); + break; + case ContentManager::InstallResult::Overwrite: + overwritten_files.append(QFileInfo(file).fileName()); + break; + case ContentManager::InstallResult::Failure: + failed_files.append(QFileInfo(file).fileName()); + break; + case ContentManager::InstallResult::BaseInstallAttempted: + failed_files.append(QFileInfo(file).fileName()); + detected_base_install = true; + break; + } + + --remaining; + } + + install_progress->close(); + + if (detected_base_install) { + QMessageBox::warning( + this, tr("Install Results"), + tr("To avoid possible conflicts, we discourage users from installing base games to the " + "NAND.\nPlease, only use this feature to install updates and DLC.")); + } + + const QString install_results = + (new_files.isEmpty() ? QString{} + : tr("%n file(s) were newly installed\n", "", new_files.size())) + + (overwritten_files.isEmpty() + ? QString{} + : tr("%n file(s) were overwritten\n", "", overwritten_files.size())) + + (failed_files.isEmpty() ? QString{} + : tr("%n file(s) failed to install\n", "", failed_files.size())); + + QMessageBox::information(this, tr("Install Results"), install_results); + Common::FS::RemoveDirRecursively(Common::FS::GetSudachiPath(Common::FS::SudachiPath::CacheDir) / + "game_list"); + game_list->PopulateAsync(UISettings::values.game_dirs); + ui->action_Install_File_NAND->setEnabled(true); +} + +ContentManager::InstallResult GMainWindow::InstallNCA(const QString& filename) { + const QStringList tt_options{tr("System Application"), + tr("System Archive"), + tr("System Application Update"), + tr("Firmware Package (Type A)"), + tr("Firmware Package (Type B)"), + tr("Game"), + tr("Game Update"), + tr("Game DLC"), + tr("Delta Title")}; + bool ok; + const auto item = QInputDialog::getItem( + this, tr("Select NCA Install Type..."), + tr("Please select the type of title you would like to install this NCA as:\n(In " + "most instances, the default 'Game' is fine.)"), + tt_options, 5, false, &ok); + + auto index = tt_options.indexOf(item); + if (!ok || index == -1) { + QMessageBox::warning(this, tr("Failed to Install"), + tr("The title type you selected for the NCA is invalid.")); + return ContentManager::InstallResult::Failure; + } + + // If index is equal to or past Game, add the jump in TitleType. + if (index >= 5) { + index += static_cast(FileSys::TitleType::Application) - + static_cast(FileSys::TitleType::FirmwarePackageB); + } + + const bool is_application = index >= static_cast(FileSys::TitleType::Application); + const auto& fs_controller = system->GetFileSystemController(); + auto* registered_cache = is_application ? fs_controller.GetUserNANDContents() + : fs_controller.GetSystemNANDContents(); + + const auto progress_callback = [this](size_t size, size_t progress) { + emit UpdateInstallProgress(); + if (install_progress->wasCanceled()) { + return true; + } + return false; + }; + return ContentManager::InstallNCA(*vfs, filename.toStdString(), *registered_cache, + static_cast(index), progress_callback); +} + +void GMainWindow::OnMenuRecentFile() { + QAction* action = qobject_cast(sender()); + assert(action); + + const QString filename = action->data().toString(); + if (QFileInfo::exists(filename)) { + BootGame(filename, ApplicationAppletParameters()); + } else { + // Display an error message and remove the file from the list. + QMessageBox::information(this, tr("File not found"), + tr("File \"%1\" not found").arg(filename)); + + UISettings::values.recent_files.removeOne(filename); + UpdateRecentFiles(); + } +} + +void GMainWindow::OnStartGame() { + PreventOSSleep(); + + emu_thread->SetRunning(true); + + UpdateMenuState(); + OnTasStateChanged(); + + play_time_manager->SetProgramId(system->GetApplicationProcessProgramID()); + play_time_manager->Start(); + + discord_rpc->Update(); + +#ifdef __unix__ + Common::Linux::StartGamemode(); +#endif +} + +void GMainWindow::OnRestartGame() { + if (!system->IsPoweredOn()) { + return; + } + + if (ConfirmShutdownGame()) { + // Make a copy since ShutdownGame edits game_path + const auto current_game = QString(current_game_path); + ShutdownGame(); + BootGame(current_game, ApplicationAppletParameters()); + } +} + +void GMainWindow::OnPauseGame() { + emu_thread->SetRunning(false); + play_time_manager->Stop(); + UpdateMenuState(); + AllowOSSleep(); + +#ifdef __unix__ + Common::Linux::StopGamemode(); +#endif +} + +void GMainWindow::OnPauseContinueGame() { + if (emulation_running) { + if (emu_thread->IsRunning()) { + OnPauseGame(); + } else { + OnStartGame(); + } + } +} + +void GMainWindow::OnStopGame() { + if (ConfirmShutdownGame()) { + play_time_manager->Stop(); + // Update game list to show new play time + game_list->PopulateAsync(UISettings::values.game_dirs); + if (OnShutdownBegin()) { + OnShutdownBeginDialog(); + } else { + OnEmulationStopped(); + } + } +} + +bool GMainWindow::ConfirmShutdownGame() { + if (UISettings::values.confirm_before_stopping.GetValue() == ConfirmStop::Ask_Always) { + if (system->GetExitLocked()) { + if (!ConfirmForceLockedExit()) { + return false; + } + } else { + if (!ConfirmChangeGame()) { + return false; + } + } + } else { + if (UISettings::values.confirm_before_stopping.GetValue() == + ConfirmStop::Ask_Based_On_Game && + system->GetExitLocked()) { + if (!ConfirmForceLockedExit()) { + return false; + } + } + } + return true; +} + +void GMainWindow::OnLoadComplete() { + loading_screen->OnLoadComplete(); +} + +void GMainWindow::OnExecuteProgram(std::size_t program_index) { + ShutdownGame(); + + auto params = ApplicationAppletParameters(); + params.program_index = static_cast(program_index); + params.launch_type = Service::AM::LaunchType::ApplicationInitiated; + BootGame(last_filename_booted, params); +} + +void GMainWindow::OnExit() { + ShutdownGame(); +} + +void GMainWindow::OnSaveConfig() { + system->ApplySettings(); + config->SaveAllValues(); +} + +void GMainWindow::ErrorDisplayDisplayError(QString error_code, QString error_text) { + error_applet = new OverlayDialog(render_window, *system, error_code, error_text, QString{}, + tr("OK"), Qt::AlignLeft | Qt::AlignVCenter); + SCOPE_EXIT { + error_applet->deleteLater(); + error_applet = nullptr; + }; + error_applet->exec(); + + emit ErrorDisplayFinished(); +} + +void GMainWindow::ErrorDisplayRequestExit() { + if (error_applet) { + error_applet->reject(); + } +} + +void GMainWindow::OnMenuReportCompatibility() { +#if defined(ARCHITECTURE_x86_64) && !defined(__APPLE__) + const auto& caps = Common::GetCPUCaps(); + const bool has_fma = caps.fma || caps.fma4; + const auto processor_count = std::thread::hardware_concurrency(); + const bool has_4threads = processor_count == 0 || processor_count >= 4; + const bool has_8gb_ram = Common::GetMemInfo().TotalPhysicalMemory >= 8_GiB; + const bool has_broken_vulkan = UISettings::values.has_broken_vulkan; + + if (!has_fma || !has_4threads || !has_8gb_ram || has_broken_vulkan) { + QMessageBox::critical(this, tr("Hardware requirements not met"), + tr("Your system does not meet the recommended hardware requirements. " + "Compatibility reporting has been disabled.")); + return; + } + + if (!Settings::values.sudachi_token.GetValue().empty() && + !Settings::values.sudachi_username.GetValue().empty()) { + CompatDB compatdb{system->TelemetrySession(), this}; + compatdb.exec(); + } else { + QMessageBox::critical( + this, tr("Missing sudachi Account"), + tr("In order to submit a game compatibility test case, you must link your sudachi " + "account.

To link your sudachi account, go to Emulation > Configuration " + "> " + "Web.")); + } +#else + QMessageBox::critical(this, tr("Hardware requirements not met"), + tr("Your system does not meet the recommended hardware requirements. " + "Compatibility reporting has been disabled.")); +#endif +} + +void GMainWindow::OpenURL(const QUrl& url) { + const bool open = QDesktopServices::openUrl(url); + if (!open) { + QMessageBox::warning(this, tr("Error opening URL"), + tr("Unable to open the URL \"%1\".").arg(url.toString())); + } +} + +void GMainWindow::OnOpenModsPage() { + OpenURL(QUrl(QStringLiteral("https://github.com/sudachi-emu/sudachi/wiki/Switch-Mods"))); +} + +void GMainWindow::OnOpenQuickstartGuide() { + OpenURL(QUrl(QStringLiteral("https://sudachi-emu.org/help/quickstart/"))); +} + +void GMainWindow::OnOpenFAQ() { + OpenURL(QUrl(QStringLiteral("https://sudachi-emu.org/wiki/faq/"))); +} + +void GMainWindow::ToggleFullscreen() { + if (!emulation_running) { + return; + } + if (ui->action_Fullscreen->isChecked()) { + ShowFullscreen(); + } else { + HideFullscreen(); + } +} + +// We're going to return the screen that the given window has the most pixels on +static QScreen* GuessCurrentScreen(QWidget* window) { + const QList screens = QGuiApplication::screens(); + return *std::max_element( + screens.cbegin(), screens.cend(), [window](const QScreen* left, const QScreen* right) { + const QSize left_size = left->geometry().intersected(window->geometry()).size(); + const QSize right_size = right->geometry().intersected(window->geometry()).size(); + return (left_size.height() * left_size.width()) < + (right_size.height() * right_size.width()); + }); +} + +bool GMainWindow::UsingExclusiveFullscreen() { + return Settings::values.fullscreen_mode.GetValue() == Settings::FullscreenMode::Exclusive || + QGuiApplication::platformName() == QStringLiteral("wayland") || + QGuiApplication::platformName() == QStringLiteral("wayland-egl"); +} + +void GMainWindow::ShowFullscreen() { + const auto show_fullscreen = [this](QWidget* window) { + if (UsingExclusiveFullscreen()) { + window->showFullScreen(); + return; + } + window->hide(); + window->setWindowFlags(window->windowFlags() | Qt::FramelessWindowHint); + const auto screen_geometry = GuessCurrentScreen(window)->geometry(); + window->setGeometry(screen_geometry.x(), screen_geometry.y(), screen_geometry.width(), + screen_geometry.height() + 1); + window->raise(); + window->showNormal(); + }; + + if (ui->action_Single_Window_Mode->isChecked()) { + UISettings::values.geometry = saveGeometry(); + + ui->menubar->hide(); + statusBar()->hide(); + + show_fullscreen(this); + } else { + UISettings::values.renderwindow_geometry = render_window->saveGeometry(); + show_fullscreen(render_window); + } +} + +void GMainWindow::HideFullscreen() { + if (ui->action_Single_Window_Mode->isChecked()) { + if (UsingExclusiveFullscreen()) { + showNormal(); + restoreGeometry(UISettings::values.geometry); + } else { + hide(); + setWindowFlags(windowFlags() & ~Qt::FramelessWindowHint); + restoreGeometry(UISettings::values.geometry); + raise(); + show(); + } + + statusBar()->setVisible(ui->action_Show_Status_Bar->isChecked()); + ui->menubar->show(); + } else { + if (UsingExclusiveFullscreen()) { + render_window->showNormal(); + render_window->restoreGeometry(UISettings::values.renderwindow_geometry); + } else { + render_window->hide(); + render_window->setWindowFlags(windowFlags() & ~Qt::FramelessWindowHint); + render_window->restoreGeometry(UISettings::values.renderwindow_geometry); + render_window->raise(); + render_window->show(); + } + } +} + +void GMainWindow::ToggleWindowMode() { + if (ui->action_Single_Window_Mode->isChecked()) { + // Render in the main window... + render_window->BackupGeometry(); + ui->horizontalLayout->addWidget(render_window); + render_window->setFocusPolicy(Qt::StrongFocus); + if (emulation_running) { + render_window->setVisible(true); + render_window->setFocus(); + game_list->hide(); + } + + } else { + // Render in a separate window... + ui->horizontalLayout->removeWidget(render_window); + render_window->setParent(nullptr); + render_window->setFocusPolicy(Qt::NoFocus); + if (emulation_running) { + render_window->setVisible(true); + render_window->RestoreGeometry(); + game_list->show(); + } + } +} + +void GMainWindow::ResetWindowSize(u32 width, u32 height) { + const auto aspect_ratio = Layout::EmulationAspectRatio( + static_cast(Settings::values.aspect_ratio.GetValue()), + static_cast(height) / width); + if (!ui->action_Single_Window_Mode->isChecked()) { + render_window->resize(height / aspect_ratio, height); + } else { + const bool show_status_bar = ui->action_Show_Status_Bar->isChecked(); + const auto status_bar_height = show_status_bar ? statusBar()->height() : 0; + resize(height / aspect_ratio, height + menuBar()->height() + status_bar_height); + } +} + +void GMainWindow::ResetWindowSize720() { + ResetWindowSize(Layout::ScreenUndocked::Width, Layout::ScreenUndocked::Height); +} + +void GMainWindow::ResetWindowSize900() { + ResetWindowSize(1600U, 900U); +} + +void GMainWindow::ResetWindowSize1080() { + ResetWindowSize(Layout::ScreenDocked::Width, Layout::ScreenDocked::Height); +} + +void GMainWindow::OnConfigure() { + const auto old_theme = UISettings::values.theme; + const bool old_discord_presence = UISettings::values.enable_discord_presence.GetValue(); + const auto old_language_index = Settings::values.language_index.GetValue(); +#ifdef __unix__ + const bool old_gamemode = Settings::values.enable_gamemode.GetValue(); +#endif + + Settings::SetConfiguringGlobal(true); + ConfigureDialog configure_dialog(this, hotkey_registry, input_subsystem.get(), + vk_device_records, *system, + !multiplayer_state->IsHostingPublicRoom()); + connect(&configure_dialog, &ConfigureDialog::LanguageChanged, this, + &GMainWindow::OnLanguageChanged); + + const auto result = configure_dialog.exec(); + if (result != QDialog::Accepted && !UISettings::values.configuration_applied && + !UISettings::values.reset_to_defaults) { + // Runs if the user hit Cancel or closed the window, and did not ever press the Apply button + // or `Reset to Defaults` button + return; + } else if (result == QDialog::Accepted) { + // Only apply new changes if user hit Okay + // This is here to avoid applying changes if the user hit Apply, made some changes, then hit + // Cancel + configure_dialog.ApplyConfiguration(); + } else if (UISettings::values.reset_to_defaults) { + LOG_INFO(Frontend, "Resetting all settings to defaults"); + if (!Common::FS::RemoveFile(config->GetConfigFilePath())) { + LOG_WARNING(Frontend, "Failed to remove configuration file"); + } + if (!Common::FS::RemoveDirContentsRecursively( + Common::FS::GetSudachiPath(Common::FS::SudachiPath::ConfigDir) / "custom")) { + LOG_WARNING(Frontend, "Failed to remove custom configuration files"); + } + if (!Common::FS::RemoveDirRecursively( + Common::FS::GetSudachiPath(Common::FS::SudachiPath::CacheDir) / "game_list")) { + LOG_WARNING(Frontend, "Failed to remove game metadata cache files"); + } + + // Explicitly save the game directories, since reinitializing config does not explicitly do + // so. + QVector old_game_dirs = std::move(UISettings::values.game_dirs); + QVector old_favorited_ids = std::move(UISettings::values.favorited_ids); + + Settings::values.disabled_addons.clear(); + + config = std::make_unique(); + UISettings::values.reset_to_defaults = false; + + UISettings::values.game_dirs = std::move(old_game_dirs); + UISettings::values.favorited_ids = std::move(old_favorited_ids); + + InitializeRecentFileMenuActions(); + + SetDefaultUIGeometry(); + RestoreUIState(); + + ShowTelemetryCallout(); + } + InitializeHotkeys(); + + if (UISettings::values.theme != old_theme) { + UpdateUITheme(); + } + if (UISettings::values.enable_discord_presence.GetValue() != old_discord_presence) { + SetDiscordEnabled(UISettings::values.enable_discord_presence.GetValue()); + } +#ifdef __unix__ + if (Settings::values.enable_gamemode.GetValue() != old_gamemode) { + SetGamemodeEnabled(Settings::values.enable_gamemode.GetValue()); + } +#endif + + if (!multiplayer_state->IsHostingPublicRoom()) { + multiplayer_state->UpdateCredentials(); + } + + emit UpdateThemedIcons(); + + const auto reload = UISettings::values.is_game_list_reload_pending.exchange(false); + if (reload || Settings::values.language_index.GetValue() != old_language_index) { + game_list->PopulateAsync(UISettings::values.game_dirs); + } + + UISettings::values.configuration_applied = false; + + config->SaveAllValues(); + + if ((UISettings::values.hide_mouse || Settings::values.mouse_panning) && emulation_running) { + render_window->installEventFilter(render_window); + render_window->setAttribute(Qt::WA_Hover, true); + } else { + render_window->removeEventFilter(render_window); + render_window->setAttribute(Qt::WA_Hover, false); + } + + if (UISettings::values.hide_mouse) { + mouse_hide_timer.start(); + } + + // Restart camera config + if (emulation_running) { + render_window->FinalizeCamera(); + render_window->InitializeCamera(); + } + + if (!UISettings::values.has_broken_vulkan) { + renderer_status_button->setEnabled(!emulation_running); + } + + UpdateStatusButtons(); + controller_dialog->refreshConfiguration(); + system->ApplySettings(); +} + +void GMainWindow::OnConfigureTas() { + ConfigureTasDialog dialog(this); + const auto result = dialog.exec(); + + if (result != QDialog::Accepted && !UISettings::values.configuration_applied) { + Settings::RestoreGlobalState(system->IsPoweredOn()); + return; + } else if (result == QDialog::Accepted) { + dialog.ApplyConfiguration(); + OnSaveConfig(); + } +} + +void GMainWindow::OnTasStartStop() { + if (!emulation_running) { + return; + } + + // Disable system buttons to prevent TAS from executing a hotkey + auto* controller = system->HIDCore().GetEmulatedController(Core::HID::NpadIdType::Player1); + controller->ResetSystemButtons(); + + input_subsystem->GetTas()->StartStop(); + OnTasStateChanged(); +} + +void GMainWindow::OnTasRecord() { + if (!emulation_running) { + return; + } + if (is_tas_recording_dialog_active) { + return; + } + + // Disable system buttons to prevent TAS from recording a hotkey + auto* controller = system->HIDCore().GetEmulatedController(Core::HID::NpadIdType::Player1); + controller->ResetSystemButtons(); + + const bool is_recording = input_subsystem->GetTas()->Record(); + if (!is_recording) { + is_tas_recording_dialog_active = true; + + bool answer = question(this, tr("TAS Recording"), tr("Overwrite file of player 1?"), + QMessageBox::Yes | QMessageBox::No, QMessageBox::Yes); + + input_subsystem->GetTas()->SaveRecording(answer); + is_tas_recording_dialog_active = false; + } + OnTasStateChanged(); +} + +void GMainWindow::OnTasReset() { + input_subsystem->GetTas()->Reset(); +} + +void GMainWindow::OnToggleDockedMode() { + const bool is_docked = Settings::IsDockedMode(); + auto* player_1 = system->HIDCore().GetEmulatedController(Core::HID::NpadIdType::Player1); + auto* handheld = system->HIDCore().GetEmulatedController(Core::HID::NpadIdType::Handheld); + + if (!is_docked && handheld->IsConnected()) { + QMessageBox::warning(this, tr("Invalid config detected"), + tr("Handheld controller can't be used on docked mode. Pro " + "controller will be selected.")); + handheld->Disconnect(); + player_1->SetNpadStyleIndex(Core::HID::NpadStyleIndex::Fullkey); + player_1->Connect(); + controller_dialog->refreshConfiguration(); + } + + Settings::values.use_docked_mode.SetValue(is_docked ? Settings::ConsoleMode::Handheld + : Settings::ConsoleMode::Docked); + UpdateDockedButton(); + OnDockedModeChanged(is_docked, !is_docked, *system); +} + +void GMainWindow::OnToggleGpuAccuracy() { + switch (Settings::values.gpu_accuracy.GetValue()) { + case Settings::GpuAccuracy::High: { + Settings::values.gpu_accuracy.SetValue(Settings::GpuAccuracy::Normal); + break; + } + case Settings::GpuAccuracy::Normal: + case Settings::GpuAccuracy::Extreme: + default: { + Settings::values.gpu_accuracy.SetValue(Settings::GpuAccuracy::High); + break; + } + } + + system->ApplySettings(); + UpdateGPUAccuracyButton(); +} + +void GMainWindow::OnMute() { + Settings::values.audio_muted = !Settings::values.audio_muted; + UpdateVolumeUI(); +} + +void GMainWindow::OnDecreaseVolume() { + Settings::values.audio_muted = false; + const auto current_volume = static_cast(Settings::values.volume.GetValue()); + int step = 5; + if (current_volume <= 30) { + step = 2; + } + if (current_volume <= 6) { + step = 1; + } + Settings::values.volume.SetValue(std::max(current_volume - step, 0)); + UpdateVolumeUI(); +} + +void GMainWindow::OnIncreaseVolume() { + Settings::values.audio_muted = false; + const auto current_volume = static_cast(Settings::values.volume.GetValue()); + int step = 5; + if (current_volume < 30) { + step = 2; + } + if (current_volume < 6) { + step = 1; + } + Settings::values.volume.SetValue(current_volume + step); + UpdateVolumeUI(); +} + +void GMainWindow::OnToggleAdaptingFilter() { + auto filter = Settings::values.scaling_filter.GetValue(); + filter = static_cast(static_cast(filter) + 1); + if (filter == Settings::ScalingFilter::MaxEnum) { + filter = Settings::ScalingFilter::NearestNeighbor; + } + Settings::values.scaling_filter.SetValue(filter); + filter_status_button->setChecked(true); + UpdateFilterText(); +} + +void GMainWindow::OnToggleGraphicsAPI() { + auto api = Settings::values.renderer_backend.GetValue(); + if (api != Settings::RendererBackend::Vulkan) { + api = Settings::RendererBackend::Vulkan; + } else { +#ifdef HAS_OPENGL + api = Settings::RendererBackend::OpenGL; +#else + api = Settings::RendererBackend::Null; +#endif + } + Settings::values.renderer_backend.SetValue(api); + renderer_status_button->setChecked(api == Settings::RendererBackend::Vulkan); + UpdateAPIText(); +} + +void GMainWindow::OnConfigurePerGame() { + const u64 title_id = system->GetApplicationProcessProgramID(); + OpenPerGameConfiguration(title_id, current_game_path.toStdString()); +} + +void GMainWindow::OpenPerGameConfiguration(u64 title_id, const std::string& file_name) { + const auto v_file = Core::GetGameFileFromPath(vfs, file_name); + + Settings::SetConfiguringGlobal(false); + ConfigurePerGame dialog(this, title_id, file_name, vk_device_records, *system); + dialog.LoadFromFile(v_file); + const auto result = dialog.exec(); + + if (result != QDialog::Accepted && !UISettings::values.configuration_applied) { + Settings::RestoreGlobalState(system->IsPoweredOn()); + return; + } else if (result == QDialog::Accepted) { + dialog.ApplyConfiguration(); + } + + const auto reload = UISettings::values.is_game_list_reload_pending.exchange(false); + if (reload) { + game_list->PopulateAsync(UISettings::values.game_dirs); + } + + // Do not cause the global config to write local settings into the config file + const bool is_powered_on = system->IsPoweredOn(); + Settings::RestoreGlobalState(is_powered_on); + system->HIDCore().ReloadInputDevices(); + + UISettings::values.configuration_applied = false; + + if (!is_powered_on) { + config->SaveAllValues(); + } +} + +void GMainWindow::OnLoadAmiibo() { + if (emu_thread == nullptr || !emu_thread->IsRunning()) { + return; + } + if (is_amiibo_file_select_active) { + return; + } + + auto* virtual_amiibo = input_subsystem->GetVirtualAmiibo(); + + // Remove amiibo if one is connected + if (virtual_amiibo->GetCurrentState() == InputCommon::VirtualAmiibo::State::TagNearby) { + virtual_amiibo->CloseAmiibo(); + QMessageBox::warning(this, tr("Amiibo"), tr("The current amiibo has been removed")); + return; + } + + if (virtual_amiibo->GetCurrentState() != InputCommon::VirtualAmiibo::State::WaitingForAmiibo) { + QMessageBox::warning(this, tr("Error"), tr("The current game is not looking for amiibos")); + return; + } + + is_amiibo_file_select_active = true; + const QString extensions{QStringLiteral("*.bin")}; + const QString file_filter = tr("Amiibo File (%1);; All Files (*.*)").arg(extensions); + const QString filename = QFileDialog::getOpenFileName(this, tr("Load Amiibo"), {}, file_filter); + is_amiibo_file_select_active = false; + + if (filename.isEmpty()) { + return; + } + + LoadAmiibo(filename); +} + +bool GMainWindow::question(QWidget* parent, const QString& title, const QString& text, + QMessageBox::StandardButtons buttons, + QMessageBox::StandardButton defaultButton) { + QMessageBox* box_dialog = new QMessageBox(parent); + box_dialog->setWindowTitle(title); + box_dialog->setText(text); + box_dialog->setStandardButtons(buttons); + box_dialog->setDefaultButton(defaultButton); + + ControllerNavigation* controller_navigation = + new ControllerNavigation(system->HIDCore(), box_dialog); + connect(controller_navigation, &ControllerNavigation::TriggerKeyboardEvent, + [box_dialog](Qt::Key key) { + QKeyEvent* event = new QKeyEvent(QEvent::KeyPress, key, Qt::NoModifier); + QCoreApplication::postEvent(box_dialog, event); + }); + int res = box_dialog->exec(); + + controller_navigation->UnloadController(); + return res == QMessageBox::Yes; +} + +void GMainWindow::LoadAmiibo(const QString& filename) { + auto* virtual_amiibo = input_subsystem->GetVirtualAmiibo(); + const QString title = tr("Error loading Amiibo data"); + // Remove amiibo if one is connected + if (virtual_amiibo->GetCurrentState() == InputCommon::VirtualAmiibo::State::TagNearby) { + virtual_amiibo->CloseAmiibo(); + QMessageBox::warning(this, tr("Amiibo"), tr("The current amiibo has been removed")); + return; + } + + switch (virtual_amiibo->LoadAmiibo(filename.toStdString())) { + case InputCommon::VirtualAmiibo::Info::NotAnAmiibo: + QMessageBox::warning(this, title, tr("The selected file is not a valid amiibo")); + break; + case InputCommon::VirtualAmiibo::Info::UnableToLoad: + QMessageBox::warning(this, title, tr("The selected file is already on use")); + break; + case InputCommon::VirtualAmiibo::Info::WrongDeviceState: + QMessageBox::warning(this, title, tr("The current game is not looking for amiibos")); + break; + case InputCommon::VirtualAmiibo::Info::Unknown: + QMessageBox::warning(this, title, tr("An unknown error occurred")); + break; + default: + break; + } +} + +void GMainWindow::OnOpenSudachiFolder() { + QDesktopServices::openUrl(QUrl::fromLocalFile( + QString::fromStdString(Common::FS::GetSudachiPathString(Common::FS::SudachiPath::SudachiDir)))); +} + +void GMainWindow::OnVerifyInstalledContents() { + // Initialize a progress dialog. + QProgressDialog progress(tr("Verifying integrity..."), tr("Cancel"), 0, 100, this); + progress.setWindowModality(Qt::WindowModal); + progress.setMinimumDuration(100); + progress.setAutoClose(false); + progress.setAutoReset(false); + + // Declare progress callback. + auto QtProgressCallback = [&](size_t total_size, size_t processed_size) { + progress.setValue(static_cast((processed_size * 100) / total_size)); + return progress.wasCanceled(); + }; + + const std::vector result = + ContentManager::VerifyInstalledContents(*system, *provider, QtProgressCallback); + progress.close(); + + if (result.empty()) { + QMessageBox::information(this, tr("Integrity verification succeeded!"), + tr("The operation completed successfully.")); + } else { + const auto failed_names = + QString::fromStdString(fmt::format("{}", fmt::join(result, "\n"))); + QMessageBox::critical( + this, tr("Integrity verification failed!"), + tr("Verification failed for the following files:\n\n%1").arg(failed_names)); + } +} + +void GMainWindow::OnInstallFirmware() { + // Don't do this while emulation is running, that'd probably be a bad idea. + if (emu_thread != nullptr && emu_thread->IsRunning()) { + return; + } + + // Check for installed keys, error out, suggest restart? + if (!ContentManager::AreKeysPresent()) { + QMessageBox::information( + this, tr("Keys not installed"), + tr("Install decryption keys and restart sudachi before attempting to install firmware.")); + return; + } + + const QString firmware_source_location = QFileDialog::getExistingDirectory( + this, tr("Select Dumped Firmware Source Location"), {}, QFileDialog::ShowDirsOnly); + if (firmware_source_location.isEmpty()) { + return; + } + + QProgressDialog progress(tr("Installing Firmware..."), tr("Cancel"), 0, 100, this); + progress.setWindowModality(Qt::WindowModal); + progress.setMinimumDuration(100); + progress.setAutoClose(false); + progress.setAutoReset(false); + progress.show(); + + // Declare progress callback. + auto QtProgressCallback = [&](size_t total_size, size_t processed_size) { + progress.setValue(static_cast((processed_size * 100) / total_size)); + return progress.wasCanceled(); + }; + + LOG_INFO(Frontend, "Installing firmware from {}", firmware_source_location.toStdString()); + + // Check for a reasonable number of .nca files (don't hardcode them, just see if there's some in + // there.) + std::filesystem::path firmware_source_path = firmware_source_location.toStdString(); + if (!Common::FS::IsDir(firmware_source_path)) { + progress.close(); + return; + } + + std::vector out; + const Common::FS::DirEntryCallable callback = + [&out](const std::filesystem::directory_entry& entry) { + if (entry.path().has_extension() && entry.path().extension() == ".nca") { + out.emplace_back(entry.path()); + } + + return true; + }; + + QtProgressCallback(100, 10); + + Common::FS::IterateDirEntries(firmware_source_path, callback, Common::FS::DirEntryFilter::File); + if (out.size() <= 0) { + progress.close(); + QMessageBox::warning(this, tr("Firmware install failed"), + tr("Unable to locate potential firmware NCA files")); + return; + } + + // Locate and erase the content of nand/system/Content/registered/*.nca, if any. + auto sysnand_content_vdir = system->GetFileSystemController().GetSystemNANDContentDirectory(); + if (!sysnand_content_vdir->CleanSubdirectoryRecursive("registered")) { + progress.close(); + QMessageBox::critical(this, tr("Firmware install failed"), + tr("Failed to delete one or more firmware file.")); + return; + } + + LOG_INFO(Frontend, + "Cleaned nand/system/Content/registered folder in preparation for new firmware."); + + QtProgressCallback(100, 20); + + auto firmware_vdir = sysnand_content_vdir->GetDirectoryRelative("registered"); + + bool success = true; + int i = 0; + for (const auto& firmware_src_path : out) { + i++; + auto firmware_src_vfile = + vfs->OpenFile(firmware_src_path.generic_string(), FileSys::OpenMode::Read); + auto firmware_dst_vfile = + firmware_vdir->CreateFileRelative(firmware_src_path.filename().string()); + + if (!VfsRawCopy(firmware_src_vfile, firmware_dst_vfile)) { + LOG_ERROR(Frontend, "Failed to copy firmware file {} to {} in registered folder!", + firmware_src_path.generic_string(), firmware_src_path.filename().string()); + success = false; + } + + if (QtProgressCallback( + 100, 20 + static_cast(((i) / static_cast(out.size())) * 70.0))) { + progress.close(); + QMessageBox::warning( + this, tr("Firmware install failed"), + tr("Firmware installation cancelled, firmware may be in bad state, " + "restart sudachi or re-install firmware.")); + return; + } + } + + if (!success) { + progress.close(); + QMessageBox::critical(this, tr("Firmware install failed"), + tr("One or more firmware files failed to copy into NAND.")); + return; + } + + // Re-scan VFS for the newly placed firmware files. + system->GetFileSystemController().CreateFactories(*vfs); + + auto VerifyFirmwareCallback = [&](size_t total_size, size_t processed_size) { + progress.setValue(90 + static_cast((processed_size * 10) / total_size)); + return progress.wasCanceled(); + }; + + auto result = + ContentManager::VerifyInstalledContents(*system, *provider, VerifyFirmwareCallback, true); + + if (result.size() > 0) { + const auto failed_names = + QString::fromStdString(fmt::format("{}", fmt::join(result, "\n"))); + progress.close(); + QMessageBox::critical( + this, tr("Firmware integrity verification failed!"), + tr("Verification failed for the following files:\n\n%1").arg(failed_names)); + return; + } + + progress.close(); + OnCheckFirmwareDecryption(); +} + +void GMainWindow::OnInstallDecryptionKeys() { + // Don't do this while emulation is running. + if (emu_thread != nullptr && emu_thread->IsRunning()) { + return; + } + + const QString key_source_location = QFileDialog::getOpenFileName( + this, tr("Select Dumped Keys Location"), {}, QStringLiteral("prod.keys (prod.keys)"), {}, + QFileDialog::ReadOnly); + if (key_source_location.isEmpty()) { + return; + } + + // Verify that it contains prod.keys, title.keys and optionally, key_retail.bin + LOG_INFO(Frontend, "Installing key files from {}", key_source_location.toStdString()); + + const std::filesystem::path prod_key_path = key_source_location.toStdString(); + const std::filesystem::path key_source_path = prod_key_path.parent_path(); + if (!Common::FS::IsDir(key_source_path)) { + return; + } + + bool prod_keys_found = false; + std::vector source_key_files; + + if (Common::FS::Exists(prod_key_path)) { + prod_keys_found = true; + source_key_files.emplace_back(prod_key_path); + } + + if (Common::FS::Exists(key_source_path / "title.keys")) { + source_key_files.emplace_back(key_source_path / "title.keys"); + } + + if (Common::FS::Exists(key_source_path / "key_retail.bin")) { + source_key_files.emplace_back(key_source_path / "key_retail.bin"); + } + + // There should be at least prod.keys. + if (source_key_files.empty() || !prod_keys_found) { + QMessageBox::warning(this, tr("Decryption Keys install failed"), + tr("prod.keys is a required decryption key file.")); + return; + } + + const auto sudachi_keys_dir = Common::FS::GetSudachiPath(Common::FS::SudachiPath::KeysDir); + for (auto key_file : source_key_files) { + std::filesystem::path destination_key_file = sudachi_keys_dir / key_file.filename(); + if (!std::filesystem::copy_file(key_file, destination_key_file, + std::filesystem::copy_options::overwrite_existing)) { + LOG_ERROR(Frontend, "Failed to copy file {} to {}", key_file.string(), + destination_key_file.string()); + QMessageBox::critical(this, tr("Decryption Keys install failed"), + tr("One or more keys failed to copy.")); + return; + } + } + + // Reinitialize the key manager, re-read the vfs (for update/dlc files), + // and re-populate the game list in the UI if the user has already added + // game folders. + Core::Crypto::KeyManager::Instance().ReloadKeys(); + system->GetFileSystemController().CreateFactories(*vfs); + game_list->PopulateAsync(UISettings::values.game_dirs); + + if (ContentManager::AreKeysPresent()) { + QMessageBox::information(this, tr("Decryption Keys install succeeded"), + tr("Decryption Keys were successfully installed")); + } else { + QMessageBox::critical( + this, tr("Decryption Keys install failed"), + tr("Decryption Keys failed to initialize. Check that your dumping tools are " + "up to date and re-dump keys.")); + } + + OnCheckFirmwareDecryption(); +} + +void GMainWindow::OnAbout() { + AboutDialog aboutDialog(this); + aboutDialog.exec(); +} + +void GMainWindow::OnToggleFilterBar() { + game_list->SetFilterVisible(ui->action_Show_Filter_Bar->isChecked()); + if (ui->action_Show_Filter_Bar->isChecked()) { + game_list->SetFilterFocus(); + } else { + game_list->ClearFilter(); + } +} + +void GMainWindow::OnToggleStatusBar() { + statusBar()->setVisible(ui->action_Show_Status_Bar->isChecked()); +} + +void GMainWindow::OnAlbum() { + constexpr u64 AlbumId = static_cast(Service::AM::AppletProgramId::PhotoViewer); + auto bis_system = system->GetFileSystemController().GetSystemNANDContents(); + if (!bis_system) { + QMessageBox::warning(this, tr("No firmware available"), + tr("Please install the firmware to use the Album applet.")); + return; + } + + auto album_nca = bis_system->GetEntry(AlbumId, FileSys::ContentRecordType::Program); + if (!album_nca) { + QMessageBox::warning(this, tr("Album Applet"), + tr("Album applet is not available. Please reinstall firmware.")); + return; + } + + system->GetFrontendAppletHolder().SetCurrentAppletId(Service::AM::AppletId::PhotoViewer); + + const auto filename = QString::fromStdString(album_nca->GetFullPath()); + UISettings::values.roms_path = QFileInfo(filename).path().toStdString(); + BootGame(filename, LibraryAppletParameters(AlbumId, Service::AM::AppletId::PhotoViewer)); +} + +void GMainWindow::OnCabinet(Service::NFP::CabinetMode mode) { + constexpr u64 CabinetId = static_cast(Service::AM::AppletProgramId::Cabinet); + auto bis_system = system->GetFileSystemController().GetSystemNANDContents(); + if (!bis_system) { + QMessageBox::warning(this, tr("No firmware available"), + tr("Please install the firmware to use the Cabinet applet.")); + return; + } + + auto cabinet_nca = bis_system->GetEntry(CabinetId, FileSys::ContentRecordType::Program); + if (!cabinet_nca) { + QMessageBox::warning(this, tr("Cabinet Applet"), + tr("Cabinet applet is not available. Please reinstall firmware.")); + return; + } + + system->GetFrontendAppletHolder().SetCurrentAppletId(Service::AM::AppletId::Cabinet); + system->GetFrontendAppletHolder().SetCabinetMode(mode); + + const auto filename = QString::fromStdString(cabinet_nca->GetFullPath()); + UISettings::values.roms_path = QFileInfo(filename).path().toStdString(); + BootGame(filename, LibraryAppletParameters(CabinetId, Service::AM::AppletId::Cabinet)); +} + +void GMainWindow::OnMiiEdit() { + constexpr u64 MiiEditId = static_cast(Service::AM::AppletProgramId::MiiEdit); + auto bis_system = system->GetFileSystemController().GetSystemNANDContents(); + if (!bis_system) { + QMessageBox::warning(this, tr("No firmware available"), + tr("Please install the firmware to use the Mii editor.")); + return; + } + + auto mii_applet_nca = bis_system->GetEntry(MiiEditId, FileSys::ContentRecordType::Program); + if (!mii_applet_nca) { + QMessageBox::warning(this, tr("Mii Edit Applet"), + tr("Mii editor is not available. Please reinstall firmware.")); + return; + } + + system->GetFrontendAppletHolder().SetCurrentAppletId(Service::AM::AppletId::MiiEdit); + + const auto filename = QString::fromStdString((mii_applet_nca->GetFullPath())); + UISettings::values.roms_path = QFileInfo(filename).path().toStdString(); + BootGame(filename, LibraryAppletParameters(MiiEditId, Service::AM::AppletId::MiiEdit)); +} + +void GMainWindow::OnOpenControllerMenu() { + constexpr u64 ControllerAppletId = static_cast(Service::AM::AppletProgramId::Controller); + auto bis_system = system->GetFileSystemController().GetSystemNANDContents(); + if (!bis_system) { + QMessageBox::warning(this, tr("No firmware available"), + tr("Please install the firmware to use the Controller Menu.")); + return; + } + + auto controller_applet_nca = + bis_system->GetEntry(ControllerAppletId, FileSys::ContentRecordType::Program); + if (!controller_applet_nca) { + QMessageBox::warning(this, tr("Controller Applet"), + tr("Controller Menu is not available. Please reinstall firmware.")); + return; + } + + system->GetFrontendAppletHolder().SetCurrentAppletId(Service::AM::AppletId::Controller); + + const auto filename = QString::fromStdString((controller_applet_nca->GetFullPath())); + UISettings::values.roms_path = QFileInfo(filename).path().toStdString(); + BootGame(filename, + LibraryAppletParameters(ControllerAppletId, Service::AM::AppletId::Controller)); +} + +void GMainWindow::OnCaptureScreenshot() { + if (emu_thread == nullptr || !emu_thread->IsRunning()) { + return; + } + + const u64 title_id = system->GetApplicationProcessProgramID(); + const auto screenshot_path = + QString::fromStdString(Common::FS::GetSudachiPathString(Common::FS::SudachiPath::ScreenshotsDir)); + const auto date = + QDateTime::currentDateTime().toString(QStringLiteral("yyyy-MM-dd_hh-mm-ss-zzz")); + QString filename = QStringLiteral("%1/%2_%3.png") + .arg(screenshot_path) + .arg(title_id, 16, 16, QLatin1Char{'0'}) + .arg(date); + + if (!Common::FS::CreateDir(screenshot_path.toStdString())) { + return; + } + +#ifdef _WIN32 + if (UISettings::values.enable_screenshot_save_as) { + OnPauseGame(); + filename = QFileDialog::getSaveFileName(this, tr("Capture Screenshot"), filename, + tr("PNG Image (*.png)")); + OnStartGame(); + if (filename.isEmpty()) { + return; + } + } +#endif + render_window->CaptureScreenshot(filename); +} + +// TODO: Written 2020-10-01: Remove per-game config migration code when it is irrelevant +void GMainWindow::MigrateConfigFiles() { + const auto config_dir_fs_path = Common::FS::GetSudachiPath(Common::FS::SudachiPath::ConfigDir); + const QDir config_dir = + QDir(QString::fromStdString(Common::FS::PathToUTF8String(config_dir_fs_path))); + const QStringList config_dir_list = config_dir.entryList(QStringList(QStringLiteral("*.ini"))); + + if (!Common::FS::CreateDirs(config_dir_fs_path / "custom")) { + LOG_ERROR(Frontend, "Failed to create new config file directory"); + } + + for (auto it = config_dir_list.constBegin(); it != config_dir_list.constEnd(); ++it) { + const auto filename = it->toStdString(); + if (filename.find_first_not_of("0123456789abcdefACBDEF", 0) < 16) { + continue; + } + const auto origin = config_dir_fs_path / filename; + const auto destination = config_dir_fs_path / "custom" / filename; + LOG_INFO(Frontend, "Migrating config file from {} to {}", origin.string(), + destination.string()); + if (!Common::FS::RenameFile(origin, destination)) { + // Delete the old config file if one already exists in the new location. + Common::FS::RemoveFile(origin); + } + } +} + +void GMainWindow::UpdateWindowTitle(std::string_view title_name, std::string_view title_version, + std::string_view gpu_vendor) { + const auto branch_name = std::string(Common::g_scm_branch); + const auto description = std::string(Common::g_scm_desc); + const auto build_id = std::string(Common::g_build_id); + + const auto sudachi_title = fmt::format("sudachi | {}-{}", branch_name, description); + const auto override_title = + fmt::format(fmt::runtime(std::string(Common::g_title_bar_format_idle)), build_id); + const auto window_title = override_title.empty() ? sudachi_title : override_title; + + if (title_name.empty()) { + setWindowTitle(QString::fromStdString(window_title)); + } else { + const auto run_title = [window_title, title_name, title_version, gpu_vendor]() { + if (title_version.empty()) { + return fmt::format("{} | {} | {}", window_title, title_name, gpu_vendor); + } + return fmt::format("{} | {} | {} | {}", window_title, title_name, title_version, + gpu_vendor); + }(); + setWindowTitle(QString::fromStdString(run_title)); + } +} + +std::string GMainWindow::CreateTASFramesString( + std::array frames) const { + std::string string = ""; + size_t maxPlayerIndex = 0; + for (size_t i = 0; i < frames.size(); i++) { + if (frames[i] != 0) { + if (maxPlayerIndex != 0) + string += ", "; + while (maxPlayerIndex++ != i) + string += "0, "; + string += std::to_string(frames[i]); + } + } + return string; +} + +QString GMainWindow::GetTasStateDescription() const { + auto [tas_status, current_tas_frame, total_tas_frames] = input_subsystem->GetTas()->GetStatus(); + std::string tas_frames_string = CreateTASFramesString(total_tas_frames); + switch (tas_status) { + case InputCommon::TasInput::TasState::Running: + return tr("TAS state: Running %1/%2") + .arg(current_tas_frame) + .arg(QString::fromStdString(tas_frames_string)); + case InputCommon::TasInput::TasState::Recording: + return tr("TAS state: Recording %1").arg(total_tas_frames[0]); + case InputCommon::TasInput::TasState::Stopped: + return tr("TAS state: Idle %1/%2") + .arg(current_tas_frame) + .arg(QString::fromStdString(tas_frames_string)); + default: + return tr("TAS State: Invalid"); + } +} + +void GMainWindow::OnTasStateChanged() { + bool is_running = false; + bool is_recording = false; + if (emulation_running) { + const InputCommon::TasInput::TasState tas_status = + std::get<0>(input_subsystem->GetTas()->GetStatus()); + is_running = tas_status == InputCommon::TasInput::TasState::Running; + is_recording = tas_status == InputCommon::TasInput::TasState::Recording; + } + + ui->action_TAS_Start->setText(is_running ? tr("&Stop Running") : tr("&Start")); + ui->action_TAS_Record->setText(is_recording ? tr("Stop R&ecording") : tr("R&ecord")); + + ui->action_TAS_Start->setEnabled(emulation_running); + ui->action_TAS_Record->setEnabled(emulation_running); + ui->action_TAS_Reset->setEnabled(emulation_running); +} + +void GMainWindow::UpdateStatusBar() { + if (emu_thread == nullptr || !system->IsPoweredOn()) { + status_bar_update_timer.stop(); + return; + } + + if (Settings::values.tas_enable) { + tas_label->setText(GetTasStateDescription()); + } else { + tas_label->clear(); + } + + auto results = system->GetAndResetPerfStats(); + auto& shader_notify = system->GPU().ShaderNotify(); + const int shaders_building = shader_notify.ShadersBuilding(); + + if (shaders_building > 0) { + shader_building_label->setText(tr("Building: %n shader(s)", "", shaders_building)); + shader_building_label->setVisible(true); + } else { + shader_building_label->setVisible(false); + } + + const auto res_info = Settings::values.resolution_info; + const auto res_scale = res_info.up_factor; + res_scale_label->setText( + tr("Scale: %1x", "%1 is the resolution scaling factor").arg(res_scale)); + + if (Settings::values.use_speed_limit.GetValue()) { + emu_speed_label->setText(tr("Speed: %1% / %2%") + .arg(results.emulation_speed * 100.0, 0, 'f', 0) + .arg(Settings::values.speed_limit.GetValue())); + } else { + emu_speed_label->setText(tr("Speed: %1%").arg(results.emulation_speed * 100.0, 0, 'f', 0)); + } + if (!Settings::values.use_speed_limit) { + game_fps_label->setText( + tr("Game: %1 FPS (Unlocked)").arg(std::round(results.average_game_fps), 0, 'f', 0)); + } else { + game_fps_label->setText( + tr("Game: %1 FPS").arg(std::round(results.average_game_fps), 0, 'f', 0)); + } + emu_frametime_label->setText(tr("Frame: %1 ms").arg(results.frametime * 1000.0, 0, 'f', 2)); + + res_scale_label->setVisible(true); + emu_speed_label->setVisible(!Settings::values.use_multi_core.GetValue()); + game_fps_label->setVisible(true); + emu_frametime_label->setVisible(true); + firmware_label->setVisible(false); +} + +void GMainWindow::UpdateGPUAccuracyButton() { + const auto gpu_accuracy = Settings::values.gpu_accuracy.GetValue(); + const auto gpu_accuracy_text = + ConfigurationShared::gpu_accuracy_texts_map.find(gpu_accuracy)->second; + gpu_accuracy_button->setText(gpu_accuracy_text.toUpper()); + gpu_accuracy_button->setChecked(gpu_accuracy != Settings::GpuAccuracy::Normal); +} + +void GMainWindow::UpdateDockedButton() { + const auto console_mode = Settings::values.use_docked_mode.GetValue(); + dock_status_button->setChecked(Settings::IsDockedMode()); + dock_status_button->setText( + ConfigurationShared::use_docked_mode_texts_map.find(console_mode)->second.toUpper()); +} + +void GMainWindow::UpdateAPIText() { + const auto api = Settings::values.renderer_backend.GetValue(); + const auto renderer_status_text = + ConfigurationShared::renderer_backend_texts_map.find(api)->second; + renderer_status_button->setText( + api == Settings::RendererBackend::OpenGL + ? tr("%1 %2").arg(renderer_status_text.toUpper(), + ConfigurationShared::shader_backend_texts_map + .find(Settings::values.shader_backend.GetValue()) + ->second) + : renderer_status_text.toUpper()); +} + +void GMainWindow::UpdateFilterText() { + const auto filter = Settings::values.scaling_filter.GetValue(); + const auto filter_text = ConfigurationShared::scaling_filter_texts_map.find(filter)->second; + filter_status_button->setText(filter == Settings::ScalingFilter::Fsr ? tr("FSR") + : filter_text.toUpper()); +} + +void GMainWindow::UpdateAAText() { + const auto aa_mode = Settings::values.anti_aliasing.GetValue(); + const auto aa_text = ConfigurationShared::anti_aliasing_texts_map.find(aa_mode)->second; + aa_status_button->setText(aa_mode == Settings::AntiAliasing::None + ? QStringLiteral(QT_TRANSLATE_NOOP("GMainWindow", "NO AA")) + : aa_text.toUpper()); +} + +void GMainWindow::UpdateVolumeUI() { + const auto volume_value = static_cast(Settings::values.volume.GetValue()); + volume_slider->setValue(volume_value); + if (Settings::values.audio_muted) { + volume_button->setChecked(false); + volume_button->setText(tr("VOLUME: MUTE")); + } else { + volume_button->setChecked(true); + volume_button->setText(tr("VOLUME: %1%", "Volume percentage (e.g. 50%)").arg(volume_value)); + } +} + +void GMainWindow::UpdateStatusButtons() { + renderer_status_button->setChecked(Settings::values.renderer_backend.GetValue() == + Settings::RendererBackend::Vulkan); + UpdateAPIText(); + UpdateGPUAccuracyButton(); + UpdateDockedButton(); + UpdateFilterText(); + UpdateAAText(); + UpdateVolumeUI(); +} + +void GMainWindow::UpdateUISettings() { + if (!ui->action_Fullscreen->isChecked()) { + UISettings::values.geometry = saveGeometry(); + UISettings::values.renderwindow_geometry = render_window->saveGeometry(); + } + UISettings::values.state = saveState(); +#if MICROPROFILE_ENABLED + UISettings::values.microprofile_geometry = microProfileDialog->saveGeometry(); + UISettings::values.microprofile_visible = microProfileDialog->isVisible(); +#endif + UISettings::values.single_window_mode = ui->action_Single_Window_Mode->isChecked(); + UISettings::values.fullscreen = ui->action_Fullscreen->isChecked(); + UISettings::values.display_titlebar = ui->action_Display_Dock_Widget_Headers->isChecked(); + UISettings::values.show_filter_bar = ui->action_Show_Filter_Bar->isChecked(); + UISettings::values.show_status_bar = ui->action_Show_Status_Bar->isChecked(); + UISettings::values.first_start = false; +} + +void GMainWindow::UpdateInputDrivers() { + if (!input_subsystem) { + return; + } + input_subsystem->PumpEvents(); +} + +void GMainWindow::HideMouseCursor() { + if (emu_thread == nullptr && UISettings::values.hide_mouse) { + mouse_hide_timer.stop(); + ShowMouseCursor(); + return; + } + render_window->setCursor(QCursor(Qt::BlankCursor)); +} + +void GMainWindow::ShowMouseCursor() { + render_window->unsetCursor(); + if (emu_thread != nullptr && UISettings::values.hide_mouse) { + mouse_hide_timer.start(); + } +} + +void GMainWindow::OnMouseActivity() { + if (!Settings::values.mouse_panning) { + ShowMouseCursor(); + } +} + +void GMainWindow::OnCheckFirmwareDecryption() { + system->GetFileSystemController().CreateFactories(*vfs); + if (!ContentManager::AreKeysPresent()) { + QMessageBox::warning( + this, tr("Derivation Components Missing"), + tr("Encryption keys are missing. " + "
Please follow the sudachi " + "quickstart guide to get all your keys, firmware and " + "games.")); + } + SetFirmwareVersion(); + UpdateMenuState(); +} + +bool GMainWindow::CheckFirmwarePresence() { + constexpr u64 MiiEditId = static_cast(Service::AM::AppletProgramId::MiiEdit); + + auto bis_system = system->GetFileSystemController().GetSystemNANDContents(); + if (!bis_system) { + return false; + } + + auto mii_applet_nca = bis_system->GetEntry(MiiEditId, FileSys::ContentRecordType::Program); + if (!mii_applet_nca) { + return false; + } + + return true; +} + +void GMainWindow::SetFirmwareVersion() { + Service::Set::FirmwareVersionFormat firmware_data{}; + const auto result = Service::Set::GetFirmwareVersionImpl( + firmware_data, *system, Service::Set::GetFirmwareVersionType::Version2); + + if (result.IsError() || !CheckFirmwarePresence()) { + LOG_INFO(Frontend, "Installed firmware: No firmware available"); + firmware_label->setVisible(false); + return; + } + + firmware_label->setVisible(true); + + const std::string display_version(firmware_data.display_version.data()); + const std::string display_title(firmware_data.display_title.data()); + + LOG_INFO(Frontend, "Installed firmware: {}", display_title); + + firmware_label->setText(QString::fromStdString(display_version)); + firmware_label->setToolTip(QString::fromStdString(display_title)); +} + +bool GMainWindow::SelectRomFSDumpTarget(const FileSys::ContentProvider& installed, u64 program_id, + u64* selected_title_id, u8* selected_content_record_type) { + using ContentInfo = std::tuple; + boost::container::flat_set available_title_ids; + + const auto RetrieveEntries = [&](FileSys::TitleType title_type, + FileSys::ContentRecordType record_type) { + const auto entries = installed.ListEntriesFilter(title_type, record_type); + for (const auto& entry : entries) { + if (FileSys::GetBaseTitleID(entry.title_id) == program_id && + installed.GetEntry(entry)->GetStatus() == Loader::ResultStatus::Success) { + available_title_ids.insert({entry.title_id, title_type, record_type}); + } + } + }; + + RetrieveEntries(FileSys::TitleType::Application, FileSys::ContentRecordType::Program); + RetrieveEntries(FileSys::TitleType::Application, FileSys::ContentRecordType::HtmlDocument); + RetrieveEntries(FileSys::TitleType::Application, FileSys::ContentRecordType::LegalInformation); + RetrieveEntries(FileSys::TitleType::AOC, FileSys::ContentRecordType::Data); + + if (available_title_ids.empty()) { + return false; + } + + size_t title_index = 0; + + if (available_title_ids.size() > 1) { + QStringList list; + for (auto& [title_id, title_type, record_type] : available_title_ids) { + const auto hex_title_id = QString::fromStdString(fmt::format("{:X}", title_id)); + if (record_type == FileSys::ContentRecordType::Program) { + list.push_back(QStringLiteral("Program [%1]").arg(hex_title_id)); + } else if (record_type == FileSys::ContentRecordType::HtmlDocument) { + list.push_back(QStringLiteral("HTML document [%1]").arg(hex_title_id)); + } else if (record_type == FileSys::ContentRecordType::LegalInformation) { + list.push_back(QStringLiteral("Legal information [%1]").arg(hex_title_id)); + } else { + list.push_back( + QStringLiteral("DLC %1 [%2]").arg(title_id & 0x7FF).arg(hex_title_id)); + } + } + + bool ok; + const auto res = QInputDialog::getItem( + this, tr("Select RomFS Dump Target"), + tr("Please select which RomFS you would like to dump."), list, 0, false, &ok); + if (!ok) { + return false; + } + + title_index = list.indexOf(res); + } + + const auto& [title_id, title_type, record_type] = *available_title_ids.nth(title_index); + *selected_title_id = title_id; + *selected_content_record_type = static_cast(record_type); + return true; +} + +bool GMainWindow::ConfirmClose() { + if (emu_thread == nullptr || + UISettings::values.confirm_before_stopping.GetValue() == ConfirmStop::Ask_Never) { + return true; + } + if (!system->GetExitLocked() && + UISettings::values.confirm_before_stopping.GetValue() == ConfirmStop::Ask_Based_On_Game) { + return true; + } + const auto text = tr("Are you sure you want to close sudachi?"); + return question(this, tr("sudachi"), text); +} + +void GMainWindow::closeEvent(QCloseEvent* event) { + if (!ConfirmClose()) { + event->ignore(); + return; + } + + UpdateUISettings(); + game_list->SaveInterfaceLayout(); + UISettings::SaveWindowState(); + hotkey_registry.SaveHotkeys(); + + // Unload controllers early + controller_dialog->UnloadController(); + game_list->UnloadController(); + + // Shutdown session if the emu thread is active... + if (emu_thread != nullptr) { + ShutdownGame(); + } + + render_window->close(); + multiplayer_state->Close(); + system->HIDCore().UnloadInputDevices(); + system->GetRoomNetwork().Shutdown(); + + QWidget::closeEvent(event); +} + +static bool IsSingleFileDropEvent(const QMimeData* mime) { + return mime->hasUrls() && mime->urls().length() == 1; +} + +void GMainWindow::AcceptDropEvent(QDropEvent* event) { + if (IsSingleFileDropEvent(event->mimeData())) { + event->setDropAction(Qt::DropAction::LinkAction); + event->accept(); + } +} + +bool GMainWindow::DropAction(QDropEvent* event) { + if (!IsSingleFileDropEvent(event->mimeData())) { + return false; + } + + const QMimeData* mime_data = event->mimeData(); + const QString& filename = mime_data->urls().at(0).toLocalFile(); + + if (emulation_running && QFileInfo(filename).suffix() == QStringLiteral("bin")) { + // Amiibo + LoadAmiibo(filename); + } else { + // Game + if (ConfirmChangeGame()) { + BootGame(filename, ApplicationAppletParameters()); + } + } + return true; +} + +void GMainWindow::dropEvent(QDropEvent* event) { + DropAction(event); +} + +void GMainWindow::dragEnterEvent(QDragEnterEvent* event) { + AcceptDropEvent(event); +} + +void GMainWindow::dragMoveEvent(QDragMoveEvent* event) { + AcceptDropEvent(event); +} + +bool GMainWindow::ConfirmChangeGame() { + if (emu_thread == nullptr) + return true; + + // Use custom question to link controller navigation + return question( + this, tr("sudachi"), + tr("Are you sure you want to stop the emulation? Any unsaved progress will be lost."), + QMessageBox::Yes | QMessageBox::No, QMessageBox::Yes); +} + +bool GMainWindow::ConfirmForceLockedExit() { + if (emu_thread == nullptr) { + return true; + } + const auto text = tr("The currently running application has requested sudachi to not exit.\n\n" + "Would you like to bypass this and exit anyway?"); + + return question(this, tr("sudachi"), text); +} + +void GMainWindow::RequestGameExit() { + if (!system->IsPoweredOn()) { + return; + } + + system->SetExitRequested(true); + system->GetAppletManager().RequestExit(); +} + +void GMainWindow::filterBarSetChecked(bool state) { + ui->action_Show_Filter_Bar->setChecked(state); + emit(OnToggleFilterBar()); +} + +static void AdjustLinkColor() { + QPalette new_pal(qApp->palette()); + if (UISettings::IsDarkTheme()) { + new_pal.setColor(QPalette::Link, QColor(0, 190, 255, 255)); + } else { + new_pal.setColor(QPalette::Link, QColor(0, 140, 200, 255)); + } + if (qApp->palette().color(QPalette::Link) != new_pal.color(QPalette::Link)) { + qApp->setPalette(new_pal); + } +} + +void GMainWindow::UpdateUITheme() { + const QString default_theme = QString::fromUtf8( + UISettings::themes[static_cast(UISettings::default_theme)].second); + QString current_theme = QString::fromStdString(UISettings::values.theme); + + if (current_theme.isEmpty()) { + current_theme = default_theme; + } + +#ifdef _WIN32 + QIcon::setThemeName(current_theme); + AdjustLinkColor(); +#else + if (current_theme == QStringLiteral("default") || current_theme == QStringLiteral("colorful")) { + QIcon::setThemeName(current_theme == QStringLiteral("colorful") ? current_theme + : startup_icon_theme); + QIcon::setThemeSearchPaths(QStringList(default_theme_paths)); + if (CheckDarkMode()) { + current_theme = QStringLiteral("default_dark"); + } + } else { + QIcon::setThemeName(current_theme); + QIcon::setThemeSearchPaths(QStringList(QStringLiteral(":/icons"))); + AdjustLinkColor(); + } +#endif + if (current_theme != default_theme) { + QString theme_uri{QStringLiteral(":%1/style.qss").arg(current_theme)}; + QFile f(theme_uri); + if (!f.open(QFile::ReadOnly | QFile::Text)) { + LOG_ERROR(Frontend, "Unable to open style \"{}\", fallback to the default theme", + UISettings::values.theme); + current_theme = default_theme; + } + } + + QString theme_uri{QStringLiteral(":%1/style.qss").arg(current_theme)}; + QFile f(theme_uri); + if (f.open(QFile::ReadOnly | QFile::Text)) { + QTextStream ts(&f); + qApp->setStyleSheet(ts.readAll()); + setStyleSheet(ts.readAll()); + } else { + LOG_ERROR(Frontend, "Unable to set style \"{}\", stylesheet file not found", + UISettings::values.theme); + qApp->setStyleSheet({}); + setStyleSheet({}); + } +} + +void GMainWindow::LoadTranslation() { + bool loaded; + + if (UISettings::values.language.GetValue().empty()) { + // If the selected language is empty, use system locale + loaded = translator.load(QLocale(), {}, {}, QStringLiteral(":/languages/")); + } else { + // Otherwise load from the specified file + loaded = translator.load(QString::fromStdString(UISettings::values.language.GetValue()), + QStringLiteral(":/languages/")); + } + + if (loaded) { + qApp->installTranslator(&translator); + } else { + UISettings::values.language = std::string("en"); + } +} + +void GMainWindow::OnLanguageChanged(const QString& locale) { + if (UISettings::values.language.GetValue() != std::string("en")) { + qApp->removeTranslator(&translator); + } + + UISettings::values.language = locale.toStdString(); + LoadTranslation(); + ui->retranslateUi(this); + multiplayer_state->retranslateUi(); + UpdateWindowTitle(); +} + +void GMainWindow::SetDiscordEnabled([[maybe_unused]] bool state) { +#ifdef USE_DISCORD_PRESENCE + if (state) { + discord_rpc = std::make_unique(*system); + } else { + discord_rpc = std::make_unique(); + } +#else + discord_rpc = std::make_unique(); +#endif + discord_rpc->Update(); +} + +#ifdef __unix__ +void GMainWindow::SetGamemodeEnabled(bool state) { + if (emulation_running) { + Common::Linux::SetGamemodeState(state); + } +} +#endif + +void GMainWindow::changeEvent(QEvent* event) { +#ifdef __unix__ + // PaletteChange event appears to only reach so far into the GUI, explicitly asking to + // UpdateUITheme is a decent work around + if (event->type() == QEvent::PaletteChange) { + const QPalette test_palette(qApp->palette()); + const QString current_theme = QString::fromStdString(UISettings::values.theme); + // Keeping eye on QPalette::Window to avoid looping. QPalette::Text might be useful too + static QColor last_window_color; + const QColor window_color = test_palette.color(QPalette::Active, QPalette::Window); + if (last_window_color != window_color && (current_theme == QStringLiteral("default") || + current_theme == QStringLiteral("colorful"))) { + UpdateUITheme(); + } + last_window_color = window_color; + } +#endif // __unix__ + QWidget::changeEvent(event); +} + +Service::AM::FrontendAppletParameters GMainWindow::ApplicationAppletParameters() { + return Service::AM::FrontendAppletParameters{ + .applet_id = Service::AM::AppletId::Application, + .applet_type = Service::AM::AppletType::Application, + }; +} + +Service::AM::FrontendAppletParameters GMainWindow::LibraryAppletParameters( + u64 program_id, Service::AM::AppletId applet_id) { + return Service::AM::FrontendAppletParameters{ + .program_id = program_id, + .applet_id = applet_id, + .applet_type = Service::AM::AppletType::LibraryApplet, + }; +} + +void VolumeButton::wheelEvent(QWheelEvent* event) { + + int num_degrees = event->angleDelta().y() / 8; + int num_steps = (num_degrees / 15) * scroll_multiplier; + // Stated in QT docs: Most mouse types work in steps of 15 degrees, in which case the delta + // value is a multiple of 120; i.e., 120 units * 1/8 = 15 degrees. + + if (num_steps > 0) { + Settings::values.volume.SetValue( + std::min(200, Settings::values.volume.GetValue() + num_steps)); + } else { + Settings::values.volume.SetValue( + std::max(0, Settings::values.volume.GetValue() + num_steps)); + } + + scroll_multiplier = std::min(MaxMultiplier, scroll_multiplier * 2); + scroll_timer.start(100); // reset the multiplier if no scroll event occurs within 100 ms + + emit VolumeChanged(); + event->accept(); +} + +void VolumeButton::ResetMultiplier() { + scroll_multiplier = 1; +} + +#ifdef main +#undef main +#endif + +static void SetHighDPIAttributes() { +#ifdef _WIN32 + // For Windows, we want to avoid scaling artifacts on fractional scaling ratios. + // This is done by setting the optimal scaling policy for the primary screen. + + // Create a temporary QApplication. + int temp_argc = 0; + char** temp_argv = nullptr; + QApplication temp{temp_argc, temp_argv}; + + // Get the current screen geometry. + const QScreen* primary_screen = QGuiApplication::primaryScreen(); + if (primary_screen == nullptr) { + return; + } + + const QRect screen_rect = primary_screen->geometry(); + const int real_width = screen_rect.width(); + const int real_height = screen_rect.height(); + const float real_ratio = primary_screen->logicalDotsPerInch() / 96.0f; + + // Recommended minimum width and height for proper window fit. + // Any screen with a lower resolution than this will still have a scale of 1. + constexpr float minimum_width = 1350.0f; + constexpr float minimum_height = 900.0f; + + const float width_ratio = std::max(1.0f, real_width / minimum_width); + const float height_ratio = std::max(1.0f, real_height / minimum_height); + + // Get the lower of the 2 ratios and truncate, this is the maximum integer scale. + const float max_ratio = std::trunc(std::min(width_ratio, height_ratio)); + + if (max_ratio > real_ratio) { + QApplication::setHighDpiScaleFactorRoundingPolicy( + Qt::HighDpiScaleFactorRoundingPolicy::Round); + } else { + QApplication::setHighDpiScaleFactorRoundingPolicy( + Qt::HighDpiScaleFactorRoundingPolicy::Floor); + } +#else + // Other OSes should be better than Windows at fractional scaling. + QApplication::setHighDpiScaleFactorRoundingPolicy( + Qt::HighDpiScaleFactorRoundingPolicy::PassThrough); +#endif + + QApplication::setAttribute(Qt::AA_EnableHighDpiScaling); + QApplication::setAttribute(Qt::AA_UseHighDpiPixmaps); +} + +int main(int argc, char* argv[]) { + std::unique_ptr config = std::make_unique(); + UISettings::RestoreWindowState(config); + bool has_broken_vulkan = false; + bool is_child = false; + if (CheckEnvVars(&is_child)) { + return 0; + } + + if (StartupChecks(argv[0], &has_broken_vulkan, + Settings::values.perform_vulkan_check.GetValue())) { + return 0; + } + +#ifdef SUDACHI_CRASH_DUMPS + Breakpad::InstallCrashHandler(); +#endif + + Common::DetachedTasks detached_tasks; + MicroProfileOnThreadCreate("Frontend"); + SCOPE_EXIT { + MicroProfileShutdown(); + }; + + Common::ConfigureNvidiaEnvironmentFlags(); + + // Init settings params + QCoreApplication::setOrganizationName(QStringLiteral("sudachi team")); + QCoreApplication::setApplicationName(QStringLiteral("sudachi")); + +#ifdef _WIN32 + // Increases the maximum open file limit to 8192 + _setmaxstdio(8192); +#endif + +#ifdef __APPLE__ + // If you start a bundle (binary) on OSX without the Terminal, the working directory is "/". + // But since we require the working directory to be the executable path for the location of + // the user folder in the Qt Frontend, we need to cd into that working directory + const auto bin_path = Common::FS::GetBundleDirectory() / ".."; + chdir(Common::FS::PathToUTF8String(bin_path).c_str()); +#endif + +#ifdef __linux__ + // Set the DISPLAY variable in order to open web browsers + // TODO (lat9nq): Find a better solution for AppImages to start external applications + if (QString::fromLocal8Bit(qgetenv("DISPLAY")).isEmpty()) { + qputenv("DISPLAY", ":0"); + } + + // Fix the Wayland appId. This needs to match the name of the .desktop file without the .desktop + // suffix. + QGuiApplication::setDesktopFileName(QStringLiteral("org.sudachi_emu.sudachi")); +#endif + + SetHighDPIAttributes(); + +#if QT_VERSION < QT_VERSION_CHECK(6, 0, 0) + // Disables the "?" button on all dialogs. Disabled by default on Qt6. + QCoreApplication::setAttribute(Qt::AA_DisableWindowContextHelpButton); +#endif + + // Enables the core to make the qt created contexts current on std::threads + QCoreApplication::setAttribute(Qt::AA_DontCheckOpenGLContextThreadAffinity); + + QApplication app(argc, argv); + +#ifdef _WIN32 + OverrideWindowsFont(); +#endif + + // Workaround for QTBUG-85409, for Suzhou numerals the number 1 is actually \u3021 + // so we can see if we get \u3008 instead + // TL;DR all other number formats are consecutive in unicode code points + // This bug is fixed in Qt6, specifically 6.0.0-alpha1 +#if QT_VERSION < QT_VERSION_CHECK(6, 0, 0) + const QLocale locale = QLocale::system(); + if (QStringLiteral("\u3008") == locale.toString(1)) { + QLocale::setDefault(QLocale::system().name()); + } +#endif + + // Qt changes the locale and causes issues in float conversion using std::to_string() when + // generating shaders + setlocale(LC_ALL, "C"); + + GMainWindow main_window{std::move(config), has_broken_vulkan}; + // After settings have been loaded by GMainWindow, apply the filter + main_window.show(); + + QObject::connect(&app, &QGuiApplication::applicationStateChanged, &main_window, + &GMainWindow::OnAppFocusStateChanged); + + int result = app.exec(); + detached_tasks.WaitForAllTasks(); + return result; +} \ No newline at end of file diff --git a/src/sudachi/main.h b/src/sudachi/main.h new file mode 100644 index 0000000..be14ae8 --- /dev/null +++ b/src/sudachi/main.h @@ -0,0 +1,580 @@ +// SPDX-FileCopyrightText: 2014 Citra Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include +#include + +#include +#include +#include +#include +#include +#include + +#include "common/announce_multiplayer_room.h" +#include "common/common_types.h" +#include "configuration/qt_config.h" +#include "frontend_common/content_manager.h" +#include "input_common/drivers/tas_input.h" +#include "sudachi/compatibility_list.h" +#include "sudachi/hotkeys.h" +#include "sudachi/util/controller_navigation.h" + +#ifdef __unix__ +#include +#include +#include +#endif + +class QtConfig; +class ClickableLabel; +class EmuThread; +class GameList; +class GImageInfo; +class GRenderWindow; +class LoadingScreen; +class MicroProfileDialog; +class OverlayDialog; +class ProfilerWidget; +class ControllerDialog; +class QLabel; +class MultiplayerState; +class QPushButton; +class QProgressDialog; +class QSlider; +class QHBoxLayout; +class WaitTreeWidget; +enum class GameListOpenTarget; +enum class GameListRemoveTarget; +enum class GameListShortcutTarget; +enum class DumpRomFSTarget; +enum class InstalledEntryType; +class GameListPlaceholder; + +class QtAmiiboSettingsDialog; +class QtControllerSelectorDialog; +class QtProfileSelectionDialog; +class QtSoftwareKeyboardDialog; +class QtNXWebEngineView; + +enum class StartGameType { + Normal, // Can use custom configuration + Global, // Only uses global configuration +}; + +namespace Core { +enum class SystemResultStatus : u32; +class System; +} // namespace Core + +namespace Core::Frontend { +struct CabinetParameters; +struct ControllerParameters; +struct InlineAppearParameters; +struct InlineTextParameters; +struct KeyboardInitializeParameters; +struct ProfileSelectParameters; +} // namespace Core::Frontend + +namespace DiscordRPC { +class DiscordInterface; +} + +namespace PlayTime { +class PlayTimeManager; +} + +namespace FileSys { +class ContentProvider; +class ManualContentProvider; +class VfsFilesystem; +} // namespace FileSys + +namespace InputCommon { +class InputSubsystem; +} + +namespace Service::AM { +struct FrontendAppletParameters; +enum class AppletId : u32; +} // namespace Service::AM + +namespace Service::AM::Frontend { +enum class SwkbdResult : u32; +enum class SwkbdTextCheckResult : u32; +enum class SwkbdReplyType : u32; +enum class WebExitReason : u32; +} // namespace Service::AM::Frontend + +namespace Service::NFC { +class NfcDevice; +} // namespace Service::NFC + +namespace Service::NFP { +enum class CabinetMode : u8; +} // namespace Service::NFP + +namespace Ui { +class MainWindow; +} + +enum class EmulatedDirectoryTarget { + NAND, + SDMC, +}; + +namespace VkDeviceInfo { +class Record; +} + +class VolumeButton : public QPushButton { + Q_OBJECT +public: + explicit VolumeButton(QWidget* parent = nullptr) : QPushButton(parent), scroll_multiplier(1) { + connect(&scroll_timer, &QTimer::timeout, this, &VolumeButton::ResetMultiplier); + } + +signals: + void VolumeChanged(); + +protected: + void wheelEvent(QWheelEvent* event) override; + +private slots: + void ResetMultiplier(); + +private: + int scroll_multiplier; + QTimer scroll_timer; + constexpr static int MaxMultiplier = 8; +}; + +class GMainWindow : public QMainWindow { + Q_OBJECT + + /// Max number of recently loaded items to keep track of + static const int max_recent_files_item = 10; + + enum { + CREATE_SHORTCUT_MSGBOX_FULLSCREEN_YES, + CREATE_SHORTCUT_MSGBOX_SUCCESS, + CREATE_SHORTCUT_MSGBOX_ERROR, + CREATE_SHORTCUT_MSGBOX_APPVOLATILE_WARNING, + }; + +public: + void filterBarSetChecked(bool state); + void UpdateUITheme(); + explicit GMainWindow(std::unique_ptr config_, bool has_broken_vulkan); + ~GMainWindow() override; + + bool DropAction(QDropEvent* event); + void AcceptDropEvent(QDropEvent* event); + +signals: + + /** + * Signal that is emitted when a new EmuThread has been created and an emulation session is + * about to start. At this time, the core system emulation has been initialized, and all + * emulation handles and memory should be valid. + * + * @param emu_thread Pointer to the newly created EmuThread (to be used by widgets that need to + * access/change emulation state). + */ + void EmulationStarting(EmuThread* emu_thread); + + /** + * Signal that is emitted when emulation is about to stop. At this time, the EmuThread and core + * system emulation handles and memory are still valid, but are about become invalid. + */ + void EmulationStopping(); + + // Signal that tells widgets to update icons to use the current theme + void UpdateThemedIcons(); + + void UpdateInstallProgress(); + + void AmiiboSettingsFinished(bool is_success, const std::string& name); + + void ControllerSelectorReconfigureFinished(bool is_success); + + void ErrorDisplayFinished(); + + void ProfileSelectorFinishedSelection(std::optional uuid); + + void SoftwareKeyboardSubmitNormalText(Service::AM::Frontend::SwkbdResult result, + std::u16string submitted_text, bool confirmed); + void SoftwareKeyboardSubmitInlineText(Service::AM::Frontend::SwkbdReplyType reply_type, + std::u16string submitted_text, s32 cursor_position); + + void WebBrowserExtractOfflineRomFS(); + void WebBrowserClosed(Service::AM::Frontend::WebExitReason exit_reason, std::string last_url); + + void SigInterrupt(); + +public slots: + void OnLoadComplete(); + void OnExecuteProgram(std::size_t program_index); + void OnExit(); + void OnSaveConfig(); + void AmiiboSettingsShowDialog(const Core::Frontend::CabinetParameters& parameters, + std::shared_ptr nfp_device); + void AmiiboSettingsRequestExit(); + void ControllerSelectorReconfigureControllers( + const Core::Frontend::ControllerParameters& parameters); + void ControllerSelectorRequestExit(); + void SoftwareKeyboardInitialize( + bool is_inline, Core::Frontend::KeyboardInitializeParameters initialize_parameters); + void SoftwareKeyboardShowNormal(); + void SoftwareKeyboardShowTextCheck( + Service::AM::Frontend::SwkbdTextCheckResult text_check_result, + std::u16string text_check_message); + void SoftwareKeyboardShowInline(Core::Frontend::InlineAppearParameters appear_parameters); + void SoftwareKeyboardHideInline(); + void SoftwareKeyboardInlineTextChanged(Core::Frontend::InlineTextParameters text_parameters); + void SoftwareKeyboardExit(); + void ErrorDisplayDisplayError(QString error_code, QString error_text); + void ErrorDisplayRequestExit(); + void ProfileSelectorSelectProfile(const Core::Frontend::ProfileSelectParameters& parameters); + void ProfileSelectorRequestExit(); + void WebBrowserOpenWebPage(const std::string& main_url, const std::string& additional_args, + bool is_local); + void WebBrowserRequestExit(); + void OnAppFocusStateChanged(Qt::ApplicationState state); + void OnTasStateChanged(); + +private: + /// Updates an action's shortcut and text to reflect an updated hotkey from the hotkey registry. + void LinkActionShortcut(QAction* action, const QString& action_name, + const bool tas_allowed = false); + + void RegisterMetaTypes(); + + void InitializeWidgets(); + void InitializeDebugWidgets(); + void InitializeRecentFileMenuActions(); + + void SetDefaultUIGeometry(); + void RestoreUIState(); + + void ConnectWidgetEvents(); + void ConnectMenuEvents(); + void UpdateMenuState(); + + void SetupPrepareForSleep(); + + void PreventOSSleep(); + void AllowOSSleep(); + + bool LoadROM(const QString& filename, Service::AM::FrontendAppletParameters params); + void BootGame(const QString& filename, Service::AM::FrontendAppletParameters params, + StartGameType with_config = StartGameType::Normal); + void BootGameFromList(const QString& filename, StartGameType with_config); + void ShutdownGame(); + + void ShowTelemetryCallout(); + void SetDiscordEnabled(bool state); + void LoadAmiibo(const QString& filename); + + bool SelectAndSetCurrentUser(const Core::Frontend::ProfileSelectParameters& parameters); + + /** + * Stores the filename in the recently loaded files list. + * The new filename is stored at the beginning of the recently loaded files list. + * After inserting the new entry, duplicates are removed meaning that if + * this was inserted from \a OnMenuRecentFile(), the entry will be put on top + * and remove from its previous position. + * + * Finally, this function calls \a UpdateRecentFiles() to update the UI. + * + * @param filename the filename to store + */ + void StoreRecentFile(const QString& filename); + + /** + * Updates the recent files menu. + * Menu entries are rebuilt from the configuration file. + * If there is no entry in the menu, the menu is greyed out. + */ + void UpdateRecentFiles(); + + /** + * If the emulation is running, + * asks the user if he really want to close the emulator + * + * @return true if the user confirmed + */ + bool ConfirmClose(); + bool ConfirmChangeGame(); + bool ConfirmForceLockedExit(); + void RequestGameExit(); + void changeEvent(QEvent* event) override; + void closeEvent(QCloseEvent* event) override; + + std::string CreateTASFramesString( + std::array frames) const; + +#ifdef __unix__ + void SetupSigInterrupts(); + static void HandleSigInterrupt(int); + void OnSigInterruptNotifierActivated(); + void SetGamemodeEnabled(bool state); +#endif + + Service::AM::FrontendAppletParameters ApplicationAppletParameters(); + Service::AM::FrontendAppletParameters LibraryAppletParameters(u64 program_id, + Service::AM::AppletId applet_id); + +private slots: + void OnStartGame(); + void OnRestartGame(); + void OnPauseGame(); + void OnPauseContinueGame(); + void OnStopGame(); + void OnPrepareForSleep(bool prepare_sleep); + void OnMenuReportCompatibility(); + void OnOpenModsPage(); + void OnOpenQuickstartGuide(); + void OnOpenFAQ(); + /// Called whenever a user selects a game in the game list widget. + void OnGameListLoadFile(QString game_path, u64 program_id); + void OnGameListOpenFolder(u64 program_id, GameListOpenTarget target, + const std::string& game_path); + void OnTransferableShaderCacheOpenFile(u64 program_id); + void OnGameListRemoveInstalledEntry(u64 program_id, InstalledEntryType type); + void OnGameListRemoveFile(u64 program_id, GameListRemoveTarget target, + const std::string& game_path); + void OnGameListRemovePlayTimeData(u64 program_id); + void OnGameListDumpRomFS(u64 program_id, const std::string& game_path, DumpRomFSTarget target); + void OnGameListVerifyIntegrity(const std::string& game_path); + void OnGameListCopyTID(u64 program_id); + void OnGameListNavigateToGamedbEntry(u64 program_id, + const CompatibilityList& compatibility_list); + void OnGameListCreateShortcut(u64 program_id, const std::string& game_path, + GameListShortcutTarget target); + void OnGameListOpenDirectory(const QString& directory); + void OnGameListAddDirectory(); + void OnGameListShowList(bool show); + void OnGameListOpenPerGameProperties(const std::string& file); + void OnMenuLoadFile(); + void OnMenuLoadFolder(); + void IncrementInstallProgress(); + void OnMenuInstallToNAND(); + void OnMenuRecentFile(); + void OnConfigure(); + void OnConfigureTas(); + void OnDecreaseVolume(); + void OnIncreaseVolume(); + void OnMute(); + void OnTasStartStop(); + void OnTasRecord(); + void OnTasReset(); + void OnToggleGraphicsAPI(); + void OnToggleDockedMode(); + void OnToggleGpuAccuracy(); + void OnToggleAdaptingFilter(); + void OnConfigurePerGame(); + void OnLoadAmiibo(); + void OnOpenSudachiFolder(); + void OnVerifyInstalledContents(); + void OnInstallFirmware(); + void OnInstallDecryptionKeys(); + void OnAbout(); + void OnToggleFilterBar(); + void OnToggleStatusBar(); + void OnDisplayTitleBars(bool); + void InitializeHotkeys(); + void ToggleFullscreen(); + bool UsingExclusiveFullscreen(); + void ShowFullscreen(); + void HideFullscreen(); + void ToggleWindowMode(); + void ResetWindowSize(u32 width, u32 height); + void ResetWindowSize720(); + void ResetWindowSize900(); + void ResetWindowSize1080(); + void OnAlbum(); + void OnCabinet(Service::NFP::CabinetMode mode); + void OnMiiEdit(); + void OnOpenControllerMenu(); + void OnCaptureScreenshot(); + void OnCheckFirmwareDecryption(); + void OnLanguageChanged(const QString& locale); + void OnMouseActivity(); + bool OnShutdownBegin(); + void OnShutdownBeginDialog(); + void OnEmulationStopped(); + void OnEmulationStopTimeExpired(); + +private: + QString GetGameListErrorRemoving(InstalledEntryType type) const; + void RemoveBaseContent(u64 program_id, InstalledEntryType type); + void RemoveUpdateContent(u64 program_id, InstalledEntryType type); + void RemoveAddOnContent(u64 program_id, InstalledEntryType type); + void RemoveTransferableShaderCache(u64 program_id, GameListRemoveTarget target); + void RemoveVulkanDriverPipelineCache(u64 program_id); + void RemoveAllTransferableShaderCaches(u64 program_id); + void RemoveCustomConfiguration(u64 program_id, const std::string& game_path); + void RemovePlayTimeData(u64 program_id); + void RemoveCacheStorage(u64 program_id); + bool SelectRomFSDumpTarget(const FileSys::ContentProvider&, u64 program_id, + u64* selected_title_id, u8* selected_content_record_type); + ContentManager::InstallResult InstallNCA(const QString& filename); + void MigrateConfigFiles(); + void UpdateWindowTitle(std::string_view title_name = {}, std::string_view title_version = {}, + std::string_view gpu_vendor = {}); + void UpdateDockedButton(); + void UpdateAPIText(); + void UpdateFilterText(); + void UpdateAAText(); + void UpdateVolumeUI(); + void UpdateStatusBar(); + void UpdateGPUAccuracyButton(); + void UpdateStatusButtons(); + void UpdateUISettings(); + void UpdateInputDrivers(); + void HideMouseCursor(); + void ShowMouseCursor(); + void OpenURL(const QUrl& url); + void LoadTranslation(); + void OpenPerGameConfiguration(u64 title_id, const std::string& file_name); + bool CheckDarkMode(); + bool CheckFirmwarePresence(); + void SetFirmwareVersion(); + void ConfigureFilesystemProvider(const std::string& filepath); + /** + * Open (or not) the right confirm dialog based on current setting and game exit lock + * @returns true if the player confirmed or the settings do no require it + */ + bool ConfirmShutdownGame(); + + QString GetTasStateDescription() const; + bool CreateShortcutMessagesGUI(QWidget* parent, int imsg, const QString& game_title); + bool MakeShortcutIcoPath(const u64 program_id, const std::string_view game_file_name, + std::filesystem::path& out_icon_path); + bool CreateShortcutLink(const std::filesystem::path& shortcut_path, const std::string& comment, + const std::filesystem::path& icon_path, + const std::filesystem::path& command, const std::string& arguments, + const std::string& categories, const std::string& keywords, + const std::string& name); + /** + * Mimic the behavior of QMessageBox::question but link controller navigation to the dialog + * The only difference is that it returns a boolean. + * + * @returns true if buttons contains QMessageBox::Yes and the user clicks on the "Yes" button. + */ + bool question(QWidget* parent, const QString& title, const QString& text, + QMessageBox::StandardButtons buttons = + QMessageBox::StandardButtons(QMessageBox::Yes | QMessageBox::No), + QMessageBox::StandardButton defaultButton = QMessageBox::NoButton); + + std::unique_ptr ui; + + std::unique_ptr system; + std::unique_ptr discord_rpc; + std::unique_ptr play_time_manager; + std::shared_ptr input_subsystem; + + MultiplayerState* multiplayer_state = nullptr; + + GRenderWindow* render_window; + GameList* game_list; + LoadingScreen* loading_screen; + QTimer shutdown_timer; + OverlayDialog* shutdown_dialog{}; + + GameListPlaceholder* game_list_placeholder; + + std::vector vk_device_records; + + // Status bar elements + QLabel* message_label = nullptr; + QLabel* shader_building_label = nullptr; + QLabel* res_scale_label = nullptr; + QLabel* emu_speed_label = nullptr; + QLabel* game_fps_label = nullptr; + QLabel* emu_frametime_label = nullptr; + QLabel* tas_label = nullptr; + QLabel* firmware_label = nullptr; + QPushButton* gpu_accuracy_button = nullptr; + QPushButton* renderer_status_button = nullptr; + QPushButton* dock_status_button = nullptr; + QPushButton* filter_status_button = nullptr; + QPushButton* aa_status_button = nullptr; + VolumeButton* volume_button = nullptr; + QWidget* volume_popup = nullptr; + QSlider* volume_slider = nullptr; + QTimer status_bar_update_timer; + + std::unique_ptr config; + + // Whether emulation is currently running in sudachi. + bool emulation_running = false; + std::unique_ptr emu_thread; + // The path to the game currently running + QString current_game_path; + // Whether a user was set on the command line (skips UserSelector if it's forced to show up) + bool user_flag_cmd_line = false; + + bool auto_paused = false; + bool auto_muted = false; + QTimer mouse_hide_timer; + QTimer update_input_timer; + + QString startup_icon_theme; + bool os_dark_mode = false; + + // FS + std::shared_ptr vfs; + std::unique_ptr provider; + + // Debugger panes + ProfilerWidget* profilerWidget; + MicroProfileDialog* microProfileDialog; + WaitTreeWidget* waitTreeWidget; + ControllerDialog* controller_dialog; + + QAction* actions_recent_files[max_recent_files_item]; + + // stores default icon theme search paths for the platform + QStringList default_theme_paths; + + HotkeyRegistry hotkey_registry; + + QTranslator translator; + + // Install progress dialog + QProgressDialog* install_progress; + + // Last game booted, used for multi-process apps + QString last_filename_booted; + + // Applets + QtAmiiboSettingsDialog* cabinet_applet = nullptr; + QtControllerSelectorDialog* controller_applet = nullptr; + QtProfileSelectionDialog* profile_select_applet = nullptr; + QDialog* error_applet = nullptr; + QtSoftwareKeyboardDialog* software_keyboard = nullptr; + QtNXWebEngineView* web_applet = nullptr; + + // True if amiibo file select is visible + bool is_amiibo_file_select_active{}; + + // True if load file select is visible + bool is_load_file_select_active{}; + + // True if TAS recording dialog is visible + bool is_tas_recording_dialog_active{}; + +#ifdef __unix__ + QSocketNotifier* sig_interrupt_notifier; + static std::array sig_interrupt_fds; +#endif + +protected: + void dropEvent(QDropEvent* event) override; + void dragEnterEvent(QDragEnterEvent* event) override; + void dragMoveEvent(QDragMoveEvent* event) override; +}; diff --git a/src/sudachi/main.ui b/src/sudachi/main.ui new file mode 100644 index 0000000..820ef8f --- /dev/null +++ b/src/sudachi/main.ui @@ -0,0 +1,483 @@ + + + MainWindow + + + + 0 + 0 + 1280 + 720 + + + + sudachi + + + + :/img/sudachi.ico:/img/sudachi.ico + + + QTabWidget::Rounded + + + true + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + + 0 + 0 + 1280 + 21 + + + + + &File + + + + &Recent Files + + + + + + + + + + + + + + + + + + &Emulation + + + + + + + + + + + &View + + + + &Reset Window Size + + + + + &Debugging + + + + + Reset Window Size to &720p + + + Reset Window Size to 720p + + + + + Reset Window Size to &900p + + + Reset Window Size to 900p + + + + + Reset Window Size to &1080p + + + Reset Window Size to 1080p + + + + + + + + + + + + + + true + + + &Multiplayer + + + + + + + + + + + &Tools + + + + &Amiibo + + + + + + + + + &TAS + + + + + + + + + + + + + + + + + + + + + + &Help + + + + + + + + + + + + + + + + + + true + + + &Install Files to NAND... + + + + + L&oad File... + + + + + Load &Folder... + + + + + E&xit + + + + + false + + + &Pause + + + + + false + + + &Stop + + + + + &Verify Installed Contents + + + + + &About sudachi + + + + + true + + + Single &Window Mode + + + + + Con&figure... + + + QAction::PreferencesRole + + + + + true + + + Display D&ock Widget Headers + + + + + true + + + Show &Filter Bar + + + + + true + + + Show &Status Bar + + + Show Status Bar + + + + + true + + + &Browse Public Game Lobby + + + + + true + + + &Create Room + + + + + false + + + &Leave Room + + + + + &Direct Connect to Room + + + + + false + + + &Show Current Room + + + + + true + + + F&ullscreen + + + + + false + + + &Restart + + + + + false + + + Load/Remove &Amiibo... + + + + + false + + + &Report Compatibility + + + false + + + + + Open &Mods Page + + + + + Open &Quickstart Guide + + + + + &FAQ + + + + + Open &sudachi Folder + + + + + false + + + &Capture Screenshot + + + + + Open &Album + + + + + &Set Nickname and Owner + + + + + &Delete Game Data + + + + + &Restore Amiibo + + + + + &Format Amiibo + + + + + Open &Mii Editor + + + + + &Configure TAS... + + + QAction::NoRole + + + + + false + + + Configure C&urrent Game... + + + QAction::NoRole + + + + + false + + + &Start + + + + + false + + + &Reset + + + + + false + + + R&ecord + + + + + Open &Controller Menu + + + + + Install Firmware + + + + + Install Decryption Keys + + + + + + + + diff --git a/src/sudachi/mini_dump.cpp b/src/sudachi/mini_dump.cpp new file mode 100644 index 0000000..d06712e --- /dev/null +++ b/src/sudachi/mini_dump.cpp @@ -0,0 +1,202 @@ +// SPDX-FileCopyrightText: 2022 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include +#include +#include +#include +#include +#include +#include "sudachi/mini_dump.h" +#include "sudachi/startup_checks.h" + +// dbghelp.h must be included after windows.h +#include + +namespace MiniDump { + +void CreateMiniDump(HANDLE process_handle, DWORD process_id, MINIDUMP_EXCEPTION_INFORMATION* info, + EXCEPTION_POINTERS* pep) { + char file_name[255]; + const std::time_t the_time = std::time(nullptr); + std::strftime(file_name, 255, "sudachi-crash-%Y%m%d%H%M%S.dmp", std::localtime(&the_time)); + + // Open the file + HANDLE file_handle = CreateFileA(file_name, GENERIC_READ | GENERIC_WRITE, 0, nullptr, + CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr); + + if (file_handle == nullptr || file_handle == INVALID_HANDLE_VALUE) { + fmt::print(stderr, "CreateFileA failed. Error: {}", GetLastError()); + return; + } + + // Create the minidump + const MINIDUMP_TYPE dump_type = MiniDumpNormal; + + const bool write_dump_status = MiniDumpWriteDump(process_handle, process_id, file_handle, + dump_type, (pep != 0) ? info : 0, 0, 0); + + if (write_dump_status) { + fmt::print(stderr, "MiniDump created: {}", file_name); + } else { + fmt::print(stderr, "MiniDumpWriteDump failed. Error: {}", GetLastError()); + } + + // Close the file + CloseHandle(file_handle); +} + +void DumpFromDebugEvent(DEBUG_EVENT& deb_ev, PROCESS_INFORMATION& pi) { + EXCEPTION_RECORD& record = deb_ev.u.Exception.ExceptionRecord; + + HANDLE thread_handle = OpenThread(THREAD_GET_CONTEXT, false, deb_ev.dwThreadId); + if (thread_handle == nullptr) { + fmt::print(stderr, "OpenThread failed ({})", GetLastError()); + return; + } + + // Get child process context + CONTEXT context = {}; + context.ContextFlags = CONTEXT_ALL; + if (!GetThreadContext(thread_handle, &context)) { + fmt::print(stderr, "GetThreadContext failed ({})", GetLastError()); + return; + } + + // Create exception pointers for minidump + EXCEPTION_POINTERS ep; + ep.ExceptionRecord = &record; + ep.ContextRecord = &context; + + MINIDUMP_EXCEPTION_INFORMATION info; + info.ThreadId = deb_ev.dwThreadId; + info.ExceptionPointers = &ep; + info.ClientPointers = false; + + CreateMiniDump(pi.hProcess, pi.dwProcessId, &info, &ep); + + if (CloseHandle(thread_handle) == 0) { + fmt::print(stderr, "error: CloseHandle(thread_handle) failed ({})", GetLastError()); + } +} + +bool SpawnDebuggee(const char* arg0, PROCESS_INFORMATION& pi) { + std::memset(&pi, 0, sizeof(pi)); + + // Don't debug if we are already being debugged + if (IsDebuggerPresent()) { + return false; + } + + if (!SpawnChild(arg0, &pi, 0)) { + fmt::print(stderr, "warning: continuing without crash dumps"); + return false; + } + + const bool can_debug = DebugActiveProcess(pi.dwProcessId); + if (!can_debug) { + fmt::print(stderr, + "warning: DebugActiveProcess failed ({}), continuing without crash dumps", + GetLastError()); + return false; + } + + return true; +} + +static const char* ExceptionName(DWORD exception) { + switch (exception) { + case EXCEPTION_ACCESS_VIOLATION: + return "EXCEPTION_ACCESS_VIOLATION"; + case EXCEPTION_DATATYPE_MISALIGNMENT: + return "EXCEPTION_DATATYPE_MISALIGNMENT"; + case EXCEPTION_BREAKPOINT: + return "EXCEPTION_BREAKPOINT"; + case EXCEPTION_SINGLE_STEP: + return "EXCEPTION_SINGLE_STEP"; + case EXCEPTION_ARRAY_BOUNDS_EXCEEDED: + return "EXCEPTION_ARRAY_BOUNDS_EXCEEDED"; + case EXCEPTION_FLT_DENORMAL_OPERAND: + return "EXCEPTION_FLT_DENORMAL_OPERAND"; + case EXCEPTION_FLT_DIVIDE_BY_ZERO: + return "EXCEPTION_FLT_DIVIDE_BY_ZERO"; + case EXCEPTION_FLT_INEXACT_RESULT: + return "EXCEPTION_FLT_INEXACT_RESULT"; + case EXCEPTION_FLT_INVALID_OPERATION: + return "EXCEPTION_FLT_INVALID_OPERATION"; + case EXCEPTION_FLT_OVERFLOW: + return "EXCEPTION_FLT_OVERFLOW"; + case EXCEPTION_FLT_STACK_CHECK: + return "EXCEPTION_FLT_STACK_CHECK"; + case EXCEPTION_FLT_UNDERFLOW: + return "EXCEPTION_FLT_UNDERFLOW"; + case EXCEPTION_INT_DIVIDE_BY_ZERO: + return "EXCEPTION_INT_DIVIDE_BY_ZERO"; + case EXCEPTION_INT_OVERFLOW: + return "EXCEPTION_INT_OVERFLOW"; + case EXCEPTION_PRIV_INSTRUCTION: + return "EXCEPTION_PRIV_INSTRUCTION"; + case EXCEPTION_IN_PAGE_ERROR: + return "EXCEPTION_IN_PAGE_ERROR"; + case EXCEPTION_ILLEGAL_INSTRUCTION: + return "EXCEPTION_ILLEGAL_INSTRUCTION"; + case EXCEPTION_NONCONTINUABLE_EXCEPTION: + return "EXCEPTION_NONCONTINUABLE_EXCEPTION"; + case EXCEPTION_STACK_OVERFLOW: + return "EXCEPTION_STACK_OVERFLOW"; + case EXCEPTION_INVALID_DISPOSITION: + return "EXCEPTION_INVALID_DISPOSITION"; + case EXCEPTION_GUARD_PAGE: + return "EXCEPTION_GUARD_PAGE"; + case EXCEPTION_INVALID_HANDLE: + return "EXCEPTION_INVALID_HANDLE"; + default: + return "unknown exception type"; + } +} + +void DebugDebuggee(PROCESS_INFORMATION& pi) { + DEBUG_EVENT deb_ev = {}; + + while (deb_ev.dwDebugEventCode != EXIT_PROCESS_DEBUG_EVENT) { + const bool wait_success = WaitForDebugEvent(&deb_ev, INFINITE); + if (!wait_success) { + fmt::print(stderr, "error: WaitForDebugEvent failed ({})", GetLastError()); + return; + } + + switch (deb_ev.dwDebugEventCode) { + case OUTPUT_DEBUG_STRING_EVENT: + case CREATE_PROCESS_DEBUG_EVENT: + case CREATE_THREAD_DEBUG_EVENT: + case EXIT_PROCESS_DEBUG_EVENT: + case EXIT_THREAD_DEBUG_EVENT: + case LOAD_DLL_DEBUG_EVENT: + case RIP_EVENT: + case UNLOAD_DLL_DEBUG_EVENT: + // Continue on all other debug events + ContinueDebugEvent(deb_ev.dwProcessId, deb_ev.dwThreadId, DBG_CONTINUE); + break; + case EXCEPTION_DEBUG_EVENT: + EXCEPTION_RECORD& record = deb_ev.u.Exception.ExceptionRecord; + + // We want to generate a crash dump if we are seeing the same exception again. + if (!deb_ev.u.Exception.dwFirstChance) { + fmt::print(stderr, "Creating MiniDump on ExceptionCode: 0x{:08x} {}\n", + record.ExceptionCode, ExceptionName(record.ExceptionCode)); + DumpFromDebugEvent(deb_ev, pi); + } + + // Continue without handling the exception. + // Lets the debuggee use its own exception handler. + // - If one does not exist, we will see the exception once more where we make a minidump + // for. Then when it reaches here again, sudachi will probably crash. + // - DBG_CONTINUE on an exception that the debuggee does not handle can set us up for an + // infinite loop of exceptions. + ContinueDebugEvent(deb_ev.dwProcessId, deb_ev.dwThreadId, DBG_EXCEPTION_NOT_HANDLED); + break; + } + } +} + +} // namespace MiniDump diff --git a/src/sudachi/mini_dump.h b/src/sudachi/mini_dump.h new file mode 100644 index 0000000..48f2b29 --- /dev/null +++ b/src/sudachi/mini_dump.h @@ -0,0 +1,19 @@ +// SPDX-FileCopyrightText: 2022 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include + +#include + +namespace MiniDump { + +void CreateMiniDump(HANDLE process_handle, DWORD process_id, MINIDUMP_EXCEPTION_INFORMATION* info, + EXCEPTION_POINTERS* pep); + +void DumpFromDebugEvent(DEBUG_EVENT& deb_ev, PROCESS_INFORMATION& pi); +bool SpawnDebuggee(const char* arg0, PROCESS_INFORMATION& pi); +void DebugDebuggee(PROCESS_INFORMATION& pi); + +} // namespace MiniDump diff --git a/src/sudachi/multiplayer/chat_room.cpp b/src/sudachi/multiplayer/chat_room.cpp new file mode 100644 index 0000000..852b3fe --- /dev/null +++ b/src/sudachi/multiplayer/chat_room.cpp @@ -0,0 +1,508 @@ +// SPDX-FileCopyrightText: Copyright 2017 Citra Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "common/logging/log.h" +#include "network/announce_multiplayer_session.h" +#include "ui_chat_room.h" +#include "sudachi/game_list_p.h" +#include "sudachi/multiplayer/chat_room.h" +#include "sudachi/multiplayer/message.h" +#ifdef ENABLE_WEB_SERVICE +#include "web_service/web_backend.h" +#endif + +class ChatMessage { +public: + explicit ChatMessage(const Network::ChatEntry& chat, Network::RoomNetwork& room_network, + QTime ts = {}) { + /// Convert the time to their default locale defined format + QLocale locale; + timestamp = locale.toString(ts.isValid() ? ts : QTime::currentTime(), QLocale::ShortFormat); + nickname = QString::fromStdString(chat.nickname); + username = QString::fromStdString(chat.username); + message = QString::fromStdString(chat.message); + + // Check for user pings + QString cur_nickname, cur_username; + if (auto room = room_network.GetRoomMember().lock()) { + cur_nickname = QString::fromStdString(room->GetNickname()); + cur_username = QString::fromStdString(room->GetUsername()); + } + + // Handle pings at the beginning and end of message + QString fixed_message = QStringLiteral(" %1 ").arg(message); + if (fixed_message.contains(QStringLiteral(" @%1 ").arg(cur_nickname)) || + (!cur_username.isEmpty() && + fixed_message.contains(QStringLiteral(" @%1 ").arg(cur_username)))) { + + contains_ping = true; + } else { + contains_ping = false; + } + } + + bool ContainsPing() const { + return contains_ping; + } + + /// Format the message using the players color + QString GetPlayerChatMessage(u16 player) const { + const bool is_dark_theme = QIcon::themeName().contains(QStringLiteral("dark")) || + QIcon::themeName().contains(QStringLiteral("midnight")); + auto color = + is_dark_theme ? player_color_dark[player % 16] : player_color_default[player % 16]; + QString name; + if (username.isEmpty() || username == nickname) { + name = nickname; + } else { + name = QStringLiteral("%1 (%2)").arg(nickname, username); + } + + QString style, text_color; + if (ContainsPing()) { + // Add a background color to these messages + style = QStringLiteral("background-color: %1").arg(QString::fromStdString(ping_color)); + // Add a font color + text_color = QStringLiteral("color='#000000'"); + } + + return QStringLiteral("[%1] <%3> %6") + .arg(timestamp, QString::fromStdString(color), name.toHtmlEscaped(), style, text_color, + message.toHtmlEscaped()); + } + +private: + static constexpr std::array player_color_default = { + {"#0000FF", "#FF0000", "#8A2BE2", "#FF69B4", "#1E90FF", "#008000", "#00FF7F", "#B22222", + "#DAA520", "#FF4500", "#2E8B57", "#5F9EA0", "#D2691E", "#9ACD32", "#FF7F50", "#FFFF00"}}; + static constexpr std::array player_color_dark = { + {"#559AD1", "#4EC9A8", "#D69D85", "#C6C923", "#B975B5", "#D81F1F", "#7EAE39", "#4F8733", + "#F7CD8A", "#6FCACF", "#CE4897", "#8A2BE2", "#D2691E", "#9ACD32", "#FF7F50", "#152ccd"}}; + static constexpr char ping_color[] = "#FFFF00"; + + QString timestamp; + QString nickname; + QString username; + QString message; + bool contains_ping; +}; + +class StatusMessage { +public: + explicit StatusMessage(const QString& msg, QTime ts = {}) { + /// Convert the time to their default locale defined format + QLocale locale; + timestamp = locale.toString(ts.isValid() ? ts : QTime::currentTime(), QLocale::ShortFormat); + message = msg; + } + + QString GetSystemChatMessage() const { + return QStringLiteral("[%1] * %3") + .arg(timestamp, QString::fromStdString(system_color), message); + } + +private: + static constexpr const char system_color[] = "#FF8C00"; + QString timestamp; + QString message; +}; + +class PlayerListItem : public QStandardItem { +public: + static const int NicknameRole = Qt::UserRole + 1; + static const int UsernameRole = Qt::UserRole + 2; + static const int AvatarUrlRole = Qt::UserRole + 3; + static const int GameNameRole = Qt::UserRole + 4; + static const int GameVersionRole = Qt::UserRole + 5; + + PlayerListItem() = default; + explicit PlayerListItem(const std::string& nickname, const std::string& username, + const std::string& avatar_url, + const AnnounceMultiplayerRoom::GameInfo& game_info) { + setEditable(false); + setData(QString::fromStdString(nickname), NicknameRole); + setData(QString::fromStdString(username), UsernameRole); + setData(QString::fromStdString(avatar_url), AvatarUrlRole); + if (game_info.name.empty()) { + setData(QObject::tr("Not playing a game"), GameNameRole); + } else { + setData(QString::fromStdString(game_info.name), GameNameRole); + } + setData(QString::fromStdString(game_info.version), GameVersionRole); + } + + QVariant data(int role) const override { + if (role != Qt::DisplayRole) { + return QStandardItem::data(role); + } + QString name; + const QString nickname = data(NicknameRole).toString(); + const QString username = data(UsernameRole).toString(); + if (username.isEmpty() || username == nickname) { + name = nickname; + } else { + name = QStringLiteral("%1 (%2)").arg(nickname, username); + } + const QString version = data(GameVersionRole).toString(); + QString version_string; + if (!version.isEmpty()) { + version_string = QStringLiteral("(%1)").arg(version); + } + return QStringLiteral("%1\n %2 %3") + .arg(name, data(GameNameRole).toString(), version_string); + } +}; + +ChatRoom::ChatRoom(QWidget* parent) : QWidget(parent), ui(std::make_unique()) { + ui->setupUi(this); + + // set the item_model for player_view + + player_list = new QStandardItemModel(ui->player_view); + ui->player_view->setModel(player_list); + ui->player_view->setContextMenuPolicy(Qt::CustomContextMenu); + // set a header to make it look better though there is only one column + player_list->insertColumns(0, 1); + player_list->setHeaderData(0, Qt::Horizontal, tr("Members")); + + ui->chat_history->document()->setMaximumBlockCount(max_chat_lines); + + auto font = ui->chat_history->font(); + font.setPointSizeF(10); + ui->chat_history->setFont(font); + + // register the network structs to use in slots and signals + qRegisterMetaType(); + qRegisterMetaType(); + qRegisterMetaType(); + qRegisterMetaType(); + + // Connect all the widgets to the appropriate events + connect(ui->player_view, &QTreeView::customContextMenuRequested, this, + &ChatRoom::PopupContextMenu); + connect(ui->chat_message, &QLineEdit::returnPressed, this, &ChatRoom::OnSendChat); + connect(ui->chat_message, &QLineEdit::textChanged, this, &ChatRoom::OnChatTextChanged); + connect(ui->send_message, &QPushButton::clicked, this, &ChatRoom::OnSendChat); +} + +ChatRoom::~ChatRoom() = default; + +void ChatRoom::Initialize(Network::RoomNetwork* room_network_) { + room_network = room_network_; + // setup the callbacks for network updates + if (auto member = room_network->GetRoomMember().lock()) { + member->BindOnChatMessageReceived( + [this](const Network::ChatEntry& chat) { emit ChatReceived(chat); }); + member->BindOnStatusMessageReceived( + [this](const Network::StatusMessageEntry& status_message) { + emit StatusMessageReceived(status_message); + }); + connect(this, &ChatRoom::ChatReceived, this, &ChatRoom::OnChatReceive); + connect(this, &ChatRoom::StatusMessageReceived, this, &ChatRoom::OnStatusMessageReceive); + } +} + +void ChatRoom::SetModPerms(bool is_mod) { + has_mod_perms = is_mod; +} + +void ChatRoom::RetranslateUi() { + ui->retranslateUi(this); +} + +void ChatRoom::Clear() { + ui->chat_history->clear(); + block_list.clear(); +} + +void ChatRoom::AppendStatusMessage(const QString& msg) { + ui->chat_history->append(StatusMessage(msg).GetSystemChatMessage()); +} + +void ChatRoom::AppendChatMessage(const QString& msg) { + ui->chat_history->append(msg); +} + +void ChatRoom::SendModerationRequest(Network::RoomMessageTypes type, const std::string& nickname) { + if (auto room = room_network->GetRoomMember().lock()) { + auto members = room->GetMemberInformation(); + auto it = std::find_if(members.begin(), members.end(), + [&nickname](const Network::RoomMember::MemberInformation& member) { + return member.nickname == nickname; + }); + if (it == members.end()) { + NetworkMessage::ErrorManager::ShowError(NetworkMessage::ErrorManager::NO_SUCH_USER); + return; + } + room->SendModerationRequest(type, nickname); + } +} + +bool ChatRoom::ValidateMessage(const std::string& msg) { + return !msg.empty(); +} + +void ChatRoom::OnRoomUpdate(const Network::RoomInformation& info) { + // TODO(B3N30): change title + if (auto room_member = room_network->GetRoomMember().lock()) { + SetPlayerList(room_member->GetMemberInformation()); + } +} + +void ChatRoom::Disable() { + ui->send_message->setDisabled(true); + ui->chat_message->setDisabled(true); +} + +void ChatRoom::Enable() { + ui->send_message->setEnabled(true); + ui->chat_message->setEnabled(true); +} + +void ChatRoom::OnChatReceive(const Network::ChatEntry& chat) { + if (!ValidateMessage(chat.message)) { + return; + } + if (auto room = room_network->GetRoomMember().lock()) { + // get the id of the player + auto members = room->GetMemberInformation(); + auto it = std::find_if(members.begin(), members.end(), + [&chat](const Network::RoomMember::MemberInformation& member) { + return member.nickname == chat.nickname && + member.username == chat.username; + }); + if (it == members.end()) { + LOG_INFO(Network, "Chat message received from unknown player. Ignoring it."); + return; + } + if (block_list.count(chat.nickname)) { + LOG_INFO(Network, "Chat message received from blocked player {}. Ignoring it.", + chat.nickname); + return; + } + auto player = std::distance(members.begin(), it); + ChatMessage m(chat, *room_network); + if (m.ContainsPing()) { + emit UserPinged(); + } + AppendChatMessage(m.GetPlayerChatMessage(player)); + } +} + +void ChatRoom::OnStatusMessageReceive(const Network::StatusMessageEntry& status_message) { + QString name; + if (status_message.username.empty() || status_message.username == status_message.nickname) { + name = QString::fromStdString(status_message.nickname); + } else { + name = QStringLiteral("%1 (%2)").arg(QString::fromStdString(status_message.nickname), + QString::fromStdString(status_message.username)); + } + QString message; + switch (status_message.type) { + case Network::IdMemberJoin: + message = tr("%1 has joined").arg(name); + break; + case Network::IdMemberLeave: + message = tr("%1 has left").arg(name); + break; + case Network::IdMemberKicked: + message = tr("%1 has been kicked").arg(name); + break; + case Network::IdMemberBanned: + message = tr("%1 has been banned").arg(name); + break; + case Network::IdAddressUnbanned: + message = tr("%1 has been unbanned").arg(name); + break; + } + if (!message.isEmpty()) + AppendStatusMessage(message); +} + +void ChatRoom::OnSendChat() { + if (auto room_member = room_network->GetRoomMember().lock()) { + if (!room_member->IsConnected()) { + return; + } + auto message = ui->chat_message->text().toStdString(); + if (!ValidateMessage(message)) { + return; + } + auto nick = room_member->GetNickname(); + auto username = room_member->GetUsername(); + Network::ChatEntry chat{nick, username, message}; + + auto members = room_member->GetMemberInformation(); + auto it = std::find_if(members.begin(), members.end(), + [&chat](const Network::RoomMember::MemberInformation& member) { + return member.nickname == chat.nickname && + member.username == chat.username; + }); + if (it == members.end()) { + LOG_INFO(Network, "Cannot find self in the player list when sending a message."); + } + auto player = std::distance(members.begin(), it); + ChatMessage m(chat, *room_network); + room_member->SendChatMessage(message); + AppendChatMessage(m.GetPlayerChatMessage(player)); + ui->chat_message->clear(); + } +} + +void ChatRoom::UpdateIconDisplay() { + for (int row = 0; row < player_list->invisibleRootItem()->rowCount(); ++row) { + QStandardItem* item = player_list->invisibleRootItem()->child(row); + const std::string avatar_url = + item->data(PlayerListItem::AvatarUrlRole).toString().toStdString(); + if (icon_cache.count(avatar_url)) { + item->setData(icon_cache.at(avatar_url), Qt::DecorationRole); + } else { + item->setData(QIcon::fromTheme(QStringLiteral("no_avatar")).pixmap(48), + Qt::DecorationRole); + } + } +} + +void ChatRoom::SetPlayerList(const Network::RoomMember::MemberList& member_list) { + // TODO(B3N30): Remember which row is selected + player_list->removeRows(0, player_list->rowCount()); + for (const auto& member : member_list) { + if (member.nickname.empty()) + continue; + QStandardItem* name_item = new PlayerListItem(member.nickname, member.username, + member.avatar_url, member.game_info); + +#ifdef ENABLE_WEB_SERVICE + if (!icon_cache.count(member.avatar_url) && !member.avatar_url.empty()) { + // Start a request to get the member's avatar + const QUrl url(QString::fromStdString(member.avatar_url)); + QFuture future = QtConcurrent::run([url] { + WebService::Client client( + QStringLiteral("%1://%2").arg(url.scheme(), url.host()).toStdString(), "", ""); + auto result = client.GetImage(url.path().toStdString(), true); + if (result.returned_data.empty()) { + LOG_ERROR(WebService, "Failed to get avatar"); + } + return result.returned_data; + }); + auto* future_watcher = new QFutureWatcher(this); + connect(future_watcher, &QFutureWatcher::finished, this, + [this, future_watcher, avatar_url = member.avatar_url] { + const std::string result = future_watcher->result(); + if (result.empty()) + return; + QPixmap pixmap; + if (!pixmap.loadFromData(reinterpret_cast(result.data()), + static_cast(result.size()))) + return; + icon_cache[avatar_url] = + pixmap.scaled(48, 48, Qt::IgnoreAspectRatio, Qt::SmoothTransformation); + // Update all the displayed icons with the new icon_cache + UpdateIconDisplay(); + }); + future_watcher->setFuture(future); + } +#endif + + player_list->invisibleRootItem()->appendRow(name_item); + } + UpdateIconDisplay(); + // TODO(B3N30): Restore row selection +} + +void ChatRoom::OnChatTextChanged() { + if (ui->chat_message->text().length() > static_cast(Network::MaxMessageSize)) + ui->chat_message->setText( + ui->chat_message->text().left(static_cast(Network::MaxMessageSize))); +} + +void ChatRoom::PopupContextMenu(const QPoint& menu_location) { + QModelIndex item = ui->player_view->indexAt(menu_location); + if (!item.isValid()) + return; + + std::string nickname = + player_list->item(item.row())->data(PlayerListItem::NicknameRole).toString().toStdString(); + + QMenu context_menu; + + QString username = player_list->item(item.row())->data(PlayerListItem::UsernameRole).toString(); + if (!username.isEmpty()) { + QAction* view_profile_action = context_menu.addAction(tr("View Profile")); + connect(view_profile_action, &QAction::triggered, [username] { + QDesktopServices::openUrl( + QUrl(QStringLiteral("https://community.citra-emu.org/u/%1").arg(username))); + }); + } + + std::string cur_nickname; + if (auto room = room_network->GetRoomMember().lock()) { + cur_nickname = room->GetNickname(); + } + + if (nickname != cur_nickname) { // You can't block yourself + QAction* block_action = context_menu.addAction(tr("Block Player")); + + block_action->setCheckable(true); + block_action->setChecked(block_list.count(nickname) > 0); + + connect(block_action, &QAction::triggered, [this, nickname] { + if (block_list.count(nickname)) { + block_list.erase(nickname); + } else { + QMessageBox::StandardButton result = QMessageBox::question( + this, tr("Block Player"), + tr("When you block a player, you will no longer receive chat messages from " + "them.

Are you sure you would like to block %1?") + .arg(QString::fromStdString(nickname)), + QMessageBox::Yes | QMessageBox::No); + if (result == QMessageBox::Yes) + block_list.emplace(nickname); + } + }); + } + + if (has_mod_perms && nickname != cur_nickname) { // You can't kick or ban yourself + context_menu.addSeparator(); + + QAction* kick_action = context_menu.addAction(tr("Kick")); + QAction* ban_action = context_menu.addAction(tr("Ban")); + + connect(kick_action, &QAction::triggered, [this, nickname] { + QMessageBox::StandardButton result = + QMessageBox::question(this, tr("Kick Player"), + tr("Are you sure you would like to kick %1?") + .arg(QString::fromStdString(nickname)), + QMessageBox::Yes | QMessageBox::No); + if (result == QMessageBox::Yes) + SendModerationRequest(Network::IdModKick, nickname); + }); + connect(ban_action, &QAction::triggered, [this, nickname] { + QMessageBox::StandardButton result = QMessageBox::question( + this, tr("Ban Player"), + tr("Are you sure you would like to kick and ban %1?\n\nThis would " + "ban both their forum username and their IP address.") + .arg(QString::fromStdString(nickname)), + QMessageBox::Yes | QMessageBox::No); + if (result == QMessageBox::Yes) + SendModerationRequest(Network::IdModBan, nickname); + }); + } + + context_menu.exec(ui->player_view->viewport()->mapToGlobal(menu_location)); +} diff --git a/src/sudachi/multiplayer/chat_room.h b/src/sudachi/multiplayer/chat_room.h new file mode 100644 index 0000000..dd71ea4 --- /dev/null +++ b/src/sudachi/multiplayer/chat_room.h @@ -0,0 +1,76 @@ +// SPDX-FileCopyrightText: Copyright 2017 Citra Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include "network/network.h" + +namespace Ui { +class ChatRoom; +} + +namespace Core { +class AnnounceMultiplayerSession; +} + +class ConnectionError; +class ComboBoxProxyModel; + +class ChatMessage; + +class ChatRoom : public QWidget { + Q_OBJECT + +public: + explicit ChatRoom(QWidget* parent); + void Initialize(Network::RoomNetwork* room_network); + void RetranslateUi(); + void SetPlayerList(const Network::RoomMember::MemberList& member_list); + void Clear(); + void AppendStatusMessage(const QString& msg); + ~ChatRoom(); + + void SetModPerms(bool is_mod); + void UpdateIconDisplay(); + +public slots: + void OnRoomUpdate(const Network::RoomInformation& info); + void OnChatReceive(const Network::ChatEntry&); + void OnStatusMessageReceive(const Network::StatusMessageEntry&); + void OnSendChat(); + void OnChatTextChanged(); + void PopupContextMenu(const QPoint& menu_location); + void Disable(); + void Enable(); + +signals: + void ChatReceived(const Network::ChatEntry&); + void StatusMessageReceived(const Network::StatusMessageEntry&); + void UserPinged(); + +private: + static constexpr u32 max_chat_lines = 1000; + void AppendChatMessage(const QString&); + bool ValidateMessage(const std::string&); + void SendModerationRequest(Network::RoomMessageTypes type, const std::string& nickname); + + bool has_mod_perms = false; + QStandardItemModel* player_list; + std::unique_ptr ui; + std::unordered_set block_list; + std::unordered_map icon_cache; + Network::RoomNetwork* room_network; +}; + +Q_DECLARE_METATYPE(Network::ChatEntry); +Q_DECLARE_METATYPE(Network::StatusMessageEntry); +Q_DECLARE_METATYPE(Network::RoomInformation); +Q_DECLARE_METATYPE(Network::RoomMember::State); +Q_DECLARE_METATYPE(Network::RoomMember::Error); diff --git a/src/sudachi/multiplayer/chat_room.ui b/src/sudachi/multiplayer/chat_room.ui new file mode 100644 index 0000000..f2b31b5 --- /dev/null +++ b/src/sudachi/multiplayer/chat_room.ui @@ -0,0 +1,59 @@ + + + ChatRoom + + + + 0 + 0 + 807 + 432 + + + + Room Window + + + + + + + + + + + false + + + true + + + Qt::TextSelectableByKeyboard|Qt::TextSelectableByMouse + + + + + + + + + Send Chat Message + + + + + + + Send Message + + + + + + + + + + + + diff --git a/src/sudachi/multiplayer/client_room.cpp b/src/sudachi/multiplayer/client_room.cpp new file mode 100644 index 0000000..fbb715a --- /dev/null +++ b/src/sudachi/multiplayer/client_room.cpp @@ -0,0 +1,115 @@ +// SPDX-FileCopyrightText: Copyright 2017 Citra Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include +#include +#include +#include +#include +#include +#include +#include +#include "common/logging/log.h" +#include "network/announce_multiplayer_session.h" +#include "ui_client_room.h" +#include "sudachi/game_list_p.h" +#include "sudachi/multiplayer/client_room.h" +#include "sudachi/multiplayer/message.h" +#include "sudachi/multiplayer/moderation_dialog.h" +#include "sudachi/multiplayer/state.h" + +ClientRoomWindow::ClientRoomWindow(QWidget* parent, Network::RoomNetwork& room_network_) + : QDialog(parent, Qt::WindowTitleHint | Qt::WindowCloseButtonHint | Qt::WindowSystemMenuHint), + ui(std::make_unique()), room_network{room_network_} { + ui->setupUi(this); + ui->chat->Initialize(&room_network); + + // setup the callbacks for network updates + if (auto member = room_network.GetRoomMember().lock()) { + member->BindOnRoomInformationChanged( + [this](const Network::RoomInformation& info) { emit RoomInformationChanged(info); }); + member->BindOnStateChanged( + [this](const Network::RoomMember::State& state) { emit StateChanged(state); }); + + connect(this, &ClientRoomWindow::RoomInformationChanged, this, + &ClientRoomWindow::OnRoomUpdate); + connect(this, &ClientRoomWindow::StateChanged, this, &::ClientRoomWindow::OnStateChange); + // Update the state + OnStateChange(member->GetState()); + } else { + // TODO (jroweboy) network was not initialized? + } + + connect(ui->disconnect, &QPushButton::clicked, this, &ClientRoomWindow::Disconnect); + ui->disconnect->setDefault(false); + ui->disconnect->setAutoDefault(false); + connect(ui->moderation, &QPushButton::clicked, [this] { + ModerationDialog dialog(room_network, this); + dialog.exec(); + }); + ui->moderation->setDefault(false); + ui->moderation->setAutoDefault(false); + connect(ui->chat, &ChatRoom::UserPinged, this, &ClientRoomWindow::ShowNotification); + UpdateView(); +} + +ClientRoomWindow::~ClientRoomWindow() = default; + +void ClientRoomWindow::SetModPerms(bool is_mod) { + ui->chat->SetModPerms(is_mod); + ui->moderation->setVisible(is_mod); + ui->moderation->setDefault(false); + ui->moderation->setAutoDefault(false); +} + +void ClientRoomWindow::RetranslateUi() { + ui->retranslateUi(this); + ui->chat->RetranslateUi(); +} + +void ClientRoomWindow::OnRoomUpdate(const Network::RoomInformation& info) { + UpdateView(); +} + +void ClientRoomWindow::OnStateChange(const Network::RoomMember::State& state) { + if (state == Network::RoomMember::State::Joined || + state == Network::RoomMember::State::Moderator) { + ui->chat->Clear(); + ui->chat->AppendStatusMessage(tr("Connected")); + SetModPerms(state == Network::RoomMember::State::Moderator); + } + UpdateView(); +} + +void ClientRoomWindow::Disconnect() { + auto parent = static_cast(parentWidget()); + if (parent->OnCloseRoom()) { + ui->chat->AppendStatusMessage(tr("Disconnected")); + close(); + } +} + +void ClientRoomWindow::UpdateView() { + if (auto member = room_network.GetRoomMember().lock()) { + if (member->IsConnected()) { + ui->chat->Enable(); + ui->disconnect->setEnabled(true); + auto memberlist = member->GetMemberInformation(); + ui->chat->SetPlayerList(memberlist); + const auto information = member->GetRoomInformation(); + setWindowTitle(QString(tr("%1 - %2 (%3/%4 members) - connected")) + .arg(QString::fromStdString(information.name)) + .arg(QString::fromStdString(information.preferred_game.name)) + .arg(memberlist.size()) + .arg(information.member_slots)); + ui->description->setText(QString::fromStdString(information.description)); + return; + } + } + // TODO(B3N30): can't get RoomMember*, show error and close window + close(); +} + +void ClientRoomWindow::UpdateIconDisplay() { + ui->chat->UpdateIconDisplay(); +} diff --git a/src/sudachi/multiplayer/client_room.h b/src/sudachi/multiplayer/client_room.h new file mode 100644 index 0000000..6f1bb6d --- /dev/null +++ b/src/sudachi/multiplayer/client_room.h @@ -0,0 +1,39 @@ +// SPDX-FileCopyrightText: Copyright 2017 Citra Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include "sudachi/multiplayer/chat_room.h" + +namespace Ui { +class ClientRoom; +} + +class ClientRoomWindow : public QDialog { + Q_OBJECT + +public: + explicit ClientRoomWindow(QWidget* parent, Network::RoomNetwork& room_network_); + ~ClientRoomWindow(); + + void RetranslateUi(); + void UpdateIconDisplay(); + +public slots: + void OnRoomUpdate(const Network::RoomInformation&); + void OnStateChange(const Network::RoomMember::State&); + +signals: + void RoomInformationChanged(const Network::RoomInformation&); + void StateChanged(const Network::RoomMember::State&); + void ShowNotification(); + +private: + void Disconnect(); + void UpdateView(); + void SetModPerms(bool is_mod); + + QStandardItemModel* player_list; + std::unique_ptr ui; + Network::RoomNetwork& room_network; +}; diff --git a/src/sudachi/multiplayer/client_room.ui b/src/sudachi/multiplayer/client_room.ui new file mode 100644 index 0000000..97e88b5 --- /dev/null +++ b/src/sudachi/multiplayer/client_room.ui @@ -0,0 +1,80 @@ + + + ClientRoom + + + + 0 + 0 + 807 + 432 + + + + Room Window + + + + + + + + 0 + + + + + Room Description + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Moderation... + + + false + + + + + + + Leave Room + + + + + + + + + + + + + + + ChatRoom + QWidget +
multiplayer/chat_room.h
+ 1 +
+
+ + +
diff --git a/src/sudachi/multiplayer/direct_connect.cpp b/src/sudachi/multiplayer/direct_connect.cpp new file mode 100644 index 0000000..a76df86 --- /dev/null +++ b/src/sudachi/multiplayer/direct_connect.cpp @@ -0,0 +1,137 @@ +// SPDX-FileCopyrightText: Copyright 2017 Citra Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include +#include +#include +#include +#include +#include +#include "common/settings.h" +#include "core/core.h" +#include "core/internal_network/network_interface.h" +#include "network/network.h" +#include "ui_direct_connect.h" +#include "sudachi/main.h" +#include "sudachi/multiplayer/client_room.h" +#include "sudachi/multiplayer/direct_connect.h" +#include "sudachi/multiplayer/message.h" +#include "sudachi/multiplayer/state.h" +#include "sudachi/multiplayer/validation.h" +#include "sudachi/uisettings.h" + +enum class ConnectionType : u8 { TraversalServer, IP }; + +DirectConnectWindow::DirectConnectWindow(Core::System& system_, QWidget* parent) + : QDialog(parent, Qt::WindowTitleHint | Qt::WindowCloseButtonHint | Qt::WindowSystemMenuHint), + ui(std::make_unique()), system{system_}, room_network{ + system.GetRoomNetwork()} { + + ui->setupUi(this); + + // setup the watcher for background connections + watcher = new QFutureWatcher; + connect(watcher, &QFutureWatcher::finished, this, &DirectConnectWindow::OnConnection); + + ui->nickname->setValidator(validation.GetNickname()); + ui->nickname->setText( + QString::fromStdString(UISettings::values.multiplayer_nickname.GetValue())); + if (ui->nickname->text().isEmpty() && !Settings::values.sudachi_username.GetValue().empty()) { + // Use sudachi Web Service user name as nickname by default + ui->nickname->setText(QString::fromStdString(Settings::values.sudachi_username.GetValue())); + } + ui->ip->setValidator(validation.GetIP()); + ui->ip->setText(QString::fromStdString(UISettings::values.multiplayer_ip.GetValue())); + ui->port->setValidator(validation.GetPort()); + ui->port->setText(QString::number(UISettings::values.multiplayer_port.GetValue())); + + // TODO(jroweboy): Show or hide the connection options based on the current value of the combo + // box. Add this back in when the traversal server support is added. + connect(ui->connect, &QPushButton::clicked, this, &DirectConnectWindow::Connect); +} + +DirectConnectWindow::~DirectConnectWindow() = default; + +void DirectConnectWindow::RetranslateUi() { + ui->retranslateUi(this); +} + +void DirectConnectWindow::Connect() { + if (!Network::GetSelectedNetworkInterface()) { + NetworkMessage::ErrorManager::ShowError( + NetworkMessage::ErrorManager::NO_INTERFACE_SELECTED); + return; + } + if (!ui->nickname->hasAcceptableInput()) { + NetworkMessage::ErrorManager::ShowError(NetworkMessage::ErrorManager::USERNAME_NOT_VALID); + return; + } + if (system.IsPoweredOn()) { + if (!NetworkMessage::WarnGameRunning()) { + return; + } + } + if (const auto member = room_network.GetRoomMember().lock()) { + // Prevent the user from trying to join a room while they are already joining. + if (member->GetState() == Network::RoomMember::State::Joining) { + return; + } else if (member->IsConnected()) { + // And ask if they want to leave the room if they are already in one. + if (!NetworkMessage::WarnDisconnect()) { + return; + } + } + } + if (!ui->ip->hasAcceptableInput()) { + NetworkMessage::ErrorManager::ShowError(NetworkMessage::ErrorManager::IP_ADDRESS_NOT_VALID); + return; + } + if (!ui->port->hasAcceptableInput()) { + NetworkMessage::ErrorManager::ShowError(NetworkMessage::ErrorManager::PORT_NOT_VALID); + return; + } + + // Store settings + UISettings::values.multiplayer_nickname = ui->nickname->text().toStdString(); + UISettings::values.multiplayer_ip = ui->ip->text().toStdString(); + if (!ui->port->text().isEmpty()) { + UISettings::values.multiplayer_port = ui->port->text().toInt(); + } else { + UISettings::values.multiplayer_port = UISettings::values.multiplayer_port.GetDefault(); + } + + emit SaveConfig(); + + // attempt to connect in a different thread + QFuture f = QtConcurrent::run([&] { + if (auto room_member = room_network.GetRoomMember().lock()) { + auto port = UISettings::values.multiplayer_port.GetValue(); + room_member->Join(ui->nickname->text().toStdString(), + ui->ip->text().toStdString().c_str(), port, 0, Network::NoPreferredIP, + ui->password->text().toStdString().c_str()); + } + }); + watcher->setFuture(f); + // and disable widgets and display a connecting while we wait + BeginConnecting(); +} + +void DirectConnectWindow::BeginConnecting() { + ui->connect->setEnabled(false); + ui->connect->setText(tr("Connecting")); +} + +void DirectConnectWindow::EndConnecting() { + ui->connect->setEnabled(true); + ui->connect->setText(tr("Connect")); +} + +void DirectConnectWindow::OnConnection() { + EndConnecting(); + + if (auto room_member = room_network.GetRoomMember().lock()) { + if (room_member->IsConnected()) { + close(); + } + } +} diff --git a/src/sudachi/multiplayer/direct_connect.h b/src/sudachi/multiplayer/direct_connect.h new file mode 100644 index 0000000..58fe59b --- /dev/null +++ b/src/sudachi/multiplayer/direct_connect.h @@ -0,0 +1,49 @@ +// SPDX-FileCopyrightText: Copyright 2017 Citra Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include +#include +#include +#include "sudachi/multiplayer/validation.h" + +namespace Ui { +class DirectConnect; +} + +namespace Core { +class System; +} + +class DirectConnectWindow : public QDialog { + Q_OBJECT + +public: + explicit DirectConnectWindow(Core::System& system_, QWidget* parent = nullptr); + ~DirectConnectWindow(); + + void RetranslateUi(); + +signals: + /** + * Signalled by this widget when it is closing itself and destroying any state such as + * connections that it might have. + */ + void Closed(); + void SaveConfig(); + +private slots: + void OnConnection(); + +private: + void Connect(); + void BeginConnecting(); + void EndConnecting(); + + QFutureWatcher* watcher; + std::unique_ptr ui; + Validation validation; + Core::System& system; + Network::RoomNetwork& room_network; +}; diff --git a/src/sudachi/multiplayer/direct_connect.ui b/src/sudachi/multiplayer/direct_connect.ui new file mode 100644 index 0000000..0dd4e68 --- /dev/null +++ b/src/sudachi/multiplayer/direct_connect.ui @@ -0,0 +1,165 @@ + + + DirectConnect + + + + 0 + 0 + 455 + 161 + + + + Direct Connect + + + + + + + + + + 0 + + + 0 + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Server Address + + + + + + + <html><head/><body><p>Server address of the host</p></body></html> + + + 253 + + + + + + + Port + + + + + + + <html><head/><body><p>Port number the host is listening on</p></body></html> + + + 5 + + + 24872 + + + + 65 + 50 + + + + + + + + + + + + + + + Nickname + + + + + + + 20 + + + + + + + Password + + + + + + + + + + + + + + Qt::Vertical + + + + 20 + 20 + + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Connect + + + + + + + + + + + + diff --git a/src/sudachi/multiplayer/host_room.cpp b/src/sudachi/multiplayer/host_room.cpp new file mode 100644 index 0000000..b33193b --- /dev/null +++ b/src/sudachi/multiplayer/host_room.cpp @@ -0,0 +1,264 @@ +// SPDX-FileCopyrightText: Copyright 2017 Citra Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "common/logging/log.h" +#include "common/settings.h" +#include "core/core.h" +#include "core/internal_network/network_interface.h" +#include "network/announce_multiplayer_session.h" +#include "ui_host_room.h" +#include "sudachi/game_list_p.h" +#include "sudachi/main.h" +#include "sudachi/multiplayer/host_room.h" +#include "sudachi/multiplayer/message.h" +#include "sudachi/multiplayer/state.h" +#include "sudachi/multiplayer/validation.h" +#include "sudachi/uisettings.h" +#ifdef ENABLE_WEB_SERVICE +#include "web_service/verify_user_jwt.h" +#endif + +HostRoomWindow::HostRoomWindow(QWidget* parent, QStandardItemModel* list, + std::shared_ptr session, + Core::System& system_) + : QDialog(parent, Qt::WindowTitleHint | Qt::WindowCloseButtonHint | Qt::WindowSystemMenuHint), + ui(std::make_unique()), + announce_multiplayer_session(session), system{system_}, room_network{ + system.GetRoomNetwork()} { + ui->setupUi(this); + + // set up validation for all of the fields + ui->room_name->setValidator(validation.GetRoomName()); + ui->username->setValidator(validation.GetNickname()); + ui->port->setValidator(validation.GetPort()); + ui->port->setPlaceholderText(QString::number(Network::DefaultRoomPort)); + + // Create a proxy to the game list to display the list of preferred games + game_list = new QStandardItemModel; + UpdateGameList(list); + + proxy = new ComboBoxProxyModel; + proxy->setSourceModel(game_list); + proxy->sort(0, Qt::AscendingOrder); + ui->game_list->setModel(proxy); + + // Connect all the widgets to the appropriate events + connect(ui->host, &QPushButton::clicked, this, &HostRoomWindow::Host); + + // Restore the settings: + ui->username->setText( + QString::fromStdString(UISettings::values.multiplayer_room_nickname.GetValue())); + if (ui->username->text().isEmpty() && !Settings::values.sudachi_username.GetValue().empty()) { + // Use sudachi Web Service user name as nickname by default + ui->username->setText(QString::fromStdString(Settings::values.sudachi_username.GetValue())); + } + ui->room_name->setText( + QString::fromStdString(UISettings::values.multiplayer_room_name.GetValue())); + ui->port->setText(QString::number(UISettings::values.multiplayer_room_port.GetValue())); + ui->max_player->setValue(UISettings::values.multiplayer_max_player.GetValue()); + int index = UISettings::values.multiplayer_host_type.GetValue(); + if (index < ui->host_type->count()) { + ui->host_type->setCurrentIndex(index); + } + index = ui->game_list->findData(UISettings::values.multiplayer_game_id.GetValue(), + GameListItemPath::ProgramIdRole); + if (index != -1) { + ui->game_list->setCurrentIndex(index); + } + ui->room_description->setText( + QString::fromStdString(UISettings::values.multiplayer_room_description.GetValue())); +} + +HostRoomWindow::~HostRoomWindow() = default; + +void HostRoomWindow::UpdateGameList(QStandardItemModel* list) { + game_list->clear(); + for (int i = 0; i < list->rowCount(); i++) { + auto parent = list->item(i, 0); + for (int j = 0; j < parent->rowCount(); j++) { + game_list->appendRow(parent->child(j)->clone()); + } + } +} + +void HostRoomWindow::RetranslateUi() { + ui->retranslateUi(this); +} + +std::unique_ptr HostRoomWindow::CreateVerifyBackend( + bool use_validation) const { + std::unique_ptr verify_backend; + if (use_validation) { +#ifdef ENABLE_WEB_SERVICE + verify_backend = + std::make_unique(Settings::values.web_api_url.GetValue()); +#else + verify_backend = std::make_unique(); +#endif + } else { + verify_backend = std::make_unique(); + } + return verify_backend; +} + +void HostRoomWindow::Host() { + if (!Network::GetSelectedNetworkInterface()) { + NetworkMessage::ErrorManager::ShowError( + NetworkMessage::ErrorManager::NO_INTERFACE_SELECTED); + return; + } + if (!ui->username->hasAcceptableInput()) { + NetworkMessage::ErrorManager::ShowError(NetworkMessage::ErrorManager::USERNAME_NOT_VALID); + return; + } + if (!ui->room_name->hasAcceptableInput()) { + NetworkMessage::ErrorManager::ShowError(NetworkMessage::ErrorManager::ROOMNAME_NOT_VALID); + return; + } + if (!ui->port->hasAcceptableInput()) { + NetworkMessage::ErrorManager::ShowError(NetworkMessage::ErrorManager::PORT_NOT_VALID); + return; + } + if (ui->game_list->currentIndex() == -1) { + NetworkMessage::ErrorManager::ShowError(NetworkMessage::ErrorManager::GAME_NOT_SELECTED); + return; + } + if (system.IsPoweredOn()) { + if (!NetworkMessage::WarnGameRunning()) { + return; + } + } + if (auto member = room_network.GetRoomMember().lock()) { + if (member->GetState() == Network::RoomMember::State::Joining) { + return; + } else if (member->IsConnected()) { + auto parent = static_cast(parentWidget()); + if (!parent->OnCloseRoom()) { + close(); + return; + } + } + ui->host->setDisabled(true); + + const AnnounceMultiplayerRoom::GameInfo game{ + .name = ui->game_list->currentData(Qt::DisplayRole).toString().toStdString(), + .id = ui->game_list->currentData(GameListItemPath::ProgramIdRole).toULongLong(), + }; + const auto port = + ui->port->isModified() ? ui->port->text().toInt() : Network::DefaultRoomPort; + const auto password = ui->password->text().toStdString(); + const bool is_public = ui->host_type->currentIndex() == 0; + Network::Room::BanList ban_list{}; + if (ui->load_ban_list->isChecked()) { + ban_list = UISettings::values.multiplayer_ban_list; + } + if (auto room = room_network.GetRoom().lock()) { + const bool created = + room->Create(ui->room_name->text().toStdString(), + ui->room_description->toPlainText().toStdString(), "", port, password, + ui->max_player->value(), Settings::values.sudachi_username.GetValue(), + game, CreateVerifyBackend(is_public), ban_list); + if (!created) { + NetworkMessage::ErrorManager::ShowError( + NetworkMessage::ErrorManager::COULD_NOT_CREATE_ROOM); + LOG_ERROR(Network, "Could not create room!"); + ui->host->setEnabled(true); + return; + } + } + // Start the announce session if they chose Public + if (is_public) { + if (auto session = announce_multiplayer_session.lock()) { + // Register the room first to ensure verify_uid is present when we connect + WebService::WebResult result = session->Register(); + if (result.result_code != WebService::WebResult::Code::Success) { + QMessageBox::warning( + this, tr("Error"), + tr("Failed to announce the room to the public lobby. In order to host a " + "room publicly, you must have a valid sudachi account configured in " + "Emulation -> Configure -> Web. If you do not want to publish a room in " + "the public lobby, then select Unlisted instead.\nDebug Message: ") + + QString::fromStdString(result.result_string), + QMessageBox::Ok); + ui->host->setEnabled(true); + if (auto room = room_network.GetRoom().lock()) { + room->Destroy(); + } + return; + } + session->Start(); + } else { + LOG_ERROR(Network, "Starting announce session failed"); + } + } + std::string token; +#ifdef ENABLE_WEB_SERVICE + if (is_public) { + WebService::Client client(Settings::values.web_api_url.GetValue(), + Settings::values.sudachi_username.GetValue(), + Settings::values.sudachi_token.GetValue()); + if (auto room = room_network.GetRoom().lock()) { + token = client.GetExternalJWT(room->GetVerifyUID()).returned_data; + } + if (token.empty()) { + LOG_ERROR(WebService, "Could not get external JWT, verification may fail"); + } else { + LOG_INFO(WebService, "Successfully requested external JWT: size={}", token.size()); + } + } +#endif + // TODO: Check what to do with this + member->Join(ui->username->text().toStdString(), "127.0.0.1", port, 0, + Network::NoPreferredIP, password, token); + + // Store settings + UISettings::values.multiplayer_room_nickname = ui->username->text().toStdString(); + UISettings::values.multiplayer_room_name = ui->room_name->text().toStdString(); + UISettings::values.multiplayer_game_id = + ui->game_list->currentData(GameListItemPath::ProgramIdRole).toLongLong(); + UISettings::values.multiplayer_max_player = ui->max_player->value(); + + UISettings::values.multiplayer_host_type = ui->host_type->currentIndex(); + if (ui->port->isModified() && !ui->port->text().isEmpty()) { + UISettings::values.multiplayer_room_port = ui->port->text().toInt(); + } else { + UISettings::values.multiplayer_room_port = Network::DefaultRoomPort; + } + UISettings::values.multiplayer_room_description = + ui->room_description->toPlainText().toStdString(); + ui->host->setEnabled(true); + emit SaveConfig(); + close(); + } +} + +QVariant ComboBoxProxyModel::data(const QModelIndex& idx, int role) const { + if (role != Qt::DisplayRole) { + auto val = QSortFilterProxyModel::data(idx, role); + // If its the icon, shrink it to 16x16 + if (role == Qt::DecorationRole) + val = val.value().scaled(16, 16, Qt::KeepAspectRatio); + return val; + } + std::string filename; + Common::SplitPath( + QSortFilterProxyModel::data(idx, GameListItemPath::FullPathRole).toString().toStdString(), + nullptr, &filename, nullptr); + QString title = QSortFilterProxyModel::data(idx, GameListItemPath::TitleRole).toString(); + return title.isEmpty() ? QString::fromStdString(filename) : title; +} + +bool ComboBoxProxyModel::lessThan(const QModelIndex& left, const QModelIndex& right) const { + auto leftData = left.data(GameListItemPath::TitleRole).toString(); + auto rightData = right.data(GameListItemPath::TitleRole).toString(); + return leftData.compare(rightData) < 0; +} diff --git a/src/sudachi/multiplayer/host_room.h b/src/sudachi/multiplayer/host_room.h new file mode 100644 index 0000000..b0ae13e --- /dev/null +++ b/src/sudachi/multiplayer/host_room.h @@ -0,0 +1,80 @@ +// SPDX-FileCopyrightText: Copyright 2017 Citra Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include +#include +#include +#include +#include +#include "network/network.h" +#include "sudachi/multiplayer/chat_room.h" +#include "sudachi/multiplayer/validation.h" + +namespace Ui { +class HostRoom; +} + +namespace Core { +class System; +class AnnounceMultiplayerSession; +} // namespace Core + +class ConnectionError; +class ComboBoxProxyModel; + +class ChatMessage; + +namespace Network::VerifyUser { +class Backend; +}; + +class HostRoomWindow : public QDialog { + Q_OBJECT + +public: + explicit HostRoomWindow(QWidget* parent, QStandardItemModel* list, + std::shared_ptr session, + Core::System& system_); + ~HostRoomWindow(); + + /** + * Updates the dialog with a new game list model. + * This model should be the original model of the game list. + */ + void UpdateGameList(QStandardItemModel* list); + void RetranslateUi(); + +signals: + void SaveConfig(); + +private: + void Host(); + std::unique_ptr CreateVerifyBackend(bool use_validation) const; + + std::unique_ptr ui; + std::weak_ptr announce_multiplayer_session; + QStandardItemModel* game_list; + ComboBoxProxyModel* proxy; + Validation validation; + Core::System& system; + Network::RoomNetwork& room_network; +}; + +/** + * Proxy Model for the game list combo box so we can reuse the game list model while still + * displaying the fields slightly differently + */ +class ComboBoxProxyModel : public QSortFilterProxyModel { + Q_OBJECT + +public: + int columnCount(const QModelIndex& idx) const override { + return 1; + } + + QVariant data(const QModelIndex& idx, int role) const override; + + bool lessThan(const QModelIndex& left, const QModelIndex& right) const override; +}; diff --git a/src/sudachi/multiplayer/host_room.ui b/src/sudachi/multiplayer/host_room.ui new file mode 100644 index 0000000..d54cf49 --- /dev/null +++ b/src/sudachi/multiplayer/host_room.ui @@ -0,0 +1,207 @@ + + + HostRoom + + + + 0 + 0 + 607 + 211 + + + + Create Room + + + + + + + 0 + + + 0 + + + 0 + + + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + Room Name + + + + + + + 50 + + + + + + + Preferred Game + + + + + + + + + + Max Players + + + + + + + 2 + + + 16 + + + 8 + + + + + + + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + + Username + + + + + + + QLineEdit::PasswordEchoOnEdit + + + (Leave blank for open game) + + + + + + + Qt::ImhDigitsOnly + + + 5 + + + + + + + Password + + + + + + + Port + + + + + + + + + + + + + + Room Description + + + + + + + + + + + + + + Load Previous Ban List + + + true + + + + + + + + + 0 + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + Public + + + + + Unlisted + + + + + + + + Host Room + + + + + + + + + + diff --git a/src/sudachi/multiplayer/lobby.cpp b/src/sudachi/multiplayer/lobby.cpp new file mode 100644 index 0000000..c479a3a --- /dev/null +++ b/src/sudachi/multiplayer/lobby.cpp @@ -0,0 +1,439 @@ +// SPDX-FileCopyrightText: Copyright 2017 Citra Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include +#include +#include +#include "common/logging/log.h" +#include "common/settings.h" +#include "core/core.h" +#include "core/hle/service/acc/profile_manager.h" +#include "core/internal_network/network_interface.h" +#include "network/network.h" +#include "ui_lobby.h" +#include "sudachi/game_list_p.h" +#include "sudachi/main.h" +#include "sudachi/multiplayer/client_room.h" +#include "sudachi/multiplayer/lobby.h" +#include "sudachi/multiplayer/lobby_p.h" +#include "sudachi/multiplayer/message.h" +#include "sudachi/multiplayer/state.h" +#include "sudachi/multiplayer/validation.h" +#include "sudachi/uisettings.h" +#ifdef ENABLE_WEB_SERVICE +#include "web_service/web_backend.h" +#endif + +Lobby::Lobby(QWidget* parent, QStandardItemModel* list, + std::shared_ptr session, Core::System& system_) + : QDialog(parent, Qt::WindowTitleHint | Qt::WindowCloseButtonHint | Qt::WindowSystemMenuHint), + ui(std::make_unique()), + announce_multiplayer_session(session), system{system_}, room_network{ + system.GetRoomNetwork()} { + ui->setupUi(this); + + // setup the watcher for background connections + watcher = new QFutureWatcher; + + model = new QStandardItemModel(ui->room_list); + + // Create a proxy to the game list to get the list of games owned + game_list = new QStandardItemModel; + UpdateGameList(list); + + proxy = new LobbyFilterProxyModel(this, game_list); + proxy->setSourceModel(model); + proxy->setDynamicSortFilter(true); + proxy->setFilterCaseSensitivity(Qt::CaseInsensitive); + proxy->setSortLocaleAware(true); + ui->room_list->setModel(proxy); + ui->room_list->header()->setSectionResizeMode(QHeaderView::Interactive); + ui->room_list->header()->stretchLastSection(); + ui->room_list->setAlternatingRowColors(true); + ui->room_list->setSelectionMode(QHeaderView::SingleSelection); + ui->room_list->setSelectionBehavior(QHeaderView::SelectRows); + ui->room_list->setVerticalScrollMode(QHeaderView::ScrollPerPixel); + ui->room_list->setHorizontalScrollMode(QHeaderView::ScrollPerPixel); + ui->room_list->setSortingEnabled(true); + ui->room_list->setEditTriggers(QHeaderView::NoEditTriggers); + ui->room_list->setExpandsOnDoubleClick(false); + ui->room_list->setContextMenuPolicy(Qt::CustomContextMenu); + + ui->nickname->setValidator(validation.GetNickname()); + ui->nickname->setText( + QString::fromStdString(UISettings::values.multiplayer_nickname.GetValue())); + + // Try find the best nickname by default + if (ui->nickname->text().isEmpty() || ui->nickname->text() == QStringLiteral("sudachi")) { + if (!Settings::values.sudachi_username.GetValue().empty()) { + ui->nickname->setText( + QString::fromStdString(Settings::values.sudachi_username.GetValue())); + } else if (!GetProfileUsername().empty()) { + ui->nickname->setText(QString::fromStdString(GetProfileUsername())); + } else { + ui->nickname->setText(QStringLiteral("sudachi")); + } + } + + // UI Buttons + connect(ui->refresh_list, &QPushButton::clicked, this, &Lobby::RefreshLobby); + connect(ui->search, &QLineEdit::textChanged, proxy, &LobbyFilterProxyModel::SetFilterSearch); + connect(ui->games_owned, &QCheckBox::toggled, proxy, &LobbyFilterProxyModel::SetFilterOwned); + connect(ui->hide_empty, &QCheckBox::toggled, proxy, &LobbyFilterProxyModel::SetFilterEmpty); + connect(ui->hide_full, &QCheckBox::toggled, proxy, &LobbyFilterProxyModel::SetFilterFull); + connect(ui->room_list, &QTreeView::doubleClicked, this, &Lobby::OnJoinRoom); + connect(ui->room_list, &QTreeView::clicked, this, &Lobby::OnExpandRoom); + + // Actions + connect(&room_list_watcher, &QFutureWatcher::finished, this, + &Lobby::OnRefreshLobby); + + // Load persistent filters after events are connected to make sure they apply + ui->search->setText( + QString::fromStdString(UISettings::values.multiplayer_filter_text.GetValue())); + ui->games_owned->setChecked(UISettings::values.multiplayer_filter_games_owned.GetValue()); + ui->hide_empty->setChecked(UISettings::values.multiplayer_filter_hide_empty.GetValue()); + ui->hide_full->setChecked(UISettings::values.multiplayer_filter_hide_full.GetValue()); +} + +Lobby::~Lobby() = default; + +void Lobby::UpdateGameList(QStandardItemModel* list) { + game_list->clear(); + for (int i = 0; i < list->rowCount(); i++) { + auto parent = list->item(i, 0); + for (int j = 0; j < parent->rowCount(); j++) { + game_list->appendRow(parent->child(j)->clone()); + } + } + if (proxy) + proxy->UpdateGameList(game_list); + ui->room_list->sortByColumn(Column::GAME_NAME, Qt::AscendingOrder); +} + +void Lobby::RetranslateUi() { + ui->retranslateUi(this); +} + +QString Lobby::PasswordPrompt() { + bool ok; + const QString text = + QInputDialog::getText(this, tr("Password Required to Join"), tr("Password:"), + QLineEdit::Password, QString(), &ok); + return ok ? text : QString(); +} + +void Lobby::OnExpandRoom(const QModelIndex& index) { + QModelIndex member_index = proxy->index(index.row(), Column::MEMBER); + auto member_list = proxy->data(member_index, LobbyItemMemberList::MemberListRole).toList(); +} + +void Lobby::OnJoinRoom(const QModelIndex& source) { + if (!Network::GetSelectedNetworkInterface()) { + LOG_INFO(WebService, "Automatically selected network interface for room network."); + Network::SelectFirstNetworkInterface(); + } + + if (!Network::GetSelectedNetworkInterface()) { + NetworkMessage::ErrorManager::ShowError( + NetworkMessage::ErrorManager::NO_INTERFACE_SELECTED); + return; + } + + if (system.IsPoweredOn()) { + if (!NetworkMessage::WarnGameRunning()) { + return; + } + } + + if (const auto member = room_network.GetRoomMember().lock()) { + // Prevent the user from trying to join a room while they are already joining. + if (member->GetState() == Network::RoomMember::State::Joining) { + return; + } else if (member->IsConnected()) { + // And ask if they want to leave the room if they are already in one. + if (!NetworkMessage::WarnDisconnect()) { + return; + } + } + } + QModelIndex index = source; + // If the user double clicks on a child row (aka the player list) then use the parent instead + if (source.parent() != QModelIndex()) { + index = source.parent(); + } + if (!ui->nickname->hasAcceptableInput()) { + NetworkMessage::ErrorManager::ShowError(NetworkMessage::ErrorManager::USERNAME_NOT_VALID); + return; + } + + // Get a password to pass if the room is password protected + QModelIndex password_index = proxy->index(index.row(), Column::ROOM_NAME); + bool has_password = proxy->data(password_index, LobbyItemName::PasswordRole).toBool(); + const std::string password = has_password ? PasswordPrompt().toStdString() : ""; + if (has_password && password.empty()) { + return; + } + + QModelIndex connection_index = proxy->index(index.row(), Column::HOST); + const std::string nickname = ui->nickname->text().toStdString(); + const std::string ip = + proxy->data(connection_index, LobbyItemHost::HostIPRole).toString().toStdString(); + int port = proxy->data(connection_index, LobbyItemHost::HostPortRole).toInt(); + const std::string verify_uid = + proxy->data(connection_index, LobbyItemHost::HostVerifyUIDRole).toString().toStdString(); + + // attempt to connect in a different thread + QFuture f = QtConcurrent::run([nickname, ip, port, password, verify_uid, this] { + std::string token; +#ifdef ENABLE_WEB_SERVICE + if (!Settings::values.sudachi_username.GetValue().empty() && + !Settings::values.sudachi_token.GetValue().empty()) { + WebService::Client client(Settings::values.web_api_url.GetValue(), + Settings::values.sudachi_username.GetValue(), + Settings::values.sudachi_token.GetValue()); + token = client.GetExternalJWT(verify_uid).returned_data; + if (token.empty()) { + LOG_ERROR(WebService, "Could not get external JWT, verification may fail"); + } else { + LOG_INFO(WebService, "Successfully requested external JWT: size={}", token.size()); + } + } +#endif + if (auto room_member = room_network.GetRoomMember().lock()) { + room_member->Join(nickname, ip.c_str(), port, 0, Network::NoPreferredIP, password, + token); + } + }); + watcher->setFuture(f); + + // TODO(jroweboy): disable widgets and display a connecting while we wait + + // Save settings + UISettings::values.multiplayer_nickname = ui->nickname->text().toStdString(); + UISettings::values.multiplayer_filter_text = ui->search->text().toStdString(); + UISettings::values.multiplayer_filter_games_owned = ui->games_owned->isChecked(); + UISettings::values.multiplayer_filter_hide_empty = ui->hide_empty->isChecked(); + UISettings::values.multiplayer_filter_hide_full = ui->hide_full->isChecked(); + UISettings::values.multiplayer_ip = + proxy->data(connection_index, LobbyItemHost::HostIPRole).value().toStdString(); + UISettings::values.multiplayer_port = + proxy->data(connection_index, LobbyItemHost::HostPortRole).toInt(); + emit SaveConfig(); +} + +void Lobby::ResetModel() { + model->clear(); + model->insertColumns(0, Column::TOTAL); + model->setHeaderData(Column::MEMBER, Qt::Horizontal, tr("Players"), Qt::DisplayRole); + model->setHeaderData(Column::ROOM_NAME, Qt::Horizontal, tr("Room Name"), Qt::DisplayRole); + model->setHeaderData(Column::GAME_NAME, Qt::Horizontal, tr("Preferred Game"), Qt::DisplayRole); + model->setHeaderData(Column::HOST, Qt::Horizontal, tr("Host"), Qt::DisplayRole); +} + +void Lobby::RefreshLobby() { + if (auto session = announce_multiplayer_session.lock()) { + ResetModel(); + ui->refresh_list->setEnabled(false); + ui->refresh_list->setText(tr("Refreshing")); + room_list_watcher.setFuture( + QtConcurrent::run([session]() { return session->GetRoomList(); })); + } else { + // TODO(jroweboy): Display an error box about announce couldn't be started + } +} + +void Lobby::OnRefreshLobby() { + AnnounceMultiplayerRoom::RoomList new_room_list = room_list_watcher.result(); + for (auto room : new_room_list) { + // find the icon for the game if this person owns that game. + QPixmap smdh_icon; + for (int r = 0; r < game_list->rowCount(); ++r) { + auto index = game_list->index(r, 0); + auto game_id = game_list->data(index, GameListItemPath::ProgramIdRole).toULongLong(); + + if (game_id != 0 && room.information.preferred_game.id == game_id) { + smdh_icon = game_list->data(index, Qt::DecorationRole).value(); + } + } + + QList members; + for (auto member : room.members) { + QVariant var; + var.setValue(LobbyMember{QString::fromStdString(member.username), + QString::fromStdString(member.nickname), member.game.id, + QString::fromStdString(member.game.name)}); + members.append(var); + } + + auto first_item = new LobbyItemGame( + room.information.preferred_game.id, + QString::fromStdString(room.information.preferred_game.name), smdh_icon); + auto row = QList({ + first_item, + new LobbyItemName(room.has_password, QString::fromStdString(room.information.name)), + new LobbyItemMemberList(members, room.information.member_slots), + new LobbyItemHost(QString::fromStdString(room.information.host_username), + QString::fromStdString(room.ip), room.information.port, + QString::fromStdString(room.verify_uid)), + }); + model->appendRow(row); + // To make the rows expandable, add the member data as a child of the first column of the + // rows with people in them and have qt set them to colspan after the model is finished + // resetting + if (!room.information.description.empty()) { + first_item->appendRow( + new LobbyItemDescription(QString::fromStdString(room.information.description))); + } + if (!room.members.empty()) { + first_item->appendRow(new LobbyItemExpandedMemberList(members)); + } + } + + // Re-enable the refresh button and resize the columns + ui->refresh_list->setEnabled(true); + ui->refresh_list->setText(tr("Refresh List")); + ui->room_list->header()->stretchLastSection(); + for (int i = 0; i < Column::TOTAL - 1; ++i) { + ui->room_list->resizeColumnToContents(i); + } + + // Set the member list child items to span all columns + for (int i = 0; i < proxy->rowCount(); i++) { + auto parent = model->item(i, 0); + for (int j = 0; j < parent->rowCount(); j++) { + ui->room_list->setFirstColumnSpanned(j, proxy->index(i, 0), true); + } + } + + ui->room_list->sortByColumn(Column::GAME_NAME, Qt::AscendingOrder); +} + +std::string Lobby::GetProfileUsername() { + const auto& current_user = + system.GetProfileManager().GetUser(Settings::values.current_user.GetValue()); + Service::Account::ProfileBase profile{}; + + if (!current_user.has_value()) { + return ""; + } + + if (!system.GetProfileManager().GetProfileBase(*current_user, profile)) { + return ""; + } + + const auto text = Common::StringFromFixedZeroTerminatedBuffer( + reinterpret_cast(profile.username.data()), profile.username.size()); + + return text; +} + +LobbyFilterProxyModel::LobbyFilterProxyModel(QWidget* parent, QStandardItemModel* list) + : QSortFilterProxyModel(parent), game_list(list) {} + +void LobbyFilterProxyModel::UpdateGameList(QStandardItemModel* list) { + game_list = list; +} + +bool LobbyFilterProxyModel::filterAcceptsRow(int sourceRow, const QModelIndex& sourceParent) const { + // Prioritize filters by fastest to compute + + // pass over any child rows (aka row that shows the players in the room) + if (sourceParent != QModelIndex()) { + return true; + } + + // filter by empty rooms + if (filter_empty) { + QModelIndex member_list = sourceModel()->index(sourceRow, Column::MEMBER, sourceParent); + int player_count = + sourceModel()->data(member_list, LobbyItemMemberList::MemberListRole).toList().size(); + if (player_count == 0) { + return false; + } + } + + // filter by filled rooms + if (filter_full) { + QModelIndex member_list = sourceModel()->index(sourceRow, Column::MEMBER, sourceParent); + int player_count = + sourceModel()->data(member_list, LobbyItemMemberList::MemberListRole).toList().size(); + int max_players = + sourceModel()->data(member_list, LobbyItemMemberList::MaxPlayerRole).toInt(); + if (player_count >= max_players) { + return false; + } + } + + // filter by search parameters + if (!filter_search.isEmpty()) { + QModelIndex game_name = sourceModel()->index(sourceRow, Column::GAME_NAME, sourceParent); + QModelIndex room_name = sourceModel()->index(sourceRow, Column::ROOM_NAME, sourceParent); + QModelIndex host_name = sourceModel()->index(sourceRow, Column::HOST, sourceParent); + bool preferred_game_match = sourceModel() + ->data(game_name, LobbyItemGame::GameNameRole) + .toString() + .contains(filter_search, filterCaseSensitivity()); + bool room_name_match = sourceModel() + ->data(room_name, LobbyItemName::NameRole) + .toString() + .contains(filter_search, filterCaseSensitivity()); + bool username_match = sourceModel() + ->data(host_name, LobbyItemHost::HostUsernameRole) + .toString() + .contains(filter_search, filterCaseSensitivity()); + if (!preferred_game_match && !room_name_match && !username_match) { + return false; + } + } + + // filter by game owned + if (filter_owned) { + QModelIndex game_name = sourceModel()->index(sourceRow, Column::GAME_NAME, sourceParent); + QList owned_games; + for (int r = 0; r < game_list->rowCount(); ++r) { + owned_games.append(QModelIndex(game_list->index(r, 0))); + } + auto current_id = sourceModel()->data(game_name, LobbyItemGame::TitleIDRole).toLongLong(); + if (current_id == 0) { + // TODO(jroweboy): homebrew often doesn't have a game id and this hides them + return false; + } + bool owned = false; + for (const auto& game : owned_games) { + auto game_id = game_list->data(game, GameListItemPath::ProgramIdRole).toLongLong(); + if (current_id == game_id) { + owned = true; + } + } + if (!owned) { + return false; + } + } + + return true; +} + +void LobbyFilterProxyModel::sort(int column, Qt::SortOrder order) { + sourceModel()->sort(column, order); +} + +void LobbyFilterProxyModel::SetFilterOwned(bool filter) { + filter_owned = filter; + invalidate(); +} + +void LobbyFilterProxyModel::SetFilterEmpty(bool filter) { + filter_empty = filter; + invalidate(); +} + +void LobbyFilterProxyModel::SetFilterFull(bool filter) { + filter_full = filter; + invalidate(); +} + +void LobbyFilterProxyModel::SetFilterSearch(const QString& filter) { + filter_search = filter; + invalidate(); +} diff --git a/src/sudachi/multiplayer/lobby.h b/src/sudachi/multiplayer/lobby.h new file mode 100644 index 0000000..bbfc977 --- /dev/null +++ b/src/sudachi/multiplayer/lobby.h @@ -0,0 +1,138 @@ +// SPDX-FileCopyrightText: Copyright 2017 Citra Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include +#include +#include +#include +#include +#include "common/announce_multiplayer_room.h" +#include "network/announce_multiplayer_session.h" +#include "network/network.h" +#include "sudachi/multiplayer/validation.h" + +namespace Ui { +class Lobby; +} + +class LobbyModel; +class LobbyFilterProxyModel; + +namespace Core { +class System; +} + +/** + * Listing of all public games pulled from services. The lobby should be simple enough for users to + * find the game they want to play, and join it. + */ +class Lobby : public QDialog { + Q_OBJECT + +public: + explicit Lobby(QWidget* parent, QStandardItemModel* list, + std::shared_ptr session, + Core::System& system_); + ~Lobby() override; + + /** + * Updates the lobby with a new game list model. + * This model should be the original model of the game list. + */ + void UpdateGameList(QStandardItemModel* list); + void RetranslateUi(); + +public slots: + /** + * Begin the process to pull the latest room list from web services. After the listing is + * returned from web services, `LobbyRefreshed` will be signalled + */ + void RefreshLobby(); + +private slots: + /** + * Pulls the list of rooms from network and fills out the lobby model with the results + */ + void OnRefreshLobby(); + + /** + * Handler for single clicking on a room in the list. Expands the treeitem to show player + * information for the people in the room + * + * index - The row of the proxy model that the user wants to join. + */ + void OnExpandRoom(const QModelIndex&); + + /** + * Handler for double clicking on a room in the list. Gathers the host ip and port and attempts + * to connect. Will also prompt for a password in case one is required. + * + * index - The row of the proxy model that the user wants to join. + */ + void OnJoinRoom(const QModelIndex&); + +signals: + void StateChanged(const Network::RoomMember::State&); + void SaveConfig(); + +private: + std::string GetProfileUsername(); + + /** + * Removes all entries in the Lobby before refreshing. + */ + void ResetModel(); + + /** + * Prompts for a password. Returns an empty QString if the user either did not provide a + * password or if the user closed the window. + */ + QString PasswordPrompt(); + + std::unique_ptr ui; + + QStandardItemModel* model{}; + QStandardItemModel* game_list{}; + LobbyFilterProxyModel* proxy{}; + + QFutureWatcher room_list_watcher; + std::weak_ptr announce_multiplayer_session; + QFutureWatcher* watcher; + Validation validation; + Core::System& system; + Network::RoomNetwork& room_network; +}; + +/** + * Proxy Model for filtering the lobby + */ +class LobbyFilterProxyModel : public QSortFilterProxyModel { + Q_OBJECT; + +public: + explicit LobbyFilterProxyModel(QWidget* parent, QStandardItemModel* list); + + /** + * Updates the filter with a new game list model. + * This model should be the processed one created by the Lobby. + */ + void UpdateGameList(QStandardItemModel* list); + + bool filterAcceptsRow(int sourceRow, const QModelIndex& sourceParent) const override; + void sort(int column, Qt::SortOrder order) override; + +public slots: + void SetFilterOwned(bool); + void SetFilterEmpty(bool); + void SetFilterFull(bool); + void SetFilterSearch(const QString&); + +private: + QStandardItemModel* game_list; + bool filter_owned = false; + bool filter_empty = false; + bool filter_full = false; + QString filter_search; +}; diff --git a/src/sudachi/multiplayer/lobby.ui b/src/sudachi/multiplayer/lobby.ui new file mode 100644 index 0000000..0ef0ef7 --- /dev/null +++ b/src/sudachi/multiplayer/lobby.ui @@ -0,0 +1,130 @@ + + + Lobby + + + + 0 + 0 + 903 + 487 + + + + Public Room Browser + + + + + + 3 + + + + + 6 + + + + + + + Nickname + + + + + + + Nickname + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Filters + + + + + + + Search + + + true + + + + + + + Games I Own + + + + + + + Hide Empty Rooms + + + + + + + Hide Full Rooms + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Refresh Lobby + + + + + + + + + + + + + + + + + + + + diff --git a/src/sudachi/multiplayer/lobby_p.h b/src/sudachi/multiplayer/lobby_p.h new file mode 100644 index 0000000..77ec1fc --- /dev/null +++ b/src/sudachi/multiplayer/lobby_p.h @@ -0,0 +1,268 @@ +// SPDX-FileCopyrightText: Copyright 2017 Citra Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include +#include +#include +#include +#include "common/common_types.h" + +namespace Column { +enum List { + GAME_NAME, + ROOM_NAME, + MEMBER, + HOST, + TOTAL, +}; +} + +class LobbyItem : public QStandardItem { +public: + LobbyItem() = default; + explicit LobbyItem(const QString& string) : QStandardItem(string) {} + virtual ~LobbyItem() override = default; +}; + +class LobbyItemName : public LobbyItem { +public: + static const int NameRole = Qt::UserRole + 1; + static const int PasswordRole = Qt::UserRole + 2; + + LobbyItemName() = default; + explicit LobbyItemName(bool has_password, QString name) : LobbyItem() { + setData(name, NameRole); + setData(has_password, PasswordRole); + } + + QVariant data(int role) const override { + if (role == Qt::DecorationRole) { + bool has_password = data(PasswordRole).toBool(); + return has_password ? QIcon::fromTheme(QStringLiteral("lock")).pixmap(16) : QIcon(); + } + if (role != Qt::DisplayRole) { + return LobbyItem::data(role); + } + return data(NameRole).toString(); + } + + bool operator<(const QStandardItem& other) const override { + return data(NameRole).toString().localeAwareCompare(other.data(NameRole).toString()) < 0; + } +}; + +class LobbyItemDescription : public LobbyItem { +public: + static const int DescriptionRole = Qt::UserRole + 1; + + LobbyItemDescription() = default; + explicit LobbyItemDescription(QString description) { + setData(description, DescriptionRole); + } + + QVariant data(int role) const override { + if (role != Qt::DisplayRole) { + return LobbyItem::data(role); + } + auto description = data(DescriptionRole).toString(); + description.prepend(QStringLiteral("Description: ")); + return description; + } + + bool operator<(const QStandardItem& other) const override { + return data(DescriptionRole) + .toString() + .localeAwareCompare(other.data(DescriptionRole).toString()) < 0; + } +}; + +class LobbyItemGame : public LobbyItem { +public: + static const int TitleIDRole = Qt::UserRole + 1; + static const int GameNameRole = Qt::UserRole + 2; + static const int GameIconRole = Qt::UserRole + 3; + + LobbyItemGame() = default; + explicit LobbyItemGame(u64 title_id, QString game_name, QPixmap smdh_icon) { + setData(static_cast(title_id), TitleIDRole); + setData(game_name, GameNameRole); + if (!smdh_icon.isNull()) { + setData(smdh_icon, GameIconRole); + } else { + setData(QIcon::fromTheme(QStringLiteral("chip")).pixmap(32), GameIconRole); + } + } + + QVariant data(int role) const override { + if (role == Qt::DecorationRole) { + auto val = data(GameIconRole); + if (val.isValid()) { + val = val.value().scaled(32, 32, Qt::KeepAspectRatio, + Qt::TransformationMode::SmoothTransformation); + } else { + auto blank_image = QPixmap(32, 32); + blank_image.fill(Qt::black); + val = blank_image; + } + return val; + } else if (role != Qt::DisplayRole) { + return LobbyItem::data(role); + } + return data(GameNameRole).toString(); + } + + bool operator<(const QStandardItem& other) const override { + return data(GameNameRole) + .toString() + .localeAwareCompare(other.data(GameNameRole).toString()) < 0; + } +}; + +class LobbyItemHost : public LobbyItem { +public: + static const int HostUsernameRole = Qt::UserRole + 1; + static const int HostIPRole = Qt::UserRole + 2; + static const int HostPortRole = Qt::UserRole + 3; + static const int HostVerifyUIDRole = Qt::UserRole + 4; + + LobbyItemHost() = default; + explicit LobbyItemHost(QString username, QString ip, u16 port, QString verify_uid) { + setData(username, HostUsernameRole); + setData(ip, HostIPRole); + setData(port, HostPortRole); + setData(verify_uid, HostVerifyUIDRole); + } + + QVariant data(int role) const override { + if (role != Qt::DisplayRole) { + return LobbyItem::data(role); + } + return data(HostUsernameRole).toString(); + } + + bool operator<(const QStandardItem& other) const override { + return data(HostUsernameRole) + .toString() + .localeAwareCompare(other.data(HostUsernameRole).toString()) < 0; + } +}; + +class LobbyMember { +public: + LobbyMember() = default; + LobbyMember(const LobbyMember& other) = default; + explicit LobbyMember(QString username_, QString nickname_, u64 title_id_, QString game_name_) + : username(std::move(username_)), nickname(std::move(nickname_)), title_id(title_id_), + game_name(std::move(game_name_)) {} + ~LobbyMember() = default; + + QString GetName() const { + if (username.isEmpty() || username == nickname) { + return nickname; + } else { + return QStringLiteral("%1 (%2)").arg(nickname, username); + } + } + u64 GetTitleId() const { + return title_id; + } + QString GetGameName() const { + return game_name; + } + +private: + QString username; + QString nickname; + u64 title_id; + QString game_name; +}; + +Q_DECLARE_METATYPE(LobbyMember); + +class LobbyItemMemberList : public LobbyItem { +public: + static const int MemberListRole = Qt::UserRole + 1; + static const int MaxPlayerRole = Qt::UserRole + 2; + + LobbyItemMemberList() = default; + explicit LobbyItemMemberList(QList members, u32 max_players) { + setData(members, MemberListRole); + setData(max_players, MaxPlayerRole); + } + + QVariant data(int role) const override { + switch (role) { + case Qt::DisplayRole: { + auto members = data(MemberListRole).toList(); + return QStringLiteral("%1 / %2 ") + .arg(QString::number(members.size()), data(MaxPlayerRole).toString()); + } + case Qt::ForegroundRole: { + auto members = data(MemberListRole).toList(); + auto max_players = data(MaxPlayerRole).toInt(); + const QColor room_full_color(255, 48, 32); + const QColor room_almost_full_color(255, 140, 32); + const QColor room_has_players_color(32, 160, 32); + const QColor room_empty_color(128, 128, 128); + + if (members.size() >= max_players) { + return QBrush(room_full_color); + } else if (members.size() == (max_players - 1)) { + return QBrush(room_almost_full_color); + } else if (members.size() == 0) { + return QBrush(room_empty_color); + } else if (members.size() > 0 && members.size() < (max_players - 1)) { + return QBrush(room_has_players_color); + } + // FIXME: How to return a value that tells Qt not to modify the + // text color from the default (as if Qt::ForegroundRole wasn't overridden)? + return QBrush(nullptr); + } + default: + return LobbyItem::data(role); + } + } + + bool operator<(const QStandardItem& other) const override { + // sort by rooms that have the most players + int left_members = data(MemberListRole).toList().size(); + int right_members = other.data(MemberListRole).toList().size(); + return left_members < right_members; + } +}; + +/** + * Member information for when a lobby is expanded in the UI + */ +class LobbyItemExpandedMemberList : public LobbyItem { +public: + static const int MemberListRole = Qt::UserRole + 1; + + LobbyItemExpandedMemberList() = default; + explicit LobbyItemExpandedMemberList(QList members) { + setData(members, MemberListRole); + } + + QVariant data(int role) const override { + if (role != Qt::DisplayRole) { + return LobbyItem::data(role); + } + auto members = data(MemberListRole).toList(); + QString out; + bool first = true; + for (const auto& member : members) { + if (!first) + out.append(QStringLiteral("\n")); + const auto& m = member.value(); + if (m.GetGameName().isEmpty()) { + out += QString(QObject::tr("%1 is not playing a game")).arg(m.GetName()); + } else { + out += QString(QObject::tr("%1 is playing %2")).arg(m.GetName(), m.GetGameName()); + } + first = false; + } + return out; + } +}; diff --git a/src/sudachi/multiplayer/message.cpp b/src/sudachi/multiplayer/message.cpp new file mode 100644 index 0000000..b20e50a --- /dev/null +++ b/src/sudachi/multiplayer/message.cpp @@ -0,0 +1,85 @@ +// SPDX-FileCopyrightText: Copyright 2017 Citra Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include +#include + +#include "sudachi/multiplayer/message.h" + +namespace NetworkMessage { +const ConnectionError ErrorManager::USERNAME_NOT_VALID( + QT_TR_NOOP("Username is not valid. Must be 4 to 20 alphanumeric characters.")); +const ConnectionError ErrorManager::ROOMNAME_NOT_VALID( + QT_TR_NOOP("Room name is not valid. Must be 4 to 20 alphanumeric characters.")); +const ConnectionError ErrorManager::USERNAME_NOT_VALID_SERVER( + QT_TR_NOOP("Username is already in use or not valid. Please choose another.")); +const ConnectionError ErrorManager::IP_ADDRESS_NOT_VALID( + QT_TR_NOOP("IP is not a valid IPv4 address.")); +const ConnectionError ErrorManager::PORT_NOT_VALID( + QT_TR_NOOP("Port must be a number between 0 to 65535.")); +const ConnectionError ErrorManager::GAME_NOT_SELECTED(QT_TR_NOOP( + "You must choose a Preferred Game to host a room. If you do not have any games in your game " + "list yet, add a game folder by clicking on the plus icon in the game list.")); +const ConnectionError ErrorManager::NO_INTERNET( + QT_TR_NOOP("Unable to find an internet connection. Check your internet settings.")); +const ConnectionError ErrorManager::UNABLE_TO_CONNECT( + QT_TR_NOOP("Unable to connect to the host. Verify that the connection settings are correct. If " + "you still cannot connect, contact the room host and verify that the host is " + "properly configured with the external port forwarded.")); +const ConnectionError ErrorManager::ROOM_IS_FULL( + QT_TR_NOOP("Unable to connect to the room because it is already full.")); +const ConnectionError ErrorManager::COULD_NOT_CREATE_ROOM( + QT_TR_NOOP("Creating a room failed. Please retry. Restarting sudachi might be necessary.")); +const ConnectionError ErrorManager::HOST_BANNED( + QT_TR_NOOP("The host of the room has banned you. Speak with the host to unban you " + "or try a different room.")); +const ConnectionError ErrorManager::WRONG_VERSION( + QT_TR_NOOP("Version mismatch! Please update to the latest version of sudachi. If the problem " + "persists, contact the room host and ask them to update the server.")); +const ConnectionError ErrorManager::WRONG_PASSWORD(QT_TR_NOOP("Incorrect password.")); +const ConnectionError ErrorManager::GENERIC_ERROR(QT_TR_NOOP( + "An unknown error occurred. If this error continues to occur, please open an issue")); +const ConnectionError ErrorManager::LOST_CONNECTION( + QT_TR_NOOP("Connection to room lost. Try to reconnect.")); +const ConnectionError ErrorManager::HOST_KICKED( + QT_TR_NOOP("You have been kicked by the room host.")); +const ConnectionError ErrorManager::IP_COLLISION( + QT_TR_NOOP("IP address is already in use. Please choose another.")); +const ConnectionError ErrorManager::PERMISSION_DENIED( + QT_TR_NOOP("You do not have enough permission to perform this action.")); +const ConnectionError ErrorManager::NO_SUCH_USER(QT_TR_NOOP( + "The user you are trying to kick/ban could not be found.\nThey may have left the room.")); +const ConnectionError ErrorManager::NO_INTERFACE_SELECTED(QT_TR_NOOP( + "No valid network interface is selected.\nPlease go to Configure -> System -> Network and " + "make a selection.")); + +static bool WarnMessage(const std::string& title, const std::string& text) { + return QMessageBox::Ok == QMessageBox::warning(nullptr, QObject::tr(title.c_str()), + QObject::tr(text.c_str()), + QMessageBox::Ok | QMessageBox::Cancel); +} + +void ErrorManager::ShowError(const ConnectionError& e) { + QMessageBox::critical(nullptr, tr("Error"), tr(e.GetString().c_str())); +} + +bool WarnGameRunning() { + return WarnMessage( + QT_TR_NOOP("Game already running"), + QT_TR_NOOP("Joining a room when the game is already running is discouraged " + "and can cause the room feature not to work correctly.\nProceed anyway?")); +} + +bool WarnCloseRoom() { + return WarnMessage( + QT_TR_NOOP("Leave Room"), + QT_TR_NOOP("You are about to close the room. Any network connections will be closed.")); +} + +bool WarnDisconnect() { + return WarnMessage( + QT_TR_NOOP("Disconnect"), + QT_TR_NOOP("You are about to leave the room. Any network connections will be closed.")); +} + +} // namespace NetworkMessage diff --git a/src/sudachi/multiplayer/message.h b/src/sudachi/multiplayer/message.h new file mode 100644 index 0000000..f038b9a --- /dev/null +++ b/src/sudachi/multiplayer/message.h @@ -0,0 +1,72 @@ +// SPDX-FileCopyrightText: Copyright 2017 Citra Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include + +namespace NetworkMessage { + +class ConnectionError { + +public: + explicit ConnectionError(std::string str) : err(std::move(str)) {} + const std::string& GetString() const { + return err; + } + +private: + std::string err; +}; + +class ErrorManager : QObject { + Q_OBJECT +public: + /// When the nickname is considered invalid by the client + static const ConnectionError USERNAME_NOT_VALID; + static const ConnectionError ROOMNAME_NOT_VALID; + /// When the nickname is considered invalid by the room server + static const ConnectionError USERNAME_NOT_VALID_SERVER; + static const ConnectionError IP_ADDRESS_NOT_VALID; + static const ConnectionError PORT_NOT_VALID; + static const ConnectionError GAME_NOT_SELECTED; + static const ConnectionError NO_INTERNET; + static const ConnectionError UNABLE_TO_CONNECT; + static const ConnectionError ROOM_IS_FULL; + static const ConnectionError COULD_NOT_CREATE_ROOM; + static const ConnectionError HOST_BANNED; + static const ConnectionError WRONG_VERSION; + static const ConnectionError WRONG_PASSWORD; + static const ConnectionError GENERIC_ERROR; + static const ConnectionError LOST_CONNECTION; + static const ConnectionError HOST_KICKED; + static const ConnectionError IP_COLLISION; + static const ConnectionError PERMISSION_DENIED; + static const ConnectionError NO_SUCH_USER; + static const ConnectionError NO_INTERFACE_SELECTED; + /** + * Shows a standard QMessageBox with a error message + */ + static void ShowError(const ConnectionError& e); +}; + +/** + * Show a standard QMessageBox with a warning message about joining a room when + * the game is already running + * return true if the user wants to close the network connection + */ +bool WarnGameRunning(); + +/** + * Show a standard QMessageBox with a warning message about leaving the room + * return true if the user wants to close the network connection + */ +bool WarnCloseRoom(); + +/** + * Show a standard QMessageBox with a warning message about disconnecting from the room + * return true if the user wants to disconnect + */ +bool WarnDisconnect(); + +} // namespace NetworkMessage diff --git a/src/sudachi/multiplayer/moderation_dialog.cpp b/src/sudachi/multiplayer/moderation_dialog.cpp new file mode 100644 index 0000000..02b7c1e --- /dev/null +++ b/src/sudachi/multiplayer/moderation_dialog.cpp @@ -0,0 +1,112 @@ +// SPDX-FileCopyrightText: Copyright 2018 Citra Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include +#include +#include "network/network.h" +#include "network/room_member.h" +#include "ui_moderation_dialog.h" +#include "sudachi/multiplayer/moderation_dialog.h" + +namespace Column { +enum { + SUBJECT, + TYPE, + COUNT, +}; +} + +ModerationDialog::ModerationDialog(Network::RoomNetwork& room_network_, QWidget* parent) + : QDialog(parent), ui(std::make_unique()), room_network{room_network_} { + ui->setupUi(this); + + qRegisterMetaType(); + + if (auto member = room_network.GetRoomMember().lock()) { + callback_handle_status_message = member->BindOnStatusMessageReceived( + [this](const Network::StatusMessageEntry& status_message) { + emit StatusMessageReceived(status_message); + }); + connect(this, &ModerationDialog::StatusMessageReceived, this, + &ModerationDialog::OnStatusMessageReceived); + callback_handle_ban_list = member->BindOnBanListReceived( + [this](const Network::Room::BanList& ban_list) { emit BanListReceived(ban_list); }); + connect(this, &ModerationDialog::BanListReceived, this, &ModerationDialog::PopulateBanList); + } + + // Initialize the UI + model = new QStandardItemModel(ui->ban_list_view); + model->insertColumns(0, Column::COUNT); + model->setHeaderData(Column::SUBJECT, Qt::Horizontal, tr("Subject")); + model->setHeaderData(Column::TYPE, Qt::Horizontal, tr("Type")); + + ui->ban_list_view->setModel(model); + + // Load the ban list in background + LoadBanList(); + + connect(ui->refresh, &QPushButton::clicked, this, [this] { LoadBanList(); }); + connect(ui->unban, &QPushButton::clicked, this, [this] { + auto index = ui->ban_list_view->currentIndex(); + SendUnbanRequest(model->item(index.row(), 0)->text()); + }); + connect(ui->ban_list_view, &QTreeView::clicked, [this] { ui->unban->setEnabled(true); }); +} + +ModerationDialog::~ModerationDialog() { + if (callback_handle_status_message) { + if (auto room = room_network.GetRoomMember().lock()) { + room->Unbind(callback_handle_status_message); + } + } + + if (callback_handle_ban_list) { + if (auto room = room_network.GetRoomMember().lock()) { + room->Unbind(callback_handle_ban_list); + } + } +} + +void ModerationDialog::LoadBanList() { + if (auto room = room_network.GetRoomMember().lock()) { + ui->refresh->setEnabled(false); + ui->refresh->setText(tr("Refreshing")); + ui->unban->setEnabled(false); + room->RequestBanList(); + } +} + +void ModerationDialog::PopulateBanList(const Network::Room::BanList& ban_list) { + model->removeRows(0, model->rowCount()); + for (const auto& username : ban_list.first) { + QStandardItem* subject_item = new QStandardItem(QString::fromStdString(username)); + QStandardItem* type_item = new QStandardItem(tr("Forum Username")); + model->invisibleRootItem()->appendRow({subject_item, type_item}); + } + for (const auto& ip : ban_list.second) { + QStandardItem* subject_item = new QStandardItem(QString::fromStdString(ip)); + QStandardItem* type_item = new QStandardItem(tr("IP Address")); + model->invisibleRootItem()->appendRow({subject_item, type_item}); + } + for (int i = 0; i < Column::COUNT - 1; ++i) { + ui->ban_list_view->resizeColumnToContents(i); + } + ui->refresh->setEnabled(true); + ui->refresh->setText(tr("Refresh")); + ui->unban->setEnabled(false); +} + +void ModerationDialog::SendUnbanRequest(const QString& subject) { + if (auto room = room_network.GetRoomMember().lock()) { + room->SendModerationRequest(Network::IdModUnban, subject.toStdString()); + } +} + +void ModerationDialog::OnStatusMessageReceived(const Network::StatusMessageEntry& status_message) { + if (status_message.type != Network::IdMemberBanned && + status_message.type != Network::IdAddressUnbanned) + return; + + // Update the ban list for ban/unban + LoadBanList(); +} diff --git a/src/sudachi/multiplayer/moderation_dialog.h b/src/sudachi/multiplayer/moderation_dialog.h new file mode 100644 index 0000000..e9e5daf --- /dev/null +++ b/src/sudachi/multiplayer/moderation_dialog.h @@ -0,0 +1,43 @@ +// SPDX-FileCopyrightText: Copyright 2018 Citra Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include +#include +#include +#include "network/room.h" +#include "network/room_member.h" + +namespace Ui { +class ModerationDialog; +} + +class QStandardItemModel; + +class ModerationDialog : public QDialog { + Q_OBJECT + +public: + explicit ModerationDialog(Network::RoomNetwork& room_network_, QWidget* parent = nullptr); + ~ModerationDialog(); + +signals: + void StatusMessageReceived(const Network::StatusMessageEntry&); + void BanListReceived(const Network::Room::BanList&); + +private: + void LoadBanList(); + void PopulateBanList(const Network::Room::BanList& ban_list); + void SendUnbanRequest(const QString& subject); + void OnStatusMessageReceived(const Network::StatusMessageEntry& status_message); + + std::unique_ptr ui; + QStandardItemModel* model; + Network::RoomMember::CallbackHandle callback_handle_status_message; + Network::RoomMember::CallbackHandle callback_handle_ban_list; + + Network::RoomNetwork& room_network; +}; + +Q_DECLARE_METATYPE(Network::Room::BanList); diff --git a/src/sudachi/multiplayer/moderation_dialog.ui b/src/sudachi/multiplayer/moderation_dialog.ui new file mode 100644 index 0000000..808d994 --- /dev/null +++ b/src/sudachi/multiplayer/moderation_dialog.ui @@ -0,0 +1,84 @@ + + + ModerationDialog + + + Moderation + + + + 0 + 0 + 500 + 300 + + + + + + + Ban List + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Refreshing + + + false + + + + + + + Unban + + + false + + + + + + + + + + + + + + + QDialogButtonBox::Ok + + + + + + + + buttonBox + accepted() + ModerationDialog + accept() + + + + diff --git a/src/sudachi/multiplayer/state.cpp b/src/sudachi/multiplayer/state.cpp new file mode 100644 index 0000000..ba64a62 --- /dev/null +++ b/src/sudachi/multiplayer/state.cpp @@ -0,0 +1,336 @@ +// SPDX-FileCopyrightText: Copyright 2018 Citra Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include +#include +#include +#include +#include +#include "common/announce_multiplayer_room.h" +#include "common/logging/log.h" +#include "core/core.h" +#include "sudachi/game_list.h" +#include "sudachi/multiplayer/client_room.h" +#include "sudachi/multiplayer/direct_connect.h" +#include "sudachi/multiplayer/host_room.h" +#include "sudachi/multiplayer/lobby.h" +#include "sudachi/multiplayer/message.h" +#include "sudachi/multiplayer/state.h" +#include "sudachi/uisettings.h" +#include "sudachi/util/clickable_label.h" + +MultiplayerState::MultiplayerState(QWidget* parent, QStandardItemModel* game_list_model_, + QAction* leave_room_, QAction* show_room_, Core::System& system_) + : QWidget(parent), game_list_model(game_list_model_), leave_room(leave_room_), + show_room(show_room_), system{system_}, room_network{system.GetRoomNetwork()} { + if (auto member = room_network.GetRoomMember().lock()) { + // register the network structs to use in slots and signals + state_callback_handle = member->BindOnStateChanged( + [this](const Network::RoomMember::State& state) { emit NetworkStateChanged(state); }); + connect(this, &MultiplayerState::NetworkStateChanged, this, + &MultiplayerState::OnNetworkStateChanged); + error_callback_handle = member->BindOnError( + [this](const Network::RoomMember::Error& error) { emit NetworkError(error); }); + connect(this, &MultiplayerState::NetworkError, this, &MultiplayerState::OnNetworkError); + } + + qRegisterMetaType(); + qRegisterMetaType(); + qRegisterMetaType(); + announce_multiplayer_session = std::make_shared(room_network); + announce_multiplayer_session->BindErrorCallback( + [this](const WebService::WebResult& result) { emit AnnounceFailed(result); }); + connect(this, &MultiplayerState::AnnounceFailed, this, &MultiplayerState::OnAnnounceFailed); + + status_text = new ClickableLabel(this); + status_icon = new ClickableLabel(this); + + connect(status_text, &ClickableLabel::clicked, this, &MultiplayerState::OnOpenNetworkRoom); + connect(status_icon, &ClickableLabel::clicked, this, &MultiplayerState::OnOpenNetworkRoom); + + connect(static_cast(QApplication::instance()), &QApplication::focusChanged, this, + [this](QWidget* /*old*/, QWidget* now) { + if (client_room && client_room->isAncestorOf(now)) { + HideNotification(); + } + }); + + retranslateUi(); +} + +MultiplayerState::~MultiplayerState() = default; + +void MultiplayerState::Close() { + if (state_callback_handle) { + if (auto member = room_network.GetRoomMember().lock()) { + member->Unbind(state_callback_handle); + } + } + + if (error_callback_handle) { + if (auto member = room_network.GetRoomMember().lock()) { + member->Unbind(error_callback_handle); + } + } + if (host_room) { + host_room->close(); + } + if (direct_connect) { + direct_connect->close(); + } + if (client_room) { + client_room->close(); + } + if (lobby) { + lobby->close(); + } +} + +void MultiplayerState::retranslateUi() { + status_text->setToolTip(tr("Current connection status")); + + UpdateNotificationStatus(); + + if (lobby) { + lobby->RetranslateUi(); + } + if (host_room) { + host_room->RetranslateUi(); + } + if (client_room) { + client_room->RetranslateUi(); + } + if (direct_connect) { + direct_connect->RetranslateUi(); + } +} + +void MultiplayerState::SetNotificationStatus(NotificationStatus status) { + notification_status = status; + UpdateNotificationStatus(); +} + +void MultiplayerState::UpdateNotificationStatus() { + switch (notification_status) { + case NotificationStatus::Uninitialized: + status_icon->setPixmap(QIcon::fromTheme(QStringLiteral("disconnected")).pixmap(16)); + status_text->setText(tr("Not Connected. Click here to find a room!")); + leave_room->setEnabled(false); + show_room->setEnabled(false); + break; + case NotificationStatus::Disconnected: + status_icon->setPixmap(QIcon::fromTheme(QStringLiteral("disconnected")).pixmap(16)); + status_text->setText(tr("Not Connected")); + leave_room->setEnabled(false); + show_room->setEnabled(false); + break; + case NotificationStatus::Connected: + status_icon->setPixmap(QIcon::fromTheme(QStringLiteral("connected")).pixmap(16)); + status_text->setText(tr("Connected")); + leave_room->setEnabled(true); + show_room->setEnabled(true); + break; + case NotificationStatus::Notification: + status_icon->setPixmap( + QIcon::fromTheme(QStringLiteral("connected_notification")).pixmap(16)); + status_text->setText(tr("New Messages Received")); + leave_room->setEnabled(true); + show_room->setEnabled(true); + break; + } + + // Clean up status bar if game is running + if (system.IsPoweredOn()) { + status_text->clear(); + } +} + +void MultiplayerState::OnNetworkStateChanged(const Network::RoomMember::State& state) { + LOG_DEBUG(Frontend, "Network State: {}", Network::GetStateStr(state)); + if (state == Network::RoomMember::State::Joined || + state == Network::RoomMember::State::Moderator) { + + OnOpenNetworkRoom(); + SetNotificationStatus(NotificationStatus::Connected); + } else { + SetNotificationStatus(NotificationStatus::Disconnected); + } + + current_state = state; +} + +void MultiplayerState::OnNetworkError(const Network::RoomMember::Error& error) { + LOG_DEBUG(Frontend, "Network Error: {}", Network::GetErrorStr(error)); + switch (error) { + case Network::RoomMember::Error::LostConnection: + NetworkMessage::ErrorManager::ShowError(NetworkMessage::ErrorManager::LOST_CONNECTION); + break; + case Network::RoomMember::Error::HostKicked: + NetworkMessage::ErrorManager::ShowError(NetworkMessage::ErrorManager::HOST_KICKED); + break; + case Network::RoomMember::Error::CouldNotConnect: + NetworkMessage::ErrorManager::ShowError(NetworkMessage::ErrorManager::UNABLE_TO_CONNECT); + break; + case Network::RoomMember::Error::NameCollision: + NetworkMessage::ErrorManager::ShowError( + NetworkMessage::ErrorManager::USERNAME_NOT_VALID_SERVER); + break; + case Network::RoomMember::Error::IpCollision: + NetworkMessage::ErrorManager::ShowError(NetworkMessage::ErrorManager::IP_COLLISION); + break; + case Network::RoomMember::Error::RoomIsFull: + NetworkMessage::ErrorManager::ShowError(NetworkMessage::ErrorManager::ROOM_IS_FULL); + break; + case Network::RoomMember::Error::WrongPassword: + NetworkMessage::ErrorManager::ShowError(NetworkMessage::ErrorManager::WRONG_PASSWORD); + break; + case Network::RoomMember::Error::WrongVersion: + NetworkMessage::ErrorManager::ShowError(NetworkMessage::ErrorManager::WRONG_VERSION); + break; + case Network::RoomMember::Error::HostBanned: + NetworkMessage::ErrorManager::ShowError(NetworkMessage::ErrorManager::HOST_BANNED); + break; + case Network::RoomMember::Error::UnknownError: + NetworkMessage::ErrorManager::ShowError(NetworkMessage::ErrorManager::UNABLE_TO_CONNECT); + break; + case Network::RoomMember::Error::PermissionDenied: + NetworkMessage::ErrorManager::ShowError(NetworkMessage::ErrorManager::PERMISSION_DENIED); + break; + case Network::RoomMember::Error::NoSuchUser: + NetworkMessage::ErrorManager::ShowError(NetworkMessage::ErrorManager::NO_SUCH_USER); + break; + } +} + +void MultiplayerState::OnAnnounceFailed(const WebService::WebResult& result) { + announce_multiplayer_session->Stop(); + QMessageBox::warning(this, tr("Error"), + tr("Failed to update the room information. Please check your Internet " + "connection and try hosting the room again.\nDebug Message: ") + + QString::fromStdString(result.result_string), + QMessageBox::Ok); +} + +void MultiplayerState::OnSaveConfig() { + emit SaveConfig(); +} + +void MultiplayerState::UpdateThemedIcons() { + if (show_notification) { + status_icon->setPixmap( + QIcon::fromTheme(QStringLiteral("connected_notification")).pixmap(16)); + } else if (current_state == Network::RoomMember::State::Joined || + current_state == Network::RoomMember::State::Moderator) { + + status_icon->setPixmap(QIcon::fromTheme(QStringLiteral("connected")).pixmap(16)); + } else { + status_icon->setPixmap(QIcon::fromTheme(QStringLiteral("disconnected")).pixmap(16)); + } + if (client_room) + client_room->UpdateIconDisplay(); +} + +static void BringWidgetToFront(QWidget* widget) { + widget->show(); + widget->activateWindow(); + widget->raise(); +} + +void MultiplayerState::OnViewLobby() { + if (lobby == nullptr) { + lobby = new Lobby(this, game_list_model, announce_multiplayer_session, system); + connect(lobby, &Lobby::SaveConfig, this, &MultiplayerState::OnSaveConfig); + } + lobby->RefreshLobby(); + BringWidgetToFront(lobby); +} + +void MultiplayerState::OnCreateRoom() { + if (host_room == nullptr) { + host_room = new HostRoomWindow(this, game_list_model, announce_multiplayer_session, system); + connect(host_room, &HostRoomWindow::SaveConfig, this, &MultiplayerState::OnSaveConfig); + } + BringWidgetToFront(host_room); +} + +bool MultiplayerState::OnCloseRoom() { + if (!NetworkMessage::WarnCloseRoom()) + return false; + if (auto room = room_network.GetRoom().lock()) { + // if you are in a room, leave it + if (auto member = room_network.GetRoomMember().lock()) { + member->Leave(); + LOG_DEBUG(Frontend, "Left the room (as a client)"); + } + + // if you are hosting a room, also stop hosting + if (room->GetState() != Network::Room::State::Open) { + return true; + } + // Save ban list + UISettings::values.multiplayer_ban_list = room->GetBanList(); + + room->Destroy(); + announce_multiplayer_session->Stop(); + LOG_DEBUG(Frontend, "Closed the room (as a server)"); + } + return true; +} + +void MultiplayerState::ShowNotification() { + if (client_room && client_room->isAncestorOf(QApplication::focusWidget())) + return; // Do not show notification if the chat window currently has focus + show_notification = true; + QApplication::alert(nullptr); + QApplication::beep(); + SetNotificationStatus(NotificationStatus::Notification); +} + +void MultiplayerState::HideNotification() { + show_notification = false; + SetNotificationStatus(NotificationStatus::Connected); +} + +void MultiplayerState::OnOpenNetworkRoom() { + if (auto member = room_network.GetRoomMember().lock()) { + if (member->IsConnected()) { + if (client_room == nullptr) { + client_room = new ClientRoomWindow(this, room_network); + connect(client_room, &ClientRoomWindow::ShowNotification, this, + &MultiplayerState::ShowNotification); + } + BringWidgetToFront(client_room); + return; + } + } + // If the user is not a member of a room, show the lobby instead. + // This is currently only used on the clickable label in the status bar + OnViewLobby(); +} + +void MultiplayerState::OnDirectConnectToRoom() { + if (direct_connect == nullptr) { + direct_connect = new DirectConnectWindow(system, this); + connect(direct_connect, &DirectConnectWindow::SaveConfig, this, + &MultiplayerState::OnSaveConfig); + } + BringWidgetToFront(direct_connect); +} + +bool MultiplayerState::IsHostingPublicRoom() const { + return announce_multiplayer_session->IsRunning(); +} + +void MultiplayerState::UpdateCredentials() { + announce_multiplayer_session->UpdateCredentials(); +} + +void MultiplayerState::UpdateGameList(QStandardItemModel* game_list) { + game_list_model = game_list; + if (lobby) { + lobby->UpdateGameList(game_list); + } + if (host_room) { + host_room->UpdateGameList(game_list); + } +} diff --git a/src/sudachi/multiplayer/state.h b/src/sudachi/multiplayer/state.h new file mode 100644 index 0000000..d614983 --- /dev/null +++ b/src/sudachi/multiplayer/state.h @@ -0,0 +1,111 @@ +// SPDX-FileCopyrightText: Copyright 2018 Citra Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include +#include "network/announce_multiplayer_session.h" +#include "network/network.h" + +class QStandardItemModel; +class Lobby; +class HostRoomWindow; +class ClientRoomWindow; +class DirectConnectWindow; +class ClickableLabel; + +namespace Core { +class System; +} + +class MultiplayerState : public QWidget { + Q_OBJECT; + +public: + enum class NotificationStatus { + Uninitialized, + Disconnected, + Connected, + Notification, + }; + + explicit MultiplayerState(QWidget* parent, QStandardItemModel* game_list, QAction* leave_room, + QAction* show_room, Core::System& system_); + ~MultiplayerState(); + + /** + * Close all open multiplayer related dialogs + */ + void Close(); + + void SetNotificationStatus(NotificationStatus state); + + void UpdateNotificationStatus(); + + ClickableLabel* GetStatusText() const { + return status_text; + } + + ClickableLabel* GetStatusIcon() const { + return status_icon; + } + + void retranslateUi(); + + /** + * Whether a public room is being hosted or not. + * When this is true, Web Services configuration should be disabled. + */ + bool IsHostingPublicRoom() const; + + void UpdateCredentials(); + + /** + * Updates the multiplayer dialogs with a new game list model. + * This model should be the original model of the game list. + */ + void UpdateGameList(QStandardItemModel* game_list); + +public slots: + void OnNetworkStateChanged(const Network::RoomMember::State& state); + void OnNetworkError(const Network::RoomMember::Error& error); + void OnViewLobby(); + void OnCreateRoom(); + bool OnCloseRoom(); + void OnOpenNetworkRoom(); + void OnDirectConnectToRoom(); + void OnAnnounceFailed(const WebService::WebResult&); + void OnSaveConfig(); + void UpdateThemedIcons(); + void ShowNotification(); + void HideNotification(); + +signals: + void NetworkStateChanged(const Network::RoomMember::State&); + void NetworkError(const Network::RoomMember::Error&); + void AnnounceFailed(const WebService::WebResult&); + void SaveConfig(); + +private: + Lobby* lobby = nullptr; + HostRoomWindow* host_room = nullptr; + ClientRoomWindow* client_room = nullptr; + DirectConnectWindow* direct_connect = nullptr; + ClickableLabel* status_icon = nullptr; + ClickableLabel* status_text = nullptr; + QStandardItemModel* game_list_model = nullptr; + QAction* leave_room; + QAction* show_room; + std::shared_ptr announce_multiplayer_session; + Network::RoomMember::State current_state = Network::RoomMember::State::Uninitialized; + NotificationStatus notification_status = NotificationStatus::Uninitialized; + bool has_mod_perms = false; + Network::RoomMember::CallbackHandle state_callback_handle; + Network::RoomMember::CallbackHandle error_callback_handle; + + bool show_notification = false; + Core::System& system; + Network::RoomNetwork& room_network; +}; + +Q_DECLARE_METATYPE(WebService::WebResult); diff --git a/src/sudachi/multiplayer/validation.h b/src/sudachi/multiplayer/validation.h new file mode 100644 index 0000000..cbbe675 --- /dev/null +++ b/src/sudachi/multiplayer/validation.h @@ -0,0 +1,67 @@ +// SPDX-FileCopyrightText: Copyright 2017 Citra Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include +#include +#include + +class Validation { +public: + Validation() + : room_name(room_name_regex), nickname(nickname_regex), ip(ip_regex), port(0, UINT16_MAX) {} + + ~Validation() = default; + + const QValidator* GetRoomName() const { + return &room_name; + } + const QValidator* GetNickname() const { + return &nickname; + } + const QValidator* GetIP() const { + return &ip; + } + const QValidator* GetPort() const { + return &port; + } + +private: + /// room name can be alphanumeric and " " "_" "." and "-" and must have a size of 4-20 + QRegularExpression room_name_regex = + QRegularExpression(QStringLiteral("^[a-zA-Z0-9._ -]{4,20}")); + QRegularExpressionValidator room_name; + + /// nickname can be alphanumeric and " " "_" "." and "-" and must have a size of 4-20 + const QRegularExpression nickname_regex = + QRegularExpression(QStringLiteral("^[a-zA-Z0-9._ -]{4,20}")); + QRegularExpressionValidator nickname; + + /// ipv4 / ipv6 / hostnames + QRegularExpression ip_regex = QRegularExpression(QStringLiteral( + // IPv4 regex + "^((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$|" + // IPv6 regex + "^((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|" + "(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-" + "5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3})|:))|" + "(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)" + "(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3})|:))|" + "(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]" + "\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}))|:))|" + "(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[" + "0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}))|:))|" + "(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[" + "0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}))|:))|" + "(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[" + "0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}))|:))|" + "(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?" + "\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}))|:)))(%.+)?$|" + // Hostname regex + "^([a-zA-Z0-9]+(-[a-zA-Z0-9]+)*\\.)+[a-zA-Z]{2,}$")); + QRegularExpressionValidator ip; + + /// port must be between 0 and 65535 + QIntValidator port; +}; diff --git a/src/sudachi/play_time.cpp b/src/sudachi/play_time.cpp new file mode 100644 index 0000000..1af5cb7 --- /dev/null +++ b/src/sudachi/play_time.cpp @@ -0,0 +1,177 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include "common/fs/file.h" +#include "common/fs/path_util.h" +#include "common/logging/log.h" +#include "common/settings.h" +#include "common/thread.h" +#include "core/hle/service/acc/profile_manager.h" + +#include "sudachi/play_time.h" + +namespace PlayTime { + +void PlayTimeManager::SetProgramId(u64 program_id) { + this->running_program_id = program_id; +} + +inline void PlayTimeManager::UpdateTimestamp() { + this->last_timestamp = std::chrono::steady_clock::now(); +} + +void PlayTimeManager::Start() { + UpdateTimestamp(); + play_time_thread = + std::jthread([&](std::stop_token stop_token) { this->AutoTimestamp(stop_token); }); +} + +void PlayTimeManager::Stop() { + play_time_thread.request_stop(); +} + +void PlayTimeManager::AutoTimestamp(std::stop_token stop_token) { + Common::SetCurrentThreadName("PlayTimeReport"); + + using namespace std::literals::chrono_literals; + + const auto duration = 30s; + while (Common::StoppableTimedWait(stop_token, duration)) { + Save(); + } + + Save(); +} + +void PlayTimeManager::Save() { + const auto now = std::chrono::steady_clock::now(); + const auto duration = + static_cast(std::chrono::duration_cast( + std::chrono::steady_clock::duration(now - this->last_timestamp)) + .count()); + UpdateTimestamp(); + if (!UpdatePlayTime(running_program_id, duration)) { + LOG_ERROR(Common, "Failed to update play time"); + } +} + +bool UpdatePlayTime(u64 program_id, u64 add_play_time) { + std::vector play_time_elements; + if (!ReadPlayTimeFile(play_time_elements)) { + return false; + } + const auto it = std::find(play_time_elements.begin(), play_time_elements.end(), program_id); + + if (it == play_time_elements.end()) { + play_time_elements.push_back({.program_id = program_id, .play_time = add_play_time}); + } else { + play_time_elements.at(it - play_time_elements.begin()).play_time += add_play_time; + } + if (!WritePlayTimeFile(play_time_elements)) { + return false; + } + return true; +} + +u64 GetPlayTime(u64 program_id) { + std::vector play_time_elements; + + if (!ReadPlayTimeFile(play_time_elements)) { + return 0; + } + const auto it = std::find(play_time_elements.begin(), play_time_elements.end(), program_id); + if (it == play_time_elements.end()) { + return 0; + } + return play_time_elements.at(it - play_time_elements.begin()).play_time; +} + +bool PlayTimeManager::ResetProgramPlayTime(u64 program_id) { + std::vector play_time_elements; + + if (!ReadPlayTimeFile(play_time_elements)) { + return false; + } + const auto it = std::find(play_time_elements.begin(), play_time_elements.end(), program_id); + if (it == play_time_elements.end()) { + return false; + } + play_time_elements.erase(it); + if (!WritePlayTimeFile(play_time_elements)) { + return false; + } + return true; +} + +std::optional GetCurrentUserPlayTimePath() { + const Service::Account::ProfileManager manager; + const auto uuid = manager.GetUser(static_cast(Settings::values.current_user)); + if (!uuid.has_value()) { + return std::nullopt; + } + return Common::FS::GetSudachiPath(Common::FS::SudachiPath::PlayTimeDir) / + uuid->RawString().append(".bin"); +} + +[[nodiscard]] bool ReadPlayTimeFile(std::vector& out_play_time_elements) { + const auto filename = GetCurrentUserPlayTimePath(); + if (!filename.has_value()) { + LOG_ERROR(Common, "Failed to get current user path"); + return false; + } + + if (Common::FS::Exists(filename.value())) { + Common::FS::IOFile file{filename.value(), Common::FS::FileAccessMode::Read, + Common::FS::FileType::BinaryFile}; + if (!file.IsOpen()) { + LOG_ERROR(Common, "Failed to open play time file: {}", + Common::FS::PathToUTF8String(filename.value())); + return false; + } + const size_t elem_num = file.GetSize() / sizeof(PlayTimeElement); + out_play_time_elements.resize(elem_num); + const bool success = file.ReadSpan(out_play_time_elements) == elem_num; + file.Close(); + return success; + } else { + out_play_time_elements.clear(); + return true; + } +} + +[[nodiscard]] bool WritePlayTimeFile(const std::vector& play_time_elements) { + const auto filename = GetCurrentUserPlayTimePath(); + if (!filename.has_value()) { + LOG_ERROR(Common, "Failed to get current user path"); + return false; + } + Common::FS::IOFile file{filename.value(), Common::FS::FileAccessMode::Write, + Common::FS::FileType::BinaryFile}; + + if (!file.IsOpen()) { + LOG_ERROR(Common, "Failed to open play time file: {}", + Common::FS::PathToUTF8String(filename.value())); + return false; + } + const bool success = + file.WriteSpan(play_time_elements) == play_time_elements.size(); + file.Close(); + return success; +} + +QString ReadablePlayTime(qulonglong time_seconds) { + static constexpr std::array units{"m", "h"}; + if (time_seconds == 0) { + return QLatin1String(""); + } + const auto time_minutes = std::max(static_cast(time_seconds) / 60, 1.0); + const auto time_hours = static_cast(time_seconds) / 3600; + const int unit = time_minutes < 60 ? 0 : 1; + const auto value = unit == 0 ? time_minutes : time_hours; + + return QStringLiteral("%L1 %2") + .arg(value, 0, 'f', unit && time_seconds % 60 != 0) + .arg(QString::fromUtf8(units[unit])); +} + +} // namespace PlayTime diff --git a/src/sudachi/play_time.h b/src/sudachi/play_time.h new file mode 100644 index 0000000..c6bfaf5 --- /dev/null +++ b/src/sudachi/play_time.h @@ -0,0 +1,68 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include + +#include +#include +#include +#include +#include + +#include "common/common_types.h" +#include "common/fs/fs.h" +#include "common/polyfill_thread.h" +#include "core/core.h" + +namespace PlayTime { +struct PlayTimeElement { + u64 program_id; + u64 play_time; + + inline bool operator==(const PlayTimeElement& other) const { + return program_id == other.program_id; + } + + inline bool operator==(const u64 _program_id) const { + return program_id == _program_id; + } +}; + +class PlayTimeManager { +public: + explicit PlayTimeManager() = default; + ~PlayTimeManager() = default; + +public: + SUDACHI_NON_COPYABLE(PlayTimeManager); + SUDACHI_NON_MOVEABLE(PlayTimeManager); + +public: + bool ResetProgramPlayTime(u64 program_id); + void SetProgramId(u64 program_id); + inline void UpdateTimestamp(); + void Start(); + void Stop(); + +private: + u64 running_program_id; + std::chrono::steady_clock::time_point last_timestamp; + std::jthread play_time_thread; + void AutoTimestamp(std::stop_token stop_token); + void Save(); +}; + +std::optional GetCurrentUserPlayTimePath(); + +bool UpdatePlayTime(u64 program_id, u64 add_play_time); + +[[nodiscard]] bool ReadPlayTimeFile(std::vector& out_play_time_elements); +[[nodiscard]] bool WritePlayTimeFile(const std::vector& play_time_elements); + +u64 GetPlayTime(u64 program_id); + +QString ReadablePlayTime(qulonglong time_seconds); + +} // namespace PlayTime diff --git a/src/sudachi/play_time_manager.cpp b/src/sudachi/play_time_manager.cpp new file mode 100644 index 0000000..691e26d --- /dev/null +++ b/src/sudachi/play_time_manager.cpp @@ -0,0 +1,182 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include "common/alignment.h" +#include "common/fs/file.h" +#include "common/fs/fs.h" +#include "common/fs/path_util.h" +#include "common/logging/log.h" +#include "common/settings.h" +#include "common/thread.h" +#include "core/hle/service/acc/profile_manager.h" +#include "sudachi/play_time_manager.h" + +namespace PlayTime { + +namespace { + +struct PlayTimeElement { + ProgramId program_id; + PlayTime play_time; +}; + +std::optional GetCurrentUserPlayTimePath( + const Service::Account::ProfileManager& manager) { + const auto uuid = manager.GetUser(static_cast(Settings::values.current_user)); + if (!uuid.has_value()) { + return std::nullopt; + } + return Common::FS::GetSudachiPath(Common::FS::SudachiPath::PlayTimeDir) / + uuid->RawString().append(".bin"); +} + +[[nodiscard]] bool ReadPlayTimeFile(PlayTimeDatabase& out_play_time_db, + const Service::Account::ProfileManager& manager) { + const auto filename = GetCurrentUserPlayTimePath(manager); + + if (!filename.has_value()) { + LOG_ERROR(Frontend, "Failed to get current user path"); + return false; + } + + out_play_time_db.clear(); + + if (Common::FS::Exists(filename.value())) { + Common::FS::IOFile file{filename.value(), Common::FS::FileAccessMode::Read, + Common::FS::FileType::BinaryFile}; + if (!file.IsOpen()) { + LOG_ERROR(Frontend, "Failed to open play time file: {}", + Common::FS::PathToUTF8String(filename.value())); + return false; + } + + const size_t num_elements = file.GetSize() / sizeof(PlayTimeElement); + std::vector elements(num_elements); + + if (file.ReadSpan(elements) != num_elements) { + return false; + } + + for (const auto& [program_id, play_time] : elements) { + if (program_id != 0) { + out_play_time_db[program_id] = play_time; + } + } + } + + return true; +} + +[[nodiscard]] bool WritePlayTimeFile(const PlayTimeDatabase& play_time_db, + const Service::Account::ProfileManager& manager) { + const auto filename = GetCurrentUserPlayTimePath(manager); + + if (!filename.has_value()) { + LOG_ERROR(Frontend, "Failed to get current user path"); + return false; + } + + Common::FS::IOFile file{filename.value(), Common::FS::FileAccessMode::Write, + Common::FS::FileType::BinaryFile}; + if (!file.IsOpen()) { + LOG_ERROR(Frontend, "Failed to open play time file: {}", + Common::FS::PathToUTF8String(filename.value())); + return false; + } + + std::vector elements; + elements.reserve(play_time_db.size()); + + for (auto& [program_id, play_time] : play_time_db) { + if (program_id != 0) { + elements.push_back(PlayTimeElement{program_id, play_time}); + } + } + + return file.WriteSpan(elements) == elements.size(); +} + +} // namespace + +PlayTimeManager::PlayTimeManager(Service::Account::ProfileManager& profile_manager) + : manager{profile_manager} { + if (!ReadPlayTimeFile(database, manager)) { + LOG_ERROR(Frontend, "Failed to read play time database! Resetting to default."); + } +} + +PlayTimeManager::~PlayTimeManager() { + Save(); +} + +void PlayTimeManager::SetProgramId(u64 program_id) { + running_program_id = program_id; +} + +void PlayTimeManager::Start() { + play_time_thread = std::jthread([&](std::stop_token stop_token) { AutoTimestamp(stop_token); }); +} + +void PlayTimeManager::Stop() { + play_time_thread = {}; +} + +void PlayTimeManager::AutoTimestamp(std::stop_token stop_token) { + Common::SetCurrentThreadName("PlayTimeReport"); + + using namespace std::literals::chrono_literals; + using std::chrono::seconds; + using std::chrono::steady_clock; + + auto timestamp = steady_clock::now(); + + const auto GetDuration = [&]() -> u64 { + const auto last_timestamp = std::exchange(timestamp, steady_clock::now()); + const auto duration = std::chrono::duration_cast(timestamp - last_timestamp); + return static_cast(duration.count()); + }; + + while (!stop_token.stop_requested()) { + Common::StoppableTimedWait(stop_token, 30s); + + database[running_program_id] += GetDuration(); + Save(); + } +} + +void PlayTimeManager::Save() { + if (!WritePlayTimeFile(database, manager)) { + LOG_ERROR(Frontend, "Failed to update play time database!"); + } +} + +u64 PlayTimeManager::GetPlayTime(u64 program_id) const { + auto it = database.find(program_id); + if (it != database.end()) { + return it->second; + } else { + return 0; + } +} + +void PlayTimeManager::ResetProgramPlayTime(u64 program_id) { + database.erase(program_id); + Save(); +} + +QString ReadablePlayTime(qulonglong time_seconds) { + if (time_seconds == 0) { + return {}; + } + const auto time_minutes = std::max(static_cast(time_seconds) / 60, 1.0); + const auto time_hours = static_cast(time_seconds) / 3600; + const bool is_minutes = time_minutes < 60; + const char* unit = is_minutes ? "m" : "h"; + const auto value = is_minutes ? time_minutes : time_hours; + + return QStringLiteral("%L1 %2") + .arg(value, 0, 'f', !is_minutes && time_seconds % 60 != 0) + .arg(QString::fromUtf8(unit)); +} + +} // namespace PlayTime diff --git a/src/sudachi/play_time_manager.h b/src/sudachi/play_time_manager.h new file mode 100644 index 0000000..f5aaa82 --- /dev/null +++ b/src/sudachi/play_time_manager.h @@ -0,0 +1,50 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include + +#include + +#include "common/common_funcs.h" +#include "common/common_types.h" +#include "common/polyfill_thread.h" + +namespace Service::Account { +class ProfileManager; +} + +namespace PlayTime { + +using ProgramId = u64; +using PlayTime = u64; +using PlayTimeDatabase = std::map; + +class PlayTimeManager { +public: + explicit PlayTimeManager(Service::Account::ProfileManager& profile_manager); + ~PlayTimeManager(); + + SUDACHI_NON_COPYABLE(PlayTimeManager); + SUDACHI_NON_MOVEABLE(PlayTimeManager); + + u64 GetPlayTime(u64 program_id) const; + void ResetProgramPlayTime(u64 program_id); + void SetProgramId(u64 program_id); + void Start(); + void Stop(); + +private: + void AutoTimestamp(std::stop_token stop_token); + void Save(); + + PlayTimeDatabase database; + u64 running_program_id; + std::jthread play_time_thread; + Service::Account::ProfileManager& manager; +}; + +QString ReadablePlayTime(qulonglong time_seconds); + +} // namespace PlayTime diff --git a/src/sudachi/precompiled_headers.h b/src/sudachi/precompiled_headers.h new file mode 100644 index 0000000..0303960 --- /dev/null +++ b/src/sudachi/precompiled_headers.h @@ -0,0 +1,6 @@ +// SPDX-FileCopyrightText: 2022 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include "common/common_precompiled_headers.h" diff --git a/src/sudachi/qt_common.cpp b/src/sudachi/qt_common.cpp new file mode 100644 index 0000000..b8cc21c --- /dev/null +++ b/src/sudachi/qt_common.cpp @@ -0,0 +1,60 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include +#include +#include +#include "common/logging/log.h" +#include "core/frontend/emu_window.h" +#include "sudachi/qt_common.h" + +#if !defined(WIN32) && !defined(__APPLE__) +#include +#elif defined(__APPLE__) +#include +#endif + +namespace QtCommon { +Core::Frontend::WindowSystemType GetWindowSystemType() { + // Determine WSI type based on Qt platform. + QString platform_name = QGuiApplication::platformName(); + if (platform_name == QStringLiteral("windows")) + return Core::Frontend::WindowSystemType::Windows; + else if (platform_name == QStringLiteral("xcb")) + return Core::Frontend::WindowSystemType::X11; + else if (platform_name == QStringLiteral("wayland")) + return Core::Frontend::WindowSystemType::Wayland; + else if (platform_name == QStringLiteral("wayland-egl")) + return Core::Frontend::WindowSystemType::Wayland; + else if (platform_name == QStringLiteral("cocoa")) + return Core::Frontend::WindowSystemType::Cocoa; + else if (platform_name == QStringLiteral("android")) + return Core::Frontend::WindowSystemType::Android; + + LOG_CRITICAL(Frontend, "Unknown Qt platform {}!", platform_name.toStdString()); + return Core::Frontend::WindowSystemType::Windows; +} // namespace Core::Frontend::WindowSystemType + +Core::Frontend::EmuWindow::WindowSystemInfo GetWindowSystemInfo(QWindow* window) { + Core::Frontend::EmuWindow::WindowSystemInfo wsi; + wsi.type = GetWindowSystemType(); + +#if defined(WIN32) + // Our Win32 Qt external doesn't have the private API. + wsi.render_surface = reinterpret_cast(window->winId()); +#elif defined(__APPLE__) + wsi.render_surface = reinterpret_cast(objc_msgSend)( + reinterpret_cast(window->winId()), sel_registerName("layer")); +#else + QPlatformNativeInterface* pni = QGuiApplication::platformNativeInterface(); + wsi.display_connection = pni->nativeResourceForWindow("display", window); + if (wsi.type == Core::Frontend::WindowSystemType::Wayland) + wsi.render_surface = window ? pni->nativeResourceForWindow("surface", window) : nullptr; + else + wsi.render_surface = window ? reinterpret_cast(window->winId()) : nullptr; +#endif + wsi.render_surface_scale = window ? static_cast(window->devicePixelRatio()) : 1.0f; + + return wsi; +} +} // namespace QtCommon diff --git a/src/sudachi/qt_common.h b/src/sudachi/qt_common.h new file mode 100644 index 0000000..b874228 --- /dev/null +++ b/src/sudachi/qt_common.h @@ -0,0 +1,15 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include +#include "core/frontend/emu_window.h" + +namespace QtCommon { + +Core::Frontend::WindowSystemType GetWindowSystemType(); + +Core::Frontend::EmuWindow::WindowSystemInfo GetWindowSystemInfo(QWindow* window); + +} // namespace QtCommon diff --git a/src/sudachi/startup_checks.cpp b/src/sudachi/startup_checks.cpp new file mode 100644 index 0000000..9f1bad0 --- /dev/null +++ b/src/sudachi/startup_checks.cpp @@ -0,0 +1,197 @@ +// SPDX-FileCopyrightText: Copyright 2022 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include "video_core/vulkan_common/vulkan_wrapper.h" + +#ifdef _WIN32 +#include +#include +#include +#elif defined(SUDACHI_UNIX) +#include +#include +#include +#include +#include +#include +#endif + +#include +#include "video_core/vulkan_common/vulkan_instance.h" +#include "video_core/vulkan_common/vulkan_library.h" +#include "sudachi/startup_checks.h" + +void CheckVulkan() { + // Just start the Vulkan loader, this will crash if something is wrong + try { + Vulkan::vk::InstanceDispatch dld; + const auto library = Vulkan::OpenLibrary(); + const Vulkan::vk::Instance instance = + Vulkan::CreateInstance(*library, dld, VK_API_VERSION_1_1); + + } catch (const Vulkan::vk::Exception& exception) { + fmt::print(stderr, "Failed to initialize Vulkan: {}\n", exception.what()); + } +} + +bool CheckEnvVars(bool* is_child) { +#ifdef _WIN32 + // Check environment variable to see if we are the child + char variable_contents[8]; + const DWORD startup_check_var = + GetEnvironmentVariableA(STARTUP_CHECK_ENV_VAR, variable_contents, 8); + if (startup_check_var > 0 && std::strncmp(variable_contents, ENV_VAR_ENABLED_TEXT, 8) == 0) { + CheckVulkan(); + return true; + } + + // Don't perform startup checks if we are a child process + char is_child_s[8]; + const DWORD is_child_len = GetEnvironmentVariableA(IS_CHILD_ENV_VAR, is_child_s, 8); + if (is_child_len > 0 && std::strncmp(is_child_s, ENV_VAR_ENABLED_TEXT, 8) == 0) { + *is_child = true; + return false; + } else if (!SetEnvironmentVariableA(IS_CHILD_ENV_VAR, ENV_VAR_ENABLED_TEXT)) { + fmt::print(stderr, "SetEnvironmentVariableA failed to set {} with error {}\n", + IS_CHILD_ENV_VAR, GetLastError()); + return true; + } +#elif defined(SUDACHI_UNIX) + const char* startup_check_var = getenv(STARTUP_CHECK_ENV_VAR); + if (startup_check_var != nullptr && + std::strncmp(startup_check_var, ENV_VAR_ENABLED_TEXT, 8) == 0) { + CheckVulkan(); + return true; + } +#endif + return false; +} + +bool StartupChecks(const char* arg0, bool* has_broken_vulkan, bool perform_vulkan_check) { +#ifdef _WIN32 + // Set the startup variable for child processes + const bool env_var_set = SetEnvironmentVariableA(STARTUP_CHECK_ENV_VAR, ENV_VAR_ENABLED_TEXT); + if (!env_var_set) { + fmt::print(stderr, "SetEnvironmentVariableA failed to set {} with error {}\n", + STARTUP_CHECK_ENV_VAR, GetLastError()); + return false; + } + + if (perform_vulkan_check) { + // Spawn child process that performs Vulkan check + PROCESS_INFORMATION process_info; + std::memset(&process_info, '\0', sizeof(process_info)); + + if (!SpawnChild(arg0, &process_info, 0)) { + return false; + } + + // Wait until the process exits and get exit code from it + WaitForSingleObject(process_info.hProcess, INFINITE); + DWORD exit_code = STILL_ACTIVE; + const int err = GetExitCodeProcess(process_info.hProcess, &exit_code); + if (err == 0) { + fmt::print(stderr, "GetExitCodeProcess failed with error {}\n", GetLastError()); + } + + // Vulkan is broken if the child crashed (return value is not zero) + *has_broken_vulkan = (exit_code != 0); + + if (CloseHandle(process_info.hProcess) == 0) { + fmt::print(stderr, "CloseHandle failed with error {}\n", GetLastError()); + } + if (CloseHandle(process_info.hThread) == 0) { + fmt::print(stderr, "CloseHandle failed with error {}\n", GetLastError()); + } + } + + if (!SetEnvironmentVariableA(STARTUP_CHECK_ENV_VAR, nullptr)) { + fmt::print(stderr, "SetEnvironmentVariableA failed to clear {} with error {}\n", + STARTUP_CHECK_ENV_VAR, GetLastError()); + } + +#elif defined(SUDACHI_UNIX) + const int env_var_set = setenv(STARTUP_CHECK_ENV_VAR, ENV_VAR_ENABLED_TEXT, 1); + if (env_var_set == -1) { + const int err = errno; + fmt::print(stderr, "setenv failed to set {} with error {}\n", STARTUP_CHECK_ENV_VAR, err); + return false; + } + + if (perform_vulkan_check) { + const pid_t pid = SpawnChild(arg0); + if (pid == -1) { + return false; + } + + // Get exit code from child process + int status; + const int r_val = waitpid(pid, &status, 0); + if (r_val == -1) { + const int err = errno; + fmt::print(stderr, "wait failed with error {}\n", err); + return false; + } + // Vulkan is broken if the child crashed (return value is not zero) + *has_broken_vulkan = (status != 0); + } + + const int env_var_cleared = unsetenv(STARTUP_CHECK_ENV_VAR); + if (env_var_cleared == -1) { + const int err = errno; + fmt::print(stderr, "unsetenv failed to clear {} with error {}\n", STARTUP_CHECK_ENV_VAR, + err); + } +#endif + return false; +} + +#ifdef _WIN32 +bool SpawnChild(const char* arg0, PROCESS_INFORMATION* pi, int flags) { + STARTUPINFOA startup_info; + + std::memset(&startup_info, '\0', sizeof(startup_info)); + startup_info.cb = sizeof(startup_info); + + char p_name[255]; + std::strncpy(p_name, arg0, 254); + p_name[254] = '\0'; + + const bool process_created = CreateProcessA(nullptr, // lpApplicationName + p_name, // lpCommandLine + nullptr, // lpProcessAttributes + nullptr, // lpThreadAttributes + false, // bInheritHandles + flags, // dwCreationFlags + nullptr, // lpEnvironment + nullptr, // lpCurrentDirectory + &startup_info, // lpStartupInfo + pi // lpProcessInformation + ); + if (!process_created) { + fmt::print(stderr, "CreateProcessA failed with error {}\n", GetLastError()); + return false; + } + + return true; +} +#elif defined(SUDACHI_UNIX) +pid_t SpawnChild(const char* arg0) { + const pid_t pid = fork(); + + if (pid == -1) { + // error + const int err = errno; + fmt::print(stderr, "fork failed with error {}\n", err); + return pid; + } else if (pid == 0) { + // child + execlp(arg0, arg0, nullptr); + const int err = errno; + fmt::print(stderr, "execl failed with error {}\n", err); + _exit(0); + } + + return pid; +} +#endif diff --git a/src/sudachi/startup_checks.h b/src/sudachi/startup_checks.h new file mode 100644 index 0000000..a2e46b8 --- /dev/null +++ b/src/sudachi/startup_checks.h @@ -0,0 +1,24 @@ +// SPDX-FileCopyrightText: Copyright 2022 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#ifdef _WIN32 +#include +#elif defined(SUDACHI_UNIX) +#include +#endif + +constexpr char IS_CHILD_ENV_VAR[] = "SUDACHI_IS_CHILD"; +constexpr char STARTUP_CHECK_ENV_VAR[] = "SUDACHI_DO_STARTUP_CHECKS"; +constexpr char ENV_VAR_ENABLED_TEXT[] = "ON"; + +void CheckVulkan(); +bool CheckEnvVars(bool* is_child); +bool StartupChecks(const char* arg0, bool* has_broken_vulkan, bool perform_vulkan_check); + +#ifdef _WIN32 +bool SpawnChild(const char* arg0, PROCESS_INFORMATION* pi, int flags); +#elif defined(SUDACHI_UNIX) +pid_t SpawnChild(const char* arg0); +#endif diff --git a/src/sudachi/sudachi.qrc b/src/sudachi/sudachi.qrc new file mode 100644 index 0000000..6564df8 --- /dev/null +++ b/src/sudachi/sudachi.qrc @@ -0,0 +1,10 @@ + + + + + ../../dist/sudachi.ico + + diff --git a/src/sudachi/sudachi.rc b/src/sudachi/sudachi.rc new file mode 100644 index 0000000..905e658 --- /dev/null +++ b/src/sudachi/sudachi.rc @@ -0,0 +1,22 @@ +// SPDX-FileCopyrightText: 2018 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include "winresrc.h" +///////////////////////////////////////////////////////////////////////////// +// +// Icon +// + +// Icon with lowest ID value placed first to ensure application icon +// remains consistent on all systems. +// QT requires that the default application icon is named IDI_ICON1 + +IDI_ICON1 ICON "../../dist/sudachi.ico" + + +///////////////////////////////////////////////////////////////////////////// +// +// RT_MANIFEST +// + +0 RT_MANIFEST "../../dist/sudachi.manifest" diff --git a/src/sudachi/uisettings.cpp b/src/sudachi/uisettings.cpp new file mode 100644 index 0000000..241e875 --- /dev/null +++ b/src/sudachi/uisettings.cpp @@ -0,0 +1,112 @@ +// SPDX-FileCopyrightText: 2016 Citra Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include +#include "common/fs/fs.h" +#include "common/fs/path_util.h" +#include "sudachi/uisettings.h" + +#ifndef CANNOT_EXPLICITLY_INSTANTIATE +namespace Settings { +template class Setting; +template class Setting; +template class Setting; +template class Setting; +template class Setting; +template class Setting; +template class Setting; +} // namespace Settings +#endif + +namespace FS = Common::FS; + +namespace UISettings { + +const Themes themes{{ + {"Default", "default"}, + {"Default Colorful", "colorful"}, + {"Dark", "qdarkstyle"}, + {"Dark Colorful", "colorful_dark"}, + {"Midnight Blue", "qdarkstyle_midnight_blue"}, + {"Midnight Blue Colorful", "colorful_midnight_blue"}, +}}; + +bool IsDarkTheme() { + const auto& theme = UISettings::values.theme; + return theme == std::string("qdarkstyle") || theme == std::string("qdarkstyle_midnight_blue") || + theme == std::string("colorful_dark") || theme == std::string("colorful_midnight_blue"); +} + +Values values = {}; + +u32 CalculateWidth(u32 height, Settings::AspectRatio ratio) { + switch (ratio) { + case Settings::AspectRatio::R4_3: + return height * 4 / 3; + case Settings::AspectRatio::R21_9: + return height * 21 / 9; + case Settings::AspectRatio::R16_10: + return height * 16 / 10; + case Settings::AspectRatio::R16_9: + case Settings::AspectRatio::Stretch: + // TODO: Move this function wherever appropriate to implement Stretched aspect + break; + } + return height * 16 / 9; +} + +void SaveWindowState() { + const auto window_state_config_loc = + FS::PathToUTF8String(FS::GetSudachiPath(FS::SudachiPath::ConfigDir) / "window_state.ini"); + + void(FS::CreateParentDir(window_state_config_loc)); + QSettings config(QString::fromStdString(window_state_config_loc), QSettings::IniFormat); + + config.setValue(QStringLiteral("geometry"), values.geometry); + config.setValue(QStringLiteral("state"), values.state); + config.setValue(QStringLiteral("geometryRenderWindow"), values.renderwindow_geometry); + config.setValue(QStringLiteral("gameListHeaderState"), values.gamelist_header_state); + config.setValue(QStringLiteral("microProfileDialogGeometry"), values.microprofile_geometry); + + config.sync(); +} + +void RestoreWindowState(std::unique_ptr& qtConfig) { + const auto window_state_config_loc = + FS::PathToUTF8String(FS::GetSudachiPath(FS::SudachiPath::ConfigDir) / "window_state.ini"); + + // Migrate window state from old location + if (!FS::Exists(window_state_config_loc) && qtConfig->Exists("UI", "UILayout\\geometry")) { + const auto config_loc = + FS::PathToUTF8String(FS::GetSudachiPath(FS::SudachiPath::ConfigDir) / "qt-config.ini"); + QSettings config(QString::fromStdString(config_loc), QSettings::IniFormat); + + config.beginGroup(QStringLiteral("UI")); + config.beginGroup(QStringLiteral("UILayout")); + values.geometry = config.value(QStringLiteral("geometry")).toByteArray(); + values.state = config.value(QStringLiteral("state")).toByteArray(); + values.renderwindow_geometry = + config.value(QStringLiteral("geometryRenderWindow")).toByteArray(); + values.gamelist_header_state = + config.value(QStringLiteral("gameListHeaderState")).toByteArray(); + values.microprofile_geometry = + config.value(QStringLiteral("microProfileDialogGeometry")).toByteArray(); + config.endGroup(); + config.endGroup(); + return; + } + + void(FS::CreateParentDir(window_state_config_loc)); + const QSettings config(QString::fromStdString(window_state_config_loc), QSettings::IniFormat); + + values.geometry = config.value(QStringLiteral("geometry")).toByteArray(); + values.state = config.value(QStringLiteral("state")).toByteArray(); + values.renderwindow_geometry = + config.value(QStringLiteral("geometryRenderWindow")).toByteArray(); + values.gamelist_header_state = + config.value(QStringLiteral("gameListHeaderState")).toByteArray(); + values.microprofile_geometry = + config.value(QStringLiteral("microProfileDialogGeometry")).toByteArray(); +} + +} // namespace UISettings diff --git a/src/sudachi/uisettings.h b/src/sudachi/uisettings.h new file mode 100644 index 0000000..fe87064 --- /dev/null +++ b/src/sudachi/uisettings.h @@ -0,0 +1,279 @@ +// SPDX-FileCopyrightText: 2016 Citra Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include "common/common_types.h" +#include "common/settings.h" +#include "common/settings_enums.h" +#include "configuration/qt_config.h" + +using Settings::Category; +using Settings::ConfirmStop; +using Settings::Setting; +using Settings::SwitchableSetting; + +#ifndef CANNOT_EXPLICITLY_INSTANTIATE +namespace Settings { +extern template class Setting; +extern template class Setting; +extern template class Setting; +extern template class Setting; +extern template class Setting; +extern template class Setting; +extern template class Setting; +} // namespace Settings +#endif + +namespace UISettings { + +bool IsDarkTheme(); + +struct ContextualShortcut { + std::string keyseq; + std::string controller_keyseq; + int context; + bool repeat; +}; + +struct Shortcut { + std::string name; + std::string group; + ContextualShortcut shortcut; +}; + +enum class Theme { + Default, + DefaultColorful, + Dark, + DarkColorful, + MidnightBlue, + MidnightBlueColorful, +}; + +static constexpr Theme default_theme{ +#ifdef _WIN32 + Theme::DarkColorful +#else + Theme::DefaultColorful +#endif +}; + +using Themes = std::array, 6>; +extern const Themes themes; + +struct GameDir { + std::string path; + bool deep_scan = false; + bool expanded = false; + bool operator==(const GameDir& rhs) const { + return path == rhs.path; + } + bool operator!=(const GameDir& rhs) const { + return !operator==(rhs); + } +}; + +struct Values { + Settings::Linkage linkage{1000}; + + QByteArray geometry; + QByteArray state; + + QByteArray renderwindow_geometry; + + QByteArray gamelist_header_state; + + QByteArray microprofile_geometry; + Setting microprofile_visible{linkage, false, "microProfileDialogVisible", + Category::UiLayout}; + + Setting single_window_mode{linkage, true, "singleWindowMode", Category::Ui}; + Setting fullscreen{linkage, false, "fullscreen", Category::Ui}; + Setting display_titlebar{linkage, true, "displayTitleBars", Category::Ui}; + Setting show_filter_bar{linkage, true, "showFilterBar", Category::Ui}; + Setting show_status_bar{linkage, true, "showStatusBar", Category::Ui}; + + SwitchableSetting confirm_before_stopping{linkage, + ConfirmStop::Ask_Always, + "confirmStop", + Category::UiGeneral, + Settings::Specialization::Default, + true, + true}; + + Setting first_start{linkage, true, "firstStart", Category::Ui}; + Setting pause_when_in_background{linkage, + false, + "pauseWhenInBackground", + Category::UiGeneral, + Settings::Specialization::Default, + true, + true}; + Setting mute_when_in_background{linkage, + false, + "muteWhenInBackground", + Category::UiAudio, + Settings::Specialization::Default, + true, + true}; + Setting hide_mouse{ + linkage, true, "hideInactiveMouse", Category::UiGeneral, Settings::Specialization::Default, + true, true}; + Setting controller_applet_disabled{linkage, false, "disableControllerApplet", + Category::UiGeneral}; + // Set when Vulkan is known to crash the application + bool has_broken_vulkan = false; + + Setting select_user_on_boot{linkage, + false, + "select_user_on_boot", + Category::UiGeneral, + Settings::Specialization::Default, + true, + true}; + Setting disable_web_applet{linkage, true, "disable_web_applet", Category::Ui}; + + // Discord RPC + Setting enable_discord_presence{linkage, true, "enable_discord_presence", Category::Ui}; + + // logging + Setting show_console{linkage, false, "showConsole", Category::Ui}; + + // Screenshots + Setting enable_screenshot_save_as{linkage, true, "enable_screenshot_save_as", + Category::Screenshots}; + Setting screenshot_height{linkage, 0, "screenshot_height", Category::Screenshots}; + + std::string roms_path; + std::string game_dir_deprecated; + bool game_dir_deprecated_deepscan; + QVector game_dirs; + QStringList recent_files; + Setting language{linkage, {}, "language", Category::Paths}; + + std::string theme; + + // Shortcut name + std::vector shortcuts; + + Setting callout_flags{linkage, 0, "calloutFlags", Category::Ui}; + + // multiplayer settings + Setting multiplayer_nickname{linkage, {}, "nickname", Category::Multiplayer}; + Setting multiplayer_filter_text{linkage, {}, "filter_text", Category::Multiplayer}; + Setting multiplayer_filter_games_owned{linkage, false, "filter_games_owned", + Category::Multiplayer}; + Setting multiplayer_filter_hide_empty{linkage, false, "filter_games_hide_empty", + Category::Multiplayer}; + Setting multiplayer_filter_hide_full{linkage, false, "filter_games_hide_full", + Category::Multiplayer}; + Setting multiplayer_ip{linkage, {}, "ip", Category::Multiplayer}; + Setting multiplayer_port{linkage, 24872, 0, + UINT16_MAX, "port", Category::Multiplayer}; + Setting multiplayer_room_nickname{ + linkage, {}, "room_nickname", Category::Multiplayer}; + Setting multiplayer_room_name{linkage, {}, "room_name", Category::Multiplayer}; + Setting multiplayer_max_player{linkage, 8, 0, 8, "max_player", Category::Multiplayer}; + Setting multiplayer_room_port{linkage, 24872, 0, + UINT16_MAX, "room_port", Category::Multiplayer}; + Setting multiplayer_host_type{linkage, 0, 0, 1, "host_type", Category::Multiplayer}; + Setting multiplayer_game_id{linkage, {}, "game_id", Category::Multiplayer}; + Setting multiplayer_room_description{ + linkage, {}, "room_description", Category::Multiplayer}; + std::pair, std::vector> multiplayer_ban_list; + + // Game List + Setting show_add_ons{linkage, true, "show_add_ons", Category::UiGameList}; + Setting game_icon_size{linkage, 64, "game_icon_size", Category::UiGameList}; + Setting folder_icon_size{linkage, 48, "folder_icon_size", Category::UiGameList}; + Setting row_1_text_id{linkage, 3, "row_1_text_id", Category::UiGameList}; + Setting row_2_text_id{linkage, 2, "row_2_text_id", Category::UiGameList}; + std::atomic_bool is_game_list_reload_pending{false}; + Setting cache_game_list{linkage, true, "cache_game_list", Category::UiGameList}; + Setting favorites_expanded{linkage, true, "favorites_expanded", Category::UiGameList}; + QVector favorited_ids; + + // Compatibility List + Setting show_compat{linkage, false, "show_compat", Category::UiGameList}; + + // Size & File Types Column + Setting show_size{linkage, true, "show_size", Category::UiGameList}; + Setting show_types{linkage, true, "show_types", Category::UiGameList}; + + // Play time + Setting show_play_time{linkage, true, "show_play_time", Category::UiGameList}; + + bool configuration_applied; + bool reset_to_defaults; + bool shortcut_already_warned{false}; +}; + +extern Values values; + +u32 CalculateWidth(u32 height, Settings::AspectRatio ratio); + +void SaveWindowState(); +void RestoreWindowState(std::unique_ptr& qtConfig); + +// This shouldn't have anything except static initializers (no functions). So +// QKeySequence(...).toString() is NOT ALLOWED HERE. +// This must be in alphabetical order according to action name as it must have the same order as +// UISetting::values.shortcuts, which is alphabetically ordered. +// clang-format off +const std::array default_hotkeys{{ + {QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Audio Mute/Unmute")).toStdString(), QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Main Window")).toStdString(), {std::string("Ctrl+M"), std::string("Home+Dpad_Right"), Qt::WindowShortcut, false}}, + {QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Audio Volume Down")).toStdString(), QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Main Window")).toStdString(), {std::string("-"), std::string("Home+Dpad_Down"), Qt::ApplicationShortcut, true}}, + {QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Audio Volume Up")).toStdString(), QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Main Window")).toStdString(), {std::string("="), std::string("Home+Dpad_Up"), Qt::ApplicationShortcut, true}}, + {QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Capture Screenshot")).toStdString(), QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Main Window")).toStdString(), {std::string("Ctrl+P"), std::string("Screenshot"), Qt::WidgetWithChildrenShortcut, false}}, + {QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Change Adapting Filter")).toStdString(), QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Main Window")).toStdString(), {std::string("F8"), std::string("Home+L"), Qt::ApplicationShortcut, false}}, + {QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Change Docked Mode")).toStdString(), QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Main Window")).toStdString(), {std::string("F10"), std::string("Home+X"), Qt::ApplicationShortcut, false}}, + {QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Change GPU Accuracy")).toStdString(), QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Main Window")).toStdString(), {std::string("F9"), std::string("Home+R"), Qt::ApplicationShortcut, false}}, + {QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Continue/Pause Emulation")).toStdString(), QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Main Window")).toStdString(), {std::string("F4"), std::string("Home+Plus"), Qt::WindowShortcut, false}}, + {QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Exit Fullscreen")).toStdString(), QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Main Window")).toStdString(), {std::string("Esc"), std::string(""), Qt::WindowShortcut, false}}, + {QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Exit sudachi")).toStdString(), QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Main Window")).toStdString(), {std::string("Ctrl+Q"), std::string("Home+Minus"), Qt::WindowShortcut, false}}, + {QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Fullscreen")).toStdString(), QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Main Window")).toStdString(), {std::string("F11"), std::string("Home+B"), Qt::WindowShortcut, false}}, + {QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Load File")).toStdString(), QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Main Window")).toStdString(), {std::string("Ctrl+O"), std::string(""), Qt::WidgetWithChildrenShortcut, false}}, + {QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Load/Remove Amiibo")).toStdString(), QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Main Window")).toStdString(), {std::string("F2"), std::string("Home+A"), Qt::WidgetWithChildrenShortcut, false}}, + {QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Multiplayer Browse Public Game Lobby")).toStdString(), QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Main Window")).toStdString(), {std::string("Ctrl+B"), std::string(""), Qt::ApplicationShortcut, false}}, + {QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Multiplayer Create Room")).toStdString(), QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Main Window")).toStdString(), {std::string("Ctrl+N"), std::string(""), Qt::ApplicationShortcut, false}}, + {QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Multiplayer Direct Connect to Room")).toStdString(), QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Main Window")).toStdString(), {std::string("Ctrl+C"), std::string(""), Qt::ApplicationShortcut, false}}, + {QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Multiplayer Leave Room")).toStdString(), QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Main Window")).toStdString(), {std::string("Ctrl+L"), std::string(""), Qt::ApplicationShortcut, false}}, + {QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Multiplayer Show Current Room")).toStdString(), QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Main Window")).toStdString(), {std::string("Ctrl+R"), std::string(""), Qt::ApplicationShortcut, false}}, + {QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Restart Emulation")).toStdString(), QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Main Window")).toStdString(), {std::string("F6"), std::string("R+Plus+Minus"), Qt::WindowShortcut, false}}, + {QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Stop Emulation")).toStdString(), QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Main Window")).toStdString(), {std::string("F5"), std::string("L+Plus+Minus"), Qt::WindowShortcut, false}}, + {QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "TAS Record")).toStdString(), QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Main Window")).toStdString(), {std::string("Ctrl+F7"), std::string(""), Qt::ApplicationShortcut, false}}, + {QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "TAS Reset")).toStdString(), QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Main Window")).toStdString(), {std::string("Ctrl+F6"), std::string(""), Qt::ApplicationShortcut, false}}, + {QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "TAS Start/Stop")).toStdString(), QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Main Window")).toStdString(), {std::string("Ctrl+F5"), std::string(""), Qt::ApplicationShortcut, false}}, + {QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Toggle Filter Bar")).toStdString(), QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Main Window")).toStdString(), {std::string("Ctrl+F"), std::string(""), Qt::WindowShortcut, false}}, + {QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Toggle Framerate Limit")).toStdString(), QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Main Window")).toStdString(), {std::string("Ctrl+U"), std::string("Home+Y"), Qt::ApplicationShortcut, false}}, + {QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Toggle Mouse Panning")).toStdString(), QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Main Window")).toStdString(), {std::string("Ctrl+F9"), std::string(""), Qt::ApplicationShortcut, false}}, + {QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Toggle Renderdoc Capture")).toStdString(), QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Main Window")).toStdString(), {std::string(""), std::string(""), Qt::ApplicationShortcut, false}}, + {QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Toggle Status Bar")).toStdString(), QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Main Window")).toStdString(), {std::string("Ctrl+S"), std::string(""), Qt::WindowShortcut, false}}, +}}; +// clang-format on + +} // namespace UISettings + +Q_DECLARE_METATYPE(UISettings::GameDir*); + +// These metatype declarations cannot be in common/settings.h because core is devoid of QT +Q_DECLARE_METATYPE(Settings::CpuAccuracy); +Q_DECLARE_METATYPE(Settings::GpuAccuracy); +Q_DECLARE_METATYPE(Settings::FullscreenMode); +Q_DECLARE_METATYPE(Settings::NvdecEmulation); +Q_DECLARE_METATYPE(Settings::ResolutionSetup); +Q_DECLARE_METATYPE(Settings::ScalingFilter); +Q_DECLARE_METATYPE(Settings::AntiAliasing); +Q_DECLARE_METATYPE(Settings::RendererBackend); +Q_DECLARE_METATYPE(Settings::ShaderBackend); +Q_DECLARE_METATYPE(Settings::AstcRecompression); +Q_DECLARE_METATYPE(Settings::AstcDecodeMode); diff --git a/src/sudachi/util/clickable_label.cpp b/src/sudachi/util/clickable_label.cpp new file mode 100644 index 0000000..fbfe49e --- /dev/null +++ b/src/sudachi/util/clickable_label.cpp @@ -0,0 +1,11 @@ +// SPDX-FileCopyrightText: Copyright 2017 Citra Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include "sudachi/util/clickable_label.h" + +ClickableLabel::ClickableLabel(QWidget* parent, [[maybe_unused]] Qt::WindowFlags f) + : QLabel(parent) {} + +void ClickableLabel::mouseReleaseEvent([[maybe_unused]] QMouseEvent* event) { + emit clicked(); +} diff --git a/src/sudachi/util/clickable_label.h b/src/sudachi/util/clickable_label.h new file mode 100644 index 0000000..4fe7441 --- /dev/null +++ b/src/sudachi/util/clickable_label.h @@ -0,0 +1,21 @@ +// SPDX-FileCopyrightText: Copyright 2017 Citra Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include +#include + +class ClickableLabel : public QLabel { + Q_OBJECT + +public: + explicit ClickableLabel(QWidget* parent = nullptr, Qt::WindowFlags f = Qt::WindowFlags()); + ~ClickableLabel() = default; + +signals: + void clicked(); + +protected: + void mouseReleaseEvent(QMouseEvent* event); +}; diff --git a/src/sudachi/util/controller_navigation.cpp b/src/sudachi/util/controller_navigation.cpp new file mode 100644 index 0000000..3d5e181 --- /dev/null +++ b/src/sudachi/util/controller_navigation.cpp @@ -0,0 +1,179 @@ +// SPDX-FileCopyrightText: Copyright 2021 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include "common/settings_input.h" +#include "hid_core/frontend/emulated_controller.h" +#include "hid_core/hid_core.h" +#include "sudachi/util/controller_navigation.h" + +ControllerNavigation::ControllerNavigation(Core::HID::HIDCore& hid_core, QWidget* parent) { + player1_controller = hid_core.GetEmulatedController(Core::HID::NpadIdType::Player1); + handheld_controller = hid_core.GetEmulatedController(Core::HID::NpadIdType::Handheld); + Core::HID::ControllerUpdateCallback engine_callback{ + .on_change = [this](Core::HID::ControllerTriggerType type) { ControllerUpdateEvent(type); }, + .is_npad_service = false, + }; + player1_callback_key = player1_controller->SetCallback(engine_callback); + handheld_callback_key = handheld_controller->SetCallback(engine_callback); + is_controller_set = true; +} + +ControllerNavigation::~ControllerNavigation() { + UnloadController(); +} + +void ControllerNavigation::UnloadController() { + if (is_controller_set) { + player1_controller->DeleteCallback(player1_callback_key); + handheld_controller->DeleteCallback(handheld_callback_key); + is_controller_set = false; + } +} + +void ControllerNavigation::TriggerButton(Settings::NativeButton::Values native_button, + Qt::Key key) { + if (button_values[native_button].value && !button_values[native_button].locked) { + emit TriggerKeyboardEvent(key); + } +} + +void ControllerNavigation::ControllerUpdateEvent(Core::HID::ControllerTriggerType type) { + std::scoped_lock lock{mutex}; + if (!Settings::values.controller_navigation) { + return; + } + if (type == Core::HID::ControllerTriggerType::Button) { + ControllerUpdateButton(); + return; + } + + if (type == Core::HID::ControllerTriggerType::Stick) { + ControllerUpdateStick(); + return; + } +} + +void ControllerNavigation::ControllerUpdateButton() { + const auto controller_type = player1_controller->GetNpadStyleIndex(); + const auto& player1_buttons = player1_controller->GetButtonsValues(); + const auto& handheld_buttons = handheld_controller->GetButtonsValues(); + + for (std::size_t i = 0; i < player1_buttons.size(); ++i) { + const bool button = player1_buttons[i].value || handheld_buttons[i].value; + // Trigger only once + button_values[i].locked = button == button_values[i].value; + button_values[i].value = button; + } + + switch (controller_type) { + case Core::HID::NpadStyleIndex::Fullkey: + case Core::HID::NpadStyleIndex::JoyconDual: + case Core::HID::NpadStyleIndex::Handheld: + case Core::HID::NpadStyleIndex::GameCube: + TriggerButton(Settings::NativeButton::A, Qt::Key_Enter); + TriggerButton(Settings::NativeButton::B, Qt::Key_Escape); + TriggerButton(Settings::NativeButton::DDown, Qt::Key_Down); + TriggerButton(Settings::NativeButton::DLeft, Qt::Key_Left); + TriggerButton(Settings::NativeButton::DRight, Qt::Key_Right); + TriggerButton(Settings::NativeButton::DUp, Qt::Key_Up); + break; + case Core::HID::NpadStyleIndex::JoyconLeft: + TriggerButton(Settings::NativeButton::DDown, Qt::Key_Enter); + TriggerButton(Settings::NativeButton::DLeft, Qt::Key_Escape); + break; + case Core::HID::NpadStyleIndex::JoyconRight: + TriggerButton(Settings::NativeButton::X, Qt::Key_Enter); + TriggerButton(Settings::NativeButton::A, Qt::Key_Escape); + break; + default: + break; + } +} + +void ControllerNavigation::ControllerUpdateStick() { + const auto controller_type = player1_controller->GetNpadStyleIndex(); + const auto& player1_sticks = player1_controller->GetSticksValues(); + const auto& handheld_sticks = player1_controller->GetSticksValues(); + bool update = false; + + for (std::size_t i = 0; i < player1_sticks.size(); ++i) { + const Common::Input::StickStatus stick{ + .left = player1_sticks[i].left || handheld_sticks[i].left, + .right = player1_sticks[i].right || handheld_sticks[i].right, + .up = player1_sticks[i].up || handheld_sticks[i].up, + .down = player1_sticks[i].down || handheld_sticks[i].down, + }; + // Trigger only once + if (stick.down != stick_values[i].down || stick.left != stick_values[i].left || + stick.right != stick_values[i].right || stick.up != stick_values[i].up) { + update = true; + } + stick_values[i] = stick; + } + + if (!update) { + return; + } + + switch (controller_type) { + case Core::HID::NpadStyleIndex::Fullkey: + case Core::HID::NpadStyleIndex::JoyconDual: + case Core::HID::NpadStyleIndex::Handheld: + case Core::HID::NpadStyleIndex::GameCube: + if (stick_values[Settings::NativeAnalog::LStick].down) { + emit TriggerKeyboardEvent(Qt::Key_Down); + return; + } + if (stick_values[Settings::NativeAnalog::LStick].left) { + emit TriggerKeyboardEvent(Qt::Key_Left); + return; + } + if (stick_values[Settings::NativeAnalog::LStick].right) { + emit TriggerKeyboardEvent(Qt::Key_Right); + return; + } + if (stick_values[Settings::NativeAnalog::LStick].up) { + emit TriggerKeyboardEvent(Qt::Key_Up); + return; + } + break; + case Core::HID::NpadStyleIndex::JoyconLeft: + if (stick_values[Settings::NativeAnalog::LStick].left) { + emit TriggerKeyboardEvent(Qt::Key_Down); + return; + } + if (stick_values[Settings::NativeAnalog::LStick].up) { + emit TriggerKeyboardEvent(Qt::Key_Left); + return; + } + if (stick_values[Settings::NativeAnalog::LStick].down) { + emit TriggerKeyboardEvent(Qt::Key_Right); + return; + } + if (stick_values[Settings::NativeAnalog::LStick].right) { + emit TriggerKeyboardEvent(Qt::Key_Up); + return; + } + break; + case Core::HID::NpadStyleIndex::JoyconRight: + if (stick_values[Settings::NativeAnalog::RStick].right) { + emit TriggerKeyboardEvent(Qt::Key_Down); + return; + } + if (stick_values[Settings::NativeAnalog::RStick].down) { + emit TriggerKeyboardEvent(Qt::Key_Left); + return; + } + if (stick_values[Settings::NativeAnalog::RStick].up) { + emit TriggerKeyboardEvent(Qt::Key_Right); + return; + } + if (stick_values[Settings::NativeAnalog::RStick].left) { + emit TriggerKeyboardEvent(Qt::Key_Up); + return; + } + break; + default: + break; + } +} diff --git a/src/sudachi/util/controller_navigation.h b/src/sudachi/util/controller_navigation.h new file mode 100644 index 0000000..023e958 --- /dev/null +++ b/src/sudachi/util/controller_navigation.h @@ -0,0 +1,50 @@ +// SPDX-FileCopyrightText: Copyright 2021 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include +#include + +#include "common/input.h" +#include "common/settings_input.h" + +namespace Core::HID { +using ButtonValues = std::array; +using SticksValues = std::array; +enum class ControllerTriggerType; +class EmulatedController; +class HIDCore; +} // namespace Core::HID + +class ControllerNavigation : public QObject { + Q_OBJECT + +public: + explicit ControllerNavigation(Core::HID::HIDCore& hid_core, QWidget* parent = nullptr); + ~ControllerNavigation(); + + /// Disables events from the emulated controller + void UnloadController(); + +signals: + void TriggerKeyboardEvent(Qt::Key key); + +private: + void TriggerButton(Settings::NativeButton::Values native_button, Qt::Key key); + void ControllerUpdateEvent(Core::HID::ControllerTriggerType type); + + void ControllerUpdateButton(); + + void ControllerUpdateStick(); + + Core::HID::ButtonValues button_values{}; + Core::HID::SticksValues stick_values{}; + + int player1_callback_key{}; + int handheld_callback_key{}; + bool is_controller_set{}; + mutable std::mutex mutex; + Core::HID::EmulatedController* player1_controller; + Core::HID::EmulatedController* handheld_controller; +}; diff --git a/src/sudachi/util/limitable_input_dialog.cpp b/src/sudachi/util/limitable_input_dialog.cpp new file mode 100644 index 0000000..4f6e523 --- /dev/null +++ b/src/sudachi/util/limitable_input_dialog.cpp @@ -0,0 +1,88 @@ +// SPDX-FileCopyrightText: Copyright 2018 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include +#include +#include +#include +#include +#include "sudachi/util/limitable_input_dialog.h" + +LimitableInputDialog::LimitableInputDialog(QWidget* parent) : QDialog{parent} { + CreateUI(); + ConnectEvents(); +} + +LimitableInputDialog::~LimitableInputDialog() = default; + +void LimitableInputDialog::CreateUI() { + text_label = new QLabel(this); + text_entry = new QLineEdit(this); + text_label_invalid = new QLabel(this); + buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this); + + auto* const layout = new QVBoxLayout; + layout->addWidget(text_label); + layout->addWidget(text_entry); + layout->addWidget(text_label_invalid); + layout->addWidget(buttons); + + setLayout(layout); +} + +void LimitableInputDialog::ConnectEvents() { + connect(buttons, &QDialogButtonBox::accepted, this, &QDialog::accept); + connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject); +} + +QString LimitableInputDialog::GetText(QWidget* parent, const QString& title, const QString& text, + int min_character_limit, int max_character_limit, + InputLimiter limit_type) { + Q_ASSERT(min_character_limit <= max_character_limit); + + LimitableInputDialog dialog{parent}; + dialog.setWindowTitle(title); + dialog.text_label->setText(text); + dialog.text_entry->setMaxLength(max_character_limit); + dialog.text_label_invalid->show(); + + switch (limit_type) { + case InputLimiter::Filesystem: + dialog.invalid_characters = QStringLiteral("<>:;\"/\\|,.!?*"); + break; + default: + dialog.invalid_characters.clear(); + dialog.text_label_invalid->hide(); + break; + } + dialog.text_label_invalid->setText( + tr("The text can't contain any of the following characters:\n%1") + .arg(dialog.invalid_characters)); + + auto* const ok_button = dialog.buttons->button(QDialogButtonBox::Ok); + ok_button->setEnabled(false); + connect(dialog.text_entry, &QLineEdit::textEdited, [&] { + if (!dialog.invalid_characters.isEmpty()) { + dialog.RemoveInvalidCharacters(); + } + ok_button->setEnabled(dialog.text_entry->text().length() >= min_character_limit); + }); + + if (dialog.exec() != QDialog::Accepted) { + return {}; + } + + return dialog.text_entry->text(); +} + +void LimitableInputDialog::RemoveInvalidCharacters() { + auto cpos = text_entry->cursorPosition(); + for (int i = 0; i < text_entry->text().length(); i++) { + if (invalid_characters.contains(text_entry->text().at(i))) { + text_entry->setText(text_entry->text().remove(i, 1)); + i--; + cpos--; + } + } + text_entry->setCursorPosition(cpos); +} diff --git a/src/sudachi/util/limitable_input_dialog.h b/src/sudachi/util/limitable_input_dialog.h new file mode 100644 index 0000000..ab8a7f4 --- /dev/null +++ b/src/sudachi/util/limitable_input_dialog.h @@ -0,0 +1,40 @@ +// SPDX-FileCopyrightText: Copyright 2018 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include + +class QDialogButtonBox; +class QLabel; +class QLineEdit; + +/// A QDialog that functions similarly to QInputDialog, however, it allows +/// restricting the minimum and total number of characters that can be entered. +class LimitableInputDialog final : public QDialog { + Q_OBJECT +public: + explicit LimitableInputDialog(QWidget* parent = nullptr); + ~LimitableInputDialog() override; + + enum class InputLimiter { + None, + Filesystem, + }; + + static QString GetText(QWidget* parent, const QString& title, const QString& text, + int min_character_limit, int max_character_limit, + InputLimiter limit_type = InputLimiter::None); + +private: + void CreateUI(); + void ConnectEvents(); + + void RemoveInvalidCharacters(); + QString invalid_characters; + + QLabel* text_label; + QLineEdit* text_entry; + QLabel* text_label_invalid; + QDialogButtonBox* buttons; +}; diff --git a/src/sudachi/util/overlay_dialog.cpp b/src/sudachi/util/overlay_dialog.cpp new file mode 100644 index 0000000..f99ecb3 --- /dev/null +++ b/src/sudachi/util/overlay_dialog.cpp @@ -0,0 +1,268 @@ +// SPDX-FileCopyrightText: Copyright 2021 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include +#include +#include + +#include "core/core.h" +#include "hid_core/frontend/input_interpreter.h" +#include "hid_core/hid_types.h" +#include "ui_overlay_dialog.h" +#include "sudachi/util/overlay_dialog.h" + +namespace { + +constexpr float BASE_TITLE_FONT_SIZE = 14.0f; +constexpr float BASE_FONT_SIZE = 18.0f; +constexpr float BASE_WIDTH = 1280.0f; +constexpr float BASE_HEIGHT = 720.0f; + +} // Anonymous namespace + +OverlayDialog::OverlayDialog(QWidget* parent, Core::System& system, const QString& title_text, + const QString& body_text, const QString& left_button_text, + const QString& right_button_text, Qt::Alignment alignment, + bool use_rich_text_) + : QDialog(parent), ui{std::make_unique()}, use_rich_text{use_rich_text_} { + ui->setupUi(this); + + setWindowFlags(Qt::Dialog | Qt::FramelessWindowHint | Qt::WindowTitleHint | + Qt::WindowSystemMenuHint | Qt::CustomizeWindowHint); + setWindowModality(Qt::WindowModal); + setAttribute(Qt::WA_TranslucentBackground); + + if (use_rich_text) { + InitializeRichTextDialog(title_text, body_text, left_button_text, right_button_text, + alignment); + } else { + InitializeRegularTextDialog(title_text, body_text, left_button_text, right_button_text, + alignment); + } + + MoveAndResizeWindow(); + + // TODO (Morph): Remove this when InputInterpreter no longer relies on the HID backend + if (system.IsPoweredOn() && !ui->buttonsDialog->isHidden()) { + input_interpreter = std::make_unique(system); + + StartInputThread(); + } +} + +OverlayDialog::~OverlayDialog() { + StopInputThread(); +} + +void OverlayDialog::InitializeRegularTextDialog(const QString& title_text, const QString& body_text, + const QString& left_button_text, + const QString& right_button_text, + Qt::Alignment alignment) { + ui->stackedDialog->setCurrentIndex(0); + + ui->label_title->setText(title_text); + ui->label_dialog->setText(body_text); + ui->button_cancel->setText(left_button_text); + ui->button_ok_label->setText(right_button_text); + + ui->label_dialog->setAlignment(alignment); + + if (title_text.isEmpty()) { + ui->label_title->hide(); + ui->verticalLayout_2->setStretch(0, 0); + ui->verticalLayout_2->setStretch(1, 219); + ui->verticalLayout_2->setStretch(2, 82); + } + + if (left_button_text.isEmpty()) { + ui->button_cancel->hide(); + ui->button_cancel->setEnabled(false); + } + + if (right_button_text.isEmpty()) { + ui->button_ok_label->hide(); + ui->button_ok_label->setEnabled(false); + } + + if (ui->button_cancel->isHidden() && ui->button_ok_label->isHidden()) { + ui->buttonsDialog->hide(); + return; + } + + connect( + ui->button_cancel, &QPushButton::clicked, this, + [this](bool) { + StopInputThread(); + QDialog::reject(); + }, + Qt::QueuedConnection); + connect( + ui->button_ok_label, &QPushButton::clicked, this, + [this](bool) { + StopInputThread(); + QDialog::accept(); + }, + Qt::QueuedConnection); +} + +void OverlayDialog::InitializeRichTextDialog(const QString& title_text, const QString& body_text, + const QString& left_button_text, + const QString& right_button_text, + Qt::Alignment alignment) { + ui->stackedDialog->setCurrentIndex(1); + + ui->label_title_rich->setText(title_text); + ui->text_browser_dialog->setText(body_text); + ui->button_cancel_rich->setText(left_button_text); + ui->button_ok_rich->setText(right_button_text); + + // TODO (Morph/Rei): Replace this with something that works better + ui->text_browser_dialog->setAlignment(alignment); + + if (title_text.isEmpty()) { + ui->label_title_rich->hide(); + ui->verticalLayout_3->setStretch(0, 0); + ui->verticalLayout_3->setStretch(1, 438); + ui->verticalLayout_3->setStretch(2, 82); + } + + if (left_button_text.isEmpty()) { + ui->button_cancel_rich->hide(); + ui->button_cancel_rich->setEnabled(false); + } + + if (right_button_text.isEmpty()) { + ui->button_ok_rich->hide(); + ui->button_ok_rich->setEnabled(false); + } + + if (ui->button_cancel_rich->isHidden() && ui->button_ok_rich->isHidden()) { + ui->buttonsRichDialog->hide(); + return; + } + + connect( + ui->button_cancel_rich, &QPushButton::clicked, this, + [this](bool) { + StopInputThread(); + QDialog::reject(); + }, + Qt::QueuedConnection); + connect( + ui->button_ok_rich, &QPushButton::clicked, this, + [this](bool) { + StopInputThread(); + QDialog::accept(); + }, + Qt::QueuedConnection); +} + +void OverlayDialog::MoveAndResizeWindow() { + const auto pos = parentWidget()->mapToGlobal(parentWidget()->rect().topLeft()); + const auto width = static_cast(parentWidget()->width()); + const auto height = static_cast(parentWidget()->height()); + + // High DPI + const float dpi_scale = screen()->logicalDotsPerInch() / 96.0f; + + const auto title_text_font_size = BASE_TITLE_FONT_SIZE * (height / BASE_HEIGHT) / dpi_scale; + const auto body_text_font_size = + BASE_FONT_SIZE * (((width / BASE_WIDTH) + (height / BASE_HEIGHT)) / 2.0f) / dpi_scale; + const auto button_text_font_size = BASE_FONT_SIZE * (height / BASE_HEIGHT) / dpi_scale; + + QFont title_text_font(QStringLiteral("MS Shell Dlg 2"), title_text_font_size, QFont::Normal); + QFont body_text_font(QStringLiteral("MS Shell Dlg 2"), body_text_font_size, QFont::Normal); + QFont button_text_font(QStringLiteral("MS Shell Dlg 2"), button_text_font_size, QFont::Normal); + + if (use_rich_text) { + ui->label_title_rich->setFont(title_text_font); + ui->text_browser_dialog->setFont(body_text_font); + ui->button_cancel_rich->setFont(button_text_font); + ui->button_ok_rich->setFont(button_text_font); + } else { + ui->label_title->setFont(title_text_font); + ui->label_dialog->setFont(body_text_font); + ui->button_cancel->setFont(button_text_font); + ui->button_ok_label->setFont(button_text_font); + } + + QDialog::move(pos); + QDialog::resize(width, height); +} + +template +void OverlayDialog::HandleButtonPressedOnce() { + const auto f = [this](Core::HID::NpadButton button) { + if (input_interpreter->IsButtonPressedOnce(button)) { + TranslateButtonPress(button); + } + }; + + (f(T), ...); +} + +void OverlayDialog::TranslateButtonPress(Core::HID::NpadButton button) { + QPushButton* left_button = use_rich_text ? ui->button_cancel_rich : ui->button_cancel; + QPushButton* right_button = use_rich_text ? ui->button_ok_rich : ui->button_ok_label; + + // TODO (Morph): Handle QTextBrowser text scrolling + // TODO (Morph): focusPrevious/NextChild() doesn't work well with the rich text dialog, fix it + + switch (button) { + case Core::HID::NpadButton::A: + case Core::HID::NpadButton::B: + if (left_button->hasFocus()) { + left_button->click(); + } else if (right_button->hasFocus()) { + right_button->click(); + } + break; + case Core::HID::NpadButton::Left: + case Core::HID::NpadButton::StickLLeft: + focusPreviousChild(); + break; + case Core::HID::NpadButton::Right: + case Core::HID::NpadButton::StickLRight: + focusNextChild(); + break; + default: + break; + } +} + +void OverlayDialog::StartInputThread() { + if (input_thread_running) { + return; + } + + input_thread_running = true; + + input_thread = std::thread(&OverlayDialog::InputThread, this); +} + +void OverlayDialog::StopInputThread() { + input_thread_running = false; + + if (input_thread.joinable()) { + input_thread.join(); + } +} + +void OverlayDialog::InputThread() { + while (input_thread_running) { + input_interpreter->PollInput(); + + HandleButtonPressedOnce(); + + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + } +} + +void OverlayDialog::keyPressEvent(QKeyEvent* e) { + if (!ui->buttonsDialog->isHidden() || e->key() != Qt::Key_Escape) { + QDialog::keyPressEvent(e); + } +} diff --git a/src/sudachi/util/overlay_dialog.h b/src/sudachi/util/overlay_dialog.h new file mode 100644 index 0000000..586a2b5 --- /dev/null +++ b/src/sudachi/util/overlay_dialog.h @@ -0,0 +1,108 @@ +// SPDX-FileCopyrightText: Copyright 2021 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include +#include +#include + +#include + +#include "common/common_types.h" + +class InputInterpreter; + +namespace Core { +class System; +} + +namespace Core::HID { +enum class NpadButton : u64; +} + +namespace Ui { +class OverlayDialog; +} + +/** + * An OverlayDialog is an interactive dialog that accepts controller input (while a game is running) + * This dialog attempts to replicate the look and feel of the Nintendo Switch's overlay dialogs and + * provide some extra features such as embedding HTML/Rich Text content in a QTextBrowser. + * The OverlayDialog provides 2 modes: one to embed regular text into a QLabel and another to embed + * HTML/Rich Text content into a QTextBrowser. + */ +class OverlayDialog final : public QDialog { + Q_OBJECT + +public: + explicit OverlayDialog(QWidget* parent, Core::System& system, const QString& title_text, + const QString& body_text, const QString& left_button_text, + const QString& right_button_text, + Qt::Alignment alignment = Qt::AlignCenter, bool use_rich_text_ = false); + ~OverlayDialog() override; + +private: + /** + * Initializes a text dialog with a QLabel storing text. + * Only use this for short text as the dialog buttons would be squashed with longer text. + * + * @param title_text Title text to be displayed + * @param body_text Main text to be displayed + * @param left_button_text Left button text. If empty, the button is hidden and disabled + * @param right_button_text Right button text. If empty, the button is hidden and disabled + * @param alignment Main text alignment + */ + void InitializeRegularTextDialog(const QString& title_text, const QString& body_text, + const QString& left_button_text, + const QString& right_button_text, Qt::Alignment alignment); + + /** + * Initializes a text dialog with a QTextBrowser storing text. + * This is ideal for longer text or rich text content. A scrollbar is shown for longer text. + * + * @param title_text Title text to be displayed + * @param body_text Main text to be displayed + * @param left_button_text Left button text. If empty, the button is hidden and disabled + * @param right_button_text Right button text. If empty, the button is hidden and disabled + * @param alignment Main text alignment + */ + void InitializeRichTextDialog(const QString& title_text, const QString& body_text, + const QString& left_button_text, const QString& right_button_text, + Qt::Alignment alignment); + + /// Moves and resizes the dialog to be fully overlaid on top of the parent window. + void MoveAndResizeWindow(); + + /** + * Handles button presses and converts them into keyboard input. + * + * @tparam HIDButton The list of buttons that can be converted into keyboard input. + */ + template + void HandleButtonPressedOnce(); + + /** + * Translates a button press to focus or click either the left or right buttons. + * + * @param button The button press to process. + */ + void TranslateButtonPress(Core::HID::NpadButton button); + + void StartInputThread(); + void StopInputThread(); + + /// The thread where input is being polled and processed. + void InputThread(); + void keyPressEvent(QKeyEvent* e) override; + + std::unique_ptr ui; + + bool use_rich_text; + + std::unique_ptr input_interpreter; + + std::thread input_thread; + + std::atomic input_thread_running{}; +}; diff --git a/src/sudachi/util/overlay_dialog.ui b/src/sudachi/util/overlay_dialog.ui new file mode 100644 index 0000000..278e2f2 --- /dev/null +++ b/src/sudachi/util/overlay_dialog.ui @@ -0,0 +1,404 @@ + + + OverlayDialog + + + + 0 + 0 + 1280 + 720 + + + + Dialog + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + 0 + + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + 14 + + + + Qt::AlignBottom|Qt::AlignLeading|Qt::AlignLeft + + + + + + + + 18 + + + + Qt::AlignCenter + + + true + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + 0 + 0 + + + + + 18 + + + + Cancel + + + + + + + + 0 + 0 + + + + + 18 + + + + OK + + + + + + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + 14 + + + + + + + Qt::AlignBottom|Qt::AlignLeading|Qt::AlignLeft + + + + + + + + 18 + + + + <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0//EN" "http://www.w3.org/TR/REC-html40/strict.dtd"> +<html><head><meta name="qrichtext" content="1" /><style type="text/css"> +p, li { white-space: pre-wrap; } +</style></head><body style=" font-family:'MS Shell Dlg 2'; font-size:18pt; font-weight:400; font-style:normal;"> +<p style="-qt-paragraph-type:empty; margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><br /></p></body></html> + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + 0 + 0 + + + + + 18 + + + + Cancel + + + + + + + + 0 + 0 + + + + + 18 + + + + OK + + + + + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + + + + + + diff --git a/src/sudachi/util/sequence_dialog/sequence_dialog.cpp b/src/sudachi/util/sequence_dialog/sequence_dialog.cpp new file mode 100644 index 0000000..c432439 --- /dev/null +++ b/src/sudachi/util/sequence_dialog/sequence_dialog.cpp @@ -0,0 +1,38 @@ +// SPDX-FileCopyrightText: 2018 Citra Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include +#include +#include +#include "sudachi/util/sequence_dialog/sequence_dialog.h" + +SequenceDialog::SequenceDialog(QWidget* parent) : QDialog(parent) { + setWindowTitle(tr("Enter a hotkey")); + + key_sequence = new QKeySequenceEdit; + + auto* const buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel); + buttons->setCenterButtons(true); + + auto* const layout = new QVBoxLayout(this); + layout->addWidget(key_sequence); + layout->addWidget(buttons); + + connect(buttons, &QDialogButtonBox::accepted, this, &QDialog::accept); + connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject); +} + +SequenceDialog::~SequenceDialog() = default; + +QKeySequence SequenceDialog::GetSequence() const { + // Only the first key is returned. The other 3, if present, are ignored. + return QKeySequence(key_sequence->keySequence()[0]); +} + +bool SequenceDialog::focusNextPrevChild(bool next) { + return false; +} + +void SequenceDialog::closeEvent(QCloseEvent*) { + reject(); +} diff --git a/src/sudachi/util/sequence_dialog/sequence_dialog.h b/src/sudachi/util/sequence_dialog/sequence_dialog.h new file mode 100644 index 0000000..85e146d --- /dev/null +++ b/src/sudachi/util/sequence_dialog/sequence_dialog.h @@ -0,0 +1,23 @@ +// SPDX-FileCopyrightText: 2018 Citra Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include + +class QKeySequenceEdit; + +class SequenceDialog : public QDialog { + Q_OBJECT + +public: + explicit SequenceDialog(QWidget* parent = nullptr); + ~SequenceDialog() override; + + QKeySequence GetSequence() const; + void closeEvent(QCloseEvent*) override; + +private: + QKeySequenceEdit* key_sequence; + bool focusNextPrevChild(bool next) override; +}; diff --git a/src/sudachi/util/url_request_interceptor.cpp b/src/sudachi/util/url_request_interceptor.cpp new file mode 100644 index 0000000..d11c986 --- /dev/null +++ b/src/sudachi/util/url_request_interceptor.cpp @@ -0,0 +1,33 @@ +// SPDX-FileCopyrightText: Copyright 2020 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#ifdef SUDACHI_USE_QT_WEB_ENGINE + +#include "sudachi/util/url_request_interceptor.h" + +UrlRequestInterceptor::UrlRequestInterceptor(QObject* p) : QWebEngineUrlRequestInterceptor(p) {} + +UrlRequestInterceptor::~UrlRequestInterceptor() = default; + +void UrlRequestInterceptor::interceptRequest(QWebEngineUrlRequestInfo& info) { + const auto resource_type = info.resourceType(); + + switch (resource_type) { + case QWebEngineUrlRequestInfo::ResourceTypeMainFrame: + requested_url = info.requestUrl(); + emit FrameChanged(); + break; + case QWebEngineUrlRequestInfo::ResourceTypeSubFrame: + case QWebEngineUrlRequestInfo::ResourceTypeXhr: + emit FrameChanged(); + break; + default: + break; + } +} + +QUrl UrlRequestInterceptor::GetRequestedURL() const { + return requested_url; +} + +#endif diff --git a/src/sudachi/util/url_request_interceptor.h b/src/sudachi/util/url_request_interceptor.h new file mode 100644 index 0000000..e81be76 --- /dev/null +++ b/src/sudachi/util/url_request_interceptor.h @@ -0,0 +1,29 @@ +// SPDX-FileCopyrightText: Copyright 2020 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#ifdef SUDACHI_USE_QT_WEB_ENGINE + +#include +#include + +class UrlRequestInterceptor : public QWebEngineUrlRequestInterceptor { + Q_OBJECT + +public: + explicit UrlRequestInterceptor(QObject* p = nullptr); + ~UrlRequestInterceptor() override; + + void interceptRequest(QWebEngineUrlRequestInfo& info) override; + + QUrl GetRequestedURL() const; + +signals: + void FrameChanged(); + +private: + QUrl requested_url; +}; + +#endif diff --git a/src/sudachi/util/util.cpp b/src/sudachi/util/util.cpp new file mode 100644 index 0000000..e0ca49f --- /dev/null +++ b/src/sudachi/util/util.cpp @@ -0,0 +1,152 @@ +// SPDX-FileCopyrightText: 2015 Citra Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include +#include +#include + +#include "common/logging/log.h" +#include "sudachi/util/util.h" + +#ifdef _WIN32 +#include +#include "common/fs/file.h" +#endif + +QFont GetMonospaceFont() { + QFont font(QStringLiteral("monospace")); + // Automatic fallback to a monospace font on on platforms without a font called "monospace" + font.setStyleHint(QFont::Monospace); + font.setFixedPitch(true); + return font; +} + +QString ReadableByteSize(qulonglong size) { + static constexpr std::array units{"B", "KiB", "MiB", "GiB", "TiB", "PiB"}; + if (size == 0) { + return QStringLiteral("0"); + } + + const int digit_groups = std::min(static_cast(std::log10(size) / std::log10(1024)), + static_cast(units.size())); + return QStringLiteral("%L1 %2") + .arg(size / std::pow(1024, digit_groups), 0, 'f', 1) + .arg(QString::fromUtf8(units[digit_groups])); +} + +QPixmap CreateCirclePixmapFromColor(const QColor& color) { + QPixmap circle_pixmap(16, 16); + circle_pixmap.fill(Qt::transparent); + QPainter painter(&circle_pixmap); + painter.setRenderHint(QPainter::Antialiasing); + painter.setPen(color); + painter.setBrush(color); + painter.drawEllipse({circle_pixmap.width() / 2.0, circle_pixmap.height() / 2.0}, 7.0, 7.0); + return circle_pixmap; +} + +bool SaveIconToFile(const std::filesystem::path& icon_path, const QImage& image) { +#if defined(WIN32) +#pragma pack(push, 2) + struct IconDir { + WORD id_reserved; + WORD id_type; + WORD id_count; + }; + + struct IconDirEntry { + BYTE width; + BYTE height; + BYTE color_count; + BYTE reserved; + WORD planes; + WORD bit_count; + DWORD bytes_in_res; + DWORD image_offset; + }; +#pragma pack(pop) + + const QImage source_image = image.convertToFormat(QImage::Format_RGB32); + constexpr std::array scale_sizes{256, 128, 64, 48, 32, 24, 16}; + constexpr int bytes_per_pixel = 4; + + const IconDir icon_dir{ + .id_reserved = 0, + .id_type = 1, + .id_count = static_cast(scale_sizes.size()), + }; + + Common::FS::IOFile icon_file(icon_path.string(), Common::FS::FileAccessMode::Write, + Common::FS::FileType::BinaryFile); + if (!icon_file.IsOpen()) { + return false; + } + + if (!icon_file.Write(icon_dir)) { + return false; + } + + std::size_t image_offset = sizeof(IconDir) + (sizeof(IconDirEntry) * scale_sizes.size()); + for (std::size_t i = 0; i < scale_sizes.size(); i++) { + const int image_size = scale_sizes[i] * scale_sizes[i] * bytes_per_pixel; + const IconDirEntry icon_entry{ + .width = static_cast(scale_sizes[i]), + .height = static_cast(scale_sizes[i]), + .color_count = 0, + .reserved = 0, + .planes = 1, + .bit_count = bytes_per_pixel * 8, + .bytes_in_res = static_cast(sizeof(BITMAPINFOHEADER) + image_size), + .image_offset = static_cast(image_offset), + }; + image_offset += icon_entry.bytes_in_res; + if (!icon_file.Write(icon_entry)) { + return false; + } + } + + for (std::size_t i = 0; i < scale_sizes.size(); i++) { + const QImage scaled_image = source_image.scaled( + scale_sizes[i], scale_sizes[i], Qt::IgnoreAspectRatio, Qt::SmoothTransformation); + const BITMAPINFOHEADER info_header{ + .biSize = sizeof(BITMAPINFOHEADER), + .biWidth = scaled_image.width(), + .biHeight = scaled_image.height() * 2, + .biPlanes = 1, + .biBitCount = bytes_per_pixel * 8, + .biCompression = BI_RGB, + .biSizeImage{}, + .biXPelsPerMeter{}, + .biYPelsPerMeter{}, + .biClrUsed{}, + .biClrImportant{}, + }; + + if (!icon_file.Write(info_header)) { + return false; + } + + for (int y = 0; y < scaled_image.height(); y++) { + const auto* line = scaled_image.scanLine(scaled_image.height() - 1 - y); + std::vector line_data(scaled_image.width() * bytes_per_pixel); + std::memcpy(line_data.data(), line, line_data.size()); + if (!icon_file.Write(line_data)) { + return false; + } + } + } + icon_file.Close(); + + return true; +#elif defined(__linux__) || defined(__FreeBSD__) + // Convert and write the icon as a PNG + if (!image.save(QString::fromStdString(icon_path.string()))) { + LOG_ERROR(Frontend, "Could not write icon as PNG to file"); + } else { + LOG_INFO(Frontend, "Wrote an icon to {}", icon_path.string()); + } + return true; +#else + return false; +#endif +} diff --git a/src/sudachi/util/util.h b/src/sudachi/util/util.h new file mode 100644 index 0000000..4094cf6 --- /dev/null +++ b/src/sudachi/util/util.h @@ -0,0 +1,29 @@ +// SPDX-FileCopyrightText: 2015 Citra Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include +#include +#include + +/// Returns a QFont object appropriate to use as a monospace font for debugging widgets, etc. +[[nodiscard]] QFont GetMonospaceFont(); + +/// Convert a size in bytes into a readable format (KiB, MiB, etc.) +[[nodiscard]] QString ReadableByteSize(qulonglong size); + +/** + * Creates a circle pixmap from a specified color + * @param color The color the pixmap shall have + * @return QPixmap circle pixmap + */ +[[nodiscard]] QPixmap CreateCirclePixmapFromColor(const QColor& color); + +/** + * Saves a windows icon to a file + * @param path The icons path + * @param image The image to save + * @return bool If the operation succeeded + */ +[[nodiscard]] bool SaveIconToFile(const std::filesystem::path& icon_path, const QImage& image); diff --git a/src/sudachi/vk_device_info.cpp b/src/sudachi/vk_device_info.cpp new file mode 100644 index 0000000..e586516 --- /dev/null +++ b/src/sudachi/vk_device_info.cpp @@ -0,0 +1,67 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include +#include + +#include "sudachi/qt_common.h" + +#include "common/dynamic_library.h" +#include "common/logging/log.h" +#include "video_core/vulkan_common/vulkan_device.h" +#include "video_core/vulkan_common/vulkan_instance.h" +#include "video_core/vulkan_common/vulkan_library.h" +#include "video_core/vulkan_common/vulkan_surface.h" +#include "video_core/vulkan_common/vulkan_wrapper.h" +#include "vulkan/vulkan_core.h" +#include "sudachi/vk_device_info.h" + +class QWindow; + +namespace VkDeviceInfo { +Record::Record(std::string_view name_, const std::vector& vsync_modes_, + bool has_broken_compute_) + : name{name_}, vsync_support{vsync_modes_}, has_broken_compute{has_broken_compute_} {} + +Record::~Record() = default; + +void PopulateRecords(std::vector& records, QWindow* window) try { + using namespace Vulkan; + + // Create a test window with a Vulkan surface type for checking present modes. + QWindow test_window(window); + test_window.setSurfaceType(QWindow::VulkanSurface); + test_window.create(); + auto wsi = QtCommon::GetWindowSystemInfo(&test_window); + + vk::InstanceDispatch dld; + const auto library = OpenLibrary(); + const vk::Instance instance = CreateInstance(*library, dld, VK_API_VERSION_1_1, wsi.type); + const std::vector physical_devices = instance.EnumeratePhysicalDevices(); + vk::SurfaceKHR surface = CreateSurface(instance, wsi); + + records.clear(); + records.reserve(physical_devices.size()); + for (const VkPhysicalDevice device : physical_devices) { + const auto physical_device = vk::PhysicalDevice(device, dld); + const std::string name = physical_device.GetProperties().deviceName; + const std::vector present_modes = + physical_device.GetSurfacePresentModesKHR(*surface); + + VkPhysicalDeviceDriverProperties driver_properties{}; + driver_properties.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_DRIVER_PROPERTIES; + driver_properties.pNext = nullptr; + VkPhysicalDeviceProperties2 properties{}; + properties.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_PROPERTIES_2_KHR; + properties.pNext = &driver_properties; + dld.vkGetPhysicalDeviceProperties2(physical_device, &properties); + + bool has_broken_compute{Vulkan::Device::CheckBrokenCompute( + driver_properties.driverID, properties.properties.driverVersion)}; + + records.push_back(VkDeviceInfo::Record(name, present_modes, has_broken_compute)); + } +} catch (const Vulkan::vk::Exception& exception) { + LOG_ERROR(Frontend, "Failed to enumerate devices with error: {}", exception.what()); +} +} // namespace VkDeviceInfo diff --git a/src/sudachi/vk_device_info.h b/src/sudachi/vk_device_info.h new file mode 100644 index 0000000..1818865 --- /dev/null +++ b/src/sudachi/vk_device_info.h @@ -0,0 +1,36 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include +#include +#include +#include +#include +#include +#include "common/common_types.h" +#include "vulkan/vulkan_core.h" + +class QWindow; + +namespace Settings { +enum class VSyncMode : u32; +} +// #include "common/settings.h" + +namespace VkDeviceInfo { +// Short class to record Vulkan driver information for configuration purposes +class Record { +public: + explicit Record(std::string_view name, const std::vector& vsync_modes, + bool has_broken_compute); + ~Record(); + + const std::string name; + const std::vector vsync_support; + const bool has_broken_compute; +}; + +void PopulateRecords(std::vector& records, QWindow* window); +} // namespace VkDeviceInfo diff --git a/src/sudachi_cmd/CMakeLists.txt b/src/sudachi_cmd/CMakeLists.txt new file mode 100644 index 0000000..9be012c --- /dev/null +++ b/src/sudachi_cmd/CMakeLists.txt @@ -0,0 +1,65 @@ +# SPDX-FileCopyrightText: 2018 sudachi Emulator Project +# SPDX-License-Identifier: GPL-2.0-or-later + +# Credits to Samantas5855 and others for this function. +function(create_resource file output filename) + # Read hex data from file + file(READ ${file} filedata HEX) + # Convert hex data for C compatibility + string(REGEX REPLACE "([0-9a-f][0-9a-f])" "0x\\1," filedata ${filedata}) + # Write data to output file + set(RESOURCES_DIR "${PROJECT_BINARY_DIR}/dist" PARENT_SCOPE) + file(WRITE "${PROJECT_BINARY_DIR}/dist/${output}" "const unsigned char ${filename}[] = {${filedata}};\nconst unsigned ${filename}_size = sizeof(${filename});\n") +endfunction() + +add_executable(sudachi-cmd + emu_window/emu_window_sdl2.cpp + emu_window/emu_window_sdl2.h + emu_window/emu_window_sdl2_gl.cpp + emu_window/emu_window_sdl2_gl.h + emu_window/emu_window_sdl2_null.cpp + emu_window/emu_window_sdl2_null.h + emu_window/emu_window_sdl2_vk.cpp + emu_window/emu_window_sdl2_vk.h + precompiled_headers.h + sdl_config.cpp + sdl_config.h + sudachi.cpp + sudachi.rc +) + +target_link_libraries(sudachi-cmd PRIVATE common core input_common frontend_common) +target_link_libraries(sudachi-cmd PRIVATE glad) +if (MSVC) + target_link_libraries(sudachi-cmd PRIVATE getopt) +endif() +target_link_libraries(sudachi-cmd PRIVATE ${PLATFORM_LIBRARIES} Threads::Threads) + +create_resource("../../dist/sudachi.bmp" "sudachi_cmd/sudachi_icon.h" "sudachi_icon") +target_include_directories(sudachi-cmd PRIVATE ${RESOURCES_DIR}) + +target_link_libraries(sudachi-cmd PRIVATE SDL2::SDL2 Vulkan::Headers) + +if(UNIX AND NOT APPLE) + install(TARGETS sudachi-cmd) +endif() + +if(WIN32) + # compile as a win32 gui application instead of a console application + if(MSVC) + set_target_properties(sudachi-cmd PROPERTIES LINK_FLAGS_RELEASE "/SUBSYSTEM:WINDOWS /ENTRY:mainCRTStartup") + elseif(MINGW) + set_target_properties(sudachi-cmd PROPERTIES LINK_FLAGS_RELEASE "-Wl,--subsystem,windows") + endif() +endif() + +if (MSVC) + include(CopySudachiSDLDeps) + copy_sudachi_SDL_deps(sudachi-cmd) +endif() + +if (SUDACHI_USE_PRECOMPILED_HEADERS) + target_precompile_headers(sudachi-cmd PRIVATE precompiled_headers.h) +endif() + +create_target_directory_groups(sudachi-cmd) diff --git a/src/sudachi_cmd/config.cpp b/src/sudachi_cmd/config.cpp new file mode 100644 index 0000000..f8fc0f3 --- /dev/null +++ b/src/sudachi_cmd/config.cpp @@ -0,0 +1,279 @@ +// SPDX-FileCopyrightText: 2014 Citra Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include +#include +#include +#include +#include +#include "common/fs/file.h" +#include "common/fs/fs.h" +#include "common/fs/path_util.h" +#include "common/logging/log.h" +#include "common/settings.h" +#include "core/hle/service/acc/profile_manager.h" +#include "input_common/main.h" +#include "sudachi_cmd/config.h" +#include "sudachi_cmd/default_ini.h" + +namespace FS = Common::FS; + +const std::filesystem::path default_config_path = + FS::GetSudachiPath(FS::SudachiPath::ConfigDir) / "sdl2-config.ini"; + +Config::Config(std::optional config_path) + : sdl2_config_loc{config_path.value_or(default_config_path)}, + sdl2_config{std::make_unique(FS::PathToUTF8String(sdl2_config_loc))} { + Reload(); +} + +Config::~Config() = default; + +bool Config::LoadINI(const std::string& default_contents, bool retry) { + const auto config_loc_str = FS::PathToUTF8String(sdl2_config_loc); + if (sdl2_config->ParseError() < 0) { + if (retry) { + LOG_WARNING(Config, "Failed to load {}. Creating file from defaults...", + config_loc_str); + + void(FS::CreateParentDir(sdl2_config_loc)); + void(FS::WriteStringToFile(sdl2_config_loc, FS::FileType::TextFile, default_contents)); + + sdl2_config = std::make_unique(config_loc_str); + + return LoadINI(default_contents, false); + } + LOG_ERROR(Config, "Failed."); + return false; + } + LOG_INFO(Config, "Successfully loaded {}", config_loc_str); + return true; +} + +static const std::array default_buttons = { + SDL_SCANCODE_A, SDL_SCANCODE_S, SDL_SCANCODE_Z, SDL_SCANCODE_X, SDL_SCANCODE_T, + SDL_SCANCODE_G, SDL_SCANCODE_F, SDL_SCANCODE_H, SDL_SCANCODE_Q, SDL_SCANCODE_W, + SDL_SCANCODE_M, SDL_SCANCODE_N, SDL_SCANCODE_1, SDL_SCANCODE_2, SDL_SCANCODE_B, +}; + +static const std::array default_motions = { + SDL_SCANCODE_7, + SDL_SCANCODE_8, +}; + +static const std::array, Settings::NativeAnalog::NumAnalogs> default_analogs{{ + { + SDL_SCANCODE_UP, + SDL_SCANCODE_DOWN, + SDL_SCANCODE_LEFT, + SDL_SCANCODE_RIGHT, + SDL_SCANCODE_D, + }, + { + SDL_SCANCODE_I, + SDL_SCANCODE_K, + SDL_SCANCODE_J, + SDL_SCANCODE_L, + SDL_SCANCODE_D, + }, +}}; + +template <> +void Config::ReadSetting(const std::string& group, Settings::Setting& setting) { + std::string setting_value = sdl2_config->Get(group, setting.GetLabel(), setting.GetDefault()); + if (setting_value.empty()) { + setting_value = setting.GetDefault(); + } + setting = std::move(setting_value); +} + +template <> +void Config::ReadSetting(const std::string& group, Settings::Setting& setting) { + setting = sdl2_config->GetBoolean(group, setting.GetLabel(), setting.GetDefault()); +} + +template +void Config::ReadSetting(const std::string& group, Settings::Setting& setting) { + setting = static_cast(sdl2_config->GetInteger(group, setting.GetLabel(), + static_cast(setting.GetDefault()))); +} + +void Config::ReadCategory(Settings::Category category) { + for (const auto setting : Settings::values.linkage.by_category[category]) { + const char* category_name = [&]() { + if (category == Settings::Category::Controls) { + // For compatibility with older configs + return "ControlsGeneral"; + } else { + return Settings::TranslateCategory(category); + } + }(); + std::string setting_value = + sdl2_config->Get(category_name, setting->GetLabel(), setting->DefaultToString()); + setting->LoadString(setting_value); + } +} + +void Config::ReadValues() { + // Controls + ReadCategory(Settings::Category::Controls); + + for (std::size_t p = 0; p < Settings::values.players.GetValue().size(); ++p) { + auto& player = Settings::values.players.GetValue()[p]; + + const auto group = fmt::format("ControlsP{}", p); + for (int i = 0; i < Settings::NativeButton::NumButtons; ++i) { + std::string default_param = InputCommon::GenerateKeyboardParam(default_buttons[i]); + player.buttons[i] = + sdl2_config->Get(group, Settings::NativeButton::mapping[i], default_param); + if (player.buttons[i].empty()) { + player.buttons[i] = default_param; + } + } + + for (int i = 0; i < Settings::NativeAnalog::NumAnalogs; ++i) { + std::string default_param = InputCommon::GenerateAnalogParamFromKeys( + default_analogs[i][0], default_analogs[i][1], default_analogs[i][2], + default_analogs[i][3], default_analogs[i][4], 0.5f); + player.analogs[i] = + sdl2_config->Get(group, Settings::NativeAnalog::mapping[i], default_param); + if (player.analogs[i].empty()) { + player.analogs[i] = default_param; + } + } + + for (int i = 0; i < Settings::NativeMotion::NumMotions; ++i) { + const std::string default_param = + InputCommon::GenerateKeyboardParam(default_motions[i]); + auto& player_motions = player.motions[i]; + + player_motions = + sdl2_config->Get(group, Settings::NativeMotion::mapping[i], default_param); + if (player_motions.empty()) { + player_motions = default_param; + } + } + + player.connected = sdl2_config->GetBoolean(group, "connected", false); + } + + for (int i = 0; i < Settings::NativeButton::NumButtons; ++i) { + std::string default_param = InputCommon::GenerateKeyboardParam(default_buttons[i]); + Settings::values.debug_pad_buttons[i] = sdl2_config->Get( + "ControlsGeneral", std::string("debug_pad_") + Settings::NativeButton::mapping[i], + default_param); + if (Settings::values.debug_pad_buttons[i].empty()) + Settings::values.debug_pad_buttons[i] = default_param; + } + + for (int i = 0; i < Settings::NativeAnalog::NumAnalogs; ++i) { + std::string default_param = InputCommon::GenerateAnalogParamFromKeys( + default_analogs[i][0], default_analogs[i][1], default_analogs[i][2], + default_analogs[i][3], default_analogs[i][4], 0.5f); + Settings::values.debug_pad_analogs[i] = sdl2_config->Get( + "ControlsGeneral", std::string("debug_pad_") + Settings::NativeAnalog::mapping[i], + default_param); + if (Settings::values.debug_pad_analogs[i].empty()) + Settings::values.debug_pad_analogs[i] = default_param; + } + + Settings::values.touchscreen.enabled = + sdl2_config->GetBoolean("ControlsGeneral", "touch_enabled", true); + Settings::values.touchscreen.rotation_angle = + sdl2_config->GetInteger("ControlsGeneral", "touch_angle", 0); + Settings::values.touchscreen.diameter_x = + sdl2_config->GetInteger("ControlsGeneral", "touch_diameter_x", 15); + Settings::values.touchscreen.diameter_y = + sdl2_config->GetInteger("ControlsGeneral", "touch_diameter_y", 15); + + int num_touch_from_button_maps = + sdl2_config->GetInteger("ControlsGeneral", "touch_from_button_map", 0); + if (num_touch_from_button_maps > 0) { + for (int i = 0; i < num_touch_from_button_maps; ++i) { + Settings::TouchFromButtonMap map; + map.name = sdl2_config->Get("ControlsGeneral", + std::string("touch_from_button_maps_") + std::to_string(i) + + std::string("_name"), + "default"); + const int num_touch_maps = sdl2_config->GetInteger( + "ControlsGeneral", + std::string("touch_from_button_maps_") + std::to_string(i) + std::string("_count"), + 0); + map.buttons.reserve(num_touch_maps); + + for (int j = 0; j < num_touch_maps; ++j) { + std::string touch_mapping = + sdl2_config->Get("ControlsGeneral", + std::string("touch_from_button_maps_") + std::to_string(i) + + std::string("_bind_") + std::to_string(j), + ""); + map.buttons.emplace_back(std::move(touch_mapping)); + } + + Settings::values.touch_from_button_maps.emplace_back(std::move(map)); + } + } else { + Settings::values.touch_from_button_maps.emplace_back( + Settings::TouchFromButtonMap{"default", {}}); + num_touch_from_button_maps = 1; + } + Settings::values.touch_from_button_map_index = std::clamp( + Settings::values.touch_from_button_map_index.GetValue(), 0, num_touch_from_button_maps - 1); + + ReadCategory(Settings::Category::Audio); + ReadCategory(Settings::Category::Core); + ReadCategory(Settings::Category::Cpu); + ReadCategory(Settings::Category::CpuDebug); + ReadCategory(Settings::Category::CpuUnsafe); + ReadCategory(Settings::Category::Renderer); + ReadCategory(Settings::Category::RendererAdvanced); + ReadCategory(Settings::Category::RendererDebug); + ReadCategory(Settings::Category::System); + ReadCategory(Settings::Category::SystemAudio); + ReadCategory(Settings::Category::DataStorage); + ReadCategory(Settings::Category::Debugging); + ReadCategory(Settings::Category::DebuggingGraphics); + ReadCategory(Settings::Category::Miscellaneous); + ReadCategory(Settings::Category::Network); + ReadCategory(Settings::Category::WebService); + + // Data Storage + FS::SetSudachiPath(FS::SudachiPath::NANDDir, + sdl2_config->Get("Data Storage", "nand_directory", + FS::GetSudachiPathString(FS::SudachiPath::NANDDir))); + FS::SetSudachiPath(FS::SudachiPath::SDMCDir, + sdl2_config->Get("Data Storage", "sdmc_directory", + FS::GetSudachiPathString(FS::SudachiPath::SDMCDir))); + FS::SetSudachiPath(FS::SudachiPath::LoadDir, + sdl2_config->Get("Data Storage", "load_directory", + FS::GetSudachiPathString(FS::SudachiPath::LoadDir))); + FS::SetSudachiPath(FS::SudachiPath::DumpDir, + sdl2_config->Get("Data Storage", "dump_directory", + FS::GetSudachiPathString(FS::SudachiPath::DumpDir))); + + // Debugging + Settings::values.record_frame_times = + sdl2_config->GetBoolean("Debugging", "record_frame_times", false); + + const auto title_list = sdl2_config->Get("AddOns", "title_ids", ""); + std::stringstream ss(title_list); + std::string line; + while (std::getline(ss, line, '|')) { + const auto title_id = std::strtoul(line.c_str(), nullptr, 16); + const auto disabled_list = sdl2_config->Get("AddOns", "disabled_" + line, ""); + + std::stringstream inner_ss(disabled_list); + std::string inner_line; + std::vector out; + while (std::getline(inner_ss, inner_line, '|')) { + out.push_back(inner_line); + } + + Settings::values.disabled_addons.insert_or_assign(title_id, out); + } +} + +void Config::Reload() { + LoadINI(DefaultINI::sdl2_config_file); + ReadValues(); +} diff --git a/src/sudachi_cmd/config.h b/src/sudachi_cmd/config.h new file mode 100644 index 0000000..cbcfb55 --- /dev/null +++ b/src/sudachi_cmd/config.h @@ -0,0 +1,38 @@ +// SPDX-FileCopyrightText: 2014 Citra Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include +#include +#include +#include + +#include "common/settings.h" + +class INIReader; + +class Config { + std::filesystem::path sdl2_config_loc; + std::unique_ptr sdl2_config; + + bool LoadINI(const std::string& default_contents = "", bool retry = true); + void ReadValues(); + +public: + explicit Config(std::optional config_path); + ~Config(); + + void Reload(); + +private: + /** + * Applies a value read from the sdl2_config to a Setting. + * + * @param group The name of the INI group + * @param setting The sudachi setting to modify + */ + template + void ReadSetting(const std::string& group, Settings::Setting& setting); + void ReadCategory(Settings::Category category); +}; diff --git a/src/sudachi_cmd/default_ini.h b/src/sudachi_cmd/default_ini.h new file mode 100644 index 0000000..6c412c0 --- /dev/null +++ b/src/sudachi_cmd/default_ini.h @@ -0,0 +1,553 @@ +// SPDX-FileCopyrightText: 2014 Citra Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +namespace DefaultINI { + +const char* sdl2_config_file = + R"( +[ControlsP0] +# The input devices and parameters for each Switch native input +# The config section determines the player number where the config will be applied on. For example "ControlsP0", "ControlsP1", ... +# It should be in the format of "engine:[engine_name],[param1]:[value1],[param2]:[value2]..." +# Escape characters $0 (for ':'), $1 (for ',') and $2 (for '$') can be used in values + +# Indicates if this player should be connected at boot +# 0 (default): Disabled, 1: Enabled +connected= + +# for button input, the following devices are available: +# - "keyboard" (default) for keyboard input. Required parameters: +# - "code": the code of the key to bind +# - "sdl" for joystick input using SDL. Required parameters: +# - "guid": SDL identification GUID of the joystick +# - "port": the index of the joystick to bind +# - "button"(optional): the index of the button to bind +# - "hat"(optional): the index of the hat to bind as direction buttons +# - "axis"(optional): the index of the axis to bind +# - "direction"(only used for hat): the direction name of the hat to bind. Can be "up", "down", "left" or "right" +# - "threshold"(only used for axis): a float value in (-1.0, 1.0) which the button is +# triggered if the axis value crosses +# - "direction"(only used for axis): "+" means the button is triggered when the axis value +# is greater than the threshold; "-" means the button is triggered when the axis value +# is smaller than the threshold +button_a= +button_b= +button_x= +button_y= +button_lstick= +button_rstick= +button_l= +button_r= +button_zl= +button_zr= +button_plus= +button_minus= +button_dleft= +button_dup= +button_dright= +button_ddown= +button_lstick_left= +button_lstick_up= +button_lstick_right= +button_lstick_down= +button_sl= +button_sr= +button_home= +button_screenshot= + +# for analog input, the following devices are available: +# - "analog_from_button" (default) for emulating analog input from direction buttons. Required parameters: +# - "up", "down", "left", "right": sub-devices for each direction. +# Should be in the format as a button input devices using escape characters, for example, "engine$0keyboard$1code$00" +# - "modifier": sub-devices as a modifier. +# - "modifier_scale": a float number representing the applied modifier scale to the analog input. +# Must be in range of 0.0-1.0. Defaults to 0.5 +# - "sdl" for joystick input using SDL. Required parameters: +# - "guid": SDL identification GUID of the joystick +# - "port": the index of the joystick to bind +# - "axis_x": the index of the axis to bind as x-axis (default to 0) +# - "axis_y": the index of the axis to bind as y-axis (default to 1) +lstick= +rstick= + +# for motion input, the following devices are available: +# - "keyboard" (default) for emulating random motion input from buttons. Required parameters: +# - "code": the code of the key to bind +# - "sdl" for motion input using SDL. Required parameters: +# - "guid": SDL identification GUID of the joystick +# - "port": the index of the joystick to bind +# - "motion": the index of the motion sensor to bind +# - "cemuhookudp" for motion input using Cemu Hook protocol. Required parameters: +# - "guid": the IP address of the cemu hook server encoded to a hex string. for example 192.168.0.1 = "c0a80001" +# - "port": the port of the cemu hook server +# - "pad": the index of the joystick +# - "motion": the index of the motion sensor of the joystick to bind +motionleft= +motionright= + +[ControlsGeneral] +# To use the debug_pad, prepend `debug_pad_` before each button setting above. +# i.e. debug_pad_button_a= + +# Enable debug pad inputs to the guest +# 0 (default): Disabled, 1: Enabled +debug_pad_enabled = + +# Enable sdl raw input. Allows to configure up to 8 xinput controllers. +# 0 (default): Disabled, 1: Enabled +enable_raw_input = + +# Enable sudachi joycon driver instead of SDL drive. +# 0: Disabled, 1 (default): Enabled +enable_joycon_driver = + +# Emulates an analog input from buttons. Allowing to dial any angle. +# 0 (default): Disabled, 1: Enabled +emulate_analog_keyboard = + +# Whether to enable or disable vibration +# 0: Disabled, 1 (default): Enabled +vibration_enabled= + +# Whether to enable or disable accurate vibrations +# 0 (default): Disabled, 1: Enabled +enable_accurate_vibrations= + +# Enables controller motion inputs +# 0: Disabled, 1 (default): Enabled +motion_enabled = + +# Defines the udp device's touch screen coordinate system for cemuhookudp devices +# - "min_x", "min_y", "max_x", "max_y" +touch_device= + +# for mapping buttons to touch inputs. +#touch_from_button_map=1 +#touch_from_button_maps_0_name=default +#touch_from_button_maps_0_count=2 +#touch_from_button_maps_0_bind_0=foo +#touch_from_button_maps_0_bind_1=bar +# etc. + +# List of Cemuhook UDP servers, delimited by ','. +# Default: 127.0.0.1:26760 +# Example: 127.0.0.1:26760,123.4.5.67:26761 +udp_input_servers = + +# Enable controlling an axis via a mouse input. +# 0 (default): Off, 1: On +mouse_panning = + +# Set mouse panning horizontal sensitivity. +# Default: 50.0 +mouse_panning_x_sensitivity = + +# Set mouse panning vertical sensitivity. +# Default: 50.0 +mouse_panning_y_sensitivity = + +# Set mouse panning deadzone horizontal counterweight. +# Default: 0.0 +mouse_panning_deadzone_x_counterweight = + +# Set mouse panning deadzone vertical counterweight. +# Default: 0.0 +mouse_panning_deadzone_y_counterweight = + +# Set mouse panning stick decay strength. +# Default: 22.0 +mouse_panning_decay_strength = + +# Set mouse panning stick minimum decay. +# Default: 5.0 +mouse_panning_minimum_decay = + +# Emulate an analog control stick from keyboard inputs. +# 0 (default): Disabled, 1: Enabled +emulate_analog_keyboard = + +# Enable mouse inputs to the guest +# 0 (default): Disabled, 1: Enabled +mouse_enabled = + +# Enable keyboard inputs to the guest +# 0 (default): Disabled, 1: Enabled +keyboard_enabled = + +)" + R"( +[Core] +# Whether to use multi-core for CPU emulation +# 0: Disabled, 1 (default): Enabled +use_multi_core = + +# Enable unsafe extended guest system memory layout (8GB DRAM) +# 0 (default): Disabled, 1: Enabled +use_unsafe_extended_memory_layout = + +[Cpu] +# Adjusts various optimizations. +# Auto-select mode enables choice unsafe optimizations. +# Accurate enables only safe optimizations. +# Unsafe allows any unsafe optimizations. +# 0 (default): Auto-select, 1: Accurate, 2: Enable unsafe optimizations +cpu_accuracy = + +# Allow disabling safe optimizations. +# 0 (default): Disabled, 1: Enabled +cpu_debug_mode = + +# Enable inline page tables optimization (faster guest memory access) +# 0: Disabled, 1 (default): Enabled +cpuopt_page_tables = + +# Enable block linking CPU optimization (reduce block dispatcher use during predictable jumps) +# 0: Disabled, 1 (default): Enabled +cpuopt_block_linking = + +# Enable return stack buffer CPU optimization (reduce block dispatcher use during predictable returns) +# 0: Disabled, 1 (default): Enabled +cpuopt_return_stack_buffer = + +# Enable fast dispatcher CPU optimization (use a two-tiered dispatcher architecture) +# 0: Disabled, 1 (default): Enabled +cpuopt_fast_dispatcher = + +# Enable context elimination CPU Optimization (reduce host memory use for guest context) +# 0: Disabled, 1 (default): Enabled +cpuopt_context_elimination = + +# Enable constant propagation CPU optimization (basic IR optimization) +# 0: Disabled, 1 (default): Enabled +cpuopt_const_prop = + +# Enable miscellaneous CPU optimizations (basic IR optimization) +# 0: Disabled, 1 (default): Enabled +cpuopt_misc_ir = + +# Enable reduction of memory misalignment checks (reduce memory fallbacks for misaligned access) +# 0: Disabled, 1 (default): Enabled +cpuopt_reduce_misalign_checks = + +# Enable Host MMU Emulation (faster guest memory access) +# 0: Disabled, 1 (default): Enabled +cpuopt_fastmem = + +# Enable Host MMU Emulation for exclusive memory instructions (faster guest memory access) +# 0: Disabled, 1 (default): Enabled +cpuopt_fastmem_exclusives = + +# Enable fallback on failure of fastmem of exclusive memory instructions (faster guest memory access) +# 0: Disabled, 1 (default): Enabled +cpuopt_recompile_exclusives = + +# Enable optimization to ignore invalid memory accesses (faster guest memory access) +# 0: Disabled, 1 (default): Enabled +cpuopt_ignore_memory_aborts = + +# Enable unfuse FMA (improve performance on CPUs without FMA) +# Only enabled if cpu_accuracy is set to Unsafe. Automatically chosen with cpu_accuracy = Auto-select. +# 0: Disabled, 1 (default): Enabled +cpuopt_unsafe_unfuse_fma = + +# Enable faster FRSQRTE and FRECPE +# Only enabled if cpu_accuracy is set to Unsafe. +# 0: Disabled, 1 (default): Enabled +cpuopt_unsafe_reduce_fp_error = + +# Enable faster ASIMD instructions (32 bits only) +# Only enabled if cpu_accuracy is set to Unsafe. Automatically chosen with cpu_accuracy = Auto-select. +# 0: Disabled, 1 (default): Enabled +cpuopt_unsafe_ignore_standard_fpcr = + +# Enable inaccurate NaN handling +# Only enabled if cpu_accuracy is set to Unsafe. Automatically chosen with cpu_accuracy = Auto-select. +# 0: Disabled, 1 (default): Enabled +cpuopt_unsafe_inaccurate_nan = + +# Disable address space checks (64 bits only) +# Only enabled if cpu_accuracy is set to Unsafe. Automatically chosen with cpu_accuracy = Auto-select. +# 0: Disabled, 1 (default): Enabled +cpuopt_unsafe_fastmem_check = + +# Enable faster exclusive instructions +# Only enabled if cpu_accuracy is set to Unsafe. Automatically chosen with cpu_accuracy = Auto-select. +# 0: Disabled, 1 (default): Enabled +cpuopt_unsafe_ignore_global_monitor = + +)" + R"( +[Renderer] +# Which backend API to use. +# 0: OpenGL, 1 (default): Vulkan +backend = + +# Whether to enable asynchronous presentation (Vulkan only) +# 0 (default): Off, 1: On +async_presentation = + +# Enable graphics API debugging mode. +# 0 (default): Disabled, 1: Enabled +debug = + +# Enable shader feedback. +# 0 (default): Disabled, 1: Enabled +renderer_shader_feedback = + +# Enable Nsight Aftermath crash dumps +# 0 (default): Disabled, 1: Enabled +nsight_aftermath = + +# Disable shader loop safety checks, executing the shader without loop logic changes +# 0 (default): Disabled, 1: Enabled +disable_shader_loop_safety_checks = + +# Which Vulkan physical device to use (defaults to 0) +vulkan_device = + +# 0: 0.5x (360p/540p) [EXPERIMENTAL] +# 1: 0.75x (540p/810p) [EXPERIMENTAL] +# 2 (default): 1x (720p/1080p) +# 3: 1.5x (1080p/1620p) [EXPERIMENTAL] +# 4: 2x (1440p/2160p) +# 5: 3x (2160p/3240p) +# 6: 4x (2880p/4320p) +# 7: 5x (3600p/5400p) +# 8: 6x (4320p/6480p) +# 9: 7x (5040p/7560p) +# 10: 8x (5760/8640p) +resolution_setup = + +# Pixel filter to use when up- or down-sampling rendered frames. +# 0: Nearest Neighbor +# 1 (default): Bilinear +# 2: Bicubic +# 3: Gaussian +# 4: ScaleForce +# 5: AMD FidelityFX™️ Super Resolution +scaling_filter = + +# Anti-Aliasing (AA) +# 0 (default): None, 1: FXAA, 2: SMAA +anti_aliasing = + +# Whether to use fullscreen or borderless window mode +# 0 (Windows default): Borderless window, 1 (All other default): Exclusive fullscreen +fullscreen_mode = + +# Aspect ratio +# 0: Default (16:9), 1: Force 4:3, 2: Force 21:9, 3: Force 16:10, 4: Stretch to Window +aspect_ratio = + +# Anisotropic filtering +# 0: Default, 1: 2x, 2: 4x, 3: 8x, 4: 16x +max_anisotropy = + +# Whether to enable VSync or not. +# OpenGL: Values other than 0 enable VSync +# Vulkan: FIFO is selected if the requested mode is not supported by the driver. +# FIFO (VSync) does not drop frames or exhibit tearing but is limited by the screen refresh rate. +# FIFO Relaxed is similar to FIFO but allows tearing as it recovers from a slow down. +# Mailbox can have lower latency than FIFO and does not tear but may drop frames. +# Immediate (no synchronization) just presents whatever is available and can exhibit tearing. +# 0: Immediate (Off), 1: Mailbox, 2 (Default): FIFO (On), 3: FIFO Relaxed +use_vsync = + +# Selects the OpenGL shader backend. NV_gpu_program5 is required for GLASM. If NV_gpu_program5 is +# not available and GLASM is selected, GLSL will be used. +# 0: GLSL, 1 (default): GLASM, 2: SPIR-V +shader_backend = + +# Uses reactive flushing instead of predictive flushing. Allowing a more accurate syncing of memory. +# 0: Off, 1 (default): On +use_reactive_flushing = + +# Whether to allow asynchronous shader building. +# 0 (default): Off, 1: On +use_asynchronous_shaders = + +# NVDEC emulation. +# 0: Disabled, 1: CPU Decoding, 2 (default): GPU Decoding +nvdec_emulation = + +# Accelerate ASTC texture decoding. +# 0: Off, 1 (default): On +accelerate_astc = + +# Decode ASTC textures asynchronously. +# 0 (default): Off, 1: On +async_astc = + +# Recompress ASTC textures to a different format. +# 0 (default): Uncompressed, 1: BC1 (Low quality), 2: BC3: (Medium quality) +async_astc = + +# Turns on the speed limiter, which will limit the emulation speed to the desired speed limit value +# 0: Off, 1: On (default) +use_speed_limit = + +# Limits the speed of the game to run no faster than this value as a percentage of target speed +# 1 - 9999: Speed limit as a percentage of target game speed. 100 (default) +speed_limit = + +# Whether to use disk based shader cache +# 0: Off, 1 (default): On +use_disk_shader_cache = + +# Which gpu accuracy level to use +# 0: Normal, 1 (default): High, 2: Extreme (Very slow) +gpu_accuracy = + +# Whether to use asynchronous GPU emulation +# 0 : Off (slow), 1 (default): On (fast) +use_asynchronous_gpu_emulation = + +# Inform the guest that GPU operations completed more quickly than they did. +# 0: Off, 1 (default): On +use_fast_gpu_time = + +# Whether to use garbage collection or not for GPU caches. +# 0 (default): Off, 1: On +use_caches_gc = + +# The clear color for the renderer. What shows up on the sides of the bottom screen. +# Must be in range of 0-255. Defaults to 0 for all. +bg_red = +bg_blue = +bg_green = + +)" + R"( +[Audio] +# Which audio output engine to use. +# auto (default): Auto-select +# cubeb: Cubeb audio engine (if available) +# sdl2: SDL2 audio engine (if available) +# null: No audio output +output_engine = + +# Which audio device to use. +# auto (default): Auto-select +output_device = + +# Output volume. +# 100 (default): 100%, 0; mute +volume = + +[Data Storage] +# Whether to create a virtual SD card. +# 1 (default): Yes, 0: No +use_virtual_sd = + +# Whether or not to enable gamecard emulation +# 1: Yes, 0 (default): No +gamecard_inserted = + +# Whether or not the gamecard should be emulated as the current game +# If 'gamecard_inserted' is 0 this setting is irrelevant +# 1: Yes, 0 (default): No +gamecard_current_game = + +# Path to an XCI file to use as the gamecard +# If 'gamecard_inserted' is 0 this setting is irrelevant +# If 'gamecard_current_game' is 1 this setting is irrelevant +gamecard_path = + +[System] +# Whether the system is docked +# 1 (default): Yes, 0: No +use_docked_mode = + +# Sets the seed for the RNG generator built into the switch +# rng_seed will be ignored and randomly generated if rng_seed_enabled is false +rng_seed_enabled = +rng_seed = + +# Sets the current time (in seconds since 12:00 AM Jan 1, 1970) that will be used by the time service +# This will auto-increment, with the time set being the time the game is started +# This override will only occur if custom_rtc_enabled is true, otherwise the current time is used +custom_rtc_enabled = +custom_rtc = + +# Sets the systems language index +# 0: Japanese, 1: English (default), 2: French, 3: German, 4: Italian, 5: Spanish, 6: Chinese, +# 7: Korean, 8: Dutch, 9: Portuguese, 10: Russian, 11: Taiwanese, 12: British English, 13: Canadian French, +# 14: Latin American Spanish, 15: Simplified Chinese, 16: Traditional Chinese, 17: Brazilian Portuguese +language_index = + +# The system region that sudachi will use during emulation +# -1: Auto-select (default), 0: Japan, 1: USA, 2: Europe, 3: Australia, 4: China, 5: Korea, 6: Taiwan +region_index = + +# The system time zone that sudachi will use during emulation +# 0: Auto-select (default), 1: Default (system archive value), Others: Index for specified time zone +time_zone_index = + +# Sets the sound output mode. +# 0: Mono, 1 (default): Stereo, 2: Surround +sound_index = + +[Miscellaneous] +# A filter which removes logs below a certain logging level. +# Examples: *:Debug Kernel.SVC:Trace Service.*:Critical +log_filter = *:Trace + +# Use developer keys +# 0 (default): Disabled, 1: Enabled +use_dev_keys = + +[Debugging] +# Record frame time data, can be found in the log directory. Boolean value +record_frame_times = +# Determines whether or not sudachi will dump the ExeFS of all games it attempts to load while loading them +dump_exefs=false +# Determines whether or not sudachi will dump all NSOs it attempts to load while loading them +dump_nso=false +# Determines whether or not sudachi will save the filesystem access log. +enable_fs_access_log=false +# Enables verbose reporting services +reporting_services = +# Determines whether or not sudachi will report to the game that the emulated console is in Kiosk Mode +# false: Retail/Normal Mode (default), true: Kiosk Mode +quest_flag = +# Determines whether debug asserts should be enabled, which will throw an exception on asserts. +# false: Disabled (default), true: Enabled +use_debug_asserts = +# Determines whether unimplemented HLE service calls should be automatically stubbed. +# false: Disabled (default), true: Enabled +use_auto_stub = +# Enables/Disables the macro JIT compiler +disable_macro_jit=false +# Determines whether to enable the GDB stub and wait for the debugger to attach before running. +# false: Disabled (default), true: Enabled +use_gdbstub=false +# The port to use for the GDB server, if it is enabled. +gdbstub_port=6543 + +[WebService] +# Whether or not to enable telemetry +# 0: No, 1 (default): Yes +enable_telemetry = +# URL for Web API +web_api_url = https://api.sudachi-emu.org +# Username and token for sudachi Web Service +# See https://profile.sudachi-emu.org/ for more info +sudachi_username = +sudachi_token = + +[Network] +# Name of the network interface device to use with sudachi LAN play. +# e.g. On *nix: 'enp7s0', 'wlp6s0u1u3u3', 'lo' +# e.g. On Windows: 'Ethernet', 'Wi-Fi' +network_interface = + +[AddOns] +# Used to disable add-ons +# List of title IDs of games that will have add-ons disabled (separated by '|'): +title_ids = +# For each title ID, have a key/value pair called `disabled_` equal to the names of the add-ons to disable (sep. by '|') +# e.x. disabled_0100000000010000 = Update|DLC <- disables Updates and DLC on Super Mario Odyssey +)"; +} // namespace DefaultINI diff --git a/src/sudachi_cmd/emu_window/emu_window_sdl2.cpp b/src/sudachi_cmd/emu_window/emu_window_sdl2.cpp new file mode 100644 index 0000000..0bc387b --- /dev/null +++ b/src/sudachi_cmd/emu_window/emu_window_sdl2.cpp @@ -0,0 +1,254 @@ +// SPDX-FileCopyrightText: 2016 Citra Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include + +#include "common/logging/log.h" +#include "common/scm_rev.h" +#include "common/settings.h" +#include "core/core.h" +#include "core/perf_stats.h" +#include "hid_core/hid_core.h" +#include "input_common/drivers/keyboard.h" +#include "input_common/drivers/mouse.h" +#include "input_common/drivers/touch_screen.h" +#include "input_common/main.h" +#include "sudachi_cmd/emu_window/emu_window_sdl2.h" +#include "sudachi_cmd/sudachi_icon.h" + +EmuWindow_SDL2::EmuWindow_SDL2(InputCommon::InputSubsystem* input_subsystem_, Core::System& system_) + : input_subsystem{input_subsystem_}, system{system_} { + input_subsystem->Initialize(); + if (SDL_Init(SDL_INIT_VIDEO | SDL_INIT_JOYSTICK | SDL_INIT_GAMECONTROLLER) < 0) { + LOG_CRITICAL(Frontend, "Failed to initialize SDL2: {}, Exiting...", SDL_GetError()); + exit(1); + } + SDL_SetMainReady(); +} + +EmuWindow_SDL2::~EmuWindow_SDL2() { + system.HIDCore().UnloadInputDevices(); + input_subsystem->Shutdown(); + SDL_Quit(); +} + +InputCommon::MouseButton EmuWindow_SDL2::SDLButtonToMouseButton(u32 button) const { + switch (button) { + case SDL_BUTTON_LEFT: + return InputCommon::MouseButton::Left; + case SDL_BUTTON_RIGHT: + return InputCommon::MouseButton::Right; + case SDL_BUTTON_MIDDLE: + return InputCommon::MouseButton::Wheel; + case SDL_BUTTON_X1: + return InputCommon::MouseButton::Backward; + case SDL_BUTTON_X2: + return InputCommon::MouseButton::Forward; + default: + return InputCommon::MouseButton::Undefined; + } +} + +std::pair EmuWindow_SDL2::MouseToTouchPos(s32 touch_x, s32 touch_y) const { + int w, h; + SDL_GetWindowSize(render_window, &w, &h); + const float fx = static_cast(touch_x) / w; + const float fy = static_cast(touch_y) / h; + + return {std::clamp(fx, 0.0f, 1.0f), std::clamp(fy, 0.0f, 1.0f)}; +} + +void EmuWindow_SDL2::OnMouseButton(u32 button, u8 state, s32 x, s32 y) { + const auto mouse_button = SDLButtonToMouseButton(button); + if (state == SDL_PRESSED) { + const auto [touch_x, touch_y] = MouseToTouchPos(x, y); + input_subsystem->GetMouse()->PressButton(x, y, mouse_button); + input_subsystem->GetMouse()->PressMouseButton(mouse_button); + input_subsystem->GetMouse()->PressTouchButton(touch_x, touch_y, mouse_button); + } else { + input_subsystem->GetMouse()->ReleaseButton(mouse_button); + } +} + +void EmuWindow_SDL2::OnMouseMotion(s32 x, s32 y) { + const auto [touch_x, touch_y] = MouseToTouchPos(x, y); + input_subsystem->GetMouse()->Move(x, y, 0, 0); + input_subsystem->GetMouse()->MouseMove(touch_x, touch_y); + input_subsystem->GetMouse()->TouchMove(touch_x, touch_y); +} + +void EmuWindow_SDL2::OnFingerDown(float x, float y, std::size_t id) { + input_subsystem->GetTouchScreen()->TouchPressed(x, y, id); +} + +void EmuWindow_SDL2::OnFingerMotion(float x, float y, std::size_t id) { + input_subsystem->GetTouchScreen()->TouchMoved(x, y, id); +} + +void EmuWindow_SDL2::OnFingerUp() { + input_subsystem->GetTouchScreen()->ReleaseAllTouch(); +} + +void EmuWindow_SDL2::OnKeyEvent(int key, u8 state) { + if (state == SDL_PRESSED) { + input_subsystem->GetKeyboard()->PressKey(static_cast(key)); + } else if (state == SDL_RELEASED) { + input_subsystem->GetKeyboard()->ReleaseKey(static_cast(key)); + } +} + +bool EmuWindow_SDL2::IsOpen() const { + return is_open; +} + +bool EmuWindow_SDL2::IsShown() const { + return is_shown; +} + +void EmuWindow_SDL2::OnResize() { + int width, height; + SDL_GL_GetDrawableSize(render_window, &width, &height); + UpdateCurrentFramebufferLayout(width, height); +} + +void EmuWindow_SDL2::ShowCursor(bool show_cursor) { + SDL_ShowCursor(show_cursor ? SDL_ENABLE : SDL_DISABLE); +} + +void EmuWindow_SDL2::Fullscreen() { + SDL_DisplayMode display_mode; + switch (Settings::values.fullscreen_mode.GetValue()) { + case Settings::FullscreenMode::Exclusive: + // Set window size to render size before entering fullscreen -- SDL2 does not resize window + // to display dimensions automatically in this mode. + if (SDL_GetDesktopDisplayMode(0, &display_mode) == 0) { + SDL_SetWindowSize(render_window, display_mode.w, display_mode.h); + } else { + LOG_ERROR(Frontend, "SDL_GetDesktopDisplayMode failed: {}", SDL_GetError()); + } + + if (SDL_SetWindowFullscreen(render_window, SDL_WINDOW_FULLSCREEN) == 0) { + return; + } + + LOG_ERROR(Frontend, "Fullscreening failed: {}", SDL_GetError()); + LOG_INFO(Frontend, "Attempting to use borderless fullscreen..."); + [[fallthrough]]; + case Settings::FullscreenMode::Borderless: + if (SDL_SetWindowFullscreen(render_window, SDL_WINDOW_FULLSCREEN_DESKTOP) == 0) { + return; + } + + LOG_ERROR(Frontend, "Borderless fullscreening failed: {}", SDL_GetError()); + [[fallthrough]]; + default: + // Fallback algorithm: Maximise window. + // Works on all systems (unless something is seriously wrong), so no fallback for this one. + LOG_INFO(Frontend, "Falling back on a maximised window..."); + SDL_MaximizeWindow(render_window); + break; + } +} + +void EmuWindow_SDL2::WaitEvent() { + // Called on main thread + SDL_Event event; + + if (!SDL_WaitEvent(&event)) { + const char* error = SDL_GetError(); + if (!error || strcmp(error, "") == 0) { + // https://github.com/libsdl-org/SDL/issues/5780 + // Sometimes SDL will return without actually having hit an error condition; + // just ignore it in this case. + return; + } + + LOG_CRITICAL(Frontend, "SDL_WaitEvent failed: {}", error); + exit(1); + } + + switch (event.type) { + case SDL_WINDOWEVENT: + switch (event.window.event) { + case SDL_WINDOWEVENT_SIZE_CHANGED: + case SDL_WINDOWEVENT_RESIZED: + case SDL_WINDOWEVENT_MAXIMIZED: + case SDL_WINDOWEVENT_RESTORED: + OnResize(); + break; + case SDL_WINDOWEVENT_MINIMIZED: + case SDL_WINDOWEVENT_EXPOSED: + is_shown = event.window.event == SDL_WINDOWEVENT_EXPOSED; + OnResize(); + break; + case SDL_WINDOWEVENT_CLOSE: + is_open = false; + break; + } + break; + case SDL_KEYDOWN: + case SDL_KEYUP: + OnKeyEvent(static_cast(event.key.keysym.scancode), event.key.state); + break; + case SDL_MOUSEMOTION: + // ignore if it came from touch + if (event.button.which != SDL_TOUCH_MOUSEID) + OnMouseMotion(event.motion.x, event.motion.y); + break; + case SDL_MOUSEBUTTONDOWN: + case SDL_MOUSEBUTTONUP: + // ignore if it came from touch + if (event.button.which != SDL_TOUCH_MOUSEID) { + OnMouseButton(event.button.button, event.button.state, event.button.x, event.button.y); + } + break; + case SDL_FINGERDOWN: + OnFingerDown(event.tfinger.x, event.tfinger.y, + static_cast(event.tfinger.touchId)); + break; + case SDL_FINGERMOTION: + OnFingerMotion(event.tfinger.x, event.tfinger.y, + static_cast(event.tfinger.touchId)); + break; + case SDL_FINGERUP: + OnFingerUp(); + break; + case SDL_QUIT: + is_open = false; + break; + default: + break; + } + + const u32 current_time = SDL_GetTicks(); + if (current_time > last_time + 2000) { + const auto results = system.GetAndResetPerfStats(); + const auto title = + fmt::format("sudachi {} | {}-{} | FPS: {:.0f} ({:.0f}%)", Common::g_build_fullname, + Common::g_scm_branch, Common::g_scm_desc, results.average_game_fps, + results.emulation_speed * 100.0); + SDL_SetWindowTitle(render_window, title.c_str()); + last_time = current_time; + } +} + +// Credits to Samantas5855 and others for this function. +void EmuWindow_SDL2::SetWindowIcon() { + SDL_RWops* const sudachi_icon_stream = SDL_RWFromConstMem((void*)sudachi_icon, sudachi_icon_size); + if (sudachi_icon_stream == nullptr) { + LOG_WARNING(Frontend, "Failed to create sudachi icon stream."); + return; + } + SDL_Surface* const window_icon = SDL_LoadBMP_RW(sudachi_icon_stream, 1); + if (window_icon == nullptr) { + LOG_WARNING(Frontend, "Failed to read BMP from stream."); + return; + } + // The icon is attached to the window pointer + SDL_SetWindowIcon(render_window, window_icon); + SDL_FreeSurface(window_icon); +} + +void EmuWindow_SDL2::OnMinimalClientAreaChangeRequest(std::pair minimal_size) { + SDL_SetWindowMinimumSize(render_window, minimal_size.first, minimal_size.second); +} diff --git a/src/sudachi_cmd/emu_window/emu_window_sdl2.h b/src/sudachi_cmd/emu_window/emu_window_sdl2.h new file mode 100644 index 0000000..0c9d057 --- /dev/null +++ b/src/sudachi_cmd/emu_window/emu_window_sdl2.h @@ -0,0 +1,95 @@ +// SPDX-FileCopyrightText: 2016 Citra Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include + +#include "core/frontend/emu_window.h" +#include "core/frontend/graphics_context.h" + +struct SDL_Window; + +namespace Core { +class System; +} + +namespace InputCommon { +class InputSubsystem; +enum class MouseButton; +} // namespace InputCommon + +class EmuWindow_SDL2 : public Core::Frontend::EmuWindow { +public: + explicit EmuWindow_SDL2(InputCommon::InputSubsystem* input_subsystem_, Core::System& system_); + ~EmuWindow_SDL2(); + + /// Whether the window is still open, and a close request hasn't yet been sent + bool IsOpen() const; + + /// Returns if window is shown (not minimized) + bool IsShown() const override; + + /// Wait for the next event on the main thread. + void WaitEvent(); + + // Sets the window icon from sudachi.bmp + void SetWindowIcon(); + +protected: + /// Called by WaitEvent when a key is pressed or released. + void OnKeyEvent(int key, u8 state); + + /// Converts a SDL mouse button into MouseInput mouse button + InputCommon::MouseButton SDLButtonToMouseButton(u32 button) const; + + /// Translates pixel position to float position + std::pair MouseToTouchPos(s32 touch_x, s32 touch_y) const; + + /// Called by WaitEvent when a mouse button is pressed or released + void OnMouseButton(u32 button, u8 state, s32 x, s32 y); + + /// Called by WaitEvent when the mouse moves. + void OnMouseMotion(s32 x, s32 y); + + /// Called by WaitEvent when a finger starts touching the touchscreen + void OnFingerDown(float x, float y, std::size_t id); + + /// Called by WaitEvent when a finger moves while touching the touchscreen + void OnFingerMotion(float x, float y, std::size_t id); + + /// Called by WaitEvent when a finger stops touching the touchscreen + void OnFingerUp(); + + /// Called by WaitEvent when any event that may cause the window to be resized occurs + void OnResize(); + + /// Called when users want to hide the mouse cursor + void ShowCursor(bool show_cursor); + + /// Called when user passes the fullscreen parameter flag + void Fullscreen(); + + /// Called when a configuration change affects the minimal size of the window + void OnMinimalClientAreaChangeRequest(std::pair minimal_size) override; + + /// Is the window still open? + bool is_open = true; + + /// Is the window being shown? + bool is_shown = true; + + /// Internal SDL2 render window + SDL_Window* render_window{}; + + /// Keeps track of how often to update the title bar during gameplay + u32 last_time = 0; + + /// Input subsystem to use with this window. + InputCommon::InputSubsystem* input_subsystem; + + /// sudachi core instance + Core::System& system; +}; + +class DummyContext : public Core::Frontend::GraphicsContext {}; diff --git a/src/sudachi_cmd/emu_window/emu_window_sdl2_gl.cpp b/src/sudachi_cmd/emu_window/emu_window_sdl2_gl.cpp new file mode 100644 index 0000000..ce37318 --- /dev/null +++ b/src/sudachi_cmd/emu_window/emu_window_sdl2_gl.cpp @@ -0,0 +1,153 @@ +// SPDX-FileCopyrightText: Copyright 2019 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include +#include +#include + +#define SDL_MAIN_HANDLED +#include + +#include +#include +#include "common/logging/log.h" +#include "common/scm_rev.h" +#include "common/settings.h" +#include "common/string_util.h" +#include "core/core.h" +#include "input_common/main.h" +#include "video_core/renderer_base.h" +#include "sudachi_cmd/emu_window/emu_window_sdl2_gl.h" + +class SDLGLContext : public Core::Frontend::GraphicsContext { +public: + explicit SDLGLContext(SDL_Window* window_) : window{window_} { + context = SDL_GL_CreateContext(window); + } + + ~SDLGLContext() { + DoneCurrent(); + SDL_GL_DeleteContext(context); + } + + void SwapBuffers() override { + SDL_GL_SwapWindow(window); + } + + void MakeCurrent() override { + if (is_current) { + return; + } + is_current = SDL_GL_MakeCurrent(window, context) == 0; + } + + void DoneCurrent() override { + if (!is_current) { + return; + } + SDL_GL_MakeCurrent(window, nullptr); + is_current = false; + } + +private: + SDL_Window* window; + SDL_GLContext context; + bool is_current = false; +}; + +bool EmuWindow_SDL2_GL::SupportsRequiredGLExtensions() { + std::vector unsupported_ext; + + // Extensions required to support some texture formats. + if (!GLAD_GL_EXT_texture_compression_s3tc) { + unsupported_ext.push_back("EXT_texture_compression_s3tc"); + } + if (!GLAD_GL_ARB_texture_compression_rgtc) { + unsupported_ext.push_back("ARB_texture_compression_rgtc"); + } + + for (const auto& extension : unsupported_ext) { + LOG_CRITICAL(Frontend, "Unsupported GL extension: {}", extension); + } + + return unsupported_ext.empty(); +} + +EmuWindow_SDL2_GL::EmuWindow_SDL2_GL(InputCommon::InputSubsystem* input_subsystem_, + Core::System& system_, bool fullscreen) + : EmuWindow_SDL2{input_subsystem_, system_} { + SDL_GL_SetAttribute(SDL_GL_CONTEXT_MAJOR_VERSION, 4); + SDL_GL_SetAttribute(SDL_GL_CONTEXT_MINOR_VERSION, 6); + SDL_GL_SetAttribute(SDL_GL_CONTEXT_PROFILE_MASK, SDL_GL_CONTEXT_PROFILE_COMPATIBILITY); + SDL_GL_SetAttribute(SDL_GL_DOUBLEBUFFER, 1); + SDL_GL_SetAttribute(SDL_GL_RED_SIZE, 8); + SDL_GL_SetAttribute(SDL_GL_GREEN_SIZE, 8); + SDL_GL_SetAttribute(SDL_GL_BLUE_SIZE, 8); + SDL_GL_SetAttribute(SDL_GL_ALPHA_SIZE, 0); + SDL_GL_SetAttribute(SDL_GL_SHARE_WITH_CURRENT_CONTEXT, 1); + if (Settings::values.renderer_debug) { + SDL_GL_SetAttribute(SDL_GL_CONTEXT_FLAGS, SDL_GL_CONTEXT_DEBUG_FLAG); + } + SDL_GL_SetSwapInterval(0); + + std::string window_title = fmt::format("sudachi {} | {}-{}", Common::g_build_fullname, + Common::g_scm_branch, Common::g_scm_desc); + render_window = + SDL_CreateWindow(window_title.c_str(), + SDL_WINDOWPOS_UNDEFINED, // x position + SDL_WINDOWPOS_UNDEFINED, // y position + Layout::ScreenUndocked::Width, Layout::ScreenUndocked::Height, + SDL_WINDOW_OPENGL | SDL_WINDOW_RESIZABLE | SDL_WINDOW_ALLOW_HIGHDPI); + + if (render_window == nullptr) { + LOG_CRITICAL(Frontend, "Failed to create SDL2 window! {}", SDL_GetError()); + exit(1); + } + + strict_context_required = strcmp(SDL_GetCurrentVideoDriver(), "wayland") == 0; + + SetWindowIcon(); + + if (fullscreen) { + Fullscreen(); + ShowCursor(false); + } + + window_context = SDL_GL_CreateContext(render_window); + core_context = CreateSharedContext(); + + if (window_context == nullptr) { + LOG_CRITICAL(Frontend, "Failed to create SDL2 GL context: {}", SDL_GetError()); + exit(1); + } + if (core_context == nullptr) { + LOG_CRITICAL(Frontend, "Failed to create shared SDL2 GL context: {}", SDL_GetError()); + exit(1); + } + + if (!gladLoadGLLoader(static_cast(SDL_GL_GetProcAddress))) { + LOG_CRITICAL(Frontend, "Failed to initialize GL functions! {}", SDL_GetError()); + exit(1); + } + + if (!SupportsRequiredGLExtensions()) { + LOG_CRITICAL(Frontend, "GPU does not support all required OpenGL extensions! Exiting..."); + exit(1); + } + + OnResize(); + OnMinimalClientAreaChangeRequest(GetActiveConfig().min_client_area_size); + SDL_PumpEvents(); + LOG_INFO(Frontend, "sudachi Version: {} | {}-{}", Common::g_build_fullname, Common::g_scm_branch, + Common::g_scm_desc); + Settings::LogSettings(); +} + +EmuWindow_SDL2_GL::~EmuWindow_SDL2_GL() { + core_context.reset(); + SDL_GL_DeleteContext(window_context); +} + +std::unique_ptr EmuWindow_SDL2_GL::CreateSharedContext() const { + return std::make_unique(render_window); +} diff --git a/src/sudachi_cmd/emu_window/emu_window_sdl2_gl.h b/src/sudachi_cmd/emu_window/emu_window_sdl2_gl.h new file mode 100644 index 0000000..da5ffc4 --- /dev/null +++ b/src/sudachi_cmd/emu_window/emu_window_sdl2_gl.h @@ -0,0 +1,37 @@ +// SPDX-FileCopyrightText: Copyright 2019 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include +#include "core/frontend/emu_window.h" +#include "sudachi_cmd/emu_window/emu_window_sdl2.h" + +namespace Core { +class System; +} + +namespace InputCommon { +class InputSubsystem; +} + +class EmuWindow_SDL2_GL final : public EmuWindow_SDL2 { +public: + explicit EmuWindow_SDL2_GL(InputCommon::InputSubsystem* input_subsystem_, Core::System& system_, + bool fullscreen); + ~EmuWindow_SDL2_GL(); + + std::unique_ptr CreateSharedContext() const override; + +private: + /// Whether the GPU and driver supports the OpenGL extension required + bool SupportsRequiredGLExtensions(); + + using SDL_GLContext = void*; + + /// The OpenGL context associated with the window + SDL_GLContext window_context; + + /// The OpenGL context associated with the core + std::unique_ptr core_context; +}; diff --git a/src/sudachi_cmd/emu_window/emu_window_sdl2_null.cpp b/src/sudachi_cmd/emu_window/emu_window_sdl2_null.cpp new file mode 100644 index 0000000..596a534 --- /dev/null +++ b/src/sudachi_cmd/emu_window/emu_window_sdl2_null.cpp @@ -0,0 +1,51 @@ +// SPDX-FileCopyrightText: Copyright 2022 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include +#include +#include + +#include + +#include "common/logging/log.h" +#include "common/scm_rev.h" +#include "video_core/renderer_null/renderer_null.h" +#include "sudachi_cmd/emu_window/emu_window_sdl2_null.h" + +#ifdef SUDACHI_USE_EXTERNAL_SDL2 +// Include this before SDL.h to prevent the external from including a dummy +#define USING_GENERATED_CONFIG_H +#include +#endif + +#include + +EmuWindow_SDL2_Null::EmuWindow_SDL2_Null(InputCommon::InputSubsystem* input_subsystem_, + Core::System& system_, bool fullscreen) + : EmuWindow_SDL2{input_subsystem_, system_} { + const std::string window_title = fmt::format("sudachi {} | {}-{} (Vulkan)", Common::g_build_name, + Common::g_scm_branch, Common::g_scm_desc); + render_window = + SDL_CreateWindow(window_title.c_str(), SDL_WINDOWPOS_UNDEFINED, SDL_WINDOWPOS_UNDEFINED, + Layout::ScreenUndocked::Width, Layout::ScreenUndocked::Height, + SDL_WINDOW_RESIZABLE | SDL_WINDOW_ALLOW_HIGHDPI); + + SetWindowIcon(); + + if (fullscreen) { + Fullscreen(); + ShowCursor(false); + } + + OnResize(); + OnMinimalClientAreaChangeRequest(GetActiveConfig().min_client_area_size); + SDL_PumpEvents(); + LOG_INFO(Frontend, "sudachi Version: {} | {}-{} (Null)", Common::g_build_name, + Common::g_scm_branch, Common::g_scm_desc); +} + +EmuWindow_SDL2_Null::~EmuWindow_SDL2_Null() = default; + +std::unique_ptr EmuWindow_SDL2_Null::CreateSharedContext() const { + return std::make_unique(); +} diff --git a/src/sudachi_cmd/emu_window/emu_window_sdl2_null.h b/src/sudachi_cmd/emu_window/emu_window_sdl2_null.h new file mode 100644 index 0000000..cc9c709 --- /dev/null +++ b/src/sudachi_cmd/emu_window/emu_window_sdl2_null.h @@ -0,0 +1,26 @@ +// SPDX-FileCopyrightText: Copyright 2022 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include + +#include "core/frontend/emu_window.h" +#include "sudachi_cmd/emu_window/emu_window_sdl2.h" + +namespace Core { +class System; +} + +namespace InputCommon { +class InputSubsystem; +} + +class EmuWindow_SDL2_Null final : public EmuWindow_SDL2 { +public: + explicit EmuWindow_SDL2_Null(InputCommon::InputSubsystem* input_subsystem_, + Core::System& system, bool fullscreen); + ~EmuWindow_SDL2_Null() override; + + std::unique_ptr CreateSharedContext() const override; +}; diff --git a/src/sudachi_cmd/emu_window/emu_window_sdl2_vk.cpp b/src/sudachi_cmd/emu_window/emu_window_sdl2_vk.cpp new file mode 100644 index 0000000..5639c13 --- /dev/null +++ b/src/sudachi_cmd/emu_window/emu_window_sdl2_vk.cpp @@ -0,0 +1,93 @@ +// SPDX-FileCopyrightText: Copyright 2018 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include +#include +#include + +#include + +#include "common/logging/log.h" +#include "common/scm_rev.h" +#include "video_core/renderer_vulkan/renderer_vulkan.h" +#include "sudachi_cmd/emu_window/emu_window_sdl2_vk.h" + +#include +#include + +EmuWindow_SDL2_VK::EmuWindow_SDL2_VK(InputCommon::InputSubsystem* input_subsystem_, + Core::System& system_, bool fullscreen) + : EmuWindow_SDL2{input_subsystem_, system_} { + const std::string window_title = fmt::format("sudachi {} | {}-{} (Vulkan)", Common::g_build_name, + Common::g_scm_branch, Common::g_scm_desc); + render_window = + SDL_CreateWindow(window_title.c_str(), SDL_WINDOWPOS_UNDEFINED, SDL_WINDOWPOS_UNDEFINED, + Layout::ScreenUndocked::Width, Layout::ScreenUndocked::Height, + SDL_WINDOW_RESIZABLE | SDL_WINDOW_ALLOW_HIGHDPI); + + SDL_SysWMinfo wm; + SDL_VERSION(&wm.version); + if (SDL_GetWindowWMInfo(render_window, &wm) == SDL_FALSE) { + LOG_CRITICAL(Frontend, "Failed to get information from the window manager: {}", + SDL_GetError()); + std::exit(EXIT_FAILURE); + } + + SetWindowIcon(); + + if (fullscreen) { + Fullscreen(); + ShowCursor(false); + } + + switch (wm.subsystem) { +#ifdef SDL_VIDEO_DRIVER_WINDOWS + case SDL_SYSWM_TYPE::SDL_SYSWM_WINDOWS: + window_info.type = Core::Frontend::WindowSystemType::Windows; + window_info.render_surface = reinterpret_cast(wm.info.win.window); + break; +#endif +#ifdef SDL_VIDEO_DRIVER_X11 + case SDL_SYSWM_TYPE::SDL_SYSWM_X11: + window_info.type = Core::Frontend::WindowSystemType::X11; + window_info.display_connection = wm.info.x11.display; + window_info.render_surface = reinterpret_cast(wm.info.x11.window); + break; +#endif +#ifdef SDL_VIDEO_DRIVER_WAYLAND + case SDL_SYSWM_TYPE::SDL_SYSWM_WAYLAND: + window_info.type = Core::Frontend::WindowSystemType::Wayland; + window_info.display_connection = wm.info.wl.display; + window_info.render_surface = wm.info.wl.surface; + break; +#endif +#ifdef SDL_VIDEO_DRIVER_COCOA + case SDL_SYSWM_TYPE::SDL_SYSWM_COCOA: + window_info.type = Core::Frontend::WindowSystemType::Cocoa; + window_info.render_surface = SDL_Metal_CreateView(render_window); + break; +#endif +#ifdef SDL_VIDEO_DRIVER_ANDROID + case SDL_SYSWM_TYPE::SDL_SYSWM_ANDROID: + window_info.type = Core::Frontend::WindowSystemType::Android; + window_info.render_surface = reinterpret_cast(wm.info.android.window); + break; +#endif + default: + LOG_CRITICAL(Frontend, "Window manager subsystem {} not implemented", wm.subsystem); + std::exit(EXIT_FAILURE); + break; + } + + OnResize(); + OnMinimalClientAreaChangeRequest(GetActiveConfig().min_client_area_size); + SDL_PumpEvents(); + LOG_INFO(Frontend, "sudachi Version: {} | {}-{} (Vulkan)", Common::g_build_name, + Common::g_scm_branch, Common::g_scm_desc); +} + +EmuWindow_SDL2_VK::~EmuWindow_SDL2_VK() = default; + +std::unique_ptr EmuWindow_SDL2_VK::CreateSharedContext() const { + return std::make_unique(); +} diff --git a/src/sudachi_cmd/emu_window/emu_window_sdl2_vk.h b/src/sudachi_cmd/emu_window/emu_window_sdl2_vk.h new file mode 100644 index 0000000..266c38e --- /dev/null +++ b/src/sudachi_cmd/emu_window/emu_window_sdl2_vk.h @@ -0,0 +1,26 @@ +// SPDX-FileCopyrightText: Copyright 2018 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include + +#include "core/frontend/emu_window.h" +#include "sudachi_cmd/emu_window/emu_window_sdl2.h" + +namespace Core { +class System; +} + +namespace InputCommon { +class InputSubsystem; +} + +class EmuWindow_SDL2_VK final : public EmuWindow_SDL2 { +public: + explicit EmuWindow_SDL2_VK(InputCommon::InputSubsystem* input_subsystem_, Core::System& system, + bool fullscreen); + ~EmuWindow_SDL2_VK() override; + + std::unique_ptr CreateSharedContext() const override; +}; diff --git a/src/sudachi_cmd/precompiled_headers.h b/src/sudachi_cmd/precompiled_headers.h new file mode 100644 index 0000000..0303960 --- /dev/null +++ b/src/sudachi_cmd/precompiled_headers.h @@ -0,0 +1,6 @@ +// SPDX-FileCopyrightText: 2022 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include "common/common_precompiled_headers.h" diff --git a/src/sudachi_cmd/sdl_config.cpp b/src/sudachi_cmd/sdl_config.cpp new file mode 100644 index 0000000..9bc25fc --- /dev/null +++ b/src/sudachi_cmd/sdl_config.cpp @@ -0,0 +1,262 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +// SDL will break our main function in sudachi-cmd if we don't define this before adding SDL.h +#define SDL_MAIN_HANDLED +#include + +#include "common/logging/log.h" +#include "input_common/main.h" +#include "sdl_config.h" + +const std::array SdlConfig::default_buttons = { + SDL_SCANCODE_A, SDL_SCANCODE_S, SDL_SCANCODE_Z, SDL_SCANCODE_X, SDL_SCANCODE_T, + SDL_SCANCODE_G, SDL_SCANCODE_F, SDL_SCANCODE_H, SDL_SCANCODE_Q, SDL_SCANCODE_W, + SDL_SCANCODE_M, SDL_SCANCODE_N, SDL_SCANCODE_1, SDL_SCANCODE_2, SDL_SCANCODE_B, +}; + +const std::array SdlConfig::default_motions = { + SDL_SCANCODE_7, + SDL_SCANCODE_8, +}; + +const std::array, Settings::NativeAnalog::NumAnalogs> SdlConfig::default_analogs{ + { + { + SDL_SCANCODE_UP, + SDL_SCANCODE_DOWN, + SDL_SCANCODE_LEFT, + SDL_SCANCODE_RIGHT, + }, + { + SDL_SCANCODE_I, + SDL_SCANCODE_K, + SDL_SCANCODE_J, + SDL_SCANCODE_L, + }, + }}; + +const std::array SdlConfig::default_stick_mod = { + SDL_SCANCODE_D, + 0, +}; + +const std::array SdlConfig::default_ringcon_analogs{{ + 0, + 0, +}}; + +SdlConfig::SdlConfig(const std::optional config_path) { + Initialize(config_path); + ReadSdlValues(); + SaveSdlValues(); +} + +SdlConfig::~SdlConfig() { + if (global) { + SdlConfig::SaveAllValues(); + } +} + +void SdlConfig::ReloadAllValues() { + Reload(); + ReadSdlValues(); + SaveSdlValues(); +} + +void SdlConfig::SaveAllValues() { + SaveValues(); + SaveSdlValues(); +} + +void SdlConfig::ReadSdlValues() { + ReadSdlControlValues(); +} + +void SdlConfig::ReadSdlControlValues() { + BeginGroup(Settings::TranslateCategory(Settings::Category::Controls)); + + Settings::values.players.SetGlobal(!IsCustomConfig()); + for (std::size_t p = 0; p < Settings::values.players.GetValue().size(); ++p) { + ReadSdlPlayerValues(p); + } + if (IsCustomConfig()) { + EndGroup(); + return; + } + ReadDebugControlValues(); + ReadHidbusValues(); + + EndGroup(); +} + +void SdlConfig::ReadSdlPlayerValues(const std::size_t player_index) { + std::string player_prefix; + if (type != ConfigType::InputProfile) { + player_prefix.append("player_").append(ToString(player_index)).append("_"); + } + + auto& player = Settings::values.players.GetValue()[player_index]; + if (IsCustomConfig()) { + const auto profile_name = + ReadStringSetting(std::string(player_prefix).append("profile_name")); + if (profile_name.empty()) { + // Use the global input config + player = Settings::values.players.GetValue(true)[player_index]; + player.profile_name = ""; + return; + } + } + + for (int i = 0; i < Settings::NativeButton::NumButtons; ++i) { + const std::string default_param = InputCommon::GenerateKeyboardParam(default_buttons[i]); + auto& player_buttons = player.buttons[i]; + + player_buttons = ReadStringSetting( + std::string(player_prefix).append(Settings::NativeButton::mapping[i]), default_param); + if (player_buttons.empty()) { + player_buttons = default_param; + } + } + + for (int i = 0; i < Settings::NativeAnalog::NumAnalogs; ++i) { + const std::string default_param = InputCommon::GenerateAnalogParamFromKeys( + default_analogs[i][0], default_analogs[i][1], default_analogs[i][2], + default_analogs[i][3], default_stick_mod[i], 0.5f); + auto& player_analogs = player.analogs[i]; + + player_analogs = ReadStringSetting( + std::string(player_prefix).append(Settings::NativeAnalog::mapping[i]), default_param); + if (player_analogs.empty()) { + player_analogs = default_param; + } + } + + for (int i = 0; i < Settings::NativeMotion::NumMotions; ++i) { + const std::string default_param = InputCommon::GenerateKeyboardParam(default_motions[i]); + auto& player_motions = player.motions[i]; + + player_motions = ReadStringSetting( + std::string(player_prefix).append(Settings::NativeMotion::mapping[i]), default_param); + if (player_motions.empty()) { + player_motions = default_param; + } + } +} + +void SdlConfig::ReadDebugControlValues() { + for (int i = 0; i < Settings::NativeButton::NumButtons; ++i) { + const std::string default_param = InputCommon::GenerateKeyboardParam(default_buttons[i]); + auto& debug_pad_buttons = Settings::values.debug_pad_buttons[i]; + debug_pad_buttons = ReadStringSetting( + std::string("debug_pad_").append(Settings::NativeButton::mapping[i]), default_param); + if (debug_pad_buttons.empty()) { + debug_pad_buttons = default_param; + } + } + for (int i = 0; i < Settings::NativeAnalog::NumAnalogs; ++i) { + const std::string default_param = InputCommon::GenerateAnalogParamFromKeys( + default_analogs[i][0], default_analogs[i][1], default_analogs[i][2], + default_analogs[i][3], default_stick_mod[i], 0.5f); + auto& debug_pad_analogs = Settings::values.debug_pad_analogs[i]; + debug_pad_analogs = ReadStringSetting( + std::string("debug_pad_").append(Settings::NativeAnalog::mapping[i]), default_param); + if (debug_pad_analogs.empty()) { + debug_pad_analogs = default_param; + } + } +} + +void SdlConfig::ReadHidbusValues() { + const std::string default_param = InputCommon::GenerateAnalogParamFromKeys( + 0, 0, default_ringcon_analogs[0], default_ringcon_analogs[1], 0, 0.05f); + auto& ringcon_analogs = Settings::values.ringcon_analogs; + + ringcon_analogs = ReadStringSetting(std::string("ring_controller"), default_param); + if (ringcon_analogs.empty()) { + ringcon_analogs = default_param; + } +} + +void SdlConfig::SaveSdlValues() { + LOG_DEBUG(Config, "Saving SDL configuration values"); + SaveSdlControlValues(); + + WriteToIni(); +} + +void SdlConfig::SaveSdlControlValues() { + BeginGroup(Settings::TranslateCategory(Settings::Category::Controls)); + + Settings::values.players.SetGlobal(!IsCustomConfig()); + for (std::size_t p = 0; p < Settings::values.players.GetValue().size(); ++p) { + SaveSdlPlayerValues(p); + } + if (IsCustomConfig()) { + EndGroup(); + return; + } + SaveDebugControlValues(); + SaveHidbusValues(); + + EndGroup(); +} + +void SdlConfig::SaveSdlPlayerValues(const std::size_t player_index) { + std::string player_prefix; + if (type != ConfigType::InputProfile) { + player_prefix = std::string("player_").append(ToString(player_index)).append("_"); + } + + const auto& player = Settings::values.players.GetValue()[player_index]; + if (IsCustomConfig() && player.profile_name.empty()) { + // No custom profile selected + return; + } + + for (int i = 0; i < Settings::NativeButton::NumButtons; ++i) { + const std::string default_param = InputCommon::GenerateKeyboardParam(default_buttons[i]); + WriteStringSetting(std::string(player_prefix).append(Settings::NativeButton::mapping[i]), + player.buttons[i], std::make_optional(default_param)); + } + for (int i = 0; i < Settings::NativeAnalog::NumAnalogs; ++i) { + const std::string default_param = InputCommon::GenerateAnalogParamFromKeys( + default_analogs[i][0], default_analogs[i][1], default_analogs[i][2], + default_analogs[i][3], default_stick_mod[i], 0.5f); + WriteStringSetting(std::string(player_prefix).append(Settings::NativeAnalog::mapping[i]), + player.analogs[i], std::make_optional(default_param)); + } + for (int i = 0; i < Settings::NativeMotion::NumMotions; ++i) { + const std::string default_param = InputCommon::GenerateKeyboardParam(default_motions[i]); + WriteStringSetting(std::string(player_prefix).append(Settings::NativeMotion::mapping[i]), + player.motions[i], std::make_optional(default_param)); + } +} + +void SdlConfig::SaveDebugControlValues() { + for (int i = 0; i < Settings::NativeButton::NumButtons; ++i) { + const std::string default_param = InputCommon::GenerateKeyboardParam(default_buttons[i]); + WriteStringSetting(std::string("debug_pad_").append(Settings::NativeButton::mapping[i]), + Settings::values.debug_pad_buttons[i], + std::make_optional(default_param)); + } + for (int i = 0; i < Settings::NativeAnalog::NumAnalogs; ++i) { + const std::string default_param = InputCommon::GenerateAnalogParamFromKeys( + default_analogs[i][0], default_analogs[i][1], default_analogs[i][2], + default_analogs[i][3], default_stick_mod[i], 0.5f); + WriteStringSetting(std::string("debug_pad_").append(Settings::NativeAnalog::mapping[i]), + Settings::values.debug_pad_analogs[i], + std::make_optional(default_param)); + } +} + +void SdlConfig::SaveHidbusValues() { + const std::string default_param = InputCommon::GenerateAnalogParamFromKeys( + 0, 0, default_ringcon_analogs[0], default_ringcon_analogs[1], 0, 0.05f); + WriteStringSetting(std::string("ring_controller"), Settings::values.ringcon_analogs, + std::make_optional(default_param)); +} + +std::vector& SdlConfig::FindRelevantList(Settings::Category category) { + return Settings::values.linkage.by_category[category]; +} diff --git a/src/sudachi_cmd/sdl_config.h b/src/sudachi_cmd/sdl_config.h new file mode 100644 index 0000000..8128532 --- /dev/null +++ b/src/sudachi_cmd/sdl_config.h @@ -0,0 +1,49 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include "frontend_common/config.h" + +class SdlConfig final : public Config { +public: + explicit SdlConfig(std::optional config_path); + ~SdlConfig() override; + + void ReloadAllValues() override; + void SaveAllValues() override; + +protected: + void ReadSdlValues(); + void ReadSdlPlayerValues(std::size_t player_index); + void ReadSdlControlValues(); + void ReadHidbusValues() override; + void ReadDebugControlValues() override; + void ReadPathValues() override {} + void ReadShortcutValues() override {} + void ReadUIValues() override {} + void ReadUIGamelistValues() override {} + void ReadUILayoutValues() override {} + void ReadMultiplayerValues() override {} + + void SaveSdlValues(); + void SaveSdlPlayerValues(std::size_t player_index); + void SaveSdlControlValues(); + void SaveHidbusValues() override; + void SaveDebugControlValues() override; + void SavePathValues() override {} + void SaveShortcutValues() override {} + void SaveUIValues() override {} + void SaveUIGamelistValues() override {} + void SaveUILayoutValues() override {} + void SaveMultiplayerValues() override {} + + std::vector& FindRelevantList(Settings::Category category) override; + +public: + static const std::array default_buttons; + static const std::array default_motions; + static const std::array, Settings::NativeAnalog::NumAnalogs> default_analogs; + static const std::array default_stick_mod; + static const std::array default_ringcon_analogs; +}; diff --git a/src/sudachi_cmd/sudachi.cpp b/src/sudachi_cmd/sudachi.cpp new file mode 100644 index 0000000..851c8fe --- /dev/null +++ b/src/sudachi_cmd/sudachi.cpp @@ -0,0 +1,459 @@ +// SPDX-FileCopyrightText: 2014 Citra Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include +#include +#include +#include +#include +#include + +#include + +#include "common/detached_tasks.h" +#include "common/logging/backend.h" +#include "common/logging/log.h" +#include "common/microprofile.h" +#include "common/nvidia_flags.h" +#include "common/scm_rev.h" +#include "common/scope_exit.h" +#include "common/settings.h" +#include "common/string_util.h" +#include "common/telemetry.h" +#include "core/core.h" +#include "core/core_timing.h" +#include "core/cpu_manager.h" +#include "core/crypto/key_manager.h" +#include "core/file_sys/registered_cache.h" +#include "core/file_sys/vfs/vfs_real.h" +#include "core/hle/service/am/applet_manager.h" +#include "core/hle/service/filesystem/filesystem.h" +#include "core/loader/loader.h" +#include "core/telemetry_session.h" +#include "frontend_common/config.h" +#include "input_common/main.h" +#include "network/network.h" +#include "sdl_config.h" +#include "video_core/renderer_base.h" +#include "sudachi_cmd/emu_window/emu_window_sdl2.h" +#include "sudachi_cmd/emu_window/emu_window_sdl2_gl.h" +#include "sudachi_cmd/emu_window/emu_window_sdl2_null.h" +#include "sudachi_cmd/emu_window/emu_window_sdl2_vk.h" + +#ifdef _WIN32 +// windows.h needs to be included before shellapi.h +#include + +#include + +#include "common/windows/timer_resolution.h" +#endif + +#undef _UNICODE +#include +#ifndef _MSC_VER +#include +#endif + +#ifdef _WIN32 +extern "C" { +// tells Nvidia and AMD drivers to use the dedicated GPU by default on laptops with switchable +// graphics +__declspec(dllexport) unsigned long NvOptimusEnablement = 0x00000001; +__declspec(dllexport) int AmdPowerXpressRequestHighPerformance = 1; +} +#endif + +#ifdef __unix__ +#include "common/linux/gamemode.h" +#endif + +static void PrintHelp(const char* argv0) { + std::cout << "Usage: " << argv0 + << " [options] \n" + "-c, --config Load the specified configuration file\n" + "-f, --fullscreen Start in fullscreen mode\n" + "-g, --game File path of the game to load\n" + "-h, --help Display this help and exit\n" + "-m, --multiplayer=nick:password@address:port" + " Nickname, password, address and port for multiplayer\n" + "-p, --program Pass following string as arguments to executable\n" + "-u, --user Select a specific user profile from 0 to 7\n" + "-v, --version Output version information and exit\n"; +} + +static void PrintVersion() { + std::cout << "sudachi " << Common::g_scm_branch << " " << Common::g_scm_desc << std::endl; +} + +static void OnStateChanged(const Network::RoomMember::State& state) { + switch (state) { + case Network::RoomMember::State::Idle: + LOG_DEBUG(Network, "Network is idle"); + break; + case Network::RoomMember::State::Joining: + LOG_DEBUG(Network, "Connection sequence to room started"); + break; + case Network::RoomMember::State::Joined: + LOG_DEBUG(Network, "Successfully joined to the room"); + break; + case Network::RoomMember::State::Moderator: + LOG_DEBUG(Network, "Successfully joined the room as a moderator"); + break; + default: + break; + } +} + +static void OnNetworkError(const Network::RoomMember::Error& error) { + switch (error) { + case Network::RoomMember::Error::LostConnection: + LOG_DEBUG(Network, "Lost connection to the room"); + break; + case Network::RoomMember::Error::CouldNotConnect: + LOG_ERROR(Network, "Error: Could not connect"); + exit(1); + break; + case Network::RoomMember::Error::NameCollision: + LOG_ERROR( + Network, + "You tried to use the same nickname as another user that is connected to the Room"); + exit(1); + break; + case Network::RoomMember::Error::IpCollision: + LOG_ERROR(Network, "You tried to use the same fake IP-Address as another user that is " + "connected to the Room"); + exit(1); + break; + case Network::RoomMember::Error::WrongPassword: + LOG_ERROR(Network, "Room replied with: Wrong password"); + exit(1); + break; + case Network::RoomMember::Error::WrongVersion: + LOG_ERROR(Network, + "You are using a different version than the room you are trying to connect to"); + exit(1); + break; + case Network::RoomMember::Error::RoomIsFull: + LOG_ERROR(Network, "The room is full"); + exit(1); + break; + case Network::RoomMember::Error::HostKicked: + LOG_ERROR(Network, "You have been kicked by the host"); + break; + case Network::RoomMember::Error::HostBanned: + LOG_ERROR(Network, "You have been banned by the host"); + break; + case Network::RoomMember::Error::UnknownError: + LOG_ERROR(Network, "UnknownError"); + break; + case Network::RoomMember::Error::PermissionDenied: + LOG_ERROR(Network, "PermissionDenied"); + break; + case Network::RoomMember::Error::NoSuchUser: + LOG_ERROR(Network, "NoSuchUser"); + break; + } +} + +static void OnMessageReceived(const Network::ChatEntry& msg) { + std::cout << std::endl << msg.nickname << ": " << msg.message << std::endl << std::endl; +} + +static void OnStatusMessageReceived(const Network::StatusMessageEntry& msg) { + std::string message; + switch (msg.type) { + case Network::IdMemberJoin: + message = fmt::format("{} has joined", msg.nickname); + break; + case Network::IdMemberLeave: + message = fmt::format("{} has left", msg.nickname); + break; + case Network::IdMemberKicked: + message = fmt::format("{} has been kicked", msg.nickname); + break; + case Network::IdMemberBanned: + message = fmt::format("{} has been banned", msg.nickname); + break; + case Network::IdAddressUnbanned: + message = fmt::format("{} has been unbanned", msg.nickname); + break; + } + if (!message.empty()) + std::cout << std::endl << "* " << message << std::endl << std::endl; +} + +/// Application entry point +int main(int argc, char** argv) { +#ifdef _WIN32 + if (AttachConsole(ATTACH_PARENT_PROCESS)) { + freopen("CONOUT$", "wb", stdout); + freopen("CONOUT$", "wb", stderr); + } +#endif + + Common::Log::Initialize(); + Common::Log::SetColorConsoleBackendEnabled(true); + Common::Log::Start(); + Common::DetachedTasks detached_tasks; + + int option_index = 0; +#ifdef _WIN32 + int argc_w; + auto argv_w = CommandLineToArgvW(GetCommandLineW(), &argc_w); + + if (argv_w == nullptr) { + LOG_CRITICAL(Frontend, "Failed to get command line arguments"); + return -1; + } +#endif + std::string filepath; + std::optional config_path; + std::string program_args; + std::optional selected_user; + + bool use_multiplayer = false; + bool fullscreen = false; + std::string nickname{}; + std::string password{}; + std::string address{}; + u16 port = Network::DefaultRoomPort; + + static struct option long_options[] = { + // clang-format off + {"config", required_argument, 0, 'c'}, + {"fullscreen", no_argument, 0, 'f'}, + {"help", no_argument, 0, 'h'}, + {"game", required_argument, 0, 'g'}, + {"multiplayer", required_argument, 0, 'm'}, + {"program", optional_argument, 0, 'p'}, + {"user", required_argument, 0, 'u'}, + {"version", no_argument, 0, 'v'}, + {0, 0, 0, 0}, + // clang-format on + }; + + while (optind < argc) { + int arg = getopt_long(argc, argv, "g:fhvp::c:u:", long_options, &option_index); + if (arg != -1) { + switch (static_cast(arg)) { + case 'c': + config_path = optarg; + break; + case 'f': + fullscreen = true; + LOG_INFO(Frontend, "Starting in fullscreen mode..."); + break; + case 'h': + PrintHelp(argv[0]); + return 0; + case 'g': { + const std::string str_arg(optarg); + filepath = str_arg; + break; + } + case 'm': { + use_multiplayer = true; + const std::string str_arg(optarg); + // regex to check if the format is nickname:password@ip:port + // with optional :password + const std::regex re("^([^:]+)(?::(.+))?@([^:]+)(?::([0-9]+))?$"); + if (!std::regex_match(str_arg, re)) { + std::cout << "Wrong format for option --multiplayer\n"; + PrintHelp(argv[0]); + return 0; + } + + std::smatch match; + std::regex_search(str_arg, match, re); + ASSERT(match.size() == 5); + nickname = match[1]; + password = match[2]; + address = match[3]; + if (!match[4].str().empty()) { + port = static_cast(std::strtoul(match[4].str().c_str(), nullptr, 0)); + } + std::regex nickname_re("^[a-zA-Z0-9._\\- ]+$"); + if (!std::regex_match(nickname, nickname_re)) { + std::cout + << "Nickname is not valid. Must be 4 to 20 alphanumeric characters.\n"; + return 0; + } + if (address.empty()) { + std::cout << "Address to room must not be empty.\n"; + return 0; + } + break; + } + case 'p': + program_args = argv[optind]; + ++optind; + break; + case 'u': + selected_user = atoi(optarg); + break; + case 'v': + PrintVersion(); + return 0; + } + } else { +#ifdef _WIN32 + filepath = Common::UTF16ToUTF8(argv_w[optind]); +#else + filepath = argv[optind]; +#endif + optind++; + } + } + + SdlConfig config{config_path}; + + // apply the log_filter setting + // the logger was initialized before and doesn't pick up the filter on its own + Common::Log::Filter filter; + filter.ParseFilterString(Settings::values.log_filter.GetValue()); + Common::Log::SetGlobalFilter(filter); + + if (!program_args.empty()) { + Settings::values.program_args = program_args; + } + + if (selected_user.has_value()) { + Settings::values.current_user = std::clamp(*selected_user, 0, 7); + } + +#ifdef _WIN32 + LocalFree(argv_w); +#endif + + MicroProfileOnThreadCreate("EmuThread"); + SCOPE_EXIT { + MicroProfileShutdown(); + }; + + Common::ConfigureNvidiaEnvironmentFlags(); + + if (filepath.empty()) { + LOG_CRITICAL(Frontend, "Failed to load ROM: No ROM specified"); + return -1; + } + + Core::System system{}; + system.Initialize(); + + InputCommon::InputSubsystem input_subsystem{}; + + // Apply the command line arguments + system.ApplySettings(); + + std::unique_ptr emu_window; + switch (Settings::values.renderer_backend.GetValue()) { + case Settings::RendererBackend::OpenGL: + emu_window = std::make_unique(&input_subsystem, system, fullscreen); + break; + case Settings::RendererBackend::Vulkan: + emu_window = std::make_unique(&input_subsystem, system, fullscreen); + break; + case Settings::RendererBackend::Null: + emu_window = std::make_unique(&input_subsystem, system, fullscreen); + break; + } + +#ifdef _WIN32 + Common::Windows::SetCurrentTimerResolutionToMaximum(); + system.CoreTiming().SetTimerResolutionNs(Common::Windows::GetCurrentTimerResolution()); +#endif + + system.SetContentProvider(std::make_unique()); + system.SetFilesystem(std::make_shared()); + system.GetFileSystemController().CreateFactories(*system.GetFilesystem()); + system.GetUserChannel().clear(); + + Service::AM::FrontendAppletParameters load_parameters{ + .applet_id = Service::AM::AppletId::Application, + }; + const Core::SystemResultStatus load_result{system.Load(*emu_window, filepath, load_parameters)}; + + switch (load_result) { + case Core::SystemResultStatus::ErrorGetLoader: + LOG_CRITICAL(Frontend, "Failed to obtain loader for {}!", filepath); + return -1; + case Core::SystemResultStatus::ErrorLoader: + LOG_CRITICAL(Frontend, "Failed to load ROM!"); + return -1; + case Core::SystemResultStatus::ErrorNotInitialized: + LOG_CRITICAL(Frontend, "CPUCore not initialized"); + return -1; + case Core::SystemResultStatus::ErrorVideoCore: + LOG_CRITICAL(Frontend, "Failed to initialize VideoCore!"); + return -1; + case Core::SystemResultStatus::Success: + break; // Expected case + default: + if (static_cast(load_result) > + static_cast(Core::SystemResultStatus::ErrorLoader)) { + const u16 loader_id = static_cast(Core::SystemResultStatus::ErrorLoader); + const u16 error_id = static_cast(load_result) - loader_id; + LOG_CRITICAL(Frontend, + "While attempting to load the ROM requested, an error occurred. Please " + "refer to the sudachi wiki for more information or the sudachi discord for " + "additional help.\n\nError Code: {:04X}-{:04X}\nError Description: {}", + loader_id, error_id, static_cast(error_id)); + } + break; + } + + system.TelemetrySession().AddField(Common::Telemetry::FieldType::App, "Frontend", "SDL"); + + if (use_multiplayer) { + if (auto member = system.GetRoomNetwork().GetRoomMember().lock()) { + member->BindOnChatMessageReceived(OnMessageReceived); + member->BindOnStatusMessageReceived(OnStatusMessageReceived); + member->BindOnStateChanged(OnStateChanged); + member->BindOnError(OnNetworkError); + LOG_DEBUG(Network, "Start connection to {}:{} with nickname {}", address, port, + nickname); + member->Join(nickname, address.c_str(), port, 0, Network::NoPreferredIP, password); + } else { + LOG_ERROR(Network, "Could not access RoomMember"); + return 0; + } + } + + // Core is loaded, start the GPU (makes the GPU contexts current to this thread) + system.GPU().Start(); + system.GetCpuManager().OnGpuReady(); + + if (Settings::values.use_disk_shader_cache.GetValue()) { + system.Renderer().ReadRasterizer()->LoadDiskResources( + system.GetApplicationProcessProgramID(), std::stop_token{}, + [](VideoCore::LoadCallbackStage, size_t value, size_t total) {}); + } + + system.RegisterExitCallback([&] { + // Just exit right away. + exit(0); + }); + +#ifdef __unix__ + Common::Linux::StartGamemode(); +#endif + + void(system.Run()); + if (system.DebuggerEnabled()) { + system.InitializeDebugger(); + } + while (emu_window->IsOpen()) { + emu_window->WaitEvent(); + } + system.DetachDebugger(); + void(system.Pause()); + system.ShutdownMainProcess(); + +#ifdef __unix__ + Common::Linux::StopGamemode(); +#endif + + detached_tasks.WaitForAllTasks(); + return 0; +} diff --git a/src/sudachi_cmd/sudachi.rc b/src/sudachi_cmd/sudachi.rc new file mode 100644 index 0000000..294e49c --- /dev/null +++ b/src/sudachi_cmd/sudachi.rc @@ -0,0 +1,20 @@ +// SPDX-FileCopyrightText: 2018 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include "winresrc.h" +///////////////////////////////////////////////////////////////////////////// +// +// Icon +// + +// Icon with lowest ID value placed first to ensure application icon +// remains consistent on all systems. +SUDACHI_ICON ICON "../../dist/sudachi.ico" + + +///////////////////////////////////////////////////////////////////////////// +// +// RT_MANIFEST +// + +0 RT_MANIFEST "../../dist/sudachi.manifest"