SDL: Add capacitive sense gamepad events (#15627)

From 5b98c1cc2f598115906c9c1f2758d3d256913468 Mon Sep 17 00:00:00 2001
From: ceski <[EMAIL REDACTED]>
Date: Sun, 17 May 2026 08:26:29 -0700
Subject: [PATCH] Add capacitive sense gamepad events (#15627)

---
 include/SDL3/SDL_events.h                     |  20 ++++
 include/SDL3/SDL_gamepad.h                    |  48 ++++++++++
 src/dynapi/SDL_dynapi.exports                 |   2 +
 src/dynapi/SDL_dynapi.sym                     |   2 +
 src/dynapi/SDL_dynapi_overrides.h             |   2 +
 src/dynapi/SDL_dynapi_procs.h                 |   2 +
 src/events/SDL_categories.c                   |   4 +
 src/events/SDL_categories_c.h                 |   1 +
 src/events/SDL_events.c                       |  12 +++
 src/joystick/SDL_gamepad.c                    |  50 +++++++++-
 src/joystick/SDL_gamepad_db.h                 |   2 +-
 src/joystick/SDL_joystick.c                   |  67 +++++++++++++
 src/joystick/SDL_joystick_c.h                 |   2 +
 src/joystick/SDL_sysjoystick.h                |   9 ++
 src/joystick/hidapi/SDL_hidapi_steam_hori.c   |  10 +-
 src/joystick/hidapi/SDL_hidapi_steam_triton.c |  25 ++---
 src/joystick/hidapi/SDL_hidapi_steamdeck.c    |  13 +--
 src/test/SDL_test_common.c                    |  27 ++++++
 test/CMakeLists.txt                           |   3 +-
 test/gamepad_grip_sense.h                     |  73 ++++++++++++++
 test/gamepad_grip_sense.png                   | Bin 0 -> 837 bytes
 test/gamepadutils.c                           |  90 ++++++++++++++++++
 test/gamepadutils.h                           |   9 ++
 test/testcontroller.c                         |  10 ++
 test/testutils.c                              |   1 +
 25 files changed, 457 insertions(+), 27 deletions(-)
 create mode 100644 test/gamepad_grip_sense.h
 create mode 100644 test/gamepad_grip_sense.png

diff --git a/include/SDL3/SDL_events.h b/include/SDL3/SDL_events.h
index ed6950ad3b1d0..dd822ecc71936 100644
--- a/include/SDL3/SDL_events.h
+++ b/include/SDL3/SDL_events.h
@@ -212,6 +212,8 @@ typedef enum SDL_EventType
     SDL_EVENT_GAMEPAD_SENSOR_UPDATE,        /**< Gamepad sensor was updated */
     SDL_EVENT_GAMEPAD_UPDATE_COMPLETE,      /**< Gamepad update is complete */
     SDL_EVENT_GAMEPAD_STEAM_HANDLE_UPDATED,  /**< Gamepad Steam handle has changed */
+    SDL_EVENT_GAMEPAD_CAPSENSE_TOUCH,       /**< Gamepad capsense was touched */
+    SDL_EVENT_GAMEPAD_CAPSENSE_RELEASE,     /**< Gamepad capsense was released */
 
     /* Touch events */
     SDL_EVENT_FINGER_DOWN      = 0x700,
@@ -711,6 +713,23 @@ typedef struct SDL_GamepadSensorEvent
     Uint64 sensor_timestamp; /**< The timestamp of the sensor reading in nanoseconds, not necessarily synchronized with the system clock */
 } SDL_GamepadSensorEvent;
 
+/**
+ * Gamepad capsense event structure (event.gcapsense.*)
+ *
+ * \since This struct is available since SDL 3.6.0.
+ */
+typedef struct SDL_GamepadCapSenseEvent
+{
+    SDL_EventType type;     /**< SDL_EVENT_GAMEPAD_CAPSENSE_TOUCH or SDL_EVENT_GAMEPAD_CAPSENSE_RELEASE */
+    Uint32 reserved;
+    Uint64 timestamp;       /**< In nanoseconds, populated using SDL_GetTicksNS() */
+    SDL_JoystickID which;   /**< The joystick instance id */
+    Uint8 capsense;         /**< The capsense type (SDL_GamepadCapSenseType) */
+    bool down;              /**< true if the capsense is touched */
+    Uint8 padding1;
+    Uint8 padding2;
+} SDL_GamepadCapSenseEvent;
+
 /**
  * Audio device event structure (event.adevice.*)
  *
@@ -1040,6 +1059,7 @@ typedef union SDL_Event
     SDL_GamepadButtonEvent gbutton;         /**< Gamepad button event data */
     SDL_GamepadTouchpadEvent gtouchpad;     /**< Gamepad touchpad event data */
     SDL_GamepadSensorEvent gsensor;         /**< Gamepad sensor event data */
+    SDL_GamepadCapSenseEvent gcapsense;     /**< Gamepad capsense event data */
     SDL_AudioDeviceEvent adevice;           /**< Audio device event data */
     SDL_CameraDeviceEvent cdevice;          /**< Camera device event data */
     SDL_SensorEvent sensor;                 /**< Sensor event data */
diff --git a/include/SDL3/SDL_gamepad.h b/include/SDL3/SDL_gamepad.h
index 9c88a770ae063..2d0e83440ad70 100644
--- a/include/SDL3/SDL_gamepad.h
+++ b/include/SDL3/SDL_gamepad.h
@@ -231,6 +231,24 @@ typedef enum SDL_GamepadAxis
     SDL_GAMEPAD_AXIS_COUNT
 } SDL_GamepadAxis;
 
