Files
netris-nestri/apps/nesgamepad/README.md
T
451b495de6 feat: controller/gamepad support (#350)
Adds `nesgamepad`, basically "diet-coke vimputti" for Nestri's microVM
needs here.

Comes with direct mapping for:
- Sony
  - DualShock 4 (v2), DualSense
- Microsoft
  - Xbox 360 pad, Xbox One S pad
- Nintendo
  - Pro Controller
- Generic pad for unknown controllers

---------

Co-authored-by: DatCaptainHorse <DatCaptainHorse@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 17:50:02 +03:00

107 lines
4.7 KiB
Markdown

# nesgamepad
Turns the controllers plugged into a client into devices inside the box, where
a game finds them the way it finds real hardware.
[`neshub`](../neshub) forwards the client's messages about its controllers
unread, tagged with which client sent them. The client only describes a
controller -- vendor, product, name, and its whole state as a positional
snapshot on every change, which is all any platform's gamepad API can say -- and
nesgamepad decides what a game finds for it. Rumble a game asks for goes back
the same way. The wire format is in
[`nesprotocol::gamepad`](../../crates/nesprotocol/src/gamepad.rs).
A box with no controllers connected has no controller devices at all. That
matters: some games stop listening to the keyboard and mouse as soon as one
exists.
---
## What a controller becomes
Each controller is matched to a template by vendor and product
([`template.rs`](src/template.rs)). One that matches no template, from a vendor
the box knows, gets that vendor's default, so it still reads as its vendor's in
a game; only one the box cannot tell at all is generic.
| Vendor | Templates | Default |
| --- | --- | --- |
| Sony | DualShock 4 (the original, v2, wireless adapter), DualSense | DualShock 4 |
| Microsoft | Xbox 360 pad, Xbox One S pad | Xbox 360 pad |
| Nintendo | Pro Controller | Pro Controller |
| Anything else | | generic |
**The DualShock 4 is rebuilt as the device itself.** Some games read a
controller as a HID device and parse its reports by hand, to tell one family
from another and show the right buttons, and Proton hands them the raw device
only when it has a hidraw node. So nesgamepad makes one through `/dev/uhid`,
from the real controller's report descriptor, and writes the reports the real
one would send for the state the client reports, at the rate it sends them.
What a game asks of the device -- calibration, firmware, its address -- is
answered from values read off real hardware, and rumble it writes goes back to
the client. What the snapshot cannot carry, motion and touch, reads as a
controller at rest. See [`replica.rs`](src/replica.rs).
Beside it goes **a gamepad under a neutral identity**, for games that read only
XInput. Proton gives XInput only to controllers it reads through SDL, and drops
one when a raw device with the same vendor and product exists, so the copy
carries neither -- which also keeps a game that recognises the family by those
numbers from mistaking the copy for the device. A game that can read a
controller both ways uses one at a time, so no press is answered twice.
**Every other template is one uinput gamepad**, built the way the Linux driver
for it builds its device (hid-playstation, xpad, hid-nintendo), from
[`layout.rs`](src/layout.rs). A game rarely reads a controller by its raw
codes -- SDL, Wine and Steam look the identity up in a mapping database written
against the exact layout that driver produces -- and claiming a real device's
identity with a different layout scrambles its buttons, which is worse than
being unrecognised. So a fallback presents its template's identity, not the
client's, and a generic controller gives up its identity altogether.
## Standing in for udev
A box has no udev, and games find controllers through libudev. nesgamepad
writes udev's database entries for its own devices and sends the hotplug
broadcast udev would have sent, for those devices and nothing else. The
details libudev checks, each of which fails silently, are written down in
[`udev.rs`](src/udev.rs).
It runs as root because libudev ignores a broadcast from anyone else, because it
opens each new device node to the workload, and because `/dev/uhid` is
root's alone.
## Running it
```bash
cargo run --release --bin nesgamepad
```
| Flag | Env | Default | Description |
| --- | --- | --- | --- |
| `--ipc` | `NESTRI_GAMEPAD_IPC` | `/tmp/nestri-gamepad.sock` | The hub's gamepad socket, which this dials |
`RUST_LOG` takes a standard tracing filter, e.g. `RUST_LOG=nesgamepad=debug`.
Every state change is logged at `trace`.
## Testing
```bash
cargo test -p nesgamepad
```
Four more build real devices through `/dev/uinput` -- every template among them
-- and read them back the way a game would, including a rumble upload, and four
build one through `/dev/uhid` -- one of them a rebuilt DualShock 4 -- and check
reports in both directions and a feature report a game asks for. They are
ignored by default because they create a device on whatever machine runs them,
and the uhid ones need root:
```bash
cargo test -p nesgamepad kernel_tests -- --ignored
```
One more sends a real udev broadcast. It needs to be uid 0 in a network
namespace of its own, which `unshare -rn` gives without root, and is only
meaningful with a libudev monitor listening in that namespace to report
whether the broadcast was accepted.