diff --git a/Data/Sys/Profiles/Wiimote/DSU Controller.ini b/Data/Sys/Profiles/Wiimote/DSU Controller.ini new file mode 100644 index 0000000000..b3359069f1 --- /dev/null +++ b/Data/Sys/Profiles/Wiimote/DSU Controller.ini @@ -0,0 +1,42 @@ +[Profile] +Buttons/A = Cross +Buttons/B = Square +Buttons/1 = Triangle +Buttons/2 = Circle +Buttons/- = Share +Buttons/+ = Options +Buttons/Home = PS +IR/Up = `Cursor Y-` +IR/Down = `Cursor Y+` +IR/Left = `Cursor X-` +IR/Right = `Cursor X+` +Tilt/Modifier/Range = 50.0 +Shake/X = `Middle Click` +Shake/Y = `Middle Click` +Shake/Z = `Middle Click` +IMUAccelerometer/Up = `Accel Up` +IMUAccelerometer/Down = `Accel Down` +IMUAccelerometer/Left = `Accel Left` +IMUAccelerometer/Right = `Accel Right` +IMUAccelerometer/Forward = `Accel Forward` +IMUAccelerometer/Backward = `Accel Backward` +IMUGyroscope/Pitch Up = `Gyro Pitch Up` +IMUGyroscope/Pitch Down = `Gyro Pitch Down` +IMUGyroscope/Roll Left = `Gyro Roll Left` +IMUGyroscope/Roll Right = `Gyro Roll Right` +IMUGyroscope/Yaw Left = `Gyro Yaw Left` +IMUGyroscope/Yaw Right = `Gyro Yaw Right` +Nunchuk/Stick/Modifier/Range = 50.0 +Nunchuk/Tilt/Modifier/Range = 50.0 +Classic/Left Stick/Modifier/Range = 50.0 +Classic/Right Stick/Modifier/Range = 50.0 +Guitar/Stick/Modifier/Range = 50.0 +Drums/Stick/Modifier/Range = 50.0 +Turntable/Stick/Modifier/Range = 50.0 +uDraw/Stylus/Modifier/Range = 50.0 +Drawsome/Stylus/Modifier/Range = 50.0 +Rumble/Motor = `Motor 0` +D-Pad/Up = `Pad N` +D-Pad/Down = `Pad S` +D-Pad/Left = `Pad W` +D-Pad/Right = `Pad E` diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/model/BooleanSetting.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/model/BooleanSetting.kt index b6a03e6b61..e573b9e93f 100644 --- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/model/BooleanSetting.kt +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/model/BooleanSetting.kt @@ -671,6 +671,12 @@ enum class BooleanSetting( "ButtonLatchingNunchukZ", false ), + DSUCLIENT_SERVERS_ENABLED( + Settings.FILE_DSUCLIENT, + Settings.SECTION_DSUCLIENT_SERVER, + "Enabled", + false + ), SYSCONF_SCREENSAVER(Settings.FILE_SYSCONF, "IPL", "SSV", false), SYSCONF_WIDESCREEN(Settings.FILE_SYSCONF, "IPL", "AR", true), SYSCONF_PROGRESSIVE_SCAN(Settings.FILE_SYSCONF, "IPL", "PGS", true), diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/model/DsuClientServerEntry.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/model/DsuClientServerEntry.kt new file mode 100644 index 0000000000..944e0e3eb6 --- /dev/null +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/model/DsuClientServerEntry.kt @@ -0,0 +1,88 @@ +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.dolphinemu.dolphinemu.features.settings.model + +data class DsuClientServerEntry( + val description: String, + val address: String, + val port: Int +) { + fun toConfigEntry(): String = "$description:$address:$port" +} + +object DsuClientServerEntries { + private const val MAX_HOST_LENGTH = 253 + private const val MAX_PORT = 65535 + + private val IPV4_REGEX = + Regex("^(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}$") + private val HOST_LABEL_REGEX = + Regex("^[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$") + + fun parseConfigEntries(configValue: String): List { + return configValue.split(';').mapNotNull(::parseEntry) + } + + fun formatConfigEntries(entries: List): String { + if (entries.isEmpty()) { + return "" + } + return entries.joinToString(";") { it.toConfigEntry() } + ";" + } + + fun parsePort(portValue: String): Int? { + val port = portValue.toIntOrNull() ?: return null + return if (isValidPort(port)) port else null + } + + fun isValidDescription(description: String): Boolean { + return !description.contains(':') && !description.contains(';') + } + + fun isValidAddress(address: String): Boolean { + if (address.isEmpty() || address.length > MAX_HOST_LENGTH) { + return false + } + + if (address.contains(':') || address.contains(';') || address.contains(' ')) { + return false + } + + if (IPV4_REGEX.matches(address)) { + return true + } + + return address.split('.') + .all { label -> HOST_LABEL_REGEX.matches(label) } + } + + fun isValidPort(port: Int): Boolean { + return port in 1..MAX_PORT + } + + private fun parseEntry(entry: String): DsuClientServerEntry? { + if (entry.isBlank()) { + return null + } + + val firstColon = entry.indexOf(':') + if (firstColon == -1) { + return null + } + + val secondColon = entry.indexOf(':', firstColon + 1) + if (secondColon == -1 || entry.indexOf(':', secondColon + 1) != -1) { + return null + } + + val description = entry.substring(0, firstColon).trim() + val address = entry.substring(firstColon + 1, secondColon).trim() + val port = parsePort(entry.substring(secondColon + 1).trim()) ?: return null + + if (!isValidDescription(description) || !isValidAddress(address)) { + return null + } + + return DsuClientServerEntry(description, address, port) + } +} diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/model/Settings.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/model/Settings.kt index 9f91a4244c..92e7140d79 100644 --- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/model/Settings.kt +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/model/Settings.kt @@ -123,6 +123,8 @@ class Settings : Closeable { const val SECTION_EMULATED_USB_DEVICES = "EmulatedUSBDevices" const val SECTION_STEREOSCOPY = "Stereoscopy" const val SECTION_ANALYTICS = "Analytics" + const val FILE_DSUCLIENT = "DualShockUDPClient" + const val SECTION_DSUCLIENT_SERVER = "Server" const val SECTION_ACHIEVEMENTS = "Achievements" } } diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/model/StringSetting.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/model/StringSetting.kt index c4f1107bcd..5a5630a8e0 100644 --- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/model/StringSetting.kt +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/model/StringSetting.kt @@ -98,6 +98,12 @@ enum class StringSetting( Settings.SECTION_ACHIEVEMENTS, "ApiToken", "" + ), + DSUCLIENT_SERVERS( + Settings.FILE_DSUCLIENT, + Settings.SECTION_DSUCLIENT_SERVER, + "Entries", + "" ); override val isOverridden: Boolean diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/ui/DsuClientAddServerDialog.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/ui/DsuClientAddServerDialog.kt new file mode 100644 index 0000000000..ac5ccc0d09 --- /dev/null +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/ui/DsuClientAddServerDialog.kt @@ -0,0 +1,100 @@ +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.dolphinemu.dolphinemu.features.settings.ui + +import android.app.AlertDialog +import android.content.Context +import android.text.InputType +import android.widget.EditText +import android.widget.LinearLayout +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import org.dolphinemu.dolphinemu.R +import org.dolphinemu.dolphinemu.features.settings.model.DsuClientServerEntries +import org.dolphinemu.dolphinemu.features.settings.model.DsuClientServerEntry + +object DsuClientAddServerDialog { + private const val DEFAULT_DESCRIPTION = "DSU Server" + private const val DEFAULT_ADDRESS = "127.0.0.1" + private const val DEFAULT_PORT = "26760" + + fun show( + context: Context, + existingEntry: DsuClientServerEntry? = null, + onServerEdited: (DsuClientServerEntry) -> Unit + ) { + val layout = LinearLayout(context).apply { + orientation = LinearLayout.VERTICAL + val padding = (16 * context.resources.displayMetrics.density).toInt() + setPadding(padding, padding, padding, 0) + } + + val descriptionInput = EditText(context).apply { + hint = context.getString(R.string.dsu_client_server_description) + inputType = InputType.TYPE_CLASS_TEXT + setText(existingEntry?.description ?: DEFAULT_DESCRIPTION) + } + layout.addView(descriptionInput) + + val addressInput = EditText(context).apply { + hint = context.getString(R.string.dsu_client_server_address) + inputType = InputType.TYPE_CLASS_TEXT + setText(existingEntry?.address ?: DEFAULT_ADDRESS) + } + layout.addView(addressInput) + + val portInput = EditText(context).apply { + hint = context.getString(R.string.dsu_client_server_port) + setText(existingEntry?.port?.toString() ?: DEFAULT_PORT) + inputType = InputType.TYPE_CLASS_NUMBER + } + layout.addView(portInput) + + val dialog = MaterialAlertDialogBuilder(context) + .setTitle( + if (existingEntry == null) { + R.string.dsu_client_add_server_title + } else { + R.string.dsu_client_edit_server_title + } + ) + .setView(layout) + .setPositiveButton(R.string.ok, null) + .setNegativeButton(R.string.cancel, null) + .show() + + dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener { + descriptionInput.error = null + addressInput.error = null + portInput.error = null + + val descriptionText = descriptionInput.text.toString().trim() + val description = if (descriptionText.isEmpty()) DEFAULT_DESCRIPTION else descriptionText + val address = addressInput.text.toString().trim() + val port = DsuClientServerEntries.parsePort(portInput.text.toString().trim()) + + var valid = true + + if (!DsuClientServerEntries.isValidDescription(description)) { + descriptionInput.error = context.getString(R.string.dsu_client_invalid_description) + valid = false + } + + if (!DsuClientServerEntries.isValidAddress(address)) { + addressInput.error = context.getString(R.string.dsu_client_invalid_address) + valid = false + } + + if (port == null) { + portInput.error = context.getString(R.string.dsu_client_invalid_port) + valid = false + } + + if (!valid) { + return@setOnClickListener + } + + onServerEdited(DsuClientServerEntry(description, address, requireNotNull(port))) + dialog.dismiss() + } + } +} diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/ui/MenuTag.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/ui/MenuTag.kt index 74556b07d9..27b628f878 100644 --- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/ui/MenuTag.kt +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/ui/MenuTag.kt @@ -52,6 +52,7 @@ enum class MenuTag { WIIMOTE_MOTION_INPUT_2("wiimote_motion_input", 1), WIIMOTE_MOTION_INPUT_3("wiimote_motion_input", 2), WIIMOTE_MOTION_INPUT_4("wiimote_motion_input", 3), + CONFIG_DSU_CLIENT("config_dsu_client"), GPU_DRIVERS("gpu_drivers"); var tag: String diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/ui/SettingsFragment.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/ui/SettingsFragment.kt index 1289d30f61..ca0fc4c199 100644 --- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/ui/SettingsFragment.kt +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/ui/SettingsFragment.kt @@ -270,6 +270,7 @@ class SettingsFragment : Fragment(), SettingsFragmentView { titles[MenuTag.CONFIG_WII] = R.string.wii_submenu titles[MenuTag.CONFIG_ACHIEVEMENTS] = R.string.achievements_submenu titles[MenuTag.CONFIG_ADVANCED] = R.string.advanced_submenu + titles[MenuTag.CONFIG_DSU_CLIENT] = R.string.dsu_client_submenu titles[MenuTag.DEBUG] = R.string.debug_submenu titles[MenuTag.GRAPHICS] = R.string.graphics_settings titles[MenuTag.ENHANCEMENTS] = R.string.enhancements_submenu diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/ui/SettingsFragmentPresenter.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/ui/SettingsFragmentPresenter.kt index 1f68bbbd1a..dca9f42651 100644 --- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/ui/SettingsFragmentPresenter.kt +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/ui/SettingsFragmentPresenter.kt @@ -17,6 +17,7 @@ import kotlinx.coroutines.withContext import org.dolphinemu.dolphinemu.NativeLibrary import org.dolphinemu.dolphinemu.R import org.dolphinemu.dolphinemu.activities.UserDataActivity +import org.dolphinemu.dolphinemu.features.input.model.ControllerInterface import org.dolphinemu.dolphinemu.features.input.model.ControlGroupEnabledSetting import org.dolphinemu.dolphinemu.features.input.model.InputMappingBooleanSetting import org.dolphinemu.dolphinemu.features.input.model.InputMappingDoubleSetting @@ -34,6 +35,7 @@ import org.dolphinemu.dolphinemu.features.settings.model.view.* import org.dolphinemu.dolphinemu.features.settings.model.AchievementModel.logout import org.dolphinemu.dolphinemu.model.GpuDriverMetadata import org.dolphinemu.dolphinemu.utils.* +import java.io.File import kotlin.collections.ArrayList import kotlin.math.ceil import kotlin.math.floor @@ -112,6 +114,7 @@ class SettingsFragmentPresenter( MenuTag.CONFIG_WII -> addWiiSettings(sl) MenuTag.CONFIG_ACHIEVEMENTS -> addAchievementSettings(sl); MenuTag.CONFIG_ADVANCED -> addAdvancedSettings(sl) + MenuTag.CONFIG_DSU_CLIENT -> addDSUClientSettings(sl) MenuTag.GRAPHICS -> addGraphicsSettings(sl) MenuTag.CONFIG_SERIALPORT1 -> addSerialPortSubSettings(sl, serialPort1Type) MenuTag.GCPAD_TYPE -> addGcPadSettings(sl) @@ -202,6 +205,7 @@ class SettingsFragmentPresenter( sl.add(SubmenuSetting(context, R.string.wii_submenu, MenuTag.CONFIG_WII)) sl.add(SubmenuSetting(context, R.string.achievements_submenu, MenuTag.CONFIG_ACHIEVEMENTS)) sl.add(SubmenuSetting(context, R.string.advanced_submenu, MenuTag.CONFIG_ADVANCED)) + sl.add(SubmenuSetting(context, R.string.dsu_client_submenu, MenuTag.CONFIG_DSU_CLIENT)) sl.add(SubmenuSetting(context, R.string.log_submenu, MenuTag.CONFIG_LOG)) sl.add(SubmenuSetting(context, R.string.debug_submenu, MenuTag.DEBUG)) sl.add( @@ -294,6 +298,106 @@ class SettingsFragmentPresenter( ) } + private fun addDSUClientSettings(sl: ArrayList) { + sl.add( + SwitchSetting( + context, + BooleanSetting.DSUCLIENT_SERVERS_ENABLED, + R.string.dsu_client_enable, + R.string.dsu_client_enable_description + ) + ) + + sl.add(HeaderSetting(context, R.string.dsu_client_servers_header, 0)) + + val serverEntries = + DsuClientServerEntries.parseConfigEntries(StringSetting.DSUCLIENT_SERVERS.string) + + if (serverEntries.isEmpty()) { + sl.add( + HeaderSetting( + context.getString(R.string.dsu_client_no_servers), + "" + ) + ) + } else { + for ((index, serverEntry) in serverEntries.withIndex()) { + val displayName = if (serverEntry.description.isBlank()) { + "${serverEntry.address}:${serverEntry.port}" + } else { + "${serverEntry.description} (${serverEntry.address}:${serverEntry.port})" + } + sl.add(HeaderSetting(displayName, "")) + + val serverIndex = index + sl.add( + RunRunnable( + context, + R.string.dsu_client_edit_server, + 0, + 0, + 0, + true + ) { + DsuClientAddServerDialog.show(context, serverEntry) { editedServer -> + val servers = getDsuClientServers() + if (serverIndex in servers.indices) { + servers[serverIndex] = editedServer + saveDsuClientServers(servers) + loadSettingsList() + } + } + } + ) + + sl.add( + RunRunnable( + context, + R.string.dsu_client_remove_server, + 0, + R.string.dsu_client_remove_server, + 0, + true + ) { + val servers = getDsuClientServers() + if (serverIndex in servers.indices) { + servers.removeAt(serverIndex) + saveDsuClientServers(servers) + loadSettingsList() + } + } + ) + } + } + + sl.add( + RunRunnable(context, R.string.dsu_client_add_server, 0, 0, 0, true) { + DsuClientAddServerDialog.show(context) { newServer -> + val servers = getDsuClientServers() + servers.add(newServer) + saveDsuClientServers(servers) + loadSettingsList() + } + } + ) + } + + private fun getDsuClientServers(): MutableList { + return DsuClientServerEntries.parseConfigEntries(StringSetting.DSUCLIENT_SERVERS.string) + .toMutableList() + } + + private fun saveDsuClientServers(serverEntries: List) { + NativeConfig.setString( + NativeConfig.LAYER_BASE, + Settings.FILE_DSUCLIENT, + Settings.SECTION_DSUCLIENT_SERVER, + "Entries", + DsuClientServerEntries.formatConfigEntries(serverEntries) + ) + NativeConfig.save(NativeConfig.LAYER_BASE) + } + private fun addInterfaceSettings(sl: ArrayList) { // Hide the orientation setting if the device only supports one orientation. Old devices which // support both portrait and landscape may report support for neither, so we use ==, not &&. @@ -2592,6 +2696,19 @@ class SettingsFragmentPresenter( true ) { fragmentView.showDialogFragment(ProfileDialog.create(menuTag)) }) + if (menuTag.isWiimoteMenu) { + sl.add( + RunRunnable( + context, + R.string.input_dsu_apply_profile, + R.string.input_dsu_apply_profile_description, + 0, + 0, + true + ) { applyDsuWiimoteProfile(controller) } + ) + } + updateOldControllerSettingsWarningVisibility(controller) } @@ -2717,6 +2834,61 @@ class SettingsFragmentPresenter( fragmentView.onControllerSettingsChanged() } + private fun applyDsuWiimoteProfile(controller: EmulatedController) { + val dsuDevice = ControllerInterface.getAllDeviceStrings().firstOrNull { + it.startsWith(DSU_DEVICE_PREFIX) + } + + if (dsuDevice == null) { + fragmentView.showToastMessage(context.getString(R.string.input_dsu_apply_profile_no_device)) + return + } + + val profilePath = ensureDsuWiimoteProfile(controller) + if (profilePath == null) { + fragmentView.showToastMessage(context.getString(R.string.input_dsu_apply_profile_missing)) + return + } + + controller.setDefaultDevice(dsuDevice) + controller.loadProfile(profilePath) + enableWiimotePointInput(controller) + fragmentView.onControllerSettingsChanged() + fragmentView.showToastMessage(context.getString(R.string.input_dsu_apply_profile_success)) + } + + private fun ensureDsuWiimoteProfile(controller: EmulatedController): String? { + val sysProfilePath = controller.getSysProfileDirectoryPath() + DSU_WIIMOTE_PROFILE_FILE_NAME + if (File(sysProfilePath).exists()) { + return sysProfilePath + } + + val userProfileFile = File(controller.getUserProfileDirectoryPath(), DSU_WIIMOTE_PROFILE_FILE_NAME) + userProfileFile.parentFile?.mkdirs() + + return try { + context.resources.openRawResource(R.raw.dsu_wiimote_profile).use { input -> + userProfileFile.outputStream().use { output -> + input.copyTo(output) + } + } + userProfileFile.path + } catch (_: Exception) { + null + } + } + + private fun enableWiimotePointInput(controller: EmulatedController) { + val groupCount = controller.getGroupCount() + for (i in 0 until groupCount) { + val group = controller.getGroup(i) + if (group.getGroupType() == ControlGroup.TYPE_IMU_CURSOR) { + group.setEnabled(true) + return + } + } + } + fun setAllLogTypes(value: Boolean) { val settings = fragmentView.settings @@ -2783,6 +2955,8 @@ class SettingsFragmentPresenter( companion object { const val ARG_CONTROLLER_TYPE = "controller_type" const val ARG_SERIALPORT1_TYPE = "serialport1_type" + private const val DSU_DEVICE_PREFIX = "DSUClient/" + private const val DSU_WIIMOTE_PROFILE_FILE_NAME = "DSU Controller.ini" // Value obtained from LogLevel in Common/Logging/Log.h private fun getLogVerbosityEntries(): Int { diff --git a/Source/Android/app/src/main/res/raw/dsu_wiimote_profile.ini b/Source/Android/app/src/main/res/raw/dsu_wiimote_profile.ini new file mode 100644 index 0000000000..b3359069f1 --- /dev/null +++ b/Source/Android/app/src/main/res/raw/dsu_wiimote_profile.ini @@ -0,0 +1,42 @@ +[Profile] +Buttons/A = Cross +Buttons/B = Square +Buttons/1 = Triangle +Buttons/2 = Circle +Buttons/- = Share +Buttons/+ = Options +Buttons/Home = PS +IR/Up = `Cursor Y-` +IR/Down = `Cursor Y+` +IR/Left = `Cursor X-` +IR/Right = `Cursor X+` +Tilt/Modifier/Range = 50.0 +Shake/X = `Middle Click` +Shake/Y = `Middle Click` +Shake/Z = `Middle Click` +IMUAccelerometer/Up = `Accel Up` +IMUAccelerometer/Down = `Accel Down` +IMUAccelerometer/Left = `Accel Left` +IMUAccelerometer/Right = `Accel Right` +IMUAccelerometer/Forward = `Accel Forward` +IMUAccelerometer/Backward = `Accel Backward` +IMUGyroscope/Pitch Up = `Gyro Pitch Up` +IMUGyroscope/Pitch Down = `Gyro Pitch Down` +IMUGyroscope/Roll Left = `Gyro Roll Left` +IMUGyroscope/Roll Right = `Gyro Roll Right` +IMUGyroscope/Yaw Left = `Gyro Yaw Left` +IMUGyroscope/Yaw Right = `Gyro Yaw Right` +Nunchuk/Stick/Modifier/Range = 50.0 +Nunchuk/Tilt/Modifier/Range = 50.0 +Classic/Left Stick/Modifier/Range = 50.0 +Classic/Right Stick/Modifier/Range = 50.0 +Guitar/Stick/Modifier/Range = 50.0 +Drums/Stick/Modifier/Range = 50.0 +Turntable/Stick/Modifier/Range = 50.0 +uDraw/Stylus/Modifier/Range = 50.0 +Drawsome/Stylus/Modifier/Range = 50.0 +Rumble/Motor = `Motor 0` +D-Pad/Up = `Pad N` +D-Pad/Down = `Pad S` +D-Pad/Left = `Pad W` +D-Pad/Right = `Pad E` diff --git a/Source/Android/app/src/main/res/values/strings.xml b/Source/Android/app/src/main/res/values/strings.xml index 855cb56ab8..2a88d03773 100644 --- a/Source/Android/app/src/main/res/values/strings.xml +++ b/Source/Android/app/src/main/res/values/strings.xml @@ -41,6 +41,11 @@ Load Save Delete + Apply DSU Preset + Loads the built-in DSU controller profile and selects the first connected DSUClient device. + No DSUClient device is currently available. Connect a DSU source first. + Could not load the built-in DSU controller profile. + DSU controller preset applied. Do you want to discard your current controller settings and load the profile \"%1$s\"? Do you want to overwrite the profile \"%1$s\"? Do you want to delete the profile \"%1$s\"? @@ -439,6 +444,25 @@ Jit Branch Disabled Jit Register Cache Disabled + + Alternate Input Sources + Enable DSU Client + Enable connecting to DSU (CemuHook) compatible input sources for motion and button data. + The DSU protocol enables the use of input and motion data from compatible sources, like phones running DS4Windows, DroidJoy, or other CemuHook-compatible apps.\n\nAdd a server below with its IP address and port to use it as an input source in controller configuration. + Add Server… + Edit + Remove + Description + Server Address (IP/host) + Server Port + No DSU servers configured. + Configured Servers + Add DSU Server + Edit DSU Server + Description cannot contain colon or semicolon. + Enter a valid IPv4 address or hostname. + Enter a port from 1 to 65535. + User Data Your user data (settings, saves, etc.) is stored in a location which will not be deleted when you uninstall the app: @@ -665,8 +689,8 @@ It can efficiently compress both junk data and encrypted Wii data. Enabled Default Values - %.0f%2$s - %.2f%2$s + %1$.0f%2$s + %1$.2f%2$s Disc %1$d GameCube Adapter couldn\'t be opened. Please re-plug the device. The selected GameCube controller is set to \"None\" diff --git a/Source/Android/jni/Config/NativeConfig.cpp b/Source/Android/jni/Config/NativeConfig.cpp index c524c0ea9e..4a6445e78a 100644 --- a/Source/Android/jni/Config/NativeConfig.cpp +++ b/Source/Android/jni/Config/NativeConfig.cpp @@ -51,6 +51,10 @@ static Config::Location GetLocation(JNIEnv* env, jstring file, jstring section, { system = Config::System::Achievements; } + else if (decoded_file == "DualShockUDPClient") + { + system = Config::System::DualShockUDPClient; + } else { ASSERT(false); diff --git a/Source/Core/InputCommon/ControllerInterface/ControllerInterface.h b/Source/Core/InputCommon/ControllerInterface/ControllerInterface.h index 05a3a99a05..4a1f8eff89 100644 --- a/Source/Core/InputCommon/ControllerInterface/ControllerInterface.h +++ b/Source/Core/InputCommon/ControllerInterface/ControllerInterface.h @@ -31,7 +31,9 @@ #if defined(USE_PIPES) #define CIFACE_USE_PIPES #endif +#ifndef CIFACE_USE_DUALSHOCKUDPCLIENT #define CIFACE_USE_DUALSHOCKUDPCLIENT +#endif #if defined(HAVE_SDL3) #define CIFACE_USE_SDL #endif diff --git a/Source/Core/InputCommon/ControllerInterface/DualShockUDPClient/DualShockUDPClient.cpp b/Source/Core/InputCommon/ControllerInterface/DualShockUDPClient/DualShockUDPClient.cpp index dad0844beb..7224d28af9 100644 --- a/Source/Core/InputCommon/ControllerInterface/DualShockUDPClient/DualShockUDPClient.cpp +++ b/Source/Core/InputCommon/ControllerInterface/DualShockUDPClient/DualShockUDPClient.cpp @@ -6,6 +6,8 @@ #include #include #include +#include +#include #include #include @@ -195,6 +197,13 @@ struct Server SteadyClock::time_point m_disconnect_time = SteadyClock::now(); }; +struct ParsedServerEntry +{ + std::string description; + std::string address; + u16 port; +}; + class InputBackend final : public ciface::InputBackend { public: @@ -232,6 +241,39 @@ static bool IsSameController(const Proto::MessageType::PortInfo& a, std::tie(b.pad_id, b.pad_state, b.model, b.connection_type, b.pad_mac_address); } +static std::optional ParseServerEntry(std::string_view entry) +{ + if (entry.empty()) + return std::nullopt; + + const size_t first_colon = entry.find(':'); + if (first_colon == std::string_view::npos) + return std::nullopt; + + const size_t second_colon = entry.find(':', first_colon + 1); + + // We use "description:address:port". Reject entries that don't match exactly. + if (second_colon == std::string_view::npos || + entry.find(':', second_colon + 1) != std::string_view::npos) + { + return std::nullopt; + } + + const std::string description{StripSpaces(entry.substr(0, first_colon))}; + const std::string address{ + StripSpaces(entry.substr(first_colon + 1, second_colon - first_colon - 1))}; + const std::string port_str{StripSpaces(entry.substr(second_colon + 1))}; + + u16 port = 0; + if (!TryParse(port_str, &port) || port == 0) + return std::nullopt; + + if (address.empty()) + return std::nullopt; + + return ParsedServerEntry{description, address, port}; +} + void InputBackend::HotplugThreadFunc() { Common::SetCurrentThreadName("DualShockUDPClient Hotplug Thread"); @@ -440,20 +482,20 @@ void InputBackend::ConfigChanged() const auto server_details = SplitString(servers_setting, ';'); for (const auto& server_detail : server_details) { - const auto server_info = SplitString(server_detail, ':'); - if (server_info.size() < 3) - continue; - - const std::string description = server_info[0]; - const std::string server_address = server_info[1]; - const auto port = std::stoi(server_info[2]); - if (port >= std::numeric_limits::max()) + const auto parsed_server = ParseServerEntry(server_detail); + if (!parsed_server.has_value()) { + if (!server_detail.empty()) + { + WARN_LOG_FMT(CONTROLLERINTERFACE, + "Ignoring invalid DSU server entry in config: \"{}\"", + server_detail); + } continue; } - u16 server_port = static_cast(port); - m_servers.emplace_back(description, server_address, server_port); + m_servers.emplace_back(parsed_server->description, parsed_server->address, + parsed_server->port); } Restart(); }