+/**
+ * The list of capsense types on a gamepad
+ *
+ * \since This enum is available since SDL 3.6.0.
+ *
+ * \sa SDL_GamepadHasCapSense
+ * \sa SDL_GetGamepadCapSense
+ */
+typedef enum SDL_GamepadCapSenseType
+{
+    SDL_GAMEPAD_CAPSENSE_INVALID = -1,
+    SDL_GAMEPAD_CAPSENSE_LEFT_STICK,    /**< Activated by touching the top of the left thumbstick */
+    SDL_GAMEPAD_CAPSENSE_RIGHT_STICK,   /**< Activated by touching the top of the right thumbstick */
+    SDL_GAMEPAD_CAPSENSE_LEFT_GRIP,     /**< Activated by gripping the left handle of the controller */
+    SDL_GAMEPAD_CAPSENSE_RIGHT_GRIP,    /**< Activated by gripping the right handle of the controller */
+    SDL_GAMEPAD_CAPSENSE_COUNT
+} SDL_GamepadCapSenseType;
+
 /**
  * Types of gamepad control bindings.
  *
@@ -1510,6 +1528,36 @@ extern SDL_DECLSPEC float SDLCALL SDL_GetGamepadSensorDataRate(SDL_Gamepad *game
  */
 extern SDL_DECLSPEC bool SDLCALL SDL_GetGamepadSensorData(SDL_Gamepad *gamepad, SDL_SensorType type, float *data, int num_values);
 
