From d05cde8217f408da5de42b5088fba68df8510f5c Mon Sep 17 00:00:00 2001 From: Austin Hamilton Date: Sun, 4 Oct 2026 21:23:13 +0000 Subject: [PATCH 1/3] test(core): add profile report conformance matrix --- docs/gamepad-report-conformance.md | 37 ++ package.json | 2 +- .../src/__tests__/report-conformance.test.ts | 322 ++++++++++++++++++ 3 files changed, 360 insertions(+), 1 deletion(-) create mode 100644 docs/gamepad-report-conformance.md create mode 100644 packages/core/src/__tests__/report-conformance.test.ts diff --git a/docs/gamepad-report-conformance.md b/docs/gamepad-report-conformance.md new file mode 100644 index 0000000..47db5f0 --- /dev/null +++ b/docs/gamepad-report-conformance.md @@ -0,0 +1,37 @@ +# Gamepad profile and report conformance + +This matrix describes fields emitted by OpenController's core encoders. It records OpenController logical control mappings and does not establish identical button semantics, glyphs, or behavior in every game. + +## Supported gamepad profiles + +| Profile | Face controls (south/east/west/north) | Shoulders | Triggers | System and profile controls | +| --- | --- | --- | --- | --- | +| Xbox | A / B / X / Y | LB / RB | LT / RT | BACK, START, GUIDE, LS, RS | +| PlayStation | CROSS / CIRCLE / SQUARE / TRIANGLE; X aliases CROSS, O aliases CIRCLE | L1 / R1 | L2 / R2 | SHARE, OPTIONS, PS, L3, R3, TOUCHPAD; touchpad contacts and motion | +| Switch | B / A / Y / X | L / R | ZL / ZR | MINUS, PLUS, HOME, CAPTURE, LS, RS; motion | +| Generic HID | BUTTON_0 / BUTTON_1 / BUTTON_2 / BUTTON_3; aliases A/B/X/Y | BUTTON_4 / BUTTON_5 | BUTTON_6 / BUTTON_7 | BUTTON_8, BUTTON_9, BUTTON_10, BUTTON_11; four D-pad directions | + +All four profiles support four D-pad directions and left/right sticks. The generic HID profile is OpenController's logical generic layout; it does not infer a physical controller's vendor-specific button assignment. + +## Output format coverage + +| Report format | Xbox | PlayStation | Switch | Generic HID | Fields and limitations | +| --- | --- | --- | --- | --- | --- | +| XInput | Yes | Yes | Yes | Yes | Face buttons, shoulders, D-pad, stick clicks, four axes, and two triggers after profile-to-universal mapping. Does not carry HOME/CAPTURE/TOUCHPAD, touchpad contacts, or motion. | +| Generic HID gamepad (input report ID 1) | Yes | Yes | Yes | Yes | Same shared face/shoulder/D-pad/stick-click controls, axes, and triggers; HOME and CAPTURE are represented where mapped. TOUCHPAD shares the generic HID CAPTURE bit. Does not carry touchpad contacts or motion. | +| PlayStation extended HID (input report ID 3) | — | Yes | — | — | Shared gamepad fields plus PlayStation touchpad pressed state, up to two contacts, acceleration, gyroscope, and orientation. | +| Switch extended HID (input report ID 4) | — | — | Yes | — | Shared gamepad fields plus acceleration, gyroscope, and orientation. It has no touchpad contact fields. | + +A dash means the format is not listed as supported for that profile. The separate HID rumble (output report ID 2) and light (output report ID 5) reports are feedback/output formats, not gamepad input formats in the matrix above. + +## Conformance checks + +Run from the repository root without a physical controller: + +```sh +bun test packages/core/src/__tests__/report-conformance.test.ts +``` + +The fixture tests drive each profile through the dry-run controller and decode OpenController's own encoded report bytes. Distinct face, shoulder, D-pad, trigger, and axis values make common alias or field swaps visible. Profile-specific fixtures also assert PlayStation touchpad contacts and motion and Switch motion. These results validate the tested OpenController state-to-report paths only. They do not demonstrate SDL, Steam Input, operating system, driver, physical device, or game compatibility. + +When adding a profile or changing an alias, update the profile row, add the profile controls and report formats it supports to the matrix, and add a table-driven case to `packages/core/src/__tests__/report-conformance.test.ts`. Assert decoded fields from encoded bytes, including controls that a selected compatibility report intentionally omits. Use values that differ across axes and triggers so swaps cannot pass accidentally. diff --git a/package.json b/package.json index b86ca94..d3679c6 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ "clean": "rm -rf packages/*/dist packages/*/tsconfig.tsbuildinfo examples/*/dist examples/*/tsconfig.tsbuildinfo .turbo output", "dev:fighter": "bun --cwd examples/agent-fighter dev", "dev:overlay": "bun --cwd examples/obs-overlay dev", - "test": "bun test packages/core/src/__tests__/controller.test.ts packages/cli/src/__tests__ packages/native/src/__tests__ packages/native-linux-uinput/src/__tests__ packages/native-windows-virtual-gamepad/src/__tests__ packages/native-macos-driverkit/src/__tests__", + "test": "bun test packages/core/src/__tests__ packages/cli/src/__tests__ packages/native/src/__tests__ packages/native-linux-uinput/src/__tests__ packages/native-windows-virtual-gamepad/src/__tests__ packages/native-macos-driverkit/src/__tests__", "typecheck": "tsc -b", "pack:check": "node scripts/check-npm-packages.mjs", "npm:status": "node scripts/check-npm-registry.mjs", diff --git a/packages/core/src/__tests__/report-conformance.test.ts b/packages/core/src/__tests__/report-conformance.test.ts new file mode 100644 index 0000000..547598d --- /dev/null +++ b/packages/core/src/__tests__/report-conformance.test.ts @@ -0,0 +1,322 @@ +import { describe, expect, test } from "bun:test"; +import { + createController, + decodeHidGamepadReport, + decodeHidPlayStationExtendedReport, + decodeHidSwitchExtendedReport, + decodeXInputReport, + encodeHidGamepadReport, + encodeHidPlayStationExtendedReport, + encodeHidSwitchExtendedReport, + encodeXInputReport, + xInputButtonBits, +} from "../index"; + +const sharedButtons = { + xbox: ["A", "B", "X", "Y", "LB", "RB", "BACK", "START", "LS", "RS"], + playstation: [ + "CROSS", + "CIRCLE", + "SQUARE", + "TRIANGLE", + "L1", + "R1", + "SHARE", + "OPTIONS", + "L3", + "R3", + ], + switch: ["B", "A", "Y", "X", "L", "R", "MINUS", "PLUS", "LS", "RS"], + "generic-hid": [ + "BUTTON_0", + "BUTTON_1", + "BUTTON_2", + "BUTTON_3", + "BUTTON_4", + "BUTTON_5", + "BUTTON_8", + "BUTTON_9", + "BUTTON_10", + "BUTTON_11", + ], +} as const; + +const commonMask = + xInputButtonBits.A | + xInputButtonBits.B | + xInputButtonBits.X | + xInputButtonBits.Y | + xInputButtonBits.LB | + xInputButtonBits.RB | + xInputButtonBits.BACK | + xInputButtonBits.START | + xInputButtonBits.LS | + xInputButtonBits.RS | + xInputButtonBits.DPAD_UP | + xInputButtonBits.DPAD_DOWN | + xInputButtonBits.DPAD_LEFT | + xInputButtonBits.DPAD_RIGHT; + +const profiles = ["xbox", "playstation", "switch", "generic-hid"] as const; + +describe("profile to report conformance", () => { + test.each(profiles)( + "%s shared controls map exactly in XInput and generic HID", + async (profile) => { + const controller = await createController({ + profile, + adapter: "dry-run", + replay: false, + }); + const buttons = Object.fromEntries([ + ...sharedButtons[profile].map((button) => [button, true]), + ...(["DPAD_UP", "DPAD_DOWN", "DPAD_LEFT", "DPAD_RIGHT"] as const).map( + (button) => [button, true], + ), + ]); + const triggers = + profile === "playstation" + ? { L2: 0.25, R2: 0.75 } + : profile === "switch" + ? { ZL: 0.25, ZR: 0.75 } + : profile === "generic-hid" + ? { BUTTON_6: 0.25, BUTTON_7: 0.75 } + : { LT: 0.25, RT: 0.75 }; + await controller.setState({ + buttons, + triggers, + sticks: { LEFT: { x: 0.125, y: -0.25 }, RIGHT: { x: -0.5, y: 0.75 } }, + }); + + const state = controller.getState(); + const xinput = decodeXInputReport(encodeXInputReport(state)); + const hid = decodeHidGamepadReport(encodeHidGamepadReport(state)); + expect(xinput).toEqual({ + buttons: commonMask, + leftTrigger: 64, + rightTrigger: 191, + leftStickX: 4096, + leftStickY: 8192, + rightStickX: -16384, + rightStickY: -24576, + }); + expect(hid).toEqual({ + reportId: 1, + buttons: commonMask, + leftTrigger: 64, + rightTrigger: 191, + leftStickX: 4096, + leftStickY: 8192, + rightStickX: -16384, + rightStickY: -24576, + }); + + const controlBits = [ + ...sharedButtons[profile].map((button, index) => { + const bit = [ + xInputButtonBits.A, + xInputButtonBits.B, + xInputButtonBits.X, + xInputButtonBits.Y, + xInputButtonBits.LB, + xInputButtonBits.RB, + xInputButtonBits.BACK, + xInputButtonBits.START, + xInputButtonBits.LS, + xInputButtonBits.RS, + ][index]; + if (bit === undefined) { + throw new Error(`Missing expected bit for ${button}`); + } + return { button, bit }; + }), + ...(["DPAD_UP", "DPAD_DOWN", "DPAD_LEFT", "DPAD_RIGHT"] as const).map( + (button) => ({ + button, + bit: { + DPAD_UP: xInputButtonBits.DPAD_UP, + DPAD_DOWN: xInputButtonBits.DPAD_DOWN, + DPAD_LEFT: xInputButtonBits.DPAD_LEFT, + DPAD_RIGHT: xInputButtonBits.DPAD_RIGHT, + }[button], + }), + ), + ]; + for (const { button, bit } of controlBits) { + await controller.setState({ + buttons: { + ...Object.fromEntries( + controlBits.map(({ button: name }) => [name, false]), + ), + [button]: true, + }, + }); + const oneControl = controller.getState(); + expect(decodeXInputReport(encodeXInputReport(oneControl)).buttons).toBe( + bit, + ); + expect( + decodeHidGamepadReport(encodeHidGamepadReport(oneControl)).buttons, + ).toBe(bit); + } + await controller.disconnect(); + }, + ); + + test("profile aliases map one at a time to their intended face buttons", async () => { + const aliases = [ + { profile: "playstation", input: "X", bit: xInputButtonBits.A }, + { profile: "playstation", input: "O", bit: xInputButtonBits.B }, + { profile: "generic-hid", input: "A", bit: xInputButtonBits.A }, + { profile: "generic-hid", input: "B", bit: xInputButtonBits.B }, + { profile: "generic-hid", input: "X", bit: xInputButtonBits.X }, + { profile: "generic-hid", input: "Y", bit: xInputButtonBits.Y }, + ] as const; + for (const { profile, input, bit } of aliases) { + const controller = await createController({ + profile, + adapter: "dry-run", + replay: false, + }); + await controller.setState({ buttons: { [input]: true } }); + const state = controller.getState(); + expect(decodeXInputReport(encodeXInputReport(state)).buttons).toBe(bit); + expect( + decodeHidGamepadReport(encodeHidGamepadReport(state)).buttons, + ).toBe(bit); + await controller.disconnect(); + } + }); + + test("PlayStation profile controls retain aliases and profile-only fields in extended HID", async () => { + const controller = await createController({ + profile: "playstation", + adapter: "dry-run", + replay: false, + safety: { allowSystemButtons: true }, + }); + await controller.setState({ + buttons: { + X: true, + O: true, + SQUARE: true, + TRIANGLE: true, + L1: true, + R1: true, + DPAD_UP: true, + DPAD_RIGHT: true, + TOUCHPAD: true, + }, + triggers: { L2: 0.25, R2: 0.75 }, + sticks: { LEFT: { x: 0.125, y: -0.25 }, RIGHT: { x: -0.5, y: 0.75 } }, + touchpad: { + pressed: true, + contacts: [{ id: 9, x: 0.25, y: 0.75, pressure: 0.5 }], + }, + motion: { + acceleration: { x: 0.125, y: -0.25, z: 0.5 }, + gyroscope: { x: -0.5, y: 0.25, z: 1 }, + orientation: { x: 0.75, y: -1, z: 0 }, + }, + }); + const state = controller.getState(); + const report = decodeHidPlayStationExtendedReport( + encodeHidPlayStationExtendedReport(state), + ); + expect(report.buttons).toBe( + (commonMask & + ~xInputButtonBits.BACK & + ~xInputButtonBits.START & + ~xInputButtonBits.LS & + ~xInputButtonBits.RS & + ~xInputButtonBits.DPAD_DOWN & + ~xInputButtonBits.DPAD_LEFT) | + 0x0800, + ); + expect([ + report.leftTrigger, + report.rightTrigger, + report.leftStickX, + report.leftStickY, + report.rightStickX, + report.rightStickY, + ]).toEqual([64, 191, 4096, 8192, -16384, -24576]); + expect(report.touchpadPressed).toBe(true); + expect(report.touchpadContacts[0]).toEqual({ + id: 9, + active: true, + x: 16384, + y: 49151, + pressure: 128, + }); + expect([ + report.accelerationX, + report.accelerationY, + report.accelerationZ, + report.gyroscopeX, + report.gyroscopeY, + report.gyroscopeZ, + report.orientationX, + report.orientationY, + report.orientationZ, + ]).toEqual([4096, -8192, 16384, -16384, 8192, 32767, 24575, -32768, 0]); + await controller.disconnect(); + }); + + test("Switch profile controls and motion map in extended HID", async () => { + const controller = await createController({ + profile: "switch", + adapter: "dry-run", + replay: false, + }); + await controller.setState({ + buttons: { + B: true, + A: true, + Y: true, + X: true, + L: true, + R: true, + MINUS: true, + PLUS: true, + LS: true, + RS: true, + DPAD_UP: true, + DPAD_DOWN: true, + DPAD_LEFT: true, + DPAD_RIGHT: true, + }, + triggers: { ZL: 0.25, ZR: 0.75 }, + sticks: { LEFT: { x: 0.125, y: -0.25 }, RIGHT: { x: -0.5, y: 0.75 } }, + motion: { + acceleration: { x: 0.125, y: -0.25, z: 0.5 }, + gyroscope: { x: -0.5, y: 0.25, z: 1 }, + orientation: { x: 0.75, y: -1, z: 0 }, + }, + }); + const report = decodeHidSwitchExtendedReport( + encodeHidSwitchExtendedReport(controller.getState()), + ); + expect(report.buttons).toBe(commonMask); + expect([ + report.leftTrigger, + report.rightTrigger, + report.leftStickX, + report.leftStickY, + report.rightStickX, + report.rightStickY, + ]).toEqual([64, 191, 4096, 8192, -16384, -24576]); + expect([ + report.accelerationX, + report.accelerationY, + report.accelerationZ, + report.gyroscopeX, + report.gyroscopeY, + report.gyroscopeZ, + report.orientationX, + report.orientationY, + report.orientationZ, + ]).toEqual([4096, -8192, 16384, -16384, 8192, 32767, 24575, -32768, 0]); + await controller.disconnect(); + }); +}); From 132b1a61f4e7f3ba366f30ec47e637b5e87f3b8b Mon Sep 17 00:00:00 2001 From: Austin Hamilton Date: Sun, 4 Oct 2026 21:27:51 +0000 Subject: [PATCH 2/3] test(core): cover profile system button mappings --- docs/gamepad-report-conformance.md | 2 +- packages/core/package.json | 2 +- .../src/__tests__/report-conformance.test.ts | 78 ++++++++++++++++++- 3 files changed, 78 insertions(+), 4 deletions(-) diff --git a/docs/gamepad-report-conformance.md b/docs/gamepad-report-conformance.md index 47db5f0..38b96cf 100644 --- a/docs/gamepad-report-conformance.md +++ b/docs/gamepad-report-conformance.md @@ -11,7 +11,7 @@ This matrix describes fields emitted by OpenController's core encoders. It recor | Switch | B / A / Y / X | L / R | ZL / ZR | MINUS, PLUS, HOME, CAPTURE, LS, RS; motion | | Generic HID | BUTTON_0 / BUTTON_1 / BUTTON_2 / BUTTON_3; aliases A/B/X/Y | BUTTON_4 / BUTTON_5 | BUTTON_6 / BUTTON_7 | BUTTON_8, BUTTON_9, BUTTON_10, BUTTON_11; four D-pad directions | -All four profiles support four D-pad directions and left/right sticks. The generic HID profile is OpenController's logical generic layout; it does not infer a physical controller's vendor-specific button assignment. +All four profiles support four D-pad directions and left/right sticks. The generic HID profile is OpenController's logical generic layout; it does not infer a physical controller's vendor-specific button assignment. It defines no HOME or CAPTURE buttons: BUTTON_10 and BUTTON_11 are left and right stick clicks. The generic HID report can still encode HOME/CAPTURE bits for profiles that map those controls, such as Xbox GUIDE or Switch HOME/CAPTURE. ## Output format coverage diff --git a/packages/core/package.json b/packages/core/package.json index 397ab3b..5323df1 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -59,7 +59,7 @@ "scripts": { "build": "rm -rf dist tsconfig.tsbuildinfo && tsc -p tsconfig.json && node ../../scripts/fix-esm-extensions.mjs dist", "typecheck": "tsc -p tsconfig.json --noEmit", - "test": "bun test src/__tests__/controller.test.ts" + "test": "bun test src/__tests__" }, "publishConfig": { "access": "public" diff --git a/packages/core/src/__tests__/report-conformance.test.ts b/packages/core/src/__tests__/report-conformance.test.ts index 547598d..fdaed29 100644 --- a/packages/core/src/__tests__/report-conformance.test.ts +++ b/packages/core/src/__tests__/report-conformance.test.ts @@ -188,12 +188,76 @@ describe("profile to report conformance", () => { } }); + test("profile system controls map only to report formats that carry them", async () => { + const controls = [ + { profile: "xbox", button: "GUIDE", hidBit: 0x0400, xinputBit: 0 }, + { profile: "playstation", button: "PS", hidBit: 0x0400, xinputBit: 0 }, + { profile: "switch", button: "HOME", hidBit: 0x0400, xinputBit: 0 }, + { profile: "switch", button: "CAPTURE", hidBit: 0x0800, xinputBit: 0 }, + { + profile: "generic-hid", + button: "BUTTON_10", + hidBit: xInputButtonBits.LS, + xinputBit: xInputButtonBits.LS, + }, + { + profile: "generic-hid", + button: "BUTTON_11", + hidBit: xInputButtonBits.RS, + xinputBit: xInputButtonBits.RS, + }, + ] as const; + for (const { profile, button, hidBit, xinputBit } of controls) { + const controller = await createController({ + profile, + adapter: "dry-run", + replay: false, + safety: { + allowSystemButtons: true, + allowGuideButton: true, + disabledButtons: [], + }, + }); + await controller.setState({ buttons: { [button]: true } }); + const state = controller.getState(); + expect( + decodeHidGamepadReport(encodeHidGamepadReport(state)).buttons, + ).toBe(hidBit); + expect(decodeXInputReport(encodeXInputReport(state)).buttons).toBe( + xinputBit, + ); + await controller.disconnect(); + } + + const generic = await createController({ + profile: "generic-hid", + adapter: "dry-run", + replay: false, + safety: { + allowSystemButtons: true, + allowGuideButton: true, + disabledButtons: [], + }, + }); + await expect( + generic.setState({ buttons: { HOME: true } }), + ).rejects.toThrow(); + await expect( + generic.setState({ buttons: { CAPTURE: true } }), + ).rejects.toThrow(); + await generic.disconnect(); + }); + test("PlayStation profile controls retain aliases and profile-only fields in extended HID", async () => { const controller = await createController({ profile: "playstation", adapter: "dry-run", replay: false, - safety: { allowSystemButtons: true }, + safety: { + allowSystemButtons: true, + allowGuideButton: true, + disabledButtons: [], + }, }); await controller.setState({ buttons: { @@ -211,7 +275,10 @@ describe("profile to report conformance", () => { sticks: { LEFT: { x: 0.125, y: -0.25 }, RIGHT: { x: -0.5, y: 0.75 } }, touchpad: { pressed: true, - contacts: [{ id: 9, x: 0.25, y: 0.75, pressure: 0.5 }], + contacts: [ + { id: 9, x: 0.25, y: 0.75, pressure: 0.5 }, + { id: 10, x: 0.875, y: 0.125, pressure: 0.25 }, + ], }, motion: { acceleration: { x: 0.125, y: -0.25, z: 0.5 }, @@ -249,6 +316,13 @@ describe("profile to report conformance", () => { y: 49151, pressure: 128, }); + expect(report.touchpadContacts[1]).toEqual({ + id: 10, + active: true, + x: 57343, + y: 8192, + pressure: 64, + }); expect([ report.accelerationX, report.accelerationY, From 0ec8471b9c26e2a7d6f9eb0ae4f1d77d08a2b644 Mon Sep 17 00:00:00 2001 From: Austin Hamilton Date: Sun, 4 Oct 2026 21:31:26 +0000 Subject: [PATCH 3/3] test(core): assert playstation touchpad compatibility bits --- .../src/__tests__/report-conformance.test.ts | 23 +++++++++++-------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/packages/core/src/__tests__/report-conformance.test.ts b/packages/core/src/__tests__/report-conformance.test.ts index fdaed29..6648db8 100644 --- a/packages/core/src/__tests__/report-conformance.test.ts +++ b/packages/core/src/__tests__/report-conformance.test.ts @@ -290,15 +290,20 @@ describe("profile to report conformance", () => { const report = decodeHidPlayStationExtendedReport( encodeHidPlayStationExtendedReport(state), ); - expect(report.buttons).toBe( - (commonMask & - ~xInputButtonBits.BACK & - ~xInputButtonBits.START & - ~xInputButtonBits.LS & - ~xInputButtonBits.RS & - ~xInputButtonBits.DPAD_DOWN & - ~xInputButtonBits.DPAD_LEFT) | - 0x0800, + const psSharedMask = + commonMask & + ~xInputButtonBits.BACK & + ~xInputButtonBits.START & + ~xInputButtonBits.LS & + ~xInputButtonBits.RS & + ~xInputButtonBits.DPAD_DOWN & + ~xInputButtonBits.DPAD_LEFT; + expect(report.buttons).toBe(psSharedMask | 0x0800); + expect(decodeHidGamepadReport(encodeHidGamepadReport(state)).buttons).toBe( + psSharedMask | 0x0800, + ); + expect(decodeXInputReport(encodeXInputReport(state)).buttons).toBe( + psSharedMask, ); expect([ report.leftTrigger,