Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 59 additions & 2 deletions src/js/gamepad-helper.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,15 @@
* Gamepad Helper Module
* This module provides a set of utilities for working with gamepads in web applications.
*/

/**
* Controller identity metadata used by browser ID lookups.
* @typedef {Object} GamepadIdentityMapping
* @property {string} name - Human-readable controller name
* @property {string[]} gamepad_api_ids - Known complete Gamepad API IDs
* @property {string[]} [vendor_product_ids] - Additional normalized `vendor:product` IDs
* @property {string} type - Controller type
*/
class GamepadHelper {
constructor() {
/**
Expand All @@ -22,7 +31,7 @@ class GamepadHelper {
/**
* Exact Gamepad Mappings
* This object maps specific gamepad API IDs to controller types and names.
* @type {Object.<number, {name: string, gamepad_api_ids: string[], type: string}>}
* @type {Object.<number, GamepadIdentityMapping>}
*/
this.exactGamepadMappings = {
0: {
Expand All @@ -35,6 +44,7 @@ class GamepadHelper {
"Core (Plus) Wired Controller (Vendor: 20d6 Product: a711)",
"Wireless Controller Extended Gamepad",
],
vendor_product_ids: ["1209:0001"],
type: this.CONTROLLER_TYPES.STANDARD,
},
1: {
Expand Down Expand Up @@ -117,12 +127,27 @@ class GamepadHelper {
/**
* Exact ID Lookup
* This object maps gamepad API IDs to their respective controller mappings.
* @type {Object.<string, {name: string, gamepad_api_ids: string[], type: string}>}
* @type {Object.<string, GamepadIdentityMapping>}
*/
this.exactIdLookup = {};

/**
* Vendor/product lookup used when browser-specific product names differ.
* @type {Object.<string, GamepadIdentityMapping>}
*/
this.vendorProductLookup = {};
Object.values(this.exactGamepadMappings).forEach(mapping => {
mapping.gamepad_api_ids.forEach(id => {
this.exactIdLookup[id] = mapping;

const vendorProductId = this.extractVendorProductId(id);
if (vendorProductId) {
this.vendorProductLookup[vendorProductId] = mapping;
}
});

mapping.vendor_product_ids?.forEach(vendorProductId => {
this.vendorProductLookup[vendorProductId] = mapping;
});
});

Expand Down Expand Up @@ -351,6 +376,29 @@ class GamepadHelper {
return !!navigator.getGamepads;
}

/**
* Extract a normalized vendor/product identifier from a browser Gamepad API ID.
* @param {string|null} gamepadId - The ID of the gamepad as given by the Gamepad API
* @returns {string|null} A lower-case, zero-padded `vendor:product` identifier
*/
extractVendorProductId(gamepadId) {
if (!gamepadId) {
return null;
}

const chromiumMatch = /Vendor:\s*([\da-f]{1,4})\s+Product:\s*([\da-f]{1,4})/i.exec(gamepadId);
const firefoxMatch = /^([\da-f]{1,4})-([\da-f]{1,4})-/i.exec(gamepadId);
const match = chromiumMatch || firefoxMatch;

if (!match) {
return null;
}

const vendorId = match[1].padStart(4, '0').toLowerCase();
const productId = match[2].padStart(4, '0').toLowerCase();
return `${vendorId}:${productId}`;
}

/**
* Get gamepad information based on the gamepad ID
* @param {string|null} gamepadId - The ID of the gamepad as given by the Gamepad API
Expand All @@ -373,6 +421,15 @@ class GamepadHelper {
};
}

const vendorProductId = this.extractVendorProductId(gamepadId);
const vendorProductMatch = vendorProductId ? this.vendorProductLookup[vendorProductId] : null;
if (vendorProductMatch) {
return {
type: vendorProductMatch.type,
name: vendorProductMatch.name
};
}

return {
type: this.CONTROLLER_TYPES.STANDARD,
name: 'Generic Controller'
Expand Down
82 changes: 82 additions & 0 deletions tests/gamepad-helper.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,13 @@ describe('GamepadHelper', () => {
expect(helper.exactIdLookup['Pro Controller (STANDARD GAMEPAD Vendor: 057e Product: 2009)'].type).toBe(helper.CONTROLLER_TYPES.SWITCH);
});

test('initializes vendorProductLookup correctly', () => {
expect(helper.vendorProductLookup['054c:05c4'].name).toBe('Sony DualShock (PS4)');
expect(helper.vendorProductLookup['054c:0ce6'].name).toBe('Sony DualSense (PS5)');
expect(helper.vendorProductLookup['057e:2009'].name).toBe('Nintendo Switch Pro Controller');
expect(helper.vendorProductLookup['1209:0001'].name).toBe('Generic Gamepad');
});

test('initializes controller mappings correctly', () => {
expect(helper.controllerMappings).toBeDefined();
expect(helper.controllerMappings[helper.CONTROLLER_TYPES.XBOX].buttonMap[0]).toBe('A');
Expand Down Expand Up @@ -87,6 +94,81 @@ describe('GamepadHelper', () => {
});
});

test('preserves browser-provided XInput labels', () => {
expect(helper.getGamepadInfo('xinput')).toEqual({
type: helper.CONTROLLER_TYPES.XBOX,
name: 'Xbox'
});
expect(helper.getGamepadInfo('Xbox 360 Controller (XInput STANDARD GAMEPAD)')).toEqual({
type: helper.CONTROLLER_TYPES.XBOX,
name: 'Xbox 360'
});
});

test.each([
{
browserController: 'Firefox DualShock 4',
gamepadId: '054c-05c4-HID VHF Driver',
type: 'playstation',
name: 'Sony DualShock (PS4)'
},
{
browserController: 'Firefox DualSense',
gamepadId: '054c-0ce6-HID VHF Driver',
type: 'playstation',
name: 'Sony DualSense (PS5)'
},
{
browserController: 'Firefox Switch Pro',
gamepadId: '057e-2009-HID VHF Driver',
type: 'switch',
name: 'Nintendo Switch Pro Controller'
},
{
browserController: 'Firefox Generic',
gamepadId: '1209-0001-HID VHF Driver',
type: 'standard',
name: 'Generic Gamepad'
},
{
browserController: 'Chrome DualShock 4',
gamepadId: 'HID VHF Driver (STANDARD GAMEPAD Vendor: 054c Product: 05c4)',
type: 'playstation',
name: 'Sony DualShock (PS4)'
},
{
browserController: 'Chrome DualSense',
gamepadId: 'HID VHF Driver (STANDARD GAMEPAD Vendor: 054c Product: 0ce6)',
type: 'playstation',
name: 'Sony DualSense (PS5)'
},
{
browserController: 'Chrome Switch Pro',
gamepadId: 'HID VHF Driver (STANDARD GAMEPAD Vendor: 057e Product: 2009)',
type: 'switch',
name: 'Nintendo Switch Pro Controller'
},
{
browserController: 'Chrome Generic',
gamepadId: 'HID VHF Driver (Vendor: 1209 Product: 0001)',
type: 'standard',
name: 'Generic Gamepad'
},
])('returns a VID/PID match for $browserController', ({ gamepadId, type, name }) => {
expect(helper.getGamepadInfo(gamepadId)).toEqual({ type, name });
});

test('normalizes shortened and uppercase VID/PID values', () => {
expect(helper.getGamepadInfo('54C-5C4-HID VHF Driver')).toEqual({
type: helper.CONTROLLER_TYPES.PLAYSTATION,
name: 'Sony DualShock (PS4)'
});
});

test('does not extract a VID/PID from a missing ID', () => {
expect(helper.extractVendorProductId(null)).toBeNull();
});

test('returns generic info for unknown gamepadId', () => {
const result = helper.getGamepadInfo('unknown-controller-id');
expect(result).toEqual({
Expand Down
2 changes: 1 addition & 1 deletion webpack.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ let config = {
// Put the Codecov webpack plugin after all other plugins
codecovWebpackPlugin({
enableBundleAnalysis: process.env.CODECOV_TOKEN !== undefined,
bundleName: "shared-web",
bundleName: "gamepad-helper",
uploadToken: process.env.CODECOV_TOKEN,
}),
],
Expand Down