+/**
+ * Return whether a gamepad has a particular capsense.
+ *
+ * \param gamepad the gamepad to query.
+ * \param type the type of capsense to query.
+ * \returns true if the capsense exists, false otherwise.
+ *
+ * \threadsafety It is safe to call this function from any thread.
+ *
+ * \since This function is available since SDL 3.6.0.
+ *
+ * \sa SDL_GetGamepadCapSense
+ */
+extern SDL_DECLSPEC bool SDLCALL SDL_GamepadHasCapSense(SDL_Gamepad *gamepad, SDL_GamepadCapSenseType type);
+
+/**
+ * Get the current state of a capsense on a gamepad.
+ *
+ * \param gamepad a gamepad.
+ * \param type the type of capsense to query.
+ * \returns true if the capsense is touched, false otherwise.
+ *
+ * \threadsafety It is safe to call this function from any thread.
+ *
+ * \since This function is available since SDL 3.6.0.
+ *
+ * \sa SDL_GamepadHasCapSense
+ */
+extern SDL_DECLSPEC bool SDLCALL SDL_GetGamepadCapSense(SDL_Gamepad *gamepad, SDL_GamepadCapSenseType type);
+
 /**
  * Start a rumble effect on a gamepad.
  *
diff --git a/src/dynapi/SDL_dynapi.exports b/src/dynapi/SDL_dynapi.exports
index 32e9fbff861c1..9864557071c3e 100644
--- a/src/dynapi/SDL_dynapi.exports
+++ b/src/dynapi/SDL_dynapi.exports
@@ -1288,3 +1288,5 @@ _SDL_IsPhone
 _SDL_LoadJPG_IO
 _SDL_LoadJPG
 _SDL_HasSVE2
+_SDL_GamepadHasCapSense
+_SDL_GetGamepadCapSense
diff --git a/src/dynapi/SDL_dynapi.sym b/src/dynapi/SDL_dynapi.sym
index ca1a1c97d940e..3958a52aa60af 100644
--- a/src/dynapi/SDL_dynapi.sym
+++ b/src/dynapi/SDL_dynapi.sym
@@ -1289,6 +1289,8 @@ SDL3_0.0.0 {
     SDL_LoadJPG_IO;
     SDL_LoadJPG;
     SDL_HasSVE2;
+    SDL_GamepadHasCapSense;
+    SDL_GetGamepadCapSense;
     # extra symbols go here (don't modify this line)
   local: *;
 };
diff --git a/src/dynapi/SDL_dynapi_overrides.h b/src/dynapi/SDL_dynapi_overrides.h
index 677768ff2f107..b54d32ae6dcf5 100644
--- a/src/dynapi/SDL_dynapi_overrides.h
+++ b/src/dynapi/SDL_dynapi_overrides.h
@@ -1315,3 +1315,5 @@
 #define SDL_LoadJPG_IO SDL_LoadJPG_IO_REAL
 #define SDL_LoadJPG SDL_LoadJPG_REAL
 #define SDL_HasSVE2 SDL_HasSVE2_REAL
+#define SDL_GamepadHasCapSense SDL_GamepadHasCapSense_REAL
+#define SDL_GetGamepadCapSense SDL_GetGamepadCapSense_REAL
diff --git a/src/dynapi/SDL_dynapi_procs.h b/src/dynapi/SDL_dynapi_procs.h
index 99899b346e9a6..4f8ac0ba0cbb4 100644
--- a/src/dynapi/SDL_dynapi_procs.h
+++ b/src/dynapi/SDL_dynapi_procs.h
@@ -1323,3 +1323,5 @@ SDL_DYNAPI_PROC(bool,SDL_IsPhone,(void),(),return)
 SDL_DYNAPI_PROC(SDL_Surface*,SDL_LoadJPG_IO,(SDL_IOStream *a,bool b),(a,b),return)
 SDL_DYNAPI_PROC(SDL_Surface*,SDL_LoadJPG,(const char *a),(a),return)
 SDL_DYNAPI_PROC(bool,SDL_HasSVE2,(void),(),return)
+SDL_DYNAPI_PROC(bool,SDL_GamepadHasCapSense,(SDL_Gamepad *a,SDL_GamepadCapSenseType b),(a,b),return)
+SDL_DYNAPI_PROC(bool,SDL_GetGamepadCapSense,(SDL_Gamepad *a,SDL_GamepadCapSenseType b),(a,b),return)
diff --git a/src/events/SDL_categories.c b/src/events/SDL_categories.c
index fa120775b2ba2..9d7722923b761 100644
--- a/src/events/SDL_categories.c
+++ b/src/events/SDL_categories.c
@@ -134,6 +134,10 @@ SDL_EventCategory SDL_GetEventCategory(Uint32 type)
     case SDL_EVENT_GAMEPAD_SENSOR_UPDATE:
         return SDL_EVENTCATEGORY_GSENSOR;
 
+    case SDL_EVENT_GAMEPAD_CAPSENSE_TOUCH:
+    case SDL_EVENT_GAMEPAD_CAPSENSE_RELEASE:
+        return SDL_EVENTCATEGORY_GCAPSENSE;
+
     case SDL_EVENT_FINGER_DOWN:
     case SDL_EVENT_FINGER_UP:
     case SDL_EVENT_FINGER_CANCELED:
diff --git a/src/events/SDL_categories_c.h b/src/events/SDL_categories_c.h
index a1259e0e90a13..a3762746d53b8 100644
--- a/src/events/SDL_categories_c.h
+++ b/src/events/SDL_categories_c.h
@@ -49,6 +49,7 @@ typedef enum SDL_EventCategory
     SDL_EVENTCATEGORY_GBUTTON,
     SDL_EVENTCATEGORY_GTOUCHPAD,
     SDL_EVENTCATEGORY_GSENSOR,
+    SDL_EVENTCATEGORY_GCAPSENSE,
     SDL_EVENTCATEGORY_ADEVICE,
     SDL_EVENTCATEGORY_CDEVICE,
     SDL_EVENTCATEGORY_SENSOR,
diff --git a/src/events/SDL_events.c b/src/events/SDL_events.c
index 315a8b6d905a9..19f5a85adc95d 100644
--- a/src/events/SDL_events.c
+++ b/src/events/SDL_events.c
@@ -758,6 +758,18 @@ int SDL_GetEventDescription(const SDL_Event *event, char *buf, int buflen)
                            event->gsensor.data[0], event->gsensor.data[1], event->gsensor.data[2]);
         break;
 
+#define PRINT_CAPSENSE_EVENT(event)                                                                            \
+    (void)SDL_snprintf(details, sizeof(details), " (timestamp=%" SDL_PRIu64 " which=%d capsense=%u state=%s)", \
+                       event->gcapsense.timestamp, (int)event->gcapsense.which,                                \
+                       event->gcapsense.capsense, event->gcapsense.down ? "touch" : "release")
+        SDL_EVENT_CASE(SDL_EVENT_GAMEPAD_CAPSENSE_TOUCH)
+        PRINT_CAPSENSE_EVENT(event);
+        break;
+        SDL_EVENT_CASE(SDL_EVENT_GAMEPAD_CAPSENSE_RELEASE)
+        PRINT_CAPSENSE_EVENT(event);
+        break;
+#undef PRINT_CAPSENSE_EVENT
+
 #define PRINT_FINGER_EVENT(event)                                                                                                                      \
     (void)SDL_snprintf(details, sizeof(details), " (timestamp=%" SDL_PRIu64 " touchid=%" SDL_PRIu64 " fingerid=%" SDL_PRIu64 " x=%f y=%f dx=%f dy=%f pressure=%f)", \
                        event->tfinger.timestamp, event->tfinger.touchID,                                                              \
diff --git a/src/joystick/SDL_gamepad.c b/src/joystick/SDL_gamepad.c
index 3b0b86fdfb8df..b9c3ade4f0bdd 100644
--- a/src/joystick/SDL_gamepad.c
+++ b/src/joystick/SDL_gamepad.c
@@ -1254,10 +1254,10 @@ static GamepadMapping_t *SDL_CreateMappingForHIDAPIGamepad(SDL_GUID guid)
             SDL_strlcat(mapping_string, "paddle1:b11,paddle2:b12,", sizeof(mapping_string));
         } else if (SDL_IsJoystickSteamDeck(vendor, product)) {
             // The Steam Deck's built-in controller has QAM, 4 back buttons, L/R trackpads, and L/R capacitive touch sticks
-            SDL_strlcat(mapping_string, "misc1:b11,paddle1:b12,paddle2:b13,paddle3:b14,paddle4:b15,touchpad:b17,misc2:b16,misc3:b19,misc4:b18", sizeof(mapping_string));
+            SDL_strlcat(mapping_string, "misc1:b11,paddle1:b12,paddle2:b13,paddle3:b14,paddle4:b15,touchpad:b17,misc2:b16", sizeof(mapping_string));
         } else if (SDL_IsJoystickSteamTriton(vendor, product)) {
             // Second generation Steam controllers have 4 back paddle buttons
-            SDL_strlcat(mapping_string, "misc1:b11,paddle1:b12,paddle2:b13,paddle3:b14,paddle4:b15,touchpad:b17,misc2:b16,misc3:b19,misc4:b18,misc5:b21,misc6:b20", sizeof(mapping_string));
+            SDL_strlcat(mapping_string, "misc1:b11,paddle1:b12,paddle2:b13,paddle3:b14,paddle4:b15,touchpad:b17,misc2:b16", sizeof(mapping_string));
         } else if (SDL_IsJoystickNintendoSwitchPro(vendor, product) ||
                    SDL_IsJoystickNintendoSwitchProInputOnly(vendor, product)) {
             // Nintendo Switch Pro controllers have a screenshot button
@@ -1281,7 +1281,7 @@ static GamepadMapping_t *SDL_CreateMappingForHIDAPIGamepad(SDL_GUID guid)
             }
         } else if (SDL_IsJoystickHoriSteamController(vendor, product)) {
             /* The Wireless HORIPad for Steam has QAM, Steam, Capsense L/R Sticks, 2 rear buttons, and 2 misc buttons */
