Skip to content

Commit c7b0e13

Browse files
fix: add vendor/product gamepad ID matching
Add a fallback lookup that normalizes Gamepad API IDs into vendor/product pairs so controllers can still be identified when browsers expose different product names. This covers Chromium and Firefox ID formats, adds an explicit VID/PID mapping for the generic controller, and expands tests for the new lookup paths while preserving existing XInput label behavior.
1 parent 81447b5 commit c7b0e13

2 files changed

Lines changed: 131 additions & 1 deletion

File tree

src/js/gamepad-helper.js

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ class GamepadHelper {
2222
/**
2323
* Exact Gamepad Mappings
2424
* This object maps specific gamepad API IDs to controller types and names.
25-
* @type {Object.<number, {name: string, gamepad_api_ids: string[], type: string}>}
25+
* @type {Object.<number, {name: string, gamepad_api_ids: string[], vendor_product_ids?: string[], type: string}>}
2626
*/
2727
this.exactGamepadMappings = {
2828
0: {
@@ -35,6 +35,7 @@ class GamepadHelper {
3535
"Core (Plus) Wired Controller (Vendor: 20d6 Product: a711)",
3636
"Wireless Controller Extended Gamepad",
3737
],
38+
vendor_product_ids: ["1209:0001"],
3839
type: this.CONTROLLER_TYPES.STANDARD,
3940
},
4041
1: {
@@ -120,9 +121,24 @@ class GamepadHelper {
120121
* @type {Object.<string, {name: string, gamepad_api_ids: string[], type: string}>}
121122
*/
122123
this.exactIdLookup = {};
124+
125+
/**
126+
* Vendor/product lookup used when browser-specific product names differ.
127+
* @type {Object.<string, {name: string, gamepad_api_ids: string[], type: string}>}
128+
*/
129+
this.vendorProductLookup = {};
123130
Object.values(this.exactGamepadMappings).forEach(mapping => {
124131
mapping.gamepad_api_ids.forEach(id => {
125132
this.exactIdLookup[id] = mapping;
133+
134+
const vendorProductId = this.extractVendorProductId(id);
135+
if (vendorProductId) {
136+
this.vendorProductLookup[vendorProductId] = mapping;
137+
}
138+
});
139+
140+
mapping.vendor_product_ids?.forEach(vendorProductId => {
141+
this.vendorProductLookup[vendorProductId] = mapping;
126142
});
127143
});
128144

@@ -351,6 +367,29 @@ class GamepadHelper {
351367
return !!navigator.getGamepads;
352368
}
353369

370+
/**
371+
* Extract a normalized vendor/product identifier from a browser Gamepad API ID.
372+
* @param {string|null} gamepadId - The ID of the gamepad as given by the Gamepad API
373+
* @returns {string|null} A lower-case, zero-padded `vendor:product` identifier
374+
*/
375+
extractVendorProductId(gamepadId) {
376+
if (!gamepadId) {
377+
return null;
378+
}
379+
380+
const chromiumMatch = /Vendor:\s*([\da-f]{1,4})\s+Product:\s*([\da-f]{1,4})/i.exec(gamepadId);
381+
const firefoxMatch = /^([\da-f]{1,4})-([\da-f]{1,4})-/i.exec(gamepadId);
382+
const match = chromiumMatch || firefoxMatch;
383+
384+
if (!match) {
385+
return null;
386+
}
387+
388+
const vendorId = match[1].padStart(4, '0').toLowerCase();
389+
const productId = match[2].padStart(4, '0').toLowerCase();
390+
return `${vendorId}:${productId}`;
391+
}
392+
354393
/**
355394
* Get gamepad information based on the gamepad ID
356395
* @param {string|null} gamepadId - The ID of the gamepad as given by the Gamepad API
@@ -373,6 +412,15 @@ class GamepadHelper {
373412
};
374413
}
375414

415+
const vendorProductId = this.extractVendorProductId(gamepadId);
416+
const vendorProductMatch = vendorProductId ? this.vendorProductLookup[vendorProductId] : null;
417+
if (vendorProductMatch) {
418+
return {
419+
type: vendorProductMatch.type,
420+
name: vendorProductMatch.name
421+
};
422+
}
423+
376424
return {
377425
type: this.CONTROLLER_TYPES.STANDARD,
378426
name: 'Generic Controller'

tests/gamepad-helper.test.js

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,13 @@ describe('GamepadHelper', () => {
5151
expect(helper.exactIdLookup['Pro Controller (STANDARD GAMEPAD Vendor: 057e Product: 2009)'].type).toBe(helper.CONTROLLER_TYPES.SWITCH);
5252
});
5353

54+
test('initializes vendorProductLookup correctly', () => {
55+
expect(helper.vendorProductLookup['054c:05c4'].name).toBe('Sony DualShock (PS4)');
56+
expect(helper.vendorProductLookup['054c:0ce6'].name).toBe('Sony DualSense (PS5)');
57+
expect(helper.vendorProductLookup['057e:2009'].name).toBe('Nintendo Switch Pro Controller');
58+
expect(helper.vendorProductLookup['1209:0001'].name).toBe('Generic Gamepad');
59+
});
60+
5461
test('initializes controller mappings correctly', () => {
5562
expect(helper.controllerMappings).toBeDefined();
5663
expect(helper.controllerMappings[helper.CONTROLLER_TYPES.XBOX].buttonMap[0]).toBe('A');
@@ -87,6 +94,81 @@ describe('GamepadHelper', () => {
8794
});
8895
});
8996

97+
test('preserves browser-provided XInput labels', () => {
98+
expect(helper.getGamepadInfo('xinput')).toEqual({
99+
type: helper.CONTROLLER_TYPES.XBOX,
100+
name: 'Xbox'
101+
});
102+
expect(helper.getGamepadInfo('Xbox 360 Controller (XInput STANDARD GAMEPAD)')).toEqual({
103+
type: helper.CONTROLLER_TYPES.XBOX,
104+
name: 'Xbox 360'
105+
});
106+
});
107+
108+
test.each([
109+
{
110+
browserController: 'Firefox DualShock 4',
111+
gamepadId: '054c-05c4-HID VHF Driver',
112+
type: 'playstation',
113+
name: 'Sony DualShock (PS4)'
114+
},
115+
{
116+
browserController: 'Firefox DualSense',
117+
gamepadId: '054c-0ce6-HID VHF Driver',
118+
type: 'playstation',
119+
name: 'Sony DualSense (PS5)'
120+
},
121+
{
122+
browserController: 'Firefox Switch Pro',
123+
gamepadId: '057e-2009-HID VHF Driver',
124+
type: 'switch',
125+
name: 'Nintendo Switch Pro Controller'
126+
},
127+
{
128+
browserController: 'Firefox Generic',
129+
gamepadId: '1209-0001-HID VHF Driver',
130+
type: 'standard',
131+
name: 'Generic Gamepad'
132+
},
133+
{
134+
browserController: 'Chrome DualShock 4',
135+
gamepadId: 'HID VHF Driver (STANDARD GAMEPAD Vendor: 054c Product: 05c4)',
136+
type: 'playstation',
137+
name: 'Sony DualShock (PS4)'
138+
},
139+
{
140+
browserController: 'Chrome DualSense',
141+
gamepadId: 'HID VHF Driver (STANDARD GAMEPAD Vendor: 054c Product: 0ce6)',
142+
type: 'playstation',
143+
name: 'Sony DualSense (PS5)'
144+
},
145+
{
146+
browserController: 'Chrome Switch Pro',
147+
gamepadId: 'HID VHF Driver (STANDARD GAMEPAD Vendor: 057e Product: 2009)',
148+
type: 'switch',
149+
name: 'Nintendo Switch Pro Controller'
150+
},
151+
{
152+
browserController: 'Chrome Generic',
153+
gamepadId: 'HID VHF Driver (Vendor: 1209 Product: 0001)',
154+
type: 'standard',
155+
name: 'Generic Gamepad'
156+
},
157+
])('returns a VID/PID match for $browserController', ({ gamepadId, type, name }) => {
158+
expect(helper.getGamepadInfo(gamepadId)).toEqual({ type, name });
159+
});
160+
161+
test('normalizes shortened and uppercase VID/PID values', () => {
162+
expect(helper.getGamepadInfo('54C-5C4-HID VHF Driver')).toEqual({
163+
type: helper.CONTROLLER_TYPES.PLAYSTATION,
164+
name: 'Sony DualShock (PS4)'
165+
});
166+
});
167+
168+
test('does not extract a VID/PID from a missing ID', () => {
169+
expect(helper.extractVendorProductId(null)).toBeNull();
170+
});
171+
90172
test('returns generic info for unknown gamepadId', () => {
91173
const result = helper.getGamepadInfo('unknown-controller-id');
92174
expect(result).toEqual({

0 commit comments

Comments
 (0)