Skip to content

Controller Records

xsyetopz edited this page Oct 15, 2026 · 4 revisions

Adding Or Changing a Controller Record

This page explains how to add a controller that OpenJoystickDriver does not know, or change how it drives a known one, with your own controller record.

Note: This page applies to OpenJoystickDriver 0.6.0-alpha.1 and later. Version 0.5.0-beta.4 and earlier do not have all of the features on this page.

Contents

How Records Work

A controller record tells OpenJoystickDriver which protocol family drives one controller model, identified by its VID:PID. The app contains a bundled record for each supported model. Your records are JSON files in ~/Library/Application Support/OpenJoystickDriver/Controllers. Each file is named after its model in lowercase hexadecimal, for example 045e-028e.json.

A file in that folder is one of two kinds:

  • Controller: a complete record for a model that has no bundled record. Name it VVVV-PPPP.json.
  • ControllerPatch: new values for a model that already has a record. Name it VVVV-PPPP.patch.json. Its selector names the model by vendorID and productID, and its patch holds the fields to change.

A patch follows RFC 7396 JSON Merge Patch. An object in patch merges into the record key by key, a value you give replaces the record's value, an array replaces the whole array, and null deletes the key. Deleting the last key of a section leaves an empty section, which a record does not accept, so set the whole section (tuning, protocol.parameters) to null instead. A patch that changes protocol.family must also set to null every protocol key of the old family that the new family does not read.

{
  "apiVersion": "openjoystickdriver/v1alpha1",
  "kind": "ControllerPatch",
  "selector": { "vendorID": 1118, "productID": 746 },
  "patch": { "tuning": { "stickDeadzone": 0.08 }, "protocol": { "parameters": { "shareButton": null } } }
}

Several patches can apply to one model. The layers apply from the weakest to the strongest: the bundled record, a bundled patch, a patch from an installed mod package, then your patch. Inside one layer, a patch with "selector": { "all": true } applies first, a vendorID and productID patch next, and a patch with "selector": { "model": "VVVV-PPPP" } last. A Controller file of yours replaces a package's Controller file for the same model. A ControllerPatch of yours applies over the package record instead of replacing it. If you remove the package later, OJD lists your patch as a problem and skips it.

A record for a model that uses raw USB works only when macOS lets OpenJoystickDriver open the device directly. The Xbox USB part of the OJD driver extension claims only the Xbox models in its signed product list, and your record cannot add a model to that list. ojd record validate says when this applies.

Choose Who Drives the Controller

macOS serves some controllers natively through the Game Controller framework. OpenJoystickDriver leaves those to macOS by default and only reads their input. The record's server field changes this:

  • system: macOS drives the controller when it can. This is the default.
  • ojd: OpenJoystickDriver opens the controller exclusively even when macOS supports it, and drives it like a controller macOS does not know.

A raw-USB family, such as xbox.gip, has no server field, because macOS cannot serve those controllers. A new server value applies to a connected controller as soon as the running service reloads the records. You do not need to unplug the controller.

To change server without editing the file, run ojd controller claim CONTROLLER or ojd controller release CONTROLLER. claim sets ojd, and release sets system. The command writes the value to your record for the model, and creates a ControllerPatch file when you have none and the bundled catalog or an installed package has a record of the model. It refuses a raw-USB family, a controller that macOS does not serve natively, and a model that has no record at all, with the code E2023. For a model with no record, run ojd record draft to start one. ojd controller show names the choice and the layer it comes from, and the serving route and persona. In the app, the controller detail has the same choice as the Served by picker, shown for a controller that macOS serves natively or that a record gives to OpenJoystickDriver, and never for a raw-USB family.

Describe a Controller's Input Reports

Some controllers send fixed input reports that their HID descriptor describes wrongly. A record with the protocol family hid.report-layout names where each control sits in the report, in its input field. The family requires input, and no other family takes it in a record. The bundled xbox.xid protocol description declares its own input layout as data, in Resources/Protocols/xbox.xid.json, with the same fields.

