ios_gamepad_state
Present a REAL game controller to an iOS device and set its state. The device registers a virtual DualShock 4 and receives genuine controller HID, so NATIVE apps see a GCExtendedGamepad and a web page sees a W3C standard-mapping pad in navigator.getGamepads(). It shows up in Settings > General > Game Controller. No Safari page is required.
HOW TO FIRE INPUT PROPERLY:
• One call = ONE frame = one instant in time. A button stays pressed until you send a frame WITHOUT it, so every press needs a matching release frame — exactly like keydown then keyup. Sending press after press just holds them all down.
• For anything that should look played rather than stepped — mashing, combos, a stick sweep — pass frames instead of calling repeatedly. The device plays the whole sequence out at intervalMs (default 33 ms ≈ 30 fps). A round trip per frame cannot reach that cadence, so a rapid sequence built out of single calls will always read as held buttons.
• Alternate press and release inside frames: [{buttons:[1]}, {}, {buttons:[0,1]}, {}] is tap A, release, tap A+B, release.
• Axes are [leftX, leftY, rightX, rightY], -1..1. Sweep them across frames to roll a stick; omitted axes read as 0.
• READ THIS BEFORE REPORTING A STICK BUG: a stick axis can only express RIGHT and UP on axes[0..1], because the report's joystick fields are unsigned. The left stick's full 360-degree analog push IS delivered, but it arrives on the D-PAD — buttons[12..15], and GCExtendedGamepad.dpad.xAxis/yAxis for native apps, which are bipolar. Reconstruct it as x = right - left, y = down - up. Pushing left and reading axes[0] === 0 is the EXPECTED, structural behaviour, not a fault to debug.
• While a sequence is playing, this call owns the pad — a controller streamed from a live viewer is paused and resumes on its own shortly after.
• Confirm the pad EXISTS with ios_gamepad_status; confirm VALUES in the app under test.
Input routes to whatever holds focus, so foreground the app under test first — measuring while Settings or another app is in front reads as "nothing works".
Input Schema
| Name | Required | Description | Default |
|---|---|---|---|
| id | No | IGNORED. Accepted so older callers do not break. The controller is a real device on the phone and its identity is fixed when it is registered, so passing an id here does NOT re-attach it as that controller and does NOT change what a tester displays — it is silently discarded. Read the current identity with ios_gamepad_status. Button mapping is always W3C standard order regardless. | |
| axes | No | Axis values -1..1: [leftX, leftY, rightX, rightY]. Omitted axes read as 0. | |
| udid | Yes | iOS device UDID | |
| reset | No | Register a FRESH controller before sending this frame, instead of reusing the one already there. Use ONLY when the controller has stopped responding — the usual sign is that calls keep succeeding while the app under test sees nothing, and ios_gamepad_status still reports one as registered. Two costs, so do not pass it routinely: the call takes several seconds while the device adopts the new controller, and every registration leaves a permanent extra entry under Settings > General > Game Controller that the device never expires. | |
| frames | No | Play a SEQUENCE of frames at intervalMs instead of setting one state. This is how you produce real gameplay input: include the release frames (an entry with no buttons releases everything). Capped at 300 frames. Overrides axes/buttons when present. | |
| buttons | No | Button values 0..1 in W3C standard order (0-3 face, 4/5 shoulders, 6/7 triggers, 12-15 d-pad, 16 home, 17 touchpad on a DualShock). >= 0.5 counts as pressed. An empty/omitted entry releases everything. | |
| intervalMs | No | Gap between frames in ms (default 33). Lower = faster mashing; 33 ms matches a 30 fps controller poll. |