-            SDL_strlcat(mapping_string, "paddle1:b13,paddle2:b12,paddle3:b15,paddle4:b14,misc1:b11,misc3:b16,misc4:b17", sizeof(mapping_string));
+            SDL_strlcat(mapping_string, "paddle1:b13,paddle2:b12,paddle3:b15,paddle4:b14,misc1:b11", sizeof(mapping_string));
         } else if (SDL_IsJoystickFlydigiController(vendor, product)) {
             SDL_strlcat(mapping_string, "paddle1:b11,paddle2:b12,paddle3:b13,paddle4:b14,", sizeof(mapping_string));
             if (guid.data[15] >= SDL_FLYDIGI_VADER2) {
@@ -3952,6 +3952,48 @@ bool SDL_GetGamepadSensorData(SDL_Gamepad *gamepad, SDL_SensorType type, float *
     return SDL_Unsupported();
 }
 
+bool SDL_GamepadHasCapSense(SDL_Gamepad *gamepad, SDL_GamepadCapSenseType type)
+{
+    bool result = false;
+
+    SDL_LockJoysticks();
+    {
+        SDL_Joystick *joystick = SDL_GetGamepadJoystick(gamepad);
+        if (joystick) {
+            for (int i = 0; i < joystick->ncapsenses; ++i) {
+                if (joystick->capsenses[i].type == type) {
+                    result = true;
+                    break;
+                }
+            }
+        }
+    }
+    SDL_UnlockJoysticks();
+
+    return result;
+}
+
+bool SDL_GetGamepadCapSense(SDL_Gamepad *gamepad, SDL_GamepadCapSenseType type)
+{
+    bool result = false;
+
+    SDL_LockJoysticks();
+    {
+        SDL_Joystick *joystick = SDL_GetGamepadJoystick(gamepad);
+        if (joystick) {
+            for (int i = 0; i < joystick->ncapsenses; ++i) {
+                if (joystick->capsenses[i].type == type) {
+                    result = joystick->capsenses[i].down;
+                    break;
+                }
+            }
+        }
+    }
+    SDL_UnlockJoysticks();
+
+    return result;
+}
+
 SDL_JoystickID SDL_GetGamepadID(SDL_Gamepad *gamepad)
 {
     SDL_Joystick *joystick = SDL_GetGamepadJoystick(gamepad);
@@ -4470,6 +4512,8 @@ static const Uint32 SDL_gamepad_event_list[] = {
     SDL_EVENT_GAMEPAD_TOUCHPAD_MOTION,
     SDL_EVENT_GAMEPAD_TOUCHPAD_UP,
     SDL_EVENT_GAMEPAD_SENSOR_UPDATE,
+    SDL_EVENT_GAMEPAD_CAPSENSE_TOUCH,
+    SDL_EVENT_GAMEPAD_CAPSENSE_RELEASE,
 };
 
 void SDL_SetGamepadEventsEnabled(bool enabled)
diff --git a/src/joystick/SDL_gamepad_db.h b/src/joystick/SDL_gamepad_db.h
index 5859e6d373d0b..6b1a95409855f 100644
--- a/src/joystick/SDL_gamepad_db.h
+++ b/src/joystick/SDL_gamepad_db.h
@@ -723,7 +723,7 @@ static const char *s_GamepadMappings[] = {
     "05000000de2800000212000001000000,Steam Controller,a:b0,b:b1,back:b6,dpdown:b14,dpleft:b15,dpright:b13,dpup:b12,guide:b8,leftshoulder:b4,leftstick:b9,lefttrigger:a2,leftx:a0,lefty:a1,paddle1:b11,paddle2:b10,rightshoulder:b5,righttrigger:a3,start:b7,x:b2,y:b3,",
     "05000000de2800000511000001000000,Steam Controller,a:b0,b:b1,back:b6,dpdown:b14,dpleft:b15,dpright:b13,dpup:b12,guide:b8,leftshoulder:b4,leftstick:b9,lefttrigger:a2,leftx:a0,lefty:a1,paddle1:b11,paddle2:b10,rightshoulder:b5,righttrigger:a3,start:b7,x:b2,y:b3,",
     "05000000de2800000611000001000000,Steam Controller,a:b0,b:b1,back:b6,dpdown:b14,dpleft:b15,dpright:b13,dpup:b12,guide:b8,leftshoulder:b4,leftstick:b9,lefttrigger:a2,leftx:a0,lefty:a1,paddle1:b11,paddle2:b10,rightshoulder:b5,righttrigger:a3,start:b7,x:b2,y:b3,",
-    "03000000de2800000512000000016800,Steam Deck Controller,a:b0,b:b1,back:b4,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,guide:b5,leftshoulder:b9,leftstick:b7,lefttrigger:a4,leftx:a0,lefty:a1,misc1:b11,paddle1:b12,paddle2:b13,paddle3:b14,paddle4:b15,touchpad:b17,misc2:b16,misc3:b19,misc4:b18,rightshoulder:b10,rightstick:b8,righttrigger:a5,rightx:a2,righty:a3,start:b6,x:b2,y:b3,",
+    "03000000de2800000512000000016800,Steam Deck Controller,a:b0,b:b1,back:b4,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,guide:b5,leftshoulder:b9,leftstick:b7,lefttrigger:a4,leftx:a0,lefty:a1,misc1:b11,paddle1:b12,paddle2:b13,paddle3:b14,paddle4:b15,touchpad:b17,misc2:b16,rightshoulder:b10,rightstick:b8,righttrigger:a5,rightx:a2,righty:a3,start:b6,x:b2,y:b3,",
     "03000000de2800000512000011010000,Steam Deck,a:b3,b:b4,back:b11,dpdown:b17,dpleft:b18,dpright:b19,dpup:b16,guide:b13,leftshoulder:b7,leftstick:b14,lefttrigger:a9,leftx:a0,lefty:a1,misc1:b2,paddle1:b21,paddle2:b20,paddle3:b23,paddle4:b22,rightshoulder:b8,rightstick:b15,righttrigger:a8,rightx:a2,righty:a3,start:b12,x:b5,y:b6,",
     "03000000de280000ff11000001000000,Steam Virtual Gamepad,a:b0,b:b1,back:b6,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,guide:b8,leftshoulder:b4,leftstick:b9,lefttrigger:a2,leftx:a0,lefty:a1,rightshoulder:b5,rightstick:b10,righttrigger:a5,rightx:a3,righty:a4,start:b7,x:b2,y:b3,",
     "0500000011010000311400001b010000,SteelSeries Stratus Duo,a:b0,b:b1,back:b10,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,guide:b32,leftshoulder:b6,leftstick:b13,lefttrigger:a5,leftx:a0,lefty:a1,rightshoulder:b7,rightstick:b14,righttrigger:a4,rightx:a2,righty:a3,start:b11,x:b3,y:b4,",
diff --git a/src/joystick/SDL_joystick.c b/src/joystick/SDL_joystick.c
index f052e2d04a987..746cb04690a53 100644
--- a/src/joystick/SDL_joystick.c
+++ b/src/joystick/SDL_joystick.c
@@ -2441,6 +2441,26 @@ void SDL_PrivateJoystickSensorRate(SDL_Joystick *joystick, SDL_SensorType type,
     }
 }
 
+void SDL_PrivateJoystickAddCapSense(SDL_Joystick *joystick, SDL_GamepadCapSenseType type)
+{
+    int ncapsenses;
+    SDL_JoystickCapSenseInfo *capsenses;
+
+    SDL_AssertJoysticksLocked();
+
+    ncapsenses = joystick->ncapsenses + 1;
+    capsenses = (SDL_JoystickCapSenseInfo *)SDL_realloc(joystick->capsenses, (ncapsenses * sizeof(SDL_JoystickCapSenseInfo)));
+    if (capsenses) {
+        SDL_JoystickCapSenseInfo *capsense = &capsenses[ncapsenses - 1];
+
+        capsense->type = type;
+        capsense->down = false;
+
+        joystick->ncapsenses = ncapsenses;
+        joystick->capsenses = capsenses;
+    }
+}
+
 void SDL_PrivateJoystickAdded(SDL_JoystickID instance_id)
 {
     SDL_JoystickDriver *driver;
@@ -3952,6 +3972,53 @@ void SDL_SendJoystickSensor(Uint64 timestamp, SDL_Joystick *joystick, SDL_Sensor
     }
 }
 
+void SDL_SendJoystickCapSense(Uint64 timestamp, SDL_Joystick *joystick, SDL_GamepadCapSenseType type, bool down)
+{
+    SDL_AssertJoysticksLocked();
+
+    // We ignore events if we don't have keyboard focus, except for button
+    // (capsense) release
+    if (SDL_PrivateJoystickShouldIgnoreEvent()) {
+        if (down) {
+            return;
+        }
+    }
+
+    for (int i = 0; i < joystick->ncapsenses; ++i) {
+        SDL_JoystickCapSenseInfo *capsense = &joystick->capsenses[i];
+
+        if (capsense->type == type) {
+            SDL_Event event;
+
+            // Ignore duplicate events
+            if (down == capsense->down) {
+                return;
+            }
+
+            // Update internal joystick state
+            capsense->down = down;
+            joystick->update_complete = timestamp;
+
+            if (down) {
+                event.type = SDL_EVENT_GAMEPAD_CAPSENSE_TOUCH;
+            } else {
+                event.type = SDL_EVENT_GAMEPAD_CAPSENSE_RELEASE;
+            }
+
+            // Post the event, if desired
+            if (SDL_EventEnabled(event.type)) {
+                event.common.timestamp = timestamp;
+                event.gcapsense.which = joystick->instance_id;
+                event.gcapsense.capsense = type;
+                event.gcapsense.down = down;
+                SDL_PushEvent(&event);
+            }
+
+            break;
+        }
+    }
+}
+
 static void SDL_LoadVIDPIDListFromHint(const char *hint, int *num_entries, int *max_entries, Uint32 **entries)
 {
     Uint32 entry;
diff --git a/src/joystick/SDL_joystick_c.h b/src/joystick/SDL_joystick_c.h
index 62e1f9f86504b..b80fb6b6512c9 100644
--- a/src/joystick/SDL_joystick_c.h
+++ b/src/joystick/SDL_joystick_c.h
@@ -181,6 +181,7 @@ extern bool SDL_ShouldIgnoreJoystick(Uint16 vendor_id, Uint16 product_id, Uint16
 extern void SDL_PrivateJoystickAddTouchpad(SDL_Joystick *joystick, int nfingers);
 extern void SDL_PrivateJoystickAddSensor(SDL_Joystick *joystick, SDL_SensorType type, float rate);
 extern void SDL_PrivateJoystickSensorRate(SDL_Joystick *joystick, SDL_SensorType type, float rate);
+extern void SDL_PrivateJoystickAddCapSense(SDL_Joystick *joystick, SDL_GamepadCapSenseType type);
 extern void SDL_PrivateJoystickAdded(SDL_JoystickID instance_id);
 extern bool SDL_IsJoystickBeingAdded(void);
 extern void SDL_PrivateJoystickRemoved(SDL_JoystickID instance_id);
@@ -191,6 +192,7 @@ extern void SDL_SendJoystickHat(Uint64 timestamp, SDL_Joystick *joystick, Uint8
 extern void SDL_SendJoystickButton(Uint64 timestamp, SDL_Joystick *joystick, Uint8 button, bool down);
 extern void SDL_SendJoystickTouchpad(Uint64 timestamp, SDL_Joystick *joystick, int touchpad, int finger, bool down, float x, float y, float pressure);
 extern void SDL_SendJoystickSensor(Uint64 timestamp, SDL_Joystick *joystick, SDL_SensorType type, Uint64 sensor_timestamp, const float *data, int num_values);
+extern void SDL_SendJoystickCapSense(Uint64 timestamp, SDL_Joystick *joystick, SDL_GamepadCapSenseType type, bool down);
 extern void SDL_SendJoystickPowerInfo(SDL_Joystick *joystick, SDL_PowerState state, int percent);
 
 // Function to get the Steam virtual gamepad info for a joystick
diff --git a/src/joystick/SDL_sysjoystick.h b/src/joystick/SDL_sysjoystick.h
index 15659b8cc09fc..76bce9006d762 100644
--- a/src/joystick/SDL_sysjoystick.h
+++ b/src/joystick/SDL_sysjoystick.h
@@ -72,6 +72,12 @@ typedef struct SDL_JoystickSensorInfo
     float data[3]; // If this needs to expand, update SDL_GamepadSensorEvent
 } SDL_JoystickSensorInfo;
 
+typedef struct SDL_JoystickCapSenseInfo
+{
+    SDL_GamepadCapSenseType type;
+    bool down;
+} SDL_JoystickCapSenseInfo;
+
 #define _guarded SDL_GUARDED_BY(SDL_joystick_lock)
 
 struct SDL_Joystick
@@ -105,6 +111,9 @@ struct SDL_Joystick
     int nsensors_enabled _guarded;
     SDL_JoystickSensorInfo *sensors _guarded;
 
+    int ncapsenses _guarded;                      // Number of capsense sources on the joystick
+    SDL_JoystickCapSenseInfo *capsenses _guarded; // Current capsense states
+
     Uint16 low_frequency_rumble _guarded;
     Uint16 high_frequency_rumble _guarded;
     Uint64 rumble_expiration _guarded;
diff --git a/src/joystick/hidapi/SDL_hidapi_steam_hori.c b/src/joystick/hidapi/SDL_hidapi_steam_hori.c
index c30b7cef0cf07..c24602f7fe8a2 100644
--- a/src/joystick/hidapi/SDL_hidapi_steam_hori.c
+++ b/src/joystick/hidapi/SDL_hidapi_steam_hori.c
@@ -39,8 +39,6 @@ enum
     SDL_GAMEPAD_BUTTON_HORI_FL,
     SDL_GAMEPAD_BUTTON_HORI_M1,
     SDL_GAMEPAD_BUTTON_HORI_M2,
-    SDL_GAMEPAD_BUTTON_HORI_JOYSTICK_TOUCH_L,
-    SDL_GAMEPAD_BUTTON_HORI_JOYSTICK_TOUCH_R,
     SDL_GAMEPAD_NUM_HORI_BUTTONS
 };
 
@@ -133,6 +131,10 @@ static bool HIDAPI_DriverSteamHori_OpenJoystick(SDL_HIDAPI_Device *device, SDL_J
 
     const Uint64 sensorupdatestep_ms = ctx->wireless ? 8333 : 4000; // Equivalent to 120hz / 250hz respectively
     ctx->simulated_sensor_step_ns = SDL_US_TO_NS(sensorupdatestep_ms);
+
+    SDL_PrivateJoystickAddCapSense(joystick, SDL_GAMEPAD_CAPSENSE_LEFT_STICK);
+    SDL_PrivateJoystickAddCapSense(joystick, SDL_GAMEPAD_CAPSENSE_RIGHT_STICK);
+
     return true;
 }
 
@@ -273,8 +275,8 @@ static void HIDAPI_DriverSteamHori_HandleStatePacket(SDL_Joystick *joystick, SDL
         SDL_SendJoystickButton(timestamp, joystick, SDL_GAMEPAD_BUTTON_LEFT_STICK, ((data[7] & 0x02) != 0));
         SDL_SendJoystickButton(timestamp, joystick, SDL_GAMEPAD_BUTTON_RIGHT_STICK, ((data[7] & 0x04) != 0));
         SDL_SendJoystickButton(timestamp, joystick, SDL_GAMEPAD_BUTTON_HORI_M2, ((data[7] & 0x08) != 0));
-        SDL_SendJoystickButton(timestamp, joystick, SDL_GAMEPAD_BUTTON_HORI_JOYSTICK_TOUCH_L, ((data[7] & 0x10) != 0));
-        SDL_SendJoystickButton(timestamp, joystick, SDL_GAMEPAD_BUTTON_HORI_JOYSTICK_TOUCH_R, ((data[7] & 0x20) != 0));
+        SDL_SendJoystickCapSense(timestamp, joystick, SDL_GAMEPAD_CAPSENSE_LEFT_STICK, ((data[7] & 0x10) != 0));
+        SDL_SendJoystickCapSense(timestamp, joystick, SDL_GAMEPAD_CAPSENSE_RIGHT_STICK, ((data[7] & 0x20) != 0));
         SDL_SendJoystickButton(timestamp, joystick, SDL_GAMEPAD_BUTTON_HORI_FR, ((data[7] & 0x40) != 0));
         SDL_SendJoystickButton(timestamp, joystick, SDL_GAMEPAD_BUTTON_HORI_FL, ((data[7] & 0x80) != 0));
     }
diff --git a/src/joystick/hidapi/SDL_hidapi_steam_triton.c b/src/joystick/hidapi/SDL_hidapi_steam_triton.c
index f1c58812291fd..97152d359cf78 100644
--- a/src/joystick/hidapi/SDL_hidapi_steam_triton.c
+++ b/src/joystick/hidapi/SDL_hidapi_steam_triton.c
@@ -47,10 +47,6 @@ enum
     SDL_GAMEPAD_BUTTON_TRITON_LEFT_PADDLE2,
     SDL_GAMEPAD_BUTTON_TRITON_RIGHT_TOUCHPAD,
     SDL_GAMEPAD_BUTTON_TRITON_LEFT_TOUCHPAD,
-    SDL_GAMEPAD_BUTTON_TRITON_RIGHT_JOYSTICK_TOUCH,
-    SDL_GAMEPAD_BUTTON_TRITON_LEFT_JOYSTICK_TOUCH,
-    SDL_GAMEPAD_BUTTON_TRITON_RIGHT_GRIP_TOUCH,
-    SDL_GAMEPAD_BUTTON_TRITON_LEFT_GRIP_TOUCH,
     SDL_GAMEPAD_NUM_TRITON_BUTTONS,
 };
 
@@ -187,15 +183,15 @@ static void HIDAPI_DriverSteamTriton_HandleState(SDL_HIDAPI_Device *device,
         SDL_SendJoystickButton(timestamp, joystick, SDL_GAMEPAD_BUTTON_TRITON_LEFT_TOUCHPAD,
                                ((pTritonReport->buttons & TRITON_LEFT_TOUCHPAD_CLICK) != 0));
 
-        SDL_SendJoystickButton(timestamp, joystick, SDL_GAMEPAD_BUTTON_TRITON_RIGHT_JOYSTICK_TOUCH,
-                               ((pTritonReport->buttons & TRITON_RIGHT_JOYSTICK_TOUCH) != 0));
-        SDL_SendJoystickButton(timestamp, joystick, SDL_GAMEPAD_BUTTON_TRITON_LEFT_JOYSTICK_TOUCH,
-                               ((pTritonReport->buttons & TRITON_LEFT_JOYSTICK_TOUCH) != 0));
+        SDL_SendJoystickCapSense(timestamp, joystick, SDL_GAMEPAD_CAPSENSE_RIGHT_STICK,
+                                 ((pTritonReport->buttons & TRITON_RIGHT_JOYSTICK_TOUCH) != 0));
+        SDL_SendJoystickCapSense(timestamp, joystick, SDL_GAMEPAD_CAPSENSE_LEFT_STICK,
+                                 ((pTritonReport->buttons & TRITON_LEFT_JOYSTICK_TOUCH) != 0));
 
-        SDL_SendJoystickButton(timestamp, joystick, SDL_GAMEPAD_BUTTON_TRITON_RIGHT_GRIP_TOUCH,
-                               ((pTritonReport->buttons & TRITON_RIGHT_GRIP_TOUCH) != 0));
-        SDL_SendJoystickButton(timestamp, joystick, SDL_GAMEPAD_BUTTON_TRITON_LEFT_GRIP_TOUCH,
-                               ((pTritonReport->buttons & TRITON_LEFT_GRIP_TOUCH) != 0));
+        SDL_SendJoystickCapSense(timestamp, joystick, SDL_GAMEPAD_CAPSENSE_RIGHT_GRIP,
+                                 ((pTritonReport->buttons & TRITON_RIGHT_GRIP_TOUCH) != 0));
+        SDL_SendJoystickCapSense(timestamp, joystick, SDL_GAMEPAD_CAPSENSE_LEFT_GRIP,
+                                 ((pTritonReport->buttons & TRITON_LEFT_GRIP_TOUCH) != 0));
 
         if (pTritonReport->buttons & TRITON_LBUTTON_DPAD_UP) {
             hat |= SDL_HAT_UP;
@@ -480,6 +476,11 @@ static bool HIDAPI_DriverSteamTriton_OpenJoystick(SDL_HIDAPI_Device *device, SDL
     SDL_PrivateJoystickAddTouchpad(joystick, 1);
     SDL_PrivateJoystickAddTouchpad(joystick, 1);
 
+    SDL_PrivateJoystickAddCapSense(joystick, SDL_GAMEPAD_CAPSENSE_LEFT_STICK);
+    SDL_PrivateJoystickAddCapSense(joystick, SDL_GAMEPAD_CAPSENSE_RIGHT_STICK);
+    SDL_PrivateJoystickAddCapSense(joystick, SDL_GAMEPAD_CAPSENSE_LEFT_GRIP);
+    SDL_PrivateJoystickAddCapSense(joystick, SDL_GAMEPAD_CAPSENSE_RIGHT_GRIP);
+
     return true;
 }
 
diff --git a/src/joystick/hidapi/SDL_hidapi_steamdeck.c b/src/joystick/hidapi/SDL_hidapi_steamdeck.c
index 92ca8402a9641..286b7c97ea8dc 100644
--- a/src/joystick/hidapi/SDL_hidapi_steamdeck.c
+++ b/src/joystick/hidapi/SDL_hidapi_steamdeck.c
@@ -41,8 +41,6 @@ enum
     SDL_GAMEPAD_BUTTON_STEAM_DECK_LEFT_PADDLE2,
     SDL_GAMEPAD_BUTTON_STEAM_DECK_RIGHT_TOUCHPAD,
     SDL_GAMEPAD_BUTTON_STEAM_DECK_LEFT_TOUCHPAD,
-    SDL_GAMEPAD_BUTTON_STEAM_DECK_RIGHT_JOYSTICK_TOUCH,
-    SDL_GAMEPAD_BUTTON_STEAM_DECK_LEFT_JOYSTICK_TOUCH,
     SDL_GAMEPAD_NUM_STEAM_DECK_BUTTONS,
 };
 
@@ -202,10 +200,10 @@ static void HIDAPI_DriverSteamDeck_HandleState(SDL_HIDAPI_Device *device,
         SDL_SendJoystickButton(timestamp, joystick, SDL_GAMEPAD_BUTTON_STEAM_DECK_LEFT_TOUCHPAD,
                                ((pInReport->payload.deckState.ulButtonsL & STEAMDECK_LBUTTON_LEFT_PAD) != 0));
 
-        SDL_SendJoystickButton(timestamp, joystick, SDL_GAMEPAD_BUTTON_STEAM_DECK_RIGHT_JOYSTICK_TOUCH,
-                               ((pInReport->payload.deckState.ulButtonsH & STEAMDECK_HBUTTON_RSTICK_TOUCH) != 0));
-        SDL_SendJoystickButton(timestamp, joystick, SDL_GAMEPAD_BUTTON_STEAM_DECK_LEFT_JOYSTICK_TOUCH,
-                               ((pInReport->payload.deckState.ulButtonsH & STEAMDECK_HBUTTON_LSTICK_TOUCH) != 0));
+        SDL_SendJoystickCapSense(timestamp, joystick, SDL_GAMEPAD_CAPSENSE_RIGHT_STICK,
+                                 ((pInReport->payload.deckState.ulButtonsH & STEAMDECK_HBUTTON_RSTICK_TOUCH) != 0));
+        SDL_SendJoystickCapSense(timestamp, joystick, SDL_GAMEPAD_CAPSENSE_LEFT_STICK,
+                                 ((pInReport->payload.deckState.ulButtonsH & STEAMDECK_HBUTTON_LSTICK_TOUCH) != 0));
 
         if (pInReport->payload.deckState.ulButtonsL & STEAMDECK_LBUTTON_DPAD_UP) {
             hat |= SDL_HAT_UP;
@@ -397,6 +395,9 @@ static bool HIDAPI_DriverSteamDeck_OpenJoystick(SDL_HIDAPI_Device *device, SDL_J
     SDL_PrivateJoystickAddTouchpad(joystick, 1);
     SDL_PrivateJoystickAddTouchpad(joystick, 1);
 
+    SDL_PrivateJoystickAddCapSense(joystick, SDL_GAMEPAD_CAPSENSE_LEFT_STICK);
+    SDL_PrivateJoystickAddCapSense(joystick, SDL_GAMEPAD_CAPSENSE_RIGHT_STICK);
+
     return true;
 }
 
diff --git a/src/test/SDL_test_common.c b/src/test/SDL_test_common.c
index 460d05d02752b..ee324a7a35795 100644
--- a/src/test/SDL_test_common.c
+++ b/src/test/SDL_test_common.c
@@ -1582,6 +1582,23 @@ static const char *GamepadButtonName(const SDL_GamepadButton button)
     }
 }
 
+static const char *CapSenseName(const SDL_GamepadCapSenseType type)
+{
+    switch (type) {
+#define CAPSENSE_CASE(cap)           \
+    case SDL_GAMEPAD_CAPSENSE_##cap: \
+        return #cap
+        CAPSENSE_CASE(INVALID);
+        CAPSENSE_CASE(LEFT_STICK);
+        CAPSENSE_CASE(RIGHT_STICK);
+        CAPSENSE_CASE(LEFT_GRIP);
+        CAPSENSE_CASE(RIGHT_GRIP);
+#undef CAPSENSE_CASE
+    default:
+        return "???";
+    }
+}
+
 void SDLTest_PrintEvent(const SDL_Event *event)
 {
     switch (event->type) {
@@ -1878,6 +1895,16 @@ void SDLTest_PrintEvent(const SDL_Event *event)
                 event->gbutton.which, event->gbutton.button,
                 GamepadButtonName((SDL_GamepadButton)event->gbutton.button));
         break;
+    case SDL_EVENT_GAMEPAD_CAPSENSE_TOUCH:
+        SDL_Log("SDL EVENT: Gamepad %" SDL_PRIu32 "capsense %u ('%s') touch",
+                event->gcapsense.which, event->gcapsense.capsense,
+                CapSenseName((SDL_GamepadCapSenseType)event->gcapsense.

(Patch may be truncated, please check the link at the top of this post.)