"protocol": { "family": "hid.report-layout" },
"input": {
  "report": { "length": 19 },
  "buttons": [
    { "control": "South", "byte": 13, "mask": 128 },
    { "control": "Back", "byte": 1, "mask": 1 }
  ],
  "axes": [
    { "control": "LeftStickX", "byte": 3 },
    { "control": "LeftStickY", "byte": 4 }
  ],
  "hat": [
    {
      "encoding": "directions",
      "up": { "byte": 9, "mask": 128 },
      "right": { "byte": 7, "mask": 128 },
      "down": { "byte": 10, "mask": 128 },
      "left": { "byte": 8, "mask": 128 }
    }
  ],
  "leftTrigger": { "byte": 17 },
  "rightTrigger": { "byte": 18 }
}

Offsets count from the first byte of the report as macOS delivers it.

  • report: length is the shortest report OpenJoystickDriver reads, from 1 to 64 bytes. With id, from 1 to 255, byte 0 must hold that report ID, and length counts it. OpenJoystickDriver ignores shorter reports and reports with another ID.
  • buttons: each entry names a control, a byte, and a mask. The button is pressed while any masked bit is set. Repeat a control to read it from several places. A button cannot be dpad, a stick axis, left-trigger, or right-trigger.
  • axes: each entry names a stick axis and its byte. bits is 8, the default, or 16 for a little-endian value in byte and the next byte. signed reads the value as two's complement. min and max set the raw range, which defaults to the full range of the width. inverted flips the direction. symmetric reads a signed value as the raw value divided by max, so the lowest raw value reads as full negative; it requires signed; min and max default to the full range, and if given must be negative and positive. X grows right and Y grows down, as in HID.
  • hat: a list of sources; the first that reads a direction wins. An 8-way source reads the masked bits as 0 for north, clockwise to 7 for north-west, and any other value as centered. With neutralUntilNonzero, 0 also reads centered until the bits have been nonzero once, for a controller that sends 0 before its hat is live. A directions source names one bit for each direction; opposing directions cancel. A table source takes a byte, a mask, and values, which maps each masked number, written in decimal without leading zeros, to a direction name from north, northEast, east, southEast, south, southWest, west, and northWest; any other number reads as centered.
  • leftTrigger and rightTrigger: a byte read from 0 to 255, a button that reads as fully pulled, or both.

Name at least one control. ojd record validate checks that every field lies inside the report.

Add Rumble To a Controller

A hid.report-layout or vendor.ps3-third-party controller is input-only unless its record names its rumble report in output.rumble:

"output": {
  "rumble": {
    "report": { "kind": "output", "id": 2, "length": 8 },
    "leftMain": { "byte": 3 },
    "rightMain": { "byte": 2 }
  }
}
  • report names the HID report: kind is output or feature, id is the report ID, and length counts every byte, including the report ID.
  • leftMain, rightMain, leftTrigger, and rightTrigger each give the byte offset of that motor's intensity, from 0 to 255. Name at least one.

OpenJoystickDriver sets the report ID in byte 0 when the ID is not 0, writes each motor's intensity, and sends 0 in every other byte. It sends this report even when macOS drives the controller, because macOS does not drive these motors.

Send Fixed Reports At Startup

A HID controller that needs a fixed report before it sends input can name it in output.startup. The Sixaxis records use one to enable a DualShock 3 over Bluetooth:

"output": {
  "startup": [
    {
      "report": { "kind": "feature", "id": 244, "length": 5 },
      "bytes": [66, 3, 0, 0],
      "transport": "bluetooth-classic"
    }
  ]
}
  • report names the HID report as in output.rumble. length is 2 to 128 bytes.
  • bytes lists every byte after the report ID, or every byte of the report when id is 0, from 0 to 255.
  • delayMilliseconds waits before this write, from 0 to 1000. Without it, a startup-phase write waits the family's startup interval and an activation-phase write does not wait.
  • phase is activation, the default, or startup. An activation write is sent after the protocol driver's own startup, when the controller opens; a startup write is sent with the driver's startup writes.
  • crc32Seed (0 to 255) makes OpenJoystickDriver write the Sony Bluetooth CRC-32 of the report, with this seed, in its last 4 bytes. length then counts those 4 bytes and must exceed 4. The DualShock 4 Bluetooth report uses it.
  • transport limits the write to usb, bluetooth-classic, or bluetooth-le. Without it, OpenJoystickDriver sends the write on every transport.

List 1 to 16 writes. OpenJoystickDriver sends them in order after the protocol driver's own startup, only when it opens the controller. A failed write is logged and the next one is still sent. A family's description may also carry startup writes, which a record's writes of the same format replace. In a description a HID write may add variants (the family variants it applies to) and when ({"parameter": "layout", "is": ["Pro"]} or isNot, naming a declared enumeration parameter). A record may not use variants or when.

Raw-USB Startup Writes

A raw-USB family that carries startup packets in its description (xbox.xusb, xbox.gip, nintendo.switch2) takes them in output.startup too, in place of the description's. A raw-USB write has no report: bytes (1 to 64), endpoint (1 to 127, default the family's output endpoint), timeoutMilliseconds (1 to 60000, default the family's timeout), delayMilliseconds (0 to 1000), sessionByte (the index of a byte the driver fills with its per-session value, such as the GIP sequence), and toleratesRejection. One list holds one format only, and a record that names protocol.initialization takes no raw-USB writes.

Tune Timings and the Stick Deadzone

A record's tuning field changes a value that the protocol driver otherwise sets. Name at least one value. A value you leave out keeps the family's default, which its protocol description holds (see Family Defaults).

"tuning": {
  "stickDeadzone": 0.02,
  "hidStartupRecoveryRounds": 3
}
Field Range Applies to What it sets
stickDeadzone more than 0, less than 1 every family The radial deadzone of both sticks on the virtual gamepad, as a fraction of full deflection. Movement past the deadzone is rescaled to the full range.
inputLivenessTimeoutMs 1 to 60000 sony.dualshock4 How long the controller can send no input before OpenJoystickDriver releases its held controls. The default is 1000.
hidStartupIntervalMs 0 to 60000 HID families The gap between the startup writes.
minimumHIDOutputIntervalMs 0 to 60000 HID families The shortest gap between rumble and lighting reports. 0 sends them without a limit.
hidStartupRecoveryIntervalMs 1 to 60000 nintendo.switch1 The gap between startup recovery rounds. The family's description gives the default (200).
hidStartupRecoveryRounds 1 to 10 nintendo.switch1 The startup recovery rounds before OpenJoystickDriver gives up. The family's description gives the default (2).
lizardResendTicks 1 to 100000 valve.steam-triton The keep-alive ticks between resends of the lizard-mode-off request. The family's description gives the default (75).
usbCommandTimeoutMs 1 to 60000 nintendo.switch2, vendor.gamesir (USB) The timeout of one USB command transfer. The description gives the default: 1000 for nintendo.switch2, 2000 for vendor.gamesir.
presenceResendReports 1 to 1000 xbox.gip, xbox.xusb The input reports between resends of the request that makes a controller announce itself. The description gives the default: 4 for xbox.gip, 16 for xbox.xusb.
adapterDisconnectDelayMs 1 to 60000 sony.dualshock4, sony.dualsense How long an adapter or dongle can stay silent before its controller counts as disconnected. The default is 500.
usbStartupIntervalMs 0 to 60000 xbox.gip The gap between the raw-USB startup writes. The default is 50.
usbStartupRetryDelayMs 1 to 60000 xbox.gip The wait before the first startup retry; each later retry waits twice as long. The default is 1000.
usbTransferTimeoutMs 1 to 60000 xbox.gip The timeout of each raw-USB transfer, startup packets and rumble. The default is 2000.
frameQueueCapacity 1 to 1024 every family How many virtual gamepad reports may wait behind the one being sent. The default is 64. When the queue is full, the newest state replaces the oldest waiting state, so a slow consumer never grows memory and always receives the newest state. A reply to an app's output report is never dropped.

The default for frameQueueCapacity is in each protocol description's defaults.tuning (Resources/Protocols/<family>.json), so a record, a patch, or all.patch.json overrides it per model or for every controller. Reports queue only while the system takes a long time to accept the previous one; button changes that arrive in the same moment still reach the virtual gamepad in order until the queue is full.

Raw-USB families, such as xbox.gip, do not take the two HID timings. The two recovery values apply only to nintendo.switch1, because the nintendo.switch2 and nintendo.switch-input-only drivers have no startup recovery. ojd record validate rejects a value outside its range or its families.

A tuning in a ControllerPatch merges per value into the bundled tuning: a value you leave out keeps its bundled value. Timings apply when the running service connects the controller again after the record changes.

Family Defaults Live In Protocol Descriptions

A family's default timings and control set are data in Resources/Protocols/<family>.json, not Swift constants. protocol-description.schema.json defines the file, and it holds these members:

  • endpoints: the USB endpoint addresses the driver uses.

  • rawUSB: true, or the variants that talk raw USB.

  • recordSections: the record sections the driver reads.

  • tuningKeys: the tuning keys a record may set for the family. rawUSBTuningKeys lists the keys of its raw-USB variants, and a description that is always raw USB takes only rawUSBTuningKeys.

  • buttonLabels: the labels of the family's buttons.

  • macOSOwnedOutput: the output that macOS owns for the family, and a rationale that says why.

  • controls: the controls the driver reports. recordCapabilities names the control deltas (present, rumbleAbsent) that a record of the family may declare; the driver adds the record-derived parts.

  • defaults.tuning: the default value of each tuning key, such as lizardResendTicks. The keep-alive interval is not a tuning key: defaults.session.keepAlive.intervalMilliseconds is its only source, and a description or record that names keepAliveIntervalMs anywhere is rejected. defaults.variants.<variant>.tuning gives another value for a transport, for example the Switch 1 hidStartupIntervalMs of 60 over Bluetooth and 20 over USB.

  • defaults.session.keepAlive: the family's keep-alive. intervalMilliseconds (1 to 60000) is the period, and the optional writes (1 to 8) are the fixed packets of each run, in the formats of output.startup (a HID report with report and bytes, or a raw-USB packet with bytes, endpoint, timeoutMilliseconds and sessionByte) without phase, delayMilliseconds, transport, crc32Seed, toleratesRejection and when. Unlike startup, one list may mix both formats, each limited with variants (vendor.gamesir sends a HID report on enhanced-hid and a raw-USB packet on usb). resendTuning: "lizardResendTicks" sends a write only every that-many runs (valve.steam-triton); the family must read the key and default it in defaults.tuning. A record carries no keep-alive writes, so nothing replaces them; a record cannot change the period either, because the interval has no record key. The writes that follow driver state, such as a running rumble, stay in the driver (see Wire Protocols). A HID write needs a family with HID variants, and a raw-USB write needs one with raw-USB variants.

  • defaults.session.subcommands (nintendo.switch1 only, and it must have it): the subcommand report. reportId is the output report that carries a subcommand, counterModulus (2 to 256) is the rule of the rolling packet counter (it starts at 0, the next value is (n + 1) modulo the modulus, and the rumble-only report shares the counter), and requests (1 to 16) are sent in order at startup. A request has a subcommand byte and exactly one of data (1 to 16 bytes) or spiRead (address, length of 1 to 29, and a purpose of factoryMotionCalibration or userMotionCalibration, each at most once). An SPI read sends the address as four little-endian bytes and then the length. A startup recovery round re-sends the reads whose reply has not arrived. The rumble bytes in the header and the player-indicator subcommand stay in the driver (see Wire Protocols).

Change a default by editing the description: every record of the family follows it, and a record's or patch's own tuning still wins. Then run ./Scripts/ojd catalog regenerate --write, which copies the descriptions into the Kit bundle.

Set a Default for Every Controller

~/Library/Application Support/OpenJoystickDriver/Controllers/all.patch.json sets a tuning default for every controller. It is a ControllerPatch with "selector": { "all": true }, and controller-patch.schema.json describes it. Only tuning may appear in its patch.

{
  "apiVersion": "openjoystickdriver/v1alpha1",
  "kind": "ControllerPatch",
  "selector": { "all": true },
  "patch": { "tuning": { "stickDeadzone": 0.05 } }
}

Each value takes the first layer that sets it, from the strongest down: a patch of yours for the model, your all.patch.json, the package, the bundled record, then the driver's own default. A value in all.patch.json applies only to the families that read it, as in the table above. The file is optional. An invalid file is ignored whole, and ojd config show, ojd status, and the config-files check of ojd diagnose report why. ojd config show prints each value and the layer that set it.

The record's stickDeadzone shapes the virtual gamepad's stick report. A profile's innerDeadzone calibrates a stick that the profile maps to aim, flick, pointer, scroll, or steering output. The two do not inherit from each other: a deadzone in a record or in all.patch.json does not change what a profile mapping does. ojd config show --controller lists the active profile's innerDeadzone values in the profile layer next to the record's keys, when the service runs.

Change the Virtual Gamepad Identity

A VirtualDevice file in ~/Library/Application Support/OpenJoystickDriver/Personas chooses the virtual gamepad that games see for one controller model, or for one controller unit. virtual-device.schema.json describes it.

{
  "apiVersion": "openjoystickdriver/v1alpha1",
  "kind": "VirtualDevice",
  "match": { "vendorID": 1356, "productID": 3302 },
  "persona": "SdlCompatible"
}

match names the model by VID:PID in decimal. Add unit with a U- ID from ojd controller show to match one controller. A unit file wins over a model file. persona is Generic or SdlCompatible. Generic is the default (1209:4A4F, pid.codes), and every controller publishes it until you pick another. SdlCompatible publishes the Xbox One S Bluetooth identity (045E:02FD) over an approximation of its report layout. SDL 3.4.x does not list it with the default hints. An optional identity gives a vendor ID, product ID, product name, manufacturer, and version, and an optional labels gives the glyph family (xbox or generic). OpenJoystickDriver publishes the virtual gamepad with that identity over the persona's report layout. The transport, the report layout, and the virtual LocationID stay those of the persona, and version defaults to the persona's. While the service runs, it watches the Personas folder. When a file is added, edited, or removed and a connected controller's identity or persona changes, OpenJoystickDriver creates that controller's virtual gamepad again with the new identity, so you do not reconnect the controller. Games and other apps see the old virtual gamepad disappear and a new one appear. A change that leaves a controller's identity and persona the same, or that concerns another controller, does not touch its virtual gamepad. A file with a wrong apiVersion or kind fails with E5001. Any other file that fails validation is skipped, and ojd status and the config-files check of ojd diagnose name it and the reason. The old VirtualHIDProfileOverrides setting is gone and is not migrated, so recreate your overrides as VirtualDevice files. The persona names hid-generic and hid-xbox-one-s-bt and the Persona kind are gone and are not accepted.

Select a Model With Protocol Parameters

Some families drive several models that differ in one detail. A typed parameter in the record's protocol.parameters object selects the detail. Each family declares its own parameters, and a record with an unknown parameter, a parameter another family declares, or a value of the wrong type is rejected. A flag parameter is true or left out.

  • shareButton (xbox.gip): {"fromEnd": N} reads the Share button N bytes back from the end of the packet (Linux xpad MAP_SHARE_OFFSET is 26).
  • layout (nintendo.switch1, nintendo.switch2): Pro, JoyConLeft or JoyConRight, and GameCube on nintendo.switch2, the NSO GameCube controller. usbProtocol (nintendo.switch1): ChargingOnly for a pad whose USB link only charges it, which binds the Bluetooth Classic link only; it excludes layout.
  • innerGrips and lightingMemory (vendor.gamesir): an enhanced HID record names exactly one. innerGrips reports the inner grips; lightingMemory: "ProfileSlots" makes lighting memory slot-based. A GameSir USB record names neither.
  • hatSource: "DpadPressure" (vendor.ps3-third-party): any nonzero D-pad pressure byte holds its direction.
  • motionCalibration: "Factory" (sony.dualshock4): read the motion calibration from the controller. Without it, OpenJoystickDriver uses nominal calibration, as for third-party DualShock 4 controllers.
  • role: "WirelessAdapter" (sony.dualshock4): the Sony DUALSHOCK 4 USB wireless adapter. Output waits until a controller connects to the adapter.
  • motionScale: "StrikePad" (sony.dualshock4): a controller whose motion reports half the acceleration in the opposite direction on all axes.
  • reportRevision: "Shield2015" (vendor.nvidia-shield): the 2015 SHIELD controller, which has rumble. Without it, the driver treats the controller as the 2017 model, which is input-only on macOS.
  • maker: "ThirdParty" (sony.dualsense): the controller is not a Sony DualSense. The driver reads feature report 0x03 for its features and limits output to them, as SDL's HIDAPI_DriverPS5 does.
  • unprobedSensors and unprobedTouchpad (sony.dualsense): a third-party DualSense that does not answer the capability probe still has motion sensors or a touchpad, and either parameter reads its input in the alternate report layout.
  • forcedVibration (sony.dualsense): a third-party DualSense keeps vibration when its probe reply omits it.
  • role: "Receiver" (sony.dualsense): a third-party DualSense wireless receiver, which reports with no controller paired. OJD treats the controller as connected only while the packet sequence advances.

A DualShock 4 record takes at most one of role and motionScale. A sony.dualsense record runs as a third-party controller only with maker: "ThirdParty", whatever its vendor ID, and the other DualSense parameters describe that model and need it; a record without parameters is a Sony DualSense. The catalog gives maker: "ThirdParty" to every sony.dualsense record whose vendor ID is not Sony's (054C). A patch merges its parameters key by key into the bundled ones and cannot remove one, but it can set a bundled parameter to another value.

Map HID Usages

A hid.descriptor record maps the usages of its report descriptor to controls in input.hidUsageMap. Without one, the record uses the standard map in Resources/Protocols/hid.descriptor.json. Each entry names a usage by decimal page and usage and one control, as a kind:

  • button: the control is pressed while the value is nonzero. It cannot be a D-pad id or a stick axis.
  • axis: the control is a stick axis (LeftStickX, LeftStickY, RightStickX, RightStickY) or LeftTrigger or RightTrigger. A stick reads -1 to 1 and a trigger 0 to 1 across the element's logical range. range ({"min": 0, "max": 100}, min below max) replaces that range, inverted reverses the axis, and deadZone (above 0 and below 1) reads 0 within that fraction of full travel and rescales the rest.
  • hat: the entry has no control; it drives DpadUp, DpadDown, DpadLeft and DpadRight. range is {"min": 0, "max": 7} or {"min": 1, "max": 8} and names the value of north, then clockwise; any other value is neutral. Without range, the element's logical minimum is north.

reportID limits the map to one report. A usage the map does not name is ignored, and both the app and ./Scripts/ojd check profiles reject a usage named twice or an axis range whose min is not below its max. The description may also list descriptorShapes that choose a map from the usages a controller's report descriptor declares: the shape in hid.descriptor.json gives a controller with Z, Rz, a Brake or Accelerator and no Rx or Ry the Z/Rz layout, with Brake as LT and Accelerator as RT. The records for the WR007 (11C1:5600), SCUF Envision (2E95:434D), 8BitDo Ultimate 2C (2DC8:301B, 301C) and DragonRise (0079:0006, by patch) show complete maps.

Control Names and Other Vocabularies

Every document, profile, endpoint message and record names a control with one UpperCamelCase id. Resources/Schemas/vocabulary.schema.json defines the ids, and Resources/Vocabulary/crosswalk.json holds the names the same control has elsewhere. The crosswalk is documentation: it is not a schema alias, and a document that uses another spelling is refused. A - means the other vocabulary has no name for the control. The W3C index is the buttons index of the standard gamepad mapping. Capture and Microphone share the SDL3 name MISC1. The JSON file also lists the HID page and usage of each control. A null usage means the device descriptor assigns it.

Control W3C index SDL3 evdev Apple GameController
South 0 SOUTH BTN_SOUTH ButtonA
East 1 EAST BTN_EAST ButtonB
West 2 WEST BTN_WEST ButtonX
North 3 NORTH BTN_NORTH ButtonY
LeftShoulder 4 LEFT_SHOULDER BTN_TL LeftShoulder
RightShoulder 5 RIGHT_SHOULDER BTN_TR RightShoulder
LeftTrigger 6 LEFT_TRIGGER ABS_Z LeftTrigger
RightTrigger 7 RIGHT_TRIGGER ABS_RZ RightTrigger
Back 8 BACK BTN_SELECT ButtonOptions
Start 9 START BTN_START ButtonMenu
LeftStickClick 10 LEFT_STICK BTN_THUMBL LeftThumbstickButton
RightStickClick 11 RIGHT_STICK BTN_THUMBR RightThumbstickButton
DpadUp 12 DPAD_UP BTN_DPAD_UP DirectionPad
DpadDown 13 DPAD_DOWN BTN_DPAD_DOWN DirectionPad
DpadLeft 14 DPAD_LEFT BTN_DPAD_LEFT DirectionPad
DpadRight 15 DPAD_RIGHT BTN_DPAD_RIGHT DirectionPad
Guide 16 GUIDE BTN_MODE ButtonHome
Capture - MISC1 KEY_RECORD ButtonShare
Microphone - MISC1 - -
TouchpadClick - TOUCHPAD BTN_TOUCH TouchpadButton
LeftPaddle1 - LEFT_PADDLE1 BTN_GRIPL XboxPaddleOne
LeftPaddle2 - LEFT_PADDLE2 BTN_GRIPL2 XboxPaddleTwo
RightPaddle1 - RIGHT_PADDLE1 BTN_GRIPR XboxPaddleThree
RightPaddle2 - RIGHT_PADDLE2 BTN_GRIPR2 XboxPaddleFour
Auxiliary1 - MISC2 BTN_TRIGGER_HAPPY1 -
Auxiliary2 - MISC3 BTN_TRIGGER_HAPPY2 -
Auxiliary3 - MISC4 BTN_TRIGGER_HAPPY3 -
Auxiliary4 - MISC5 BTN_TRIGGER_HAPPY4 -

PlayStation Share (Create) and Xbox View are Back. PlayStation Options, Xbox Menu and Switch Plus are Start. Xbox Share and Switch Capture are Capture. The names Share and Options are not controls.

Offer Alternative Modes Chosen By a Probe

Some controllers can run one of several protocols, such as XInput, DInput, or Switch mode. A HID record can list the alternatives in modes. The record's own protocol is the default mode and has no probes:

"modes": [
  {
    "id": "feature-ef-answers",
    "protocol": { "family": "sony.sixaxis" },
    "match": {
      "probes": [{ "report": 239, "length": 49, "expect": "answers" }]
    }
  }
]
  • id is lowercase words joined by hyphens, and is not default. IDs are unique in a record.
  • protocol names the protocol that mode runs, as the record's own protocol does.
  • match.probes lists 1 or more probes. A mode is selected only when all of its probes hold.
  • A probe reads one feature report (report 0 to 255, length 1 to 65535) and expects answers, stalls, or bytes. bytes also takes offset and bytes, uppercase hex pairs the reply must hold at that offset; the other expectations take neither.

A probe reads a feature report from the controller and selects a mode by the reply. OpenJoystickDriver never switches a controller's mode. It tries the modes in record order and runs the first whose probes all hold. When no mode matches, the default mode runs. A report that several probes name is read once.

A probe only reads. It cannot write to the controller. Each read gives up after 2 seconds. When a probe gets no usable reply, the selection stops, the controller stays enabled in its default mode, and OpenJoystickDriver logs a line that names the report. No usable reply means a timeout, an unavailable route, or any failure other than the controller's own refusal, such as a device that is not open, gone, or held exclusively. Only a stalled pipe (0xE000404F) or an unsupported request (kIOReturnUnsupported) is a refusal. It satisfies stalls and fails the other expectations, so a stall where a mode expects answers leaves the default mode running. Probes read HID feature reports, so a controller on another route, such as a Bluetooth LE GATT peripheral, always runs its default mode.

Modes apply to HID controllers only. A record with a raw-USB family cannot use modes, and a mode cannot name one. Every mode follows the record-level rules of the default protocol for input.layout, output.rumble, and capabilities. A patch can replace modes.

The DualShock 3 record carries one mode that probes feature report 0xEF. It is a candidate discriminator only: the mode runs the same protocol as the default, and the record does not say which unit is genuine. The length of 49 is a chosen length, the size of a Sixaxis input report, not a size recorded from a DualShock 3 reply.

Connect a Switch 2 Controller Over Bluetooth LE

OpenJoystickDriver connects a Nintendo Switch 2 controller over Bluetooth LE only when its record names the GATT characteristic that carries vibration:

"bluetoothLE": {
  "vibrationCharacteristic": "FA19B0FB-CD1F-46A7-84A1-BBB09E00C149"
}

Write the UUID in uppercase. Only a nintendo.switch2 record takes bluetoothLE. The service looks for Switch 2 advertisements with the Nintendo vendor ID (057E) only.

A Switch 2 record without bluetoothLE connects over USB only, and ojd record validate and ojd record show say so. The bundled Switch 2 records have the section, and a ControllerPatch that leaves bluetoothLE out keeps the bundled value. A Controller for a new Switch 2 model needs its own bluetoothLE.

Draft a Record From a Connected Controller

ojd record draft CONTROLLER builds a starting record for a connected controller. The service must be running.

  1. Run ojd record draft CONTROLLER > record.json, where CONTROLLER is an ID from ojd controller list or the controller's VID:PID.
  2. For 10 seconds, press every button and move each stick, trigger, and the D-pad. --duration SECONDS changes the time.
  3. Read the bytes that changed. For each report byte that took more than one value, the command prints its offset and its lowest and highest value on stderr.

The record maps what the HID report descriptor states plainly: buttons 1 to 11 in the order the hid.descriptor family reads them, X, Y, Rx, and Ry sticks, Z and Rz triggers, and the hat, with the hid.report-layout family. When nothing maps, it names hid.descriptor instead. Check every control against the changed bytes and correct the record before you install it. The draft is a ControllerPatch for a model with a bundled record and a Controller otherwise.

Install a Record

  1. Find the VID:PID of your controller. For more information, see Finding your controller ID.
  2. Write the record file. Set apiVersion to openjoystickdriver/v1alpha1 and kind to Controller or ControllerPatch.
  3. Check the file. Run ojd record validate FILE.
  4. Install the file. Run ojd record install FILE.

The running service applies the record when the folder changes. It connects each connected controller of that model again with the new record, and leaves other controllers connected. A controller that you disconnected stays disconnected. You do not need to restart the service or unplug the controller.

Check Which Records Apply

Run ojd record list. Each line shows a model, its protocol family, and its layer: bundled, package, or user.

Run ojd record show VVVV:PPPP to see one model's effective record and the layer of each field.

OpenJoystickDriver skips a file that is not valid and uses the bundled record for that model. ojd record list, ojd status, and ojd diagnose name each skipped file and the reason.

Remove a Record

  1. Run ojd record remove VVVV:PPPP.
  2. Confirm the deletion.

The command removes the Controller file and the patch file of that model. A bundled model then goes back to its bundled record.

Schema Versions and Compatibility

Each file that OJD reads declares apiVersion: openjoystickdriver/v1alpha1 and its kind, such as Controller, ControllerPatch, Persona, Profile, or ModPackage. Files do not declare $schema. OJD accepts only the apiVersion it supports and rejects every other one, a missing one, and a kind it does not read there. The error names the file, the version it found, and the supported one. It does not convert files. The schemas are flat files in Resources/Schemas/, and each $id is https://xsyetopz.github.io/OpenJoystickDriver/schemas/v1alpha1/<kind>.schema.json. Nothing publishes those IDs yet.

  • Before 1.0, a v1alpha1 schema may change incompatibly. The release notes list each such change and how to update a file.
  • At 1.0 the schemas are promoted to v1. A v1 schema takes only additive changes: a new optional field, or a new value where a reader can ignore it.
  • A change that is not additive needs a new version, v2. OJD names the version it accepts, so a file for an unknown version is rejected rather than half read.

The --json output schema has its own release check: within a major version, or a minor version before 1.0, it only gains keys and values. See Using the command line.

Further Reading

Clone this wiki locally