Implement Control4Free 1.2.0 mapping and reliability

This commit is contained in:
MoHadiShibli committed 2026-10-09 17:38:23 +03:00
1 parent 017b8e2473
commit 41c476b2e2
47 files changed
+8180 -226

No files matched your search

+11 -1
View File
@@ -34,6 +34,16 @@ jobs:
docker run --rm --network none -v "$PWD:/src" -w /src \
control4free-launcher-build python3 -B tests/run.py
- name: Browser editor checks
run: |
docker run --rm -v "$PWD:/src" -w /src mcr.microsoft.com/playwright:v1.63.0-noble \
bash -lc 'mkdir -p /tmp/c4f-browser; cd /tmp/c4f-browser; npm init -y >/dev/null; npm install playwright@1.63.0; cd /src; NODE_PATH=/tmp/c4f-browser/node_modules node tests/browser_test.cjs'
- name: Emulated gamepad checks
run: |
docker run --rm -v "$PWD:/src" -w /src mcr.microsoft.com/playwright:v1.63.0-noble \
bash -lc 'mkdir -p /tmp/c4f-browser; cd /tmp/c4f-browser; npm init -y >/dev/null; npm install playwright@1.63.0; cd /src; NODE_PATH=/tmp/c4f-browser/node_modules node tests/fake_gamepads_test.cjs'
- name: Collect what was built
run: |
version=$(cat VERSION)
@@ -60,6 +70,6 @@ jobs:
run: |
# The release notes are this version's section of the changelog.
version=$(cat VERSION)
awk -v head="## $version " 'index($0, head) == 1 { keep = 1; next } /^## / { keep = 0 } keep' CHANGELOG.md > notes.md
python3 tools/release_notes.py CHANGELOG.md "$version" "$GITHUB_REPOSITORY" > notes.md
test -s notes.md || { echo "CHANGELOG.md has no section for $version"; exit 1; }
gh release create "${GITHUB_REF#refs/tags/}" artifacts/* --verify-tag --title "Control4Free $version" --notes-file notes.md
+29
View File
@@ -3,6 +3,35 @@
Versions follow [Semantic Versioning](https://semver.org): PATCH for fixes, MINOR for features that break
nothing, MAJOR for anything people rely on that changes.
## 1.2.0 — 2026-10-08
Gamepad mapping.
- Map a gamepad on a picture of a DualShock 4: each control shows what drives it and lights up when pressed.
Click a control, then press the gamepad button for it. It works for gamepads the browser doesn't know, and
for wheels, guitars, pedals and arcade sticks, as DualShock 4 buttons, sticks and triggers. Sticks and
triggers show their position with a dead zone and a curve. **Everything it sends** shows every button and
axis live, and sets an axis's range a step at a time. See [Gamepad mapping](docs/gamepad-mapping.md).
- Keyboard keys are chosen on the same picture.
- Mappings are saved as profiles in the browser, used for every gamepad of the same kind, and can be exported
to a file and imported on another device.
- Several gamepads can play as the same controller, for example a wheel and its pedals.
- A button still held when a gamepad starts playing does nothing until it's let go.
- A phone that drops off the network no longer keeps a connection to the PS4 open for ever. Before, enough of
those could stop new phones and computers from opening the page.
- When two people pick controllers at the same moment, one is asked to try again, instead of one taking the
other's controller.
- A controller the console refused input for recovers once it accepts input again.
- The page only asks for the PS4's address when it was saved to the device and opened from there.
- The address box takes a full address, such as `http://192.168.1.20:4264/`, and adds `:4264` when it's
missing.
- A touch on the touchpad that the browser cancels no longer clicks it.
- Keys with Ctrl, Alt or the Windows key can't be chosen any more: they never worked in play.
- The app on the PS4 says when the Control4Free running is a different version from the app, and how to switch.
It tells a missing auto-start copy apart from auto-start being off.
- The service's log stays under 2 MB, however long it runs.
- The diagnostic app says whether it managed to save its report.
## 1.1.1 — 2026-10-07
Fixes.
+2
View File
@@ -54,6 +54,8 @@ The suites build the real sources for the host with only the PS4 calls stubbed,
and checks the package's `param.sfo`;
- `test_logging` covers the log, the kernel-log opener and DeviceId matching.
`tests/fake_gamepads_test.cjs` runs researched gamepad emulations against the controller page in the Playwright Docker image, without console access or hardware compatibility claims.
Add a test with your fix: one that fails without it. Then test on a real console if you can, and say in your
pull request which firmware, GoldHEN version and games you tried.
+7 -1
View File
@@ -38,7 +38,10 @@ sees a real controller, so it works wherever a DualShock does:
Move, resize or hide any button with the layout editor; each device keeps its own layout.
- **The gamepads you already have.** Connect one to the phone or PC and give it a controller. One computer
can run all four.
- **A keyboard**, with keys you can change.
- **A keyboard**, with keys you choose on the same picture.
- **Your own gamepad mapping.** Map any gamepad on a picture of a DualShock 4: click a control, then press the
button you want. Works for gamepads the browser doesn't know, and for wheels, guitars and pedals too. See
[Gamepad mapping](docs/gamepad-mapping.md).
- **Rumble and light bar.** When a game rumbles, your phone buzzes (Android), and so does your gamepad,
though not every controller supports vibration in the browser. The page glows in the controller's
light-bar colour, the one the PS4 or the game sets.
@@ -92,6 +95,9 @@ New to GoldHEN packages? The **[installation guide](docs/installation.md)** walk
## Limitations
- **The PS4 always sees a DualShock 4.** A wheel plays as sticks and triggers, so games that need a real
wheel or guitar don't accept it, and there's no force feedback.
- **Six pages at a time.** Up to six phones or computers can have the page open at once.
- **No motion controls.** Browsers only give phone motion sensors to secure (`https`) pages, and the
console's page is plain `http`.
- **iPhones don't vibrate.** Safari doesn't let pages use the phone's vibration. A gamepad connected to the
+3 -1
View File
@@ -34,7 +34,9 @@
- The on-screen DualShock 4 buttons in `client/index.html` are drawn after the
[DualShock 4 layout diagram](https://commons.wikimedia.org/wiki/File:Dualshock_4_Layout.svg)
by Tokyoship, licensed under CC BY 3.0
(https://creativecommons.org/licenses/by/3.0/).
(https://creativecommons.org/licenses/by/3.0/). The PS button's mark uses the
diagram's paths, resized and recolored; the surrounding buttons are adapted
to the interactive page.
These license notices are also included inside the package.
+1 -1
View File
@@ -1 +1 @@
1.1.1
1.2.0
+1828 -164
View File
File diff suppressed because it is too large. Load diff
+93
View File
@@ -0,0 +1,93 @@
# Gamepad mapping
Any gamepad, wheel, guitar or other game controller the browser can see can play as one of the four
controllers, with every button and stick doing what you choose. You map it on a picture of a DualShock 4. The
keyboard is mapped the same way. Mappings are kept in the browser, like your layout.
## Change a button
1. Connect the gamepad to the phone or computer and press a button on it, so it shows up under **Gamepads on
this device**.
2. Press **Mapping** on its row. You can also go to **☰ → All settings → Gamepads** and press **Mapping**.
3. The **DS4 Mapping** tab shows a DualShock 4. Each control is labelled with what drives it: **B3** is button 3 on your
gamepad, **A0** is axis 0, and **—** means nothing. Press anything on the gamepad and the control it drives
lights up.
4. Click a control, press **Change**, then press the gamepad button you want for it.
5. Press **Save**.
**Add another** gives a control a second button, for example a back paddle that also presses Cross.
**Remove** takes one away. A gamepad button drives one control, as a keyboard key does: give it to Circle, and
Cross lets go of it.
While you map a gamepad, it stops playing. Its controller stays connected, and everyone else keeps playing.
When you leave, a button you're still holding does nothing until you let go of it, so nothing gets pressed by
accident. **Back** with changes that aren't saved asks you to press it again before it throws them away.
On a phone, the picture is small, so a list of the controls sits under it: tap one there instead.
## Sticks and triggers
Click a stick, press **Change**, and move the stick you want all the way to the right, then all the way down.
If you move it the other way, that direction is reversed for you. A wheel works the same way: turn it right,
then press **Cancel** when the page asks for down.
A stick's panel shows where it is: the grey dot is the stick itself, the lit dot is what the PS4 gets. Under
**Response**:
- **Dead zone**: how far the stick moves before it counts. The ring around the centre shows it. Raise it if a
worn stick drifts.
- **Curve**: **straight** follows the stick exactly; **gentler** gives finer control near the centre.
- **Reverse** turns a direction round.
For L2 and R2, the bar shows how far the trigger is pulled. A trigger or pedal that the browser reports as an
axis is set to its whole travel when you press **Change** and push it.
## Gamepads the browser doesn't know
When the browser doesn't know a gamepad's layout, its row says *buttons may be mixed up*, and the page starts
from the standard layout as a guess. Wheels, guitars, arcade sticks and many cheap USB gamepads are like this.
Click each control on the picture that's wrong and change it.
A D-pad that the browser reports as a single axis (a "hat") works the same way: click a D-pad direction, press
**Change**, and press that direction.
## Gamepad Controls
The **Gamepad Controls** tab shows every button and axis on the gamepad, live, and what each one drives.
Click one to:
- **Make it drive…** another control: click that control on the picture. For a stick, choose the direction.
- set its **Dead zone**, **Curve** and **Reverse**; a small graph shows the curve. For an axis that presses a
button, **Presses at** sets how far.
- **Set its range**, for an axis that doesn't use its whole range. It asks what the axis is: a stick or wheel
(let go, then all the way one way, then the other), a pedal or trigger (let go, then all the way), or a D-pad
hat (let go, then up, right, down and left).
## Profiles
A mapping is saved as a profile, named in the **Name** box. It's used for every gamepad of the same kind in this
browser, now and when you connect one again. **Save** changes the profile for every connected gamepad using it.
The **Profile** list switches between your profiles and the **Standard layout**, which can't be changed.
The **⋯** menu has **Save as a new profile**, **New empty profile**, **Back to the standard layout**, **Export to a
file**, **Import from a file**, and **Delete this profile**, which asks you to press it twice. A profile made for a
different gamepad says so: press **Fit it to this gamepad** to drop the inputs this one doesn't have.
Several gamepads can play as the same controller, for example a wheel and a separate set of pedals: pick the
same controller on both rows.
## The keyboard
**Keyboard keys** at the bottom of the home screen, or **All settings → Keyboard → Choose keys**, opens the same
picture with the keyboard's keys on it. Click a control, then the key, and press its new key. Sticks and the
touchpad have one key per direction or corner. **Standard keys** puts them all back. Keys with Ctrl, Alt or the
Windows key can't be used.
## Limits
- Only what the browser can see can be mapped. Some extra buttons, like paddles, either aren't shown to
browsers or send the same button as another one.
- The PS4 always sees a DualShock 4: a wheel plays as sticks and triggers, so games that need a real wheel or
guitar don't accept it, and there is no force feedback.
- Pedals combined into one axis by the device can't be split back into two.
- The page reads gamepads about 60 times a second, so a press shorter than that can be missed.
+11 -1
View File
@@ -78,7 +78,17 @@ turns it into a `ScePadData` sample and hands it to `scePadVirtualDeviceInsertDa
stick movement only ever keeps the latest position.
When a page stops sending, its buttons are released at once; after 3 seconds the controller goes neutral, and
after 15 it's removed. A page that reconnects within 15 seconds gets its controller back.
after 15 it's removed. A page that reconnects forgets which controllers it had; picking the same one again
within those 15 seconds gets it back, still signed in.
Gamepad mapping happens on the page, before anything is sent. Each gamepad's inputs go through its profile's
bindings, and everything driving one controller is combined: buttons are pressed if any source presses them,
triggers take the strongest pull, and sticks take the input pushed furthest. See
[Gamepad mapping](gamepad-mapping.md).
The service sends each page a WebSocket ping every 5 seconds and closes a connection that hasn't answered
within 10, so a phone that left the network doesn't hold a connection for ever. At most six pages are
connected at once, which keeps room for loading the page itself.
## Rumble and light bar
+2 -1
View File
@@ -89,7 +89,7 @@ screen, that's fine too: the app doesn't get in the way of Control4Free.
again to start the new one (this needs GoldHEN's PayLoader). Or restart the PS4 and run the jailbreak:
auto-start then starts the new one.
Your controller layouts and keys live in each phone's browser, so they survive updates.
Your controller layouts, keys and gamepad mappings live in each phone's or computer's browser, so they survive updates.
## Without the app
@@ -127,4 +127,5 @@ or remove the `control4free.elf` line from `/data/GoldHEN/payloads.ini` by hand
| `/data/payloads/control4free.elf` | The copy of the service that AutoRun starts. |
| `/data/GoldHEN/payloads.ini` | GoldHEN's AutoRun list. The app adds or removes one line. |
| `/data/control4free/control4free.log` | The service's log, with the previous run in `control4free.log.previous`. |
| `/data/control4free/control4free.log.1` | The older part of the log, once it passes 1 MB. |
| `/data/control4free/instance.lock` | Keeps a second copy from starting next to the first. |
+9 -4
View File
@@ -46,8 +46,9 @@ and landscape.
## Keyboard
On a computer the keyboard drives the controller open on the page. Change any key in **All settings →
Keyboard**, and turn on **Show keyboard keys** to see them on the buttons.
On a computer the keyboard drives the controller open on the page. To change a key, press **Keyboard keys** at
the bottom of the home screen: click a control on the picture of the controller, then press its new key (see
[Gamepad mapping](gamepad-mapping.md#the-keyboard)). Turn on **Show keyboard keys** to see them on the buttons.
| Controller | Key | Controller | Key |
|---|---|---|---|
@@ -67,7 +68,9 @@ appears under **Gamepads on this device**:
- a gamepad that turns up while a controller is open on the page drives that controller;
- otherwise, pick the controller each gamepad plays as. One computer can run all four controllers this way;
- a row lights up while you use its gamepad, so you can tell which is which.
- a row lights up while you use its gamepad, so you can tell which is which;
- several gamepads can play as the same controller, a wheel and its pedals for example;
- **Mapping** changes what its buttons and sticks do. See [Gamepad mapping](gamepad-mapping.md).
Keep the page's window in front: browsers only read gamepads for the active window.
@@ -79,6 +82,7 @@ way.
## Settings
**☰ → All settings** has this device's settings. They stay in this browser.
**Gamepads** in the settings changes each gamepad's mapping, and **Keyboard** the keys.
| Setting | What it does |
|---|---|
@@ -125,7 +129,8 @@ whatever colour the game sets. Until one is set, a controller shows its own colo
- **☰ → Disconnect controller** removes it from the PS4 straight away.
- **Closing the page or locking the phone** releases its buttons at once. After 3 seconds without input the
controller goes idle, and after 15 seconds it's removed. Come back within 15 seconds and you keep it.
controller goes idle, and after 15 seconds it's removed. Pick it again within those 15 seconds and you keep
it, still signed in.
- **☰ → Stop Control4Free** stops the service and disconnects everyone. It's refused while someone else is
still playing; the app on the PS4 can always stop it.
+10 -1
View File
@@ -86,7 +86,16 @@ ignored: input never claims a controller.
Send the state whenever it changes, and at least once a second while the controller is in use. After 3
seconds without input the controller goes neutral and shows as `paused`; after 15 it's removed. When a
connection closes, its controllers' buttons are released at once, and they're removed 15 seconds later unless
a connection claims them again; the page does so when it reconnects.
a connection claims them again. The page doesn't do that by itself after reconnecting: the player picks the
controller again.
At most six WebSocket connections are open at once; a seventh handshake gets `503`. The service pings each
connection every 5 seconds and closes it if the matching pong hasn't come back within 10. Browsers answer
pings by themselves; another client has to answer them too. Other messages don't count as an answer.
While a claim is being answered, every controller in it is reserved: another `claim` from the same
connection, or one that wants any of those controllers, gets `409`, and so does `leave` from that connection.
Send one claim at a time. Creating all four controllers can take up to about 18 seconds.
**`stop`** is refused with `409` while another connection owns a controller.
+10 -4
View File
@@ -79,8 +79,12 @@ front. And not every game rumbles every player.
enter the console's address in its connection box. See [Gamepads](playing.md#gamepads).
**The buttons are mixed up.** The page says *"buttons may be mixed up"* when the browser doesn't recognise the
gamepad's layout. Try another browser, or connect the gamepad another way (cable instead of Bluetooth, or the
other way round).
gamepad's layout. Press **Mapping** on its row and change each control that's wrong: see
[Gamepad mapping](gamepad-mapping.md). Another browser, or connecting the gamepad another way (cable instead
of Bluetooth, or the other way round), can also help.
**A gamepad button does nothing after changing its mapping.** Let go of it and press it again: a button held
down while a gamepad starts playing waits until it's released.
**The gamepad does nothing.** Keep the page's window in front, press a button on the gamepad after the page
has loaded, and check it has a controller under **Gamepads on this device**.
@@ -137,5 +141,7 @@ When you're done, install the normal package again over the diagnostic one, open
**Cross** to stop the diagnostic version, and **Cross** to start the normal one. That also puts the normal
one back on auto-start.
The service's own log is `/data/control4free/control4free.log` on the PS4, with the previous run in
`control4free.log.previous`, readable over GoldHEN's FTP server (port 2121). The diagnostic app shows both.
The service's own log is `/data/control4free/control4free.log` on the PS4, readable over GoldHEN's FTP
server (port 2121). When it reaches 1 MB it moves to `control4free.log.1` and a new one starts; the previous
run is in `control4free.log.previous`. The diagnostic app shows the end of each, and its first page says
whether it managed to save the report.
+8 -1
View File
@@ -5,10 +5,17 @@
#include <stdint.h>
#define C4F_NET_CLIENTS 8
#define C4F_NET_WEBSOCKETS 6
#ifndef C4F_NET_PING_MS
#define C4F_NET_PING_MS 5000
#endif
#ifndef C4F_NET_PONG_MS
#define C4F_NET_PONG_MS 10000
#endif
#define C4F_NET_MESSAGE 4096
typedef struct C4fNetClient {
int fd, websocket, closing, fragmented;
uint64_t openedMs;
uint64_t openedMs, pingAt, pongDeadline, pingToken;
unsigned char rx[8192], tx[8192];
size_t rxUsed, txUsed, txSent, messageUsed;
char message[C4F_NET_MESSAGE + 1];
+10
View File
@@ -174,11 +174,21 @@ int c4fAutorunCheck(char *problem, size_t size)
if (c4fSandboxLeave()) { snprintf(problem, size, "GoldHEN did not let the app check (errno %d)", errno); return C4F_AUTORUN_UNKNOWN; }
int state = C4F_AUTORUN_OFF;
char *ini = c4fPath(path, sizeof(path), "GoldHEN/payloads.ini") ? NULL : (char *)c4fReadFile(path, &length, 1u << 20);
if (!ini && errno != ENOENT) {
snprintf(problem, size, "Could not inspect AutoRun (errno %d)", errno); return C4F_AUTORUN_UNKNOWN;
}
if (ini && c4fIniEnabled(ini) && !c4fPath(path, sizeof(path), "payloads/control4free.elf")) {
size_t installedSize = 0;
unsigned char *installed = c4fReadFile(path, &installedSize, 16u << 20);
if (installed)
state = installedSize == c4fBundledSize && !memcmp(installed, c4fBundled, installedSize) ? C4F_AUTORUN_ON : C4F_AUTORUN_OUTDATED;
else if (errno == ENOENT) {
state = C4F_AUTORUN_OUTDATED;
snprintf(problem, size, "Enabled, but the payload is missing; Triangle repairs it");
} else {
state = C4F_AUTORUN_UNKNOWN;
snprintf(problem, size, "Enabled, but the payload cannot be inspected (errno %d)", errno);
}
free(installed);
}
free(ini);
+33 -13
View File
@@ -15,6 +15,7 @@
#include <stdlib.h>
#include <string.h>
#include <sys/select.h>
#include <sys/stat.h>
#include <unistd.h>
#include <orbis/libkernel.h>
#include "autorun.h"
@@ -22,7 +23,9 @@
#include "sandbox.h"
#include "service.h"
#ifndef C4F_DIAG_DIR
#define C4F_DIAG_DIR "/user/data/control4free"
#endif
#define C4F_DIAG_LOG C4F_DIAG_DIR "/control4free.log"
#define C4F_DIAG_REPORT C4F_DIAG_DIR "/diag-report.txt"
#define C4F_DIAG_KLOG C4F_DIAG_DIR "/diag-klog.txt"
@@ -181,18 +184,19 @@ static int c4fContains(const char *haystack, const char *needle)
return 0;
}
static void c4fWriteAll(const char *path, const char *text, int append)
static int c4fWriteAll(const char *path, const char *text, int append)
{
int fd = open(path, O_WRONLY | O_CREAT | (append ? O_APPEND : O_TRUNC), 0666);
if (fd < 0) return;
if (fd < 0) return -1;
size_t size = strlen(text);
while (size) {
ssize_t n = write(fd, text, size);
if (n < 0 && errno == EINTR) continue;
if (n <= 0) break;
if (n <= 0) { int saved = n < 0 ? errno : EIO; close(fd); errno = saved; return -1; }
text += n; size -= (size_t)n;
}
close(fd);
if (fsync(fd)) { int saved = errno; close(fd); errno = saved; return -1; }
return close(fd);
}
/* ---- the kernel log ---- */
@@ -284,6 +288,8 @@ void c4fDiagCapture(unsigned milliseconds)
void c4fDiagStart(int sandboxResult, int sandboxError)
{
/* A pre-main payload failure has never created this directory. */
(void)mkdir(C4F_DIAG_DIR, 0777);
c4fBootMs = c4fLauncherTimeMs();
OrbisKernelSwVersion version;
memset(&version, 0, sizeof(version));
@@ -447,7 +453,20 @@ static char *c4fLogPage(const char *path)
c4fAdd(&t, "%s is not there (errno %d).\n", path, errno);
return c4fTake(&t);
}
return text;
C4fText t = {0};
c4fAdd(&t, "===== %s (%lld bytes%s) =====\n%s", path, size,
size > C4F_LOG_TAIL ? "; truncated to last 64 KiB" : "", text);
free(text);
return c4fTake(&t);
}
static char *c4fCurrentLogPage(void)
{
char *rollover = c4fLogPage(C4F_DIAG_LOG ".1"), *active = c4fLogPage(C4F_DIAG_LOG);
C4fText t = {0};
c4fAdd(&t, "%s\n%s", rollover, active);
free(rollover); free(active);
return c4fTake(&t);
}
/* Lines that tend to say why a payload did not run. */
@@ -484,18 +503,19 @@ static void c4fSaveReport(void)
c4fAdd(&t, "===== %s =====\n%s\n", c4fPageNames[i], c4fPages[i] ? c4fPages[i] : "");
pthread_mutex_unlock(&c4fLock);
char *text = c4fTake(&t);
c4fWriteAll(C4F_DIAG_REPORT, text, 0);
snprintf(c4fReportNote, sizeof(c4fReportNote), "/data/control4free/diag-report.txt");
if (!c4fWriteAll(C4F_DIAG_REPORT, text, 0))
snprintf(c4fReportNote, sizeof(c4fReportNote), "Saved /data/control4free/diag-report.txt");
else snprintf(c4fReportNote, sizeof(c4fReportNote), "Internal save failed (errno %d)", errno);
static const char *const usb[] = { "/mnt/usb0", "/mnt/usb1" };
for (int i = 0; i < 2; i++) {
char path[64];
snprintf(path, sizeof(path), "%s/control4free-diag.txt", usb[i]);
int fd = open(path, O_WRONLY | O_CREAT | O_TRUNC, 0666);
if (fd < 0) continue;
close(fd);
c4fWriteAll(path, text, 0);
struct stat info;
if (stat(usb[i], &info) || !S_ISDIR(info.st_mode)) continue;
int result = c4fWriteAll(path, text, 0), saved = errno;
size_t used = strlen(c4fReportNote);
snprintf(c4fReportNote + used, sizeof(c4fReportNote) - used, " and %s", path);
if (!result) snprintf(c4fReportNote + used, sizeof(c4fReportNote) - used, "; saved %s", path);
else snprintf(c4fReportNote + used, sizeof(c4fReportNote) - used, "; USB%d save failed (%d)", i, saved);
}
free(text);
}
@@ -508,7 +528,7 @@ unsigned c4fDiagRefresh(const C4fDiagState *state)
pthread_mutex_unlock(&c4fLock);
if (!c4fRefreshed++ && state->running != 1) c4fDiagCapture(4000);
char *pages[C4F_DIAG_PAGES] = {
c4fSummary(state), c4fLogPage(C4F_DIAG_LOG), c4fLogPage(C4F_DIAG_LOG ".previous"),
c4fSummary(state), c4fCurrentLogPage(), c4fLogPage(C4F_DIAG_LOG ".previous"),
c4fKlogPage(1), c4fKlogPage(0),
};
int changed = 0;
+1
View File
@@ -101,6 +101,7 @@ static void *c4fWorker(void *)
}
c4fScreen.running = running;
c4fScreen.controllers = status.controllers;
snprintf(c4fScreen.runningVersion, sizeof(c4fScreen.runningVersion), "%s", running == 1 ? status.version : "");
c4fScreen.autorun = autorun;
snprintf(c4fScreen.autorunNote, sizeof(c4fScreen.autorunNote), "%s", autorunNote);
snprintf(c4fScreen.address, sizeof(c4fScreen.address), "%s", address);
+8 -1
View File
@@ -524,7 +524,14 @@ void c4fDrawLauncher(uint32_t *pixels, const C4fLauncherScreen *s)
/* .panel: whether GoldHEN starts it by itself. */
const char *panelTitle, *panelText;
C4fColor panelDot = C4F_FAINT;
if (s->autorun == C4F_AUTORUN_ON) {
char versions[160];
int mismatch = live && s->runningVersion[0] && strcmp(s->runningVersion, C4F_LAUNCHER_VERSION);
if (mismatch) {
snprintf(versions, sizeof(versions), "Installed %s; running %s", C4F_LAUNCHER_VERSION, s->runningVersion);
panelTitle = versions;
panelText = "To apply this app's version now, press Square, confirm the stop with Cross, then press Cross to start. This disconnects all players.";
panelDot = C4F_WARN;
} else if (s->autorun == C4F_AUTORUN_ON) {
panelTitle = "Starts by itself with GoldHEN";
panelText = "GoldHEN starts Control4Free each time it loads. Closing this app leaves your controllers "
"running. Turn off other controller plugins in your games.";
+1 -1
View File
@@ -10,7 +10,7 @@
typedef struct {
int running, busy, controllers, confirmStop, locked;
int autorun; /* C4F_AUTORUN_* from autorun.h */
char address[64], message[160], autorunNote[96];
char address[64], message[160], autorunNote[96], runningVersion[32];
#ifdef C4F_DIAG
unsigned diagGeneration; /* bumped when a diagnostic page changes */
int diagPage, diagScroll[8];
+16 -1
View File
@@ -16,6 +16,10 @@
static int g_logFd = -1;
static int g_klogEnabled = 1;
static uint64_t g_startedMs;
static size_t g_logSize;
#ifndef C4F_LOG_SEGMENT
#define C4F_LOG_SEGMENT (1024u * 1024u)
#endif
static uint64_t c4fLogNowMs(void)
{
@@ -31,12 +35,15 @@ void c4fLogOpen(void)
{
(void)mkdir("/data", 0777);
(void)mkdir(C4F_LOG_DIR, 0777);
(void)unlink(C4F_LOG_PATH ".1");
/* Keep the previous run for post-restart diagnosis. */
if (rename(C4F_LOG_PATH, C4F_LOG_PATH ".previous") != 0 && errno != ENOENT)
g_logFd = open(C4F_LOG_PATH, O_WRONLY | O_CREAT | O_APPEND, 0666);
else
g_logFd = open(C4F_LOG_PATH, O_WRONLY | O_CREAT | O_TRUNC, 0666);
g_startedMs = c4fLogNowMs();
struct stat info;
g_logSize = g_logFd >= 0 && !fstat(g_logFd, &info) ? (size_t)info.st_size : 0;
c4fLog("---- Control4Free %s; timestamps are elapsed time ----\n", C4F_VERSION);
}
@@ -75,12 +82,20 @@ void c4fLog(const char *fmt, ...)
(unsigned long long)(elapsed % 1000), (int)length, p);
if (g_klogEnabled) klog_printf("%s", line);
if (g_logFd >= 0) {
if (g_logSize + (size_t)used > C4F_LOG_SEGMENT) {
(void)close(g_logFd); g_logFd = -1;
if (!rename(C4F_LOG_PATH, C4F_LOG_PATH ".1")) {
g_logFd = open(C4F_LOG_PATH, O_WRONLY | O_CREAT | O_TRUNC, 0666);
g_logSize = 0;
}
}
size_t offset = 0;
while (offset < (size_t)used) {
while (g_logFd >= 0 && offset < (size_t)used) {
ssize_t written = write(g_logFd, line + offset, (size_t)used - offset);
if (written < 0 && errno == EINTR) continue;
if (written <= 0) break;
offset += (size_t)written;
g_logSize += (size_t)written;
}
}
p += length + (end != NULL);
+20 -1
View File
@@ -241,10 +241,14 @@ static void c4fHttp(C4fNet *net, C4fNetClient *c)
}
for (size_t i = 0; connection[i]; i++) connection[i] = (char)tolower((unsigned char)connection[i]);
if (!strstr(connection, "upgrade")) { c4fHttpError(c, 400, "Upgrade required"); return; }
int sockets = 0;
for (int i = 0; i < C4F_NET_CLIENTS; i++) sockets += net->clients[i].websocket != 0;
if (sockets >= C4F_NET_WEBSOCKETS) { c4fHttpError(c, 503, "Controller connections full; close an unused page"); return; }
c4fWebSocketAccept(key, accept);
n = snprintf(reply, sizeof(reply), "HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: %s\r\n\r\n", accept);
C4F_NET_LOG(c, "websocket open%s", "");
c4fQueue(c, reply, (size_t)n); c->websocket = 1;
c->pingAt = c4fTimeMs() + C4F_NET_PING_MS;
memmove(c->rx, c->rx+used, c->rxUsed-used); c->rxUsed -= used;
net->handler(c, C4F_NET_OPEN, NULL, 0, net->context);
} else if (asset) {
@@ -281,7 +285,14 @@ static void c4fWs(C4fNet *net, C4fNetClient *c)
if (op == 8) {
c4fFrame(c, 8, NULL, 0); c->closing = 2;
} else if (op == 9) c4fFrame(c, 10, data, n);
else if (op == 10) { /* pong */ }
else if (op == 10) {
/* Only our current challenge proves the peer is still reachable. */
if (c->pongDeadline && n == sizeof(c->pingToken) &&
!memcmp(data, &c->pingToken, n) && c4fTimeMs() < c->pongDeadline) {
c->pongDeadline = 0;
c->pingAt = c4fTimeMs() + C4F_NET_PING_MS;
}
}
else if ((op == 1 && !c->fragmented) || (op == 0 && c->fragmented)) {
if (n > C4F_NET_MESSAGE - c->messageUsed) { c->closing = 1; break; }
memcpy(c->message+c->messageUsed, data, n); c->messageUsed += n;
@@ -338,6 +349,14 @@ int c4fNetPoll(C4fNet *net, int timeoutMs)
for (int i = 0; i < C4F_NET_CLIENTS; i++) {
C4fNetClient *c = &net->clients[i];
if (c->fd < 0) continue;
if (c->websocket && !c->closing) {
if (c->pongDeadline && now >= c->pongDeadline) c->closing = 1;
else if (!c->pongDeadline && now >= c->pingAt) {
c->pingToken++;
if (!c4fFrame(c, 9, &c->pingToken, sizeof(c->pingToken)))
c->pongDeadline = now + C4F_NET_PONG_MS;
}
}
if (c->closing == 1) { c4fDrop(net, c); continue; }
if (!c->closing && !c->body) FD_SET(c->fd, &rd);
if (c->txSent < c->txUsed || c->body) FD_SET(c->fd, &wr);
+43 -17
View File
@@ -47,7 +47,9 @@ typedef struct {
C4fPadFeedback feedback;
int feedbackKnown, rumbleSentValid, feedbackFailed;
uint8_t rumbleSent[2];
uint64_t feedbackAt;
uint64_t feedbackAt, colorLogAt;
uint8_t colorLogged[3];
int colorLogKnown;
/* The signed-in user's name, once the PS4 will tell it. */
char userName[72];
int nameTries;
@@ -85,7 +87,7 @@ typedef struct {
typedef struct {
C4fNet net;
C4fWebPad pads[C4F_MAX_PADS];
int klogFd, stop, changed, creationBlocked, feedbackMissing;
int klogFd, changed, creationBlocked, feedbackMissing;
unsigned rumbleChanges; /* since the last heartbeat, to tell a silent game from a silent phone */
/* What reading rumble and light bar costs, since the last heartbeat. Every
* microsecond here is one an arriving input could have to wait. */
@@ -260,6 +262,7 @@ static void c4fReportPad(C4fWeb *app, C4fWebPad *p, int index, uint64_t now)
c4fNeutralize(p);
c4fLog("web controller %d InsertData = 0x%08x\n", index+1, (uint32_t)ret);
}
if (ret >= 0 && p->error) { p->error = 0; app->changed = 1; }
}
static void c4fReportPads(C4fWeb *app)
@@ -333,10 +336,14 @@ static void c4fPollFeedback(C4fWeb *app, uint64_t now)
continue;
}
if (!p->feedbackKnown || f.r != p->feedback.r || f.g != p->feedback.g || f.b != p->feedback.b) {
/* Rare: a sign-in, or a game setting its colour. */
c4fLog("web controller %d light bar %02x %02x %02x\n", i + 1, f.r, f.g, f.b);
app->changed = 1;
}
if ((!p->colorLogKnown || f.r != p->colorLogged[0] || f.g != p->colorLogged[1] ||
f.b != p->colorLogged[2]) && now >= p->colorLogAt) {
c4fLog("web controller %d light bar %02x %02x %02x\n", i + 1, f.r, f.g, f.b);
p->colorLogged[0] = f.r; p->colorLogged[1] = f.g; p->colorLogged[2] = f.b;
p->colorLogKnown = 1; p->colorLogAt = now + 1000;
}
if (p->feedbackKnown && (f.large != p->feedback.large || f.small != p->feedback.small))
app->rumbleChanges++;
p->feedback = f;
@@ -532,6 +539,16 @@ static void c4fAddFail(C4fWeb *app, int code, const char *message, int blocked)
if (client) c4fError(client, reply.hasId ? &reply : NULL, code, message);
}
/* Never abandon an issued AddDevice: capture its handle, then roll it back.
* Unissued work can be canceled immediately without touching retained pads. */
static void c4fCancelAdd(C4fWeb *app)
{
if (!app->add.state) return;
app->add.client = NULL;
if (app->add.state != C4F_ADD_DEVICE)
c4fAddFail(app, 503, "Controller connection canceled", 0);
}
/* Issues AddDevice for the lowest slot still waiting. */
static void c4fAddNextDevice(C4fWeb *app, uint64_t now)
{
@@ -593,7 +610,6 @@ static void c4fAdvanceAdd(C4fWeb *app, uint64_t now, long klogBytes)
app->add.created |= 1u << app->add.slot;
app->add.pending &= ~(1u << app->add.slot);
app->changed = 1;
if (app->add.pending) { c4fAddNextDevice(app, now); return; }
/* Done. If the player left while we worked, the devices have nobody
* to drive them. */
if (!app->add.client) {
@@ -602,12 +618,19 @@ static void c4fAdvanceAdd(C4fWeb *app, uint64_t now, long klogBytes)
c4fAddReset(app);
return;
}
if (app->add.pending) { c4fAddNextDevice(app, now); return; }
{
C4fNetClient *client = app->add.client;
unsigned wanted = app->add.wanted;
C4fRequest reply;
memset(&reply, 0, sizeof(reply));
reply.id = app->add.id; reply.hasId = app->add.hasId;
for (int i = 0; i < C4F_MAX_PADS; i++) if (wanted & (1u << i)) {
if (!app->pads[i].active || (app->pads[i].owner && app->pads[i].owner != client)) {
c4fAddFail(app, 409, "Controller ownership changed; select controllers again", 0);
return;
}
}
c4fAddReset(app);
c4fApplyClaim(app, client, wanted, reply.hasId ? &reply : NULL);
}
@@ -634,6 +657,9 @@ static void c4fClaim(C4fWeb *app, C4fNetClient *c, C4fRequest *r)
if (r->args[i] < 0 || r->args[i] >= C4F_MAX_PADS) { c4fError(c, r, 400, "There is no controller with that number"); return; }
wanted |= 1u << r->args[i];
}
if (app->add.state && (app->add.client == c || (wanted & app->add.wanted))) {
c4fError(c, r, 409, "A pending controller claim reserves these slots; try again in a moment"); return;
}
/* Validate all claims before changing any ownership. */
for (int i = 0; i < C4F_MAX_PADS; i++) if (wanted & (1u << i)) {
C4fWebPad *p = &app->pads[i];
@@ -746,7 +772,7 @@ static void c4fWebEvent(C4fNetClient *c, int event, const char *text, size_t len
c4fNetHttpJson(c, reply); return;
}
if (event == C4F_NET_HTTP_STOP) {
if (app->add.state) c4fAddFail(app, 503, "Control4Free is shutting down", 0);
c4fCancelAdd(app);
for (int i = 0; i < C4F_MAX_PADS; i++) c4fRemove(app, &app->pads[i]);
c4fNetHttpJson(c, "{\"application\":\"Control4Free\",\"stopping\":true}");
if (!app->stopAt) app->stopAt = c4fTimeMs() + 250;
@@ -755,7 +781,7 @@ static void c4fWebEvent(C4fNetClient *c, int event, const char *text, size_t len
if (event == C4F_NET_OPEN) { c4fStatus(app, c, NULL); return; }
if (event == C4F_NET_CLOSE) {
/* Before the slot can be reused by someone else. */
if (app->add.client == c) app->add.client = NULL;
if (app->add.client == c) c4fCancelAdd(app);
for (int i = 0; i < C4F_MAX_PADS; i++) if (app->pads[i].owner == c) {
C4fWebPad *p = &app->pads[i];
c4fNeutralize(p); p->owner = NULL; p->detachedAt = c4fTimeMs(); app->changed = 1;
@@ -777,13 +803,15 @@ static void c4fWebEvent(C4fNetClient *c, int event, const char *text, size_t len
else if (!strcmp(r.method, "claim")) c4fClaim(app, c, &r);
else if (!strcmp(r.method, "u")) c4fUpdate(app, c, &r);
else if (!strcmp(r.method, "leave")) {
if (app->add.client == c) { c4fError(c, &r, 409, "Wait for the pending controller claim"); return; }
if (r.argc != 1 || r.args[0] < 0 || r.args[0] >= C4F_MAX_PADS || app->pads[r.args[0]].owner != c) { c4fError(c, &r, 409, "That controller is not yours to disconnect"); return; }
c4fRemove(app, &app->pads[r.args[0]]); c4fStatus(app, c, &r);
} else if (!strcmp(r.method, "stop")) {
int occupied = 0;
for (int i = 0; i < C4F_MAX_PADS; i++) if (app->pads[i].owner && app->pads[i].owner != c) occupied = 1;
if (occupied) { c4fError(c, &r, 409, "Someone else is still using a controller. Ask them to disconnect, or stop Control4Free from its app on the PS4."); return; }
app->stop = 1;
c4fCancelAdd(app);
app->stopAt = c4fTimeMs() + 250;
} else c4fError(c, &r, 404, "Control4Free on the PS4 is older than this page. Update the PS4 side.");
}
@@ -812,19 +840,18 @@ int c4fWebRun(int klogFd)
}
uint64_t previous = c4fTimeMs(), retryAt = 0;
time_t previousWall = time(NULL);
while (!app->stop) {
for (;;) {
uint64_t now = c4fTimeMs();
time_t wall = time(NULL);
if (app->stopAt && now >= app->stopAt) break;
if (app->stopAt && now >= app->stopAt && !app->add.state) break;
/* Some clocks exclude suspension. Wall time is only a second signal
* for a gap, never an input deadline or a trusted calendar timestamp. */
if (now < previous || now - previous > 5000 || wall - previousWall > 5) {
c4fLog("service resumed after a gap (monotonic=%lldms wall=%llds); resetting connections\n",
(long long)now - (long long)previous, (long long)wall - (long long)previousWall);
c4fNetClose(&app->net); /* close events neutralize and discard queued input */
c4fReleaseKlog(app);
if (app->add.state) c4fAddFail(app, 503, "The PS4 paused while the controller was being connected. Try again.",
app->add.state == C4F_ADD_DEVICE);
c4fCancelAdd(app);
if (!app->add.state) c4fReleaseKlog(app);
for (int i = 0; i < C4F_MAX_PADS; i++) if (app->pads[i].active)
app->pads[i].nextReport = now;
app->broadcastAt = app->heartbeatAt = now;
@@ -847,7 +874,7 @@ int c4fWebRun(int klogFd)
now = c4fTimeMs();
for (int i = 0; i < C4F_MAX_PADS; i++) {
C4fWebPad *p = &app->pads[i];
if (app->add.created & (1u << i)) continue; /* mid-claim, not abandoned */
if (app->add.state && (app->add.wanted & (1u << i))) continue;
if (p->active && ((!p->owner && c4fSince(now, p->detachedAt) > C4F_RELEASE_MS) ||
(p->owner && c4fSince(now, p->lastInput) > C4F_RELEASE_MS))) {
C4fNetClient *owner = p->owner;
@@ -896,9 +923,8 @@ int c4fWebRun(int klogFd)
if (result == -2) c4fLog("service resumed after a gap in select; resetting connections\n");
else c4fLog("network failed errno=%d; resetting connections\n", errno);
c4fNetClose(&app->net);
c4fReleaseKlog(app);
if (app->add.state) c4fAddFail(app, 503, "The connection dropped while the controller was being connected. Try again.",
app->add.state == C4F_ADD_DEVICE);
c4fCancelAdd(app);
if (!app->add.state) c4fReleaseKlog(app);
for (int i = 0; i < C4F_MAX_PADS; i++) if (app->pads[i].active)
app->pads[i].nextReport = c4fTimeMs();
retryAt = c4fTimeMs() + (result == -2 ? 0 : 1000);
+304
View File
@@ -0,0 +1,304 @@
// Optional real-browser integration checks, run in the Playwright Docker image.
// Test instrumentation is inserted into the page in memory, never shipped.
const { chromium } = require('playwright');
const fs = require('node:fs'), assert = require('node:assert/strict'), { spawn } = require('node:child_process');
const hook = '\nwindow.c4fTest = { ControllerMapping, mapper, profileStore, gamepadSources, pollGamepads, setGamepadPad, mergedState, renderGamepads, renderPads, openSettings, closeSettings, openPad, profileFor, loadProfiles, persistProfiles, clearDevice, getKeys: () => keymap };\n';
const html = fs.readFileSync('/src/client/index.html', 'utf8').replace('\n })();', hook + ' })();');
fs.copyFileSync('/src/build/web-host-test', '/tmp/c4f-browser-service');
const server = spawn('/tmp/c4f-browser-service', [], { stdio: ['ignore', 'pipe', 'pipe'] });
server.on('error', error => console.error('Service harness:', error));
server.stderr.on('data', data => console.error(data.toString()));
server.stdout.on('data', data => { const rows = data.toString().split('\n').filter(line => !line.startsWith('FRAME') && line); if (rows.length) console.log(rows.join('\n')); });
// Click a control on the mapping view's picture: its tag, which every control has.
const control = (page, target) => page.click(`#mapChips [data-target="${target}"]`);
const listening = page => page.waitForFunction(() => c4fTest.mapper.capture?.ready);
const heard = page => page.waitForFunction(() => !c4fTest.mapper.capture);
const bindings = (page, output) => page.evaluate(o => c4fTest.mapper.draft.bindings.filter(b => b.output === o).map(b => ({ ...b.input, invert: b.invert })), output);
(async () => {
let browser;
try {
browser = await chromium.launch({ headless: true });
const context = await browser.newContext({ viewport: { width: 1440, height: 1000 } });
// A fulfilled test document has no network address-space metadata. Allow
// its local test service explicitly, as a user would allow local access.
await context.grantPermissions(['local-network-access']);
await context.addInitScript(() => {
window.testPads = [0, 1, 2].map(index => ({ index, id: index < 2 ? 'Synthetic standard controller' : 'Synthetic wheel / pedals',
mapping: index < 2 ? 'standard' : '', connected: true, buttons: Array.from({ length: 24 }, () => ({ value: 0, pressed: false })), axes: Array(6).fill(0) }));
Object.defineProperty(navigator, 'getGamepads', { value: () => { if (window.failPolling) throw Error('SecurityError'); return window.testPads; } });
});
const page = await context.newPage(), errors = [];
page.on('websocket', ws => ws.on('socketerror', error => console.error('WebSocket:', error)));
page.on('pageerror', e => { errors.push(e.message); console.error('Page error:', e.stack); });
await page.route(/^http:\/\/127\.0\.0\.1:4264\/(?:\?.*)?$/, async r => r.fulfill({ response: await r.fetch(), body: html }));
await page.goto('http://127.0.0.1:4264/');
await page.waitForFunction(() => window.c4fTest?.gamepadSources.size === 3);
await page.waitForFunction(() => document.querySelector('#conn').dataset.conn === 'open');
assert.equal(await page.locator('#addressForm').isHidden(), true, 'a page served by the PS4 never asks for its address');
await page.evaluate(() => { const h = c4fTest; h.setGamepadPad(0, 0); h.setGamepadPad(1, 1); h.setGamepadPad(2, 0); });
await page.waitForFunction(() => !c4fTest.gamepadSources.get(0).waitNeutral);
// The gamepad's row opens its mapping. While it's mapped it doesn't play;
// the other gamepads do.
await page.click('.gp-row[data-index="0"] [data-focus="configure"]');
assert.equal(await page.locator('#mapView').isVisible(), true);
await page.fill('#mapName', 'Shared paddles');
await control(page, 'CROSS');
await page.click('#mapPanel button:has-text("Add another")');
await listening(page);
await page.evaluate(() => { testPads[0].buttons[23].value = 1; testPads[1].buttons[0].value = 1; });
await heard(page);
assert.deepEqual((await bindings(page, 'CROSS')).map(i => i.index).sort((a, b) => a - b), [0, 23], 'Add another keeps the old input');
await page.waitForFunction(() => c4fTest.gamepadSources.get(1).buttons === 0x4000);
assert.equal(await page.evaluate(() => c4fTest.gamepadSources.get(0).buttons), 0, 'a gamepad being mapped never plays');
await page.waitForFunction(() => document.querySelector('.map-area [data-btn="CROSS"].on') !== null);
await page.click('#mapSave');
const originalId = await page.evaluate(() => c4fTest.gamepadSources.get(0).profileId);
assert.notEqual(originalId, 'default');
await page.click('#mapBack');
assert.equal(await page.evaluate(() => c4fTest.gamepadSources.get(0).waitNeutral), true, 'a held paddle doesn\'t press anything on leaving');
await page.evaluate(() => { testPads[0].buttons[23].value = 0; testPads[1].buttons[0].value = 0; });
await page.waitForFunction(() => !c4fTest.gamepadSources.get(0).waitNeutral);
await page.evaluate(id => { c4fTest.mapper.open(1); c4fTest.mapper.choose(id); }, originalId);
await page.click('#mapSave');
assert.equal(await page.evaluate(() => c4fTest.gamepadSources.get(1).profileId), originalId);
await page.click('#mapBack');
console.log('PASS real browser: mapping on the picture, Add another, the mapped gamepad muted, others playing, shared profile');
// Change, then press: the input moves from the control that had it. A stick
// is two moves, right then down, reversed when moved the other way.
await page.evaluate(() => c4fTest.mapper.open(0));
await control(page, 'CIRCLE');
await page.click('#mapPanel button:has-text("Change")');
await listening(page);
await page.evaluate(() => { testPads[0].buttons[0].value = 1; });
await heard(page);
assert.deepEqual((await bindings(page, 'CIRCLE')).map(i => i.index), [0]);
assert(!(await bindings(page, 'CROSS')).some(i => i.index === 0), 'button 0 left Cross');
await page.evaluate(() => { testPads[0].buttons[0].value = 0; });
await control(page, 'LS');
await page.click('#mapPanel button:has-text("Change")');
await listening(page);
await page.evaluate(() => { testPads[0].axes[2] = -1; });
await page.waitForFunction(() => c4fTest.mapper.capture?.output === 'ly' && !c4fTest.mapper.capture.ready);
await page.evaluate(() => { testPads[0].axes[2] = 0; });
await listening(page);
await page.evaluate(() => { testPads[0].axes[3] = 1; });
await heard(page);
const lx = await bindings(page, 'lx'), ly = await bindings(page, 'ly');
assert.deepEqual(lx, [{ kind: 'axis', index: 2, mode: 'center', invert: true }], 'moved left when asked for right');
assert.deepEqual(ly, [{ kind: 'axis', index: 3, mode: 'center', invert: false }]);
assert.deepEqual(await bindings(page, 'rx'), [], 'axis 2 left the right stick');
await page.evaluate(() => { testPads[0].axes[3] = 0; });
await page.click('#mapSave');
await page.click('#mapBack');
await page.waitForFunction(() => !c4fTest.gamepadSources.get(0).waitNeutral);
await page.evaluate(() => { testPads[0].buttons[0].value = 1; });
await page.waitForFunction(() => c4fTest.gamepadSources.get(0).buttons === 0x2000);
await page.evaluate(() => { testPads[0].buttons[0].value = 0; });
console.log('PASS real browser: Change then press remaps a button and a two-step stick, taking the input from its old control');
// Gamepad Controls: an axis made to drive a stick from the picture, then
// its range set a step at a time; a pedal; a D-pad hat.
await page.evaluate(() => c4fTest.mapper.open(2));
await page.click('#mapTabs [data-tab="inputs"]');
await page.click('.in-axis[data-input="axis:4"]');
await page.click('#mapPanel button:has-text("Make it drive")');
await control(page, 'RS');
await page.click('#mapPanel button:has-text("Left and right")');
assert((await bindings(page, 'rx')).some(i => i.kind === 'axis' && i.index === 4 && i.mode === 'center'), 'the axis drives the stick, beside what already did');
await page.click('#mapTabs [data-tab="inputs"]');
await page.click('.in-axis[data-input="axis:4"]');
await page.click('#mapPanel button:has-text("Set its range")');
await page.click('#mapPanel button:has-text("A stick or wheel")');
for (const [value, button] of [[.04, 'middle'], [-.9, 'Holding'], [.95, 'Done']]) {
await page.evaluate(v => { testPads[2].axes[4] = v; }, value);
await page.waitForTimeout(60);
await page.click(`#mapPanel button:has-text("${button}")`);
}
assert.deepEqual(await page.evaluate(() => c4fTest.mapper.draft.calibration[4]), { type: 'center', min: -.9, center: .04, max: .95 });
await page.evaluate(() => { testPads[2].axes[4] = 0; testPads[2].axes[5] = -1; });
await page.click('#mapTabs [data-tab="pad"]');
await control(page, 'R2');
await page.click('#mapPanel button:has-text("Change")');
await listening(page);
await page.evaluate(() => { testPads[2].axes[5] = 1; });
await heard(page);
assert.deepEqual(await page.evaluate(() => c4fTest.mapper.draft.calibration[5]), { type: 'pedal', released: -1, full: 1 }, 'a pedal gets its whole travel');
await page.evaluate(() => { testPads[2].axes[5] = -1; testPads[2].axes[1] = 3.2857; });
await control(page, 'UP');
await page.click('#mapPanel button:has-text("Change")');
await listening(page);
await page.evaluate(() => { testPads[2].axes[1] = -1; });
await heard(page);
assert.deepEqual(await bindings(page, 'UP'), [{ kind: 'hat', index: 1, direction: 'UP', invert: false }]);
await page.evaluate(() => { testPads[2].axes[1] = 3.2857; });
console.log('PASS real browser: inputs tab, picking a stick, the range wizard, a pedal and a D-pad hat');
// Polling stops while listening: listening stops. Back with changes asks once.
await control(page, 'CROSS');
await page.click('#mapPanel button:has-text("Change")');
await listening(page);
await page.evaluate(() => { window.failPolling = true; });
await heard(page);
await page.evaluate(() => { window.failPolling = false; });
await page.click('#mapBack');
assert.match(await page.textContent('#mapStatus'), /aren't saved/);
await page.click('#mapBack');
assert.equal(await page.locator('#mapView').isHidden(), true, 'the second Back throws the changes away');
assert.equal(await page.evaluate(() => c4fTest.profileFor(c4fTest.gamepadSources.get(2)).id), 'default');
await page.evaluate(() => { testPads[2] = { ...testPads[2], id: 'Replacement wheel' }; });
await page.waitForFunction(() => c4fTest.gamepadSources.get(2).signature.id === 'Replacement wheel');
assert.equal(await page.evaluate(() => c4fTest.gamepadSources.get(2).profileId), 'default');
console.log('PASS real browser: listening stops with polling, Back asks before throwing changes away, browser-index reuse');
// Import a profile made for another gamepad: fit it, save it, and it's there after a reload.
await page.evaluate(() => c4fTest.mapper.open(2));
const imported = await page.evaluate(() => c4fTest.ControllerMapping.exportProfile(c4fTest.mapper.draft));
imported.profile.signature.id = 'Other model';
imported.profile.bindings.push({ ...imported.profile.bindings[0], input: { kind: 'button', index: 99, mode: 'digital' } });
imported.profile.signature.buttons = 100;
await page.setInputFiles('#mapImport', { name: 'import.json', mimeType: 'application/json', buffer: Buffer.from(JSON.stringify(imported)) });
await page.waitForFunction(() => c4fTest.mapper.draft.signature.id === 'Other model');
await page.click('#mapSave');
assert.match(await page.textContent('#mapStatus'), /different gamepad/);
await page.click('#mapBanner button:has-text("Fit it to this gamepad")');
assert.equal(await page.evaluate(() => c4fTest.mapper.draft.bindings.some(b => b.input.index === 99)), false);
await page.fill('#mapName', 'Imported wheel');
await page.click('#mapSave');
assert.equal(await page.evaluate(() => c4fTest.profileFor(c4fTest.gamepadSources.get(2)).name), 'Imported wheel');
await page.click('#mapBack');
await page.reload();
await page.waitForFunction(() => c4fTest.gamepadSources.size === 3);
assert.equal(await page.evaluate(() => c4fTest.profileFor(c4fTest.gamepadSources.get(0)).name), 'Shared paddles', 'saved profiles come back for the same kind of gamepad');
assert(await page.evaluate(() => c4fTest.profileStore.profiles.some(p => p.name === 'Imported wheel')), 'the imported profile is kept');
console.log('PASS real browser: import, fit to this gamepad, profile persistence');
// Export, Save as new, Delete with its second press.
await page.evaluate(() => c4fTest.mapper.open(0));
await page.click('#mapMenuBtn');
const downloadPromise = page.waitForEvent('download'); await page.click('#mapExport');
const exported = JSON.parse(fs.readFileSync(await (await downloadPromise).path(), 'utf8'));
assert.equal(exported.format, 'control4free-controller-profile'); assert(exported.profile.bindings.length);
await page.fill('#mapName', 'Branched');
await page.click('#mapMenuBtn'); await page.click('#mapSaveNew');
const branched = await page.evaluate(() => c4fTest.gamepadSources.get(0).profileId);
assert.notEqual(branched, originalId);
assert.equal(await page.evaluate(() => c4fTest.gamepadSources.get(1).profileId), originalId, 'Save as new leaves the others');
await page.click('#mapMenuBtn'); await page.click('#mapDelete');
assert.equal(await page.evaluate(id => c4fTest.profileStore.profiles.some(p => p.id === id), branched), true, 'one press only asks');
assert.equal(await page.textContent('#mapDelete'), 'Press again to delete it');
await page.click('#mapDelete');
assert.equal(await page.evaluate(id => c4fTest.profileStore.profiles.some(p => p.id === id), branched), false);
assert.equal(await page.evaluate(() => c4fTest.gamepadSources.get(0).profileId), 'default');
await page.locator('#mapView').evaluate(e => { e.scrollTop = 0; });
await control(page, 'LS');
await page.screenshot({ path: '/src/build/mapping-desktop.png' });
await page.click('#mapTabs [data-tab="inputs"]');
await page.click('.in-tile[data-input="button:0"]');
assert.equal(await page.getAttribute('#mapTabs [data-tab="inputs"]', 'aria-selected'), 'true');
await page.screenshot({ path: '/src/build/mapping-inputs.png' });
await page.click('#mapTabs [data-tab="pad"]');
await page.setViewportSize({ width: 390, height: 844 });
await page.screenshot({ path: '/src/build/mapping-mobile.png', fullPage: true });
assert(await page.evaluate(() => document.querySelector('#mapView').scrollWidth <= innerWidth), 'mobile horizontal overflow');
await page.click('#mapList [data-target="CIRCLE"]');
assert.equal(await page.evaluate(() => c4fTest.mapper.target), 'CIRCLE', 'the list picks controls on a phone');
await page.setViewportSize({ width: 1440, height: 1000 });
await page.click('#mapBack');
console.log('PASS real browser: export, Save as new, Delete asks first, layouts on desktop and phone');
// The keyboard on the same picture: modifiers refused, a key moves between controls.
await page.click('#keysLink');
assert.equal(await page.evaluate(() => c4fTest.mapper.source), 'keyboard');
await control(page, 'CROSS');
await page.click('#mapPanel .key-btn');
await page.keyboard.press('Control');
assert.equal(await page.evaluate(() => c4fTest.mapper.draft.CROSS), 'Space', 'Ctrl is refused');
await page.keyboard.press('KeyQ');
assert.equal(await page.evaluate(() => c4fTest.mapper.draft.CROSS), 'KeyQ');
assert.equal(await page.evaluate(() => c4fTest.mapper.draft.L1), '', 'Q left L1');
await page.keyboard.down('KeyQ');
await page.waitForFunction(() => document.querySelector('.map-area [data-btn="CROSS"].on') !== null);
await page.keyboard.up('KeyQ');
await page.click('#mapSave');
assert.equal(await page.evaluate(() => c4fTest.getKeys().CROSS), 'KeyQ');
await page.click('#mapReset');
await page.click('#mapSave');
assert.equal(await page.evaluate(() => c4fTest.getKeys().CROSS), 'Space');
await page.screenshot({ path: '/src/build/mapping-keys.png' });
await page.click('#mapBack');
await page.evaluate(() => {
c4fTest.openPad(0);
const pad = document.querySelector('#touchpad'), r = pad.getBoundingClientRect();
const fields = { pointerId: 77, bubbles: true, clientX: r.x + r.width / 2, clientY: r.y + r.height / 2 };
pad.dispatchEvent(new PointerEvent('pointerdown', fields)); pad.dispatchEvent(new PointerEvent('pointercancel', fields));
});
assert.equal(await page.evaluate(() => c4fTest.mergedState(0).buttons & 0x100000), 0, 'canceled gesture generated a click');
console.log('PASS real browser: keys on the picture, modifier refused, a key moves between controls, canceled touch');
// Pointer capture is on the pad view, so PS feedback must follow the input
// state rather than relying on the browser's :active pseudo-class.
const ps = page.locator('#psBtn'), psBox = await ps.boundingBox();
await page.mouse.move(psBox.x + psBox.width / 2, psBox.y + psBox.height / 2);
await page.mouse.down();
await page.waitForFunction(() => document.querySelector('#psBtn').classList.contains('on'));
await page.waitForFunction(() => new DOMMatrix(getComputedStyle(document.querySelector('#psBtn')).transform).a < .95);
await page.screenshot({ path: '/src/build/ps-button-held.png' });
await page.mouse.up();
await page.waitForFunction(() => !document.querySelector('#psBtn').classList.contains('on'));
await page.keyboard.down('KeyP');
await page.waitForFunction(() => document.querySelector('#psBtn').classList.contains('on'));
await page.waitForFunction(() => new DOMMatrix(getComputedStyle(document.querySelector('#psBtn')).transform).a < .95);
assert.equal(await ps.evaluate(e => e.matches(':active')), false, 'keyboard feedback does not depend on :active');
await page.keyboard.up('KeyP');
await page.waitForFunction(() => !document.querySelector('#psBtn').classList.contains('on'));
await page.evaluate(() => {
c4fTest.setGamepadPad(0, 0);
c4fTest.pollGamepads(performance.now()); // Let the assignment's held-input gate observe neutral first.
testPads[0].buttons[16].value = 1;
});
await page.waitForFunction(() => document.querySelector('#psBtn').classList.contains('on'));
await page.evaluate(() => { testPads[0].buttons[16].value = 0; });
await page.waitForFunction(() => !document.querySelector('#psBtn').classList.contains('on'));
await page.waitForFunction(() => getComputedStyle(document.querySelector('#psBtn')).transform === 'none');
await page.screenshot({ path: '/src/build/ps-button-idle.png' });
await page.evaluate(() => { c4fTest.mapper.open(0); testPads[0].buttons[16].value = 1; });
await page.waitForFunction(() => document.querySelector('.map-area .ps-btn').classList.contains('on'));
await page.waitForFunction(() => new DOMMatrix(getComputedStyle(document.querySelector('.map-area .ps-btn')).transform).a < .95);
await page.evaluate(() => { testPads[0].buttons[16].value = 0; c4fTest.mapper.back(); });
console.log('PASS real browser: PS press/release feedback from pointer, keyboard, gamepad and mapping tester');
assert.deepEqual(errors, []);
// Actual served HTTP document works without instrumentation or grants.
const plain = await browser.newContext(), smoke = await plain.newPage();
await smoke.goto('http://127.0.0.1:4264/');
await smoke.waitForFunction(() => document.querySelector('#conn').dataset.conn === 'open');
await plain.close();
const saved = await browser.newContext(), filePage = await saved.newPage();
await saved.grantPermissions(['local-network-access']);
await filePage.goto('file:///src/client/index.html');
assert.equal(await filePage.locator('#addressForm').isVisible(), true, 'a saved copy asks for the address');
await filePage.fill('#address', '127.0.0.1');
await filePage.click('#addressForm button');
await filePage.waitForFunction(() => document.querySelector('#conn').dataset.conn === 'open');
await saved.close();
await page.evaluate(() => localStorage.setItem('c4f.gamepadProfiles', '{"schema":99,"future":"preserve"}'));
await page.reload(); await page.waitForFunction(() => !!window.c4fTest);
await page.evaluate(() => c4fTest.persistProfiles());
assert.equal(await page.evaluate(() => localStorage.getItem('c4f.gamepadProfiles')), '{"schema":99,"future":"preserve"}');
await page.evaluate(() => {
localStorage.setItem('c4f.gamepadProfiles', '{broken');
localStorage.setItem('c4f.keymap', '{"CROSS":23,"CIRCLE":{},"SQUARE":"ControlLeft"}');
});
await page.reload(); await page.waitForFunction(() => c4fTest.gamepadSources.size === 3);
assert.equal(await page.evaluate(() => c4fTest.profileStore.profiles.length), 0);
assert.equal(await page.evaluate(() => c4fTest.getKeys().CROSS), 'Space');
await page.evaluate(() => { Storage.prototype.setItem = () => { throw Error('quota'); }; c4fTest.persistProfiles(); });
assert.match(await page.textContent('#toastText'), /session-only/);
console.log('PASS real browser: the address box only for a saved copy, unknown/corrupt storage and session-only fallback');
} finally { if (browser) await browser.close(); server.kill(); }
})().catch(error => { console.error(error); process.exitCode = 1; });
+44
View File
@@ -0,0 +1,44 @@
/* Real diagnostic storage code; no console or kernel-log reader is contacted. */
#define C4F_DIAG_DIR "/tmp/c4f-diag-host-test"
#define C4F_DIAG 1
#include "../launcher/diag.c"
#include <assert.h>
static int fault, calls;
ssize_t __real_write(int fd, const void *buf, size_t n);
int __real_fsync(int fd);
ssize_t __wrap_write(int fd, const void *buf, size_t n)
{
if (fault == 1) { errno = ENOSPC; return -1; }
if (fault == 2) return 0;
if (fault == 3 && calls++ == 0) { errno = EINTR; return -1; }
return __real_write(fd, buf, n > 3 ? 3 : n);
}
int __wrap_fsync(int fd) { if (fault == 4) { errno = EIO; return -1; } return __real_fsync(fd); }
int sceKernelGetSystemSwVersion(OrbisKernelSwVersion *version)
{ snprintf(version->VersionString, sizeof(version->VersionString), "host test"); return 0; }
long c4fGoldHenCommand(unsigned long command, void *data) { (void)command; (void)data; return 0; }
const unsigned char *c4fAutorunBundled(size_t *size) { *size = 0; return NULL; }
uint64_t c4fLauncherTimeMs(void) { return 1000; }
int main(void)
{
assert(access(C4F_DIAG_DIR, F_OK) != 0);
c4fDiagStart(0, 0); c4fDiagStop();
struct stat info; assert(!stat(C4F_DIAG_DIR, &info) && S_ISDIR(info.st_mode));
fault = 3; calls = 0;
assert(!c4fWriteAll(C4F_DIAG_REPORT, "complete write", 0));
char *text = c4fReadTail(C4F_DIAG_REPORT, 100, NULL); assert(text && !strcmp(text, "complete write")); free(text);
for (fault = 1; fault <= 4; fault++) {
if (fault == 3) continue;
assert(c4fWriteAll(C4F_DIAG_REPORT, "failure", 0) == -1);
c4fSaveReport(); assert(strstr(c4fReportNote, "Internal save failed"));
assert(!strstr(c4fReportNote, "Saved /data"));
}
fault = 0; c4fSaveReport(); assert(strstr(c4fReportNote, "Saved /data"));
assert(!c4fWriteAll(C4F_DIAG_LOG ".1", "older segment\n", 0));
assert(!c4fWriteAll(C4F_DIAG_LOG, "latest segment\n", 0));
text = c4fCurrentLogPage(); assert(strstr(text, "older segment") && strstr(text, "latest segment")); free(text);
unlink(C4F_DIAG_KLOG); unlink(C4F_DIAG_REPORT); unlink(C4F_DIAG_LOG); unlink(C4F_DIAG_LOG ".1"); rmdir(C4F_DIAG_DIR);
puts("PASS independent diagnostic directory, EINTR/short writes, ENOSPC/zero/fsync failures and truthful report results");
}
+160
View File
@@ -0,0 +1,160 @@
<!doctype html>
<html lang="en">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Control4Free local gamepad lab</title>
<style>
:root { color-scheme: dark; font: 15px/1.5 system-ui, sans-serif; background: #08152a; color: #f0f4fa; }
* { box-sizing: border-box; }
body { margin: 0; }
header { padding: 20px 24px; border-bottom: 1px solid #304158; }
h1 { font-size: 22px; margin: 0 0 4px; }
p { margin: 4px 0 12px; }
main { display: grid; grid-template-columns: 350px 1fr; min-height: calc(100vh - 125px); }
aside { padding: 20px; overflow: auto; max-height: calc(100vh - 125px); }
iframe { width: 100%; height: calc(100vh - 125px); border: 0; background: #04143a; }
label { display: block; margin-bottom: 6px; }
select, button, input { font: inherit; }
select, button { padding: 8px 10px; border: 1px solid #56708d; border-radius: 5px; background: #142b47; color: inherit; }
select { width: 100%; }
button { cursor: pointer; }
button:focus-visible, select:focus-visible, input:focus-visible { outline: 2px solid #a3caff; outline-offset: 3px; }
button:disabled { opacity: .5; cursor: default; }
.actions { display: flex; flex-wrap: wrap; gap: 8px; margin: 12px 0; }
.control { padding: 10px 0; border-bottom: 1px solid #304158; }
#controls { display: grid; grid-template-columns: 1fr 1fr; gap: 0 12px; }
.control input { width: 100%; }
.control small, #evidence { display: block; color: #b9c8da; font-size: 12px; }
details { margin: 16px 0; }
pre { font-size: 11px; white-space: pre-wrap; overflow-wrap: anywhere; }
#message { min-height: 24px; color: #ffcf9b; }
@media(max-width: 800px) { main { grid-template-columns: 1fr; } aside { max-height: 50vh; } iframe { height: 80vh; } }
</style>
<header>
<h1>Local gamepad lab</h1>
<p>Drive researched emulations through the existing controller page. Inferred values are test assumptions, not hardware results.</p>
</header>
<main>
<aside>
<label for="profile">Device and browser profile</label>
<select id="profile" disabled></select>
<div class="actions"><button id="connect" disabled>Connect</button><button id="disconnect" disabled>Disconnect</button><button id="reset" disabled>Let go of everything</button></div>
<p id="message" role="status">Loading local files…</p>
<p id="evidence"></p>
<div id="controls"></div>
<details><summary>Evidence and assumptions</summary><pre id="sources"></pre></details>
<details><summary>Opening from disk</summary><p>If the browser blocks local file reads, select the tests folder and the original controller page below.</p>
<label>Tests folder <input id="folder" type="file" webkitdirectory multiple></label>
<label>client/index.html <input id="pageFile" type="file" accept=".html"></label>
<button id="loadFiles">Load selected files</button>
</details>
<p><small>Run fake_gamepads_server.cjs in Docker for the local service and browser storage. This harness permits only the local test service. Choose Mapping on its gamepad row.</small></p>
</aside>
<iframe id="controller" title="Control4Free controller page with fake gamepads"></iframe>
</main>
<script>
(() => {
const $ = id => document.getElementById(id), frame = $('controller');
let profiles = [], index = null;
const message = text => { $('message').textContent = text; };
const api = () => frame.contentWindow.fakePads;
function act(fn) { try { fn(); message(''); } catch (error) { message(error.message); } }
function selected() { return profiles.find(p => p.name === $('profile').value); }
function button(parent, text, fn) {
const b = document.createElement('button'); b.type = 'button'; b.textContent = text;
b.addEventListener('click', () => act(fn)); parent.appendChild(b); return b;
}
function render() {
const profile = selected(); if (!profile) return;
$('sources').textContent = JSON.stringify(profile, null, 2);
const status = Object.values(profile.gamepad).filter(e => e?.status === 'inferred').length;
$('evidence').textContent = status + ' inferred report fields; control bindings may also be inferred. See Evidence and assumptions below.';
$('controls').textContent = '';
$('connect').disabled = index !== null; $('disconnect').disabled = $('reset').disabled = index === null;
$('profile').disabled = index !== null;
if (index === null) return;
const sampled = frame.contentWindow.navigator.getGamepads()[index];
for (const [name, c] of Object.entries(profile.controls)) {
const row = document.createElement('div'); row.className = 'control'; $('controls').appendChild(row);
const title = document.createElement('label'); title.textContent = name; row.appendChild(title);
if (c.kind === 'unreported') {
const info = document.createElement('small'); info.textContent = c.reason || 'Not independently exposed'; row.appendChild(info); continue;
}
if (c.kind === 'button' || c.kind === 'alias') {
if (c.analog) {
const pressure = document.createElement('input'); pressure.type = 'range'; pressure.min = 0; pressure.max = 1; pressure.step = c.steps ? 1 / c.steps : .01;
pressure.value = sampled.buttons[c.index].value; pressure.setAttribute('aria-label', name + ' pressure');
pressure.addEventListener('input', () => act(() => api().set(index, name, +pressure.value))); row.appendChild(pressure);
}
const b = button(row, 'Hold', () => {});
const up = () => act(() => api().release(index, name));
b.addEventListener('pointerdown', e => { b.setPointerCapture(e.pointerId); act(() => api().press(index, name)); });
b.addEventListener('pointerup', up); b.addEventListener('pointercancel', up); b.addEventListener('lostpointercapture', up);
b.addEventListener('keydown', e => { if ([' ', 'Enter'].includes(e.key)) { e.preventDefault(); act(() => api().press(index, name)); } });
b.addEventListener('keyup', e => { if ([' ', 'Enter'].includes(e.key)) up(); });
b.addEventListener('blur', up);
} else if (c.kind === 'hat') {
const select = document.createElement('select'); select.setAttribute('aria-label', name);
for (const direction of ['none', ...Object.keys(c.directions)]) select.add(new Option(direction, direction));
select.value = Object.keys(c.directions).find(direction => c.directions[direction] === sampled.axes[c.index]) || 'none';
select.addEventListener('change', () => act(() => api().hat(index, select.value, name))); row.appendChild(select);
} else {
const values = c.kind === 'stick' ? [sampled.axes[c.x], sampled.axes[c.y]] : [sampled.axes[c.index]];
values.forEach((value, part) => {
const input = document.createElement('input'); input.type = 'range'; input.min = c.min ?? -1; input.max = c.max ?? 1; input.step = .01; input.value = value;
input.setAttribute('aria-label', name + (c.kind === 'stick' ? [' horizontal', ' vertical'][part] : ''));
input.addEventListener('input', () => act(() => { values[part] = +input.value;
if (c.kind === 'stick') api().tilt(index, name, ...values); else api().set(index, name, values[0]); })); row.appendChild(input);
});
button(row, 'Release', () => { api().release(index, name); render(); });
}
const note = document.createElement('small'); note.textContent = c.evidence.status; row.appendChild(note);
}
for (const [name, spec] of Object.entries(profile.switches || {})) {
const row = document.createElement('div'); row.className = 'control'; $('controls').appendChild(row);
const label = document.createElement('label'); label.textContent = name; row.appendChild(label);
if (spec.kind === 'unreported') { const note = document.createElement('small'); note.textContent = spec.reason; row.appendChild(note); continue; }
const select = document.createElement('select'); select.setAttribute('aria-label', name); select.add(new Option('Choose…', ''));
for (const value of spec.kind === 'profile' ? Object.keys(spec.values) : spec.values) select.add(new Option(value, value));
if (spec.kind === 'state') select.value = api().switchState(index)[name] || '';
select.addEventListener('change', () => act(() => { api().switch(index, name, select.value);
$('profile').value = api().describe(index).name; render(); })); row.appendChild(select);
}
}
function install(html, script, data) {
profiles = data; index = null;
$('profile').textContent = '';
for (const profile of profiles) $('profile').add(new Option(profile.label + ' — ' + profile.name, profile.name));
const relay = location.protocol === 'http:' && location.hostname === '127.0.0.1' ? 'ws://' + location.host + '/local-service' : null;
const bootstrap = script + '\nwindow.fakePads.loadProfiles(' + JSON.stringify(profiles).replace(/</g, '\\u003c') + ');\n' +
'try { localStorage.setItem("c4f.address", JSON.stringify("127.0.0.1:4264")); } catch(e) {}\n' +
'window.WebSocket = class LocalTestSocket extends WebSocket { constructor(address, protocols) {\n' +
'const url = new URL(address); if (url.protocol !== "ws:" || url.hostname !== "127.0.0.1" || url.port !== "4264") throw new Error("The gamepad lab only connects to the local test service");\n' +
'super(' + JSON.stringify(relay) + ' || address, protocols); } };';
frame.srcdoc = html.replace(/<head>/i, '<head><script>' + bootstrap.replace(/<\/script/gi, '<\\/script') + '<\/script>');
frame.onload = () => { $('profile').disabled = false; render(); message('Choose a profile, then Connect.'); };
}
$('profile').addEventListener('change', render);
$('connect').addEventListener('click', () => act(() => { index = api().connect(selected().name); render(); }));
$('disconnect').addEventListener('click', () => act(() => { api().disconnect(index); index = null; render(); }));
$('reset').addEventListener('click', () => act(() => { api().reset(index); render(); }));
$('loadFiles').addEventListener('click', async () => {
try {
const files = [...$('folder').files], html = await $('pageFile').files[0].text();
const script = await files.find(f => f.name === 'fake_gamepads.js').text();
const data = await Promise.all(files.filter(f => /fake_gamepads\//.test(f.webkitRelativePath) && f.name.endsWith('.json') && f.name !== 'manifest.json').map(async f => JSON.parse(await f.text())));
if (!data.length) throw Error('Select the tests folder containing the profile JSON files'); install(html, script, data);
} catch (error) { message(error.message); }
});
(async () => {
const read = async path => { const response = await fetch(path); if (!response.ok) throw Error('Could not read ' + path); return response; };
try {
const manifest = await (await read('fake_gamepads/manifest.json')).json();
const [html, script, data] = await Promise.all([(await read('../client/index.html')).text(), (await read('fake_gamepads.js')).text(),
Promise.all(manifest.profiles.map(async file => (await read('fake_gamepads/' + file)).json()))]);
install(html, script, data);
} catch (error) { message('Serve this folder locally, or use Opening from disk below. ' + error.message); }
})();
})();
</script>
</html>
+214
View File
@@ -0,0 +1,214 @@
/* Local browser test double. This file is never loaded by client/index.html.
* Inject this script and call fakePads.loadProfiles(researched JSON profiles).
* Example: fakePads.press(fakePads.connect(profileName), 'Green fret').
* API contract: https://w3c.github.io/gamepad/#dom-navigator-getgamepads
* Slots retain null holes; button pressure/touch and timestamps update on polls.
* These snapshots emulate the documented profiles, not hardware compatibility.
*/
(() => {
'use strict';
// Chromium Gamepads::kItemsLengthCap and Windows XInput both cap at four.
// https://chromium.googlesource.com/chromium/src/+/main/device/gamepad/public/cpp/gamepads.h
const profiles = new Map(), devices = Array(4).fill(null), feedback = [];
const copy = value => JSON.parse(JSON.stringify(value));
const finite = value => {
if (!Number.isFinite(value)) throw new TypeError('Input must be a finite number');
return value;
};
const clamp = (value, min, max) => Math.max(min, Math.min(max, finite(value)));
const valueOf = entry => entry.value;
const evidence = (entry, label) => {
if (!entry || !['confirmed', 'inferred'].includes(entry.status) || !entry.sources?.length ||
entry.sources.some(source => !/^https?:\/\//.test(source.url) || !source.says) ||
entry.status === 'inferred' && !entry.reason) throw new Error('Missing evidence: ' + label);
};
function validate(profile) {
if (!profile?.name || !profile.label || !profile.environment || !profile.controls) throw new Error('Invalid profile');
for (const key of ['id', 'mapping', 'buttons', 'axesRest', 'vibration']) evidence(profile.gamepad?.[key], profile.name + '.' + key);
const pad = profile.gamepad, count = valueOf(pad.buttons), axes = valueOf(pad.axesRest);
if (typeof valueOf(pad.id) !== 'string' || !['', 'standard'].includes(valueOf(pad.mapping)) ||
!Number.isInteger(count) || count < 1 || !Array.isArray(axes) || axes.some(v => !Number.isFinite(v))) throw new Error('Invalid layout: ' + profile.name);
for (const [name, control] of Object.entries(profile.controls)) {
evidence(control.evidence, profile.name + '.' + name);
if (control.kind === 'button' && (!Number.isInteger(control.index) || control.index < 0 || control.index >= count)) throw new Error('Button out of range: ' + name);
if (['axis', 'hat'].includes(control.kind) && (!Number.isInteger(control.index) || control.index < 0 || control.index >= axes.length)) throw new Error('Axis out of range: ' + name);
if (control.kind === 'stick' && [control.x, control.y].some(index => !Number.isInteger(index) || index < 0 || index >= axes.length)) throw new Error('Stick out of range: ' + name);
if (control.kind === 'alias' && !profile.controls[control.target]) throw new Error('Unknown alias target: ' + name);
if (!['button', 'axis', 'stick', 'hat', 'alias', 'unreported'].includes(control.kind)) throw new Error('Unknown control kind: ' + name);
if (control.kind === 'hat' && (!Number.isFinite(control.neutral) || !control.directions)) throw new Error('Invalid hat: ' + name);
}
for (const [name, spec] of Object.entries(profile.switches || {})) evidence(spec.evidence, profile.name + '.' + name);
return copy(profile);
}
function device(index) {
const found = devices[index];
if (!found) throw new Error('No connected fake gamepad at index ' + index);
return found;
}
function controlFor(pad, name, seen = new Set()) {
const control = pad.profile.controls[name];
if (!control) throw new Error('Unknown control: ' + name);
if (seen.has(name)) throw new Error('Circular control alias: ' + name);
seen.add(name);
if (control.kind === 'alias') return controlFor(pad, control.target, seen);
if (control.kind === 'unreported') throw new Error(name + ' is not independently exposed: ' + (control.reason || pad.profile.notes?.join(' ') || 'see the profile evidence'));
return control;
}
function snapshot(pad, connected = true) {
const spec = pad.profile.gamepad;
pad.sample = { connected, timestamp: pad.timestamp, buttons: [...pad.buttons], axes: Object.freeze([...pad.axes]) };
if (!pad.public) {
const buttons = Object.freeze(pad.buttons.map((_, index) => {
const control = Object.values(pad.profile.controls).find(c => c.kind === 'button' && c.index === index);
const threshold = control?.pressedThreshold ?? .5;
return Object.freeze({
get pressed() { const value = pad.sample.buttons[index]; return control?.pressedInclusive ? value >= threshold : value > threshold; },
get touched() {
const value = pad.sample.buttons[index];
if (control?.touchedPolicy === 'pressed') return control.pressedInclusive ? value >= threshold : value > threshold;
return value > 0;
},
get value() { return pad.sample.buttons[index]; }, [Symbol.toStringTag]: 'GamepadButton'
});
}));
const result = {
id: valueOf(spec.id), index: pad.index, mapping: valueOf(spec.mapping),
get connected() { return pad.sample.connected; }, get timestamp() { return pad.sample.timestamp; },
buttons, get axes() { return pad.sample.axes; }, [Symbol.toStringTag]: 'Gamepad'
};
if (valueOf(spec.vibration)) result.vibrationActuator = pad.actuator;
else if (spec.vibration.presence === 'null') result.vibrationActuator = null;
pad.public = Object.freeze(result);
}
return pad.public;
}
function fire(type, pad, connected) {
// GamepadEvent's constructor rejects JS test doubles as native Gamepads.
// The page sees the same event name and read-only gamepad property.
const event = new Event(type);
Object.defineProperty(event, 'gamepad', { value: snapshot(pad, connected), enumerable: true });
window.dispatchEvent(event);
}
function connectAt(name, index) {
const profile = profiles.get(name);
if (!profile) throw new Error('Unknown profile: ' + name);
const pad = { profile, index, timestamp: performance.now(), dirty: false, contributions: new Map(), buttons: Array(valueOf(profile.gamepad.buttons)).fill(0),
axes: [...valueOf(profile.gamepad.axesRest)], switches: {} };
const vibration = valueOf(profile.gamepad.vibration);
if (vibration) {
pad.actuator = Object.freeze({ type: vibration.type, effects: Object.freeze([...vibration.effects]),
playEffect(type, parameters = {}) {
if (!['dual-rumble', 'trigger-rumble'].includes(type)) throw new TypeError('Unknown haptic effect');
if (!vibration.effects.includes(type)) return Promise.resolve('not-supported');
// Blink playEffect parameter ranges; defaults are zero.
// https://chromium.googlesource.com/chromium/src/+/main/third_party/blink/renderer/modules/gamepad/gamepad_haptic_actuator.cc
if (['duration', 'startDelay'].some(key => parameters[key] !== undefined && (!Number.isFinite(parameters[key]) || parameters[key] < 0)) ||
['strongMagnitude', 'weakMagnitude', 'leftTrigger', 'rightTrigger'].some(key => parameters[key] !== undefined &&
(!Number.isFinite(parameters[key]) || parameters[key] < 0 || parameters[key] > 1))) return Promise.resolve('invalid-parameter');
feedback.push({ index, type, parameters: copy(parameters) });
// Capture stub: no motor timing, preemption, or physical validation.
return Promise.resolve('complete');
},
reset() { feedback.push({ index, type: 'reset' }); return Promise.resolve('complete'); },
[Symbol.toStringTag]: 'GamepadHapticActuator' });
}
devices[index] = pad;
fire('gamepadconnected', pad, true);
return index;
}
function set(index, name, value) {
const pad = device(index), control = controlFor(pad, name);
if (control.kind === 'button') {
const pressure = clamp(value, 0, 1);
if (!control.analog && ![0, 1].includes(pressure)) throw new TypeError(name + ' is a digital button');
pad.contributions.set(name, { index: control.index, value: control.steps ? Math.round(pressure * control.steps) / control.steps : pressure });
const next = Math.max(0, ...[...pad.contributions.values()].filter(c => c.index === control.index).map(c => c.value));
pad.dirty ||= pad.buttons[control.index] !== next; pad.buttons[control.index] = next;
}
else if (control.kind === 'axis') axisValue(pad, control.index, clamp(value, control.min ?? -1, control.max ?? 1));
else throw new Error('Use tilt or hat for ' + name);
}
function axisValue(pad, index, value) { pad.dirty ||= pad.axes[index] !== value; pad.axes[index] = value; }
function release(index, name) {
const pad = device(index), control = controlFor(pad, name);
if (control.kind === 'button') set(index, name, 0);
else if (control.kind === 'axis') axisValue(pad, control.index, control.rest ?? valueOf(pad.profile.gamepad.axesRest)[control.index]);
else if (control.kind === 'stick') {
axisValue(pad, control.x, valueOf(pad.profile.gamepad.axesRest)[control.x]);
axisValue(pad, control.y, valueOf(pad.profile.gamepad.axesRest)[control.y]);
} else if (control.kind === 'hat') axisValue(pad, control.index, control.neutral);
}
function directionKey(direction) { return direction.toLowerCase().replace(/\s+/g, '-'); }
function hat(index, direction, name) {
const pad = device(index);
name ||= Object.keys(pad.profile.controls).find(key => pad.profile.controls[key].kind === 'hat') || 'D-pad';
const control = pad.profile.controls[name] ? controlFor(pad, name) : null, key = directionKey(direction);
if (control?.kind === 'hat') {
if (key === 'none') axisValue(pad, control.index, control.neutral);
else if (Object.hasOwn(control.directions, key)) axisValue(pad, control.index, control.directions[key]);
else throw new Error('Unknown hat direction: ' + direction);
return;
}
// A browser-standard D-pad is four buttons, including diagonals.
const names = ['up', 'right', 'down', 'left'];
if (!['none', ...names, 'up-right', 'down-right', 'down-left', 'up-left'].includes(key)) throw new Error('Unknown D-pad direction');
for (const part of names) {
const keyName = Object.keys(pad.profile.controls).find(n => n.toLowerCase() === 'd-pad ' + part);
if (!keyName) throw new Error('No D-pad control in this profile');
set(index, keyName, key.split('-').includes(part) ? 1 : 0);
}
}
const api = {
loadProfiles(list) {
if (!Array.isArray(list)) list = [list];
const checked = list.map(validate);
for (const profile of checked) profiles.set(profile.name, profile);
},
profiles: () => [...profiles.values()].map(copy),
describe: index => copy(device(index).profile),
connect(name) { const index = devices.findIndex(pad => !pad); if (index < 0) throw new Error('All four fake gamepad slots are occupied'); return connectAt(name, index); },
disconnect(index) { const pad = device(index); devices[index] = null; pad.timestamp = performance.now(); fire('gamepaddisconnected', pad, false); },
press(index, name) {
const pad = device(index), control = controlFor(pad, name);
if (control.kind === 'button') set(index, name, 1);
else if (control.kind === 'axis') set(index, name, control.full ?? (control.rest > 0 ? -1 : 1));
else throw new Error('Use tilt or hat for ' + name);
},
release, set,
tilt(index, name, x, y) {
const pad = device(index), control = controlFor(pad, name);
if (control.kind !== 'stick') throw new Error(name + ' is not a stick');
axisValue(pad, control.x, clamp(x, -1, 1)); axisValue(pad, control.y, clamp(y, -1, 1));
},
hat,
switch(index, name, value) {
const pad = device(index), spec = pad.profile.switches?.[name];
if (!spec) throw new Error('Unknown switch: ' + name);
if (spec.kind === 'unreported') throw new Error(name + ': ' + (spec.reason || 'browser effect not found; see evidence'));
if (spec.kind === 'profile') {
const target = spec.values[value];
if (!target || !profiles.has(target)) throw new Error('No researched profile for mode ' + value);
api.disconnect(index); return connectAt(target, index);
}
if (spec.kind !== 'state' || spec.effect !== 'none' || !spec.values.includes(value)) throw new Error('Unsupported switch value');
pad.switches[name] = value;
return index;
},
switchState: index => copy(device(index).switches),
feedback: () => copy(feedback),
reset(index) {
const pad = device(index);
pad.dirty ||= pad.buttons.some(v => v !== 0) || pad.axes.some((v, i) => v !== valueOf(pad.profile.gamepad.axesRest)[i]);
pad.contributions.clear();
pad.buttons.fill(0); pad.axes = [...valueOf(pad.profile.gamepad.axesRest)];
}
};
// New snapshots expose the latest input only when sampled, like getGamepads.
Object.defineProperty(navigator, 'getGamepads', { configurable: true, value: () => devices.map(pad => {
if (!pad) return null;
if (pad.dirty || valueOf(pad.profile.gamepad.timestampPolicy || { value: 'poll' }) === 'poll') pad.timestamp = Math.max(performance.now(), pad.timestamp + .001);
pad.dirty = false;
return snapshot(pad);
}) });
Object.defineProperty(window, 'fakePads', { value: Object.freeze(api), configurable: true });
})();
@@ -0,0 +1,545 @@
{
"name": "crkd-les-paul-chrome-win-dongle-mode1-pre3",
"label": "CRKD Gibson Les Paul — PC Mode 1 (earlier firmware, inferred)",
"gamepad": {
"id": {
"value": "Xbox 360 Controller (XInput STANDARD GAMEPAD)",
"status": "inferred",
"sources": [
{
"url": "https://github.com/sanjay900/xinputemu/pull/1",
"says": "Patch author tested two CRKD guitars; VID/PID match a first-party 360 gamepad (045e:028e in patch); whammy is Z/LT, converted to signed right-stick range for old PC guitar games."
},
{
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
}
],
"reason": "The hardware-tested original Mode 1 report uses the first-party 360 VID/PID and YARG identifies it as an XInput gamepad. Applying Chromium's subtype-1 name is a source-derived candidate, not a raw CRKD browser capture."
},
"mapping": {
"value": "standard",
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
}
]
},
"buttons": {
"value": 17,
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
}
]
},
"axesRest": {
"value": [
1.52587890625E-05,
-1.52587890625E-05,
1.52587890625E-05,
-1.52587890625E-05
],
"status": "inferred",
"sources": [
{
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
},
{
"url": "https://cdn.shopify.com/s/files/1/0679/3392/9791/files/Guitar_User_Guide_Multi_V1.4_08Jan26.pdf?v=1767902895",
"says": "CK25GM PC connections include USB cable and included USB wireless receiver; PC slider and mode dial select game preset; the hardware includes both sticks, View/Capture/Menu, CTRL, Home and D-pad."
}
],
"reason": "Nominal neutral signed XInput stick words are assumed to be zero. Chromium normalizes signed zero to +0.0000152587890625 and negates Y; physical CRKD neutral raw words were not captured."
},
"timestampPolicy": {
"value": "poll",
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
"says": "GetXInputPadData assigns CurrentTimeInMicroseconds to pad.timestamp after every successful XInput state poll, even if the input values did not change."
}
]
},
"vibration": {
"presence": "object",
"value": {
"type": "dual-rumble",
"effects": [
"dual-rumble"
]
},
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
}
]
}
},
"controls": {
"Green fret": {
"kind": "button",
"index": 0,
"pressedThreshold": 0,
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://github.com/YARC-Official/YARG/blob/f5bfbe6996b4af35722d4204a93c6cc5c8f52a45/Assets/Script/Input/Bindings/Defaults/BindingCollection.Gamepad.cs",
"says": "CrkdGuitar_Mode1 maps Green/Red/Yellow/Blue/Orange to A/B/Y/X/LB, strum up/down to D-pad, star power to Select, whammy to leftTrigger; no dedicated tilt axis. Menu includes left-stick and D-pad."
},
{
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
}
],
"reason": "YARG's CRKD Mode 1 Green fret binding is translated through Chromium's XInput button order. Factory button aliases are inferred, not a browser dump."
}
},
"Red fret": {
"kind": "button",
"index": 1,
"pressedThreshold": 0,
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://github.com/YARC-Official/YARG/blob/f5bfbe6996b4af35722d4204a93c6cc5c8f52a45/Assets/Script/Input/Bindings/Defaults/BindingCollection.Gamepad.cs",
"says": "CrkdGuitar_Mode1 maps Green/Red/Yellow/Blue/Orange to A/B/Y/X/LB, strum up/down to D-pad, star power to Select, whammy to leftTrigger; no dedicated tilt axis. Menu includes left-stick and D-pad."
},
{
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
}
],
"reason": "YARG's CRKD Mode 1 Red fret binding is translated through Chromium's XInput button order. Factory button aliases are inferred, not a browser dump."
}
},
"Yellow fret": {
"kind": "button",
"index": 3,
"pressedThreshold": 0,
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://github.com/YARC-Official/YARG/blob/f5bfbe6996b4af35722d4204a93c6cc5c8f52a45/Assets/Script/Input/Bindings/Defaults/BindingCollection.Gamepad.cs",
"says": "CrkdGuitar_Mode1 maps Green/Red/Yellow/Blue/Orange to A/B/Y/X/LB, strum up/down to D-pad, star power to Select, whammy to leftTrigger; no dedicated tilt axis. Menu includes left-stick and D-pad."
},
{
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
}
],
"reason": "YARG's CRKD Mode 1 Yellow fret binding is translated through Chromium's XInput button order. Factory button aliases are inferred, not a browser dump."
}
},
"Blue fret": {
"kind": "button",
"index": 2,
"pressedThreshold": 0,
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://github.com/YARC-Official/YARG/blob/f5bfbe6996b4af35722d4204a93c6cc5c8f52a45/Assets/Script/Input/Bindings/Defaults/BindingCollection.Gamepad.cs",
"says": "CrkdGuitar_Mode1 maps Green/Red/Yellow/Blue/Orange to A/B/Y/X/LB, strum up/down to D-pad, star power to Select, whammy to leftTrigger; no dedicated tilt axis. Menu includes left-stick and D-pad."
},
{
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
}
],
"reason": "YARG's CRKD Mode 1 Blue fret binding is translated through Chromium's XInput button order. Factory button aliases are inferred, not a browser dump."
}
},
"Orange fret": {
"kind": "button",
"index": 4,
"pressedThreshold": 0,
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://github.com/YARC-Official/YARG/blob/f5bfbe6996b4af35722d4204a93c6cc5c8f52a45/Assets/Script/Input/Bindings/Defaults/BindingCollection.Gamepad.cs",
"says": "CrkdGuitar_Mode1 maps Green/Red/Yellow/Blue/Orange to A/B/Y/X/LB, strum up/down to D-pad, star power to Select, whammy to leftTrigger; no dedicated tilt axis. Menu includes left-stick and D-pad."
},
{
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
}
],
"reason": "YARG's CRKD Mode 1 Orange fret binding is translated through Chromium's XInput button order. Factory button aliases are inferred, not a browser dump."
}
},
"Strum up": {
"kind": "button",
"index": 12,
"pressedThreshold": 0,
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://github.com/YARC-Official/YARG/blob/f5bfbe6996b4af35722d4204a93c6cc5c8f52a45/Assets/Script/Input/Bindings/Defaults/BindingCollection.Gamepad.cs",
"says": "CrkdGuitar_Mode1 maps Green/Red/Yellow/Blue/Orange to A/B/Y/X/LB, strum up/down to D-pad, star power to Select, whammy to leftTrigger; no dedicated tilt axis. Menu includes left-stick and D-pad."
},
{
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
}
],
"reason": "YARG's CRKD Mode 1 Strum up binding is translated through Chromium's XInput button order. Factory button aliases are inferred, not a browser dump."
}
},
"Strum down": {
"kind": "button",
"index": 13,
"pressedThreshold": 0,
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://github.com/YARC-Official/YARG/blob/f5bfbe6996b4af35722d4204a93c6cc5c8f52a45/Assets/Script/Input/Bindings/Defaults/BindingCollection.Gamepad.cs",
"says": "CrkdGuitar_Mode1 maps Green/Red/Yellow/Blue/Orange to A/B/Y/X/LB, strum up/down to D-pad, star power to Select, whammy to leftTrigger; no dedicated tilt axis. Menu includes left-stick and D-pad."
},
{
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
}
],
"reason": "YARG's CRKD Mode 1 Strum down binding is translated through Chromium's XInput button order. Factory button aliases are inferred, not a browser dump."
}
},
"CTRL": {
"kind": "button",
"index": 8,
"pressedThreshold": 0,
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://github.com/YARC-Official/YARG/blob/f5bfbe6996b4af35722d4204a93c6cc5c8f52a45/Assets/Script/Input/Bindings/Defaults/BindingCollection.Gamepad.cs",
"says": "CrkdGuitar_Mode1 maps Green/Red/Yellow/Blue/Orange to A/B/Y/X/LB, strum up/down to D-pad, star power to Select, whammy to leftTrigger; no dedicated tilt axis. Menu includes left-stick and D-pad."
},
{
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
}
],
"reason": "YARG's CRKD Mode 1 star-power/select binding is translated through Chromium's XInput button order. Factory button aliases are inferred, not a browser dump."
}
},
"View": {
"kind": "button",
"index": 8,
"pressedThreshold": 0,
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://github.com/YARC-Official/YARG/blob/f5bfbe6996b4af35722d4204a93c6cc5c8f52a45/Assets/Script/Input/Bindings/Defaults/BindingCollection.Gamepad.cs",
"says": "CrkdGuitar_Mode1 maps Green/Red/Yellow/Blue/Orange to A/B/Y/X/LB, strum up/down to D-pad, star power to Select, whammy to leftTrigger; no dedicated tilt axis. Menu includes left-stick and D-pad."
},
{
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
}
],
"reason": "YARG's CRKD Mode 1 View binding is translated through Chromium's XInput button order. Factory button aliases are inferred, not a browser dump."
}
},
"Menu": {
"kind": "button",
"index": 9,
"pressedThreshold": 0,
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://github.com/YARC-Official/YARG/blob/f5bfbe6996b4af35722d4204a93c6cc5c8f52a45/Assets/Script/Input/Bindings/Defaults/BindingCollection.Gamepad.cs",
"says": "CrkdGuitar_Mode1 maps Green/Red/Yellow/Blue/Orange to A/B/Y/X/LB, strum up/down to D-pad, star power to Select, whammy to leftTrigger; no dedicated tilt axis. Menu includes left-stick and D-pad."
},
{
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
}
],
"reason": "YARG's CRKD Mode 1 Menu binding is translated through Chromium's XInput button order. Factory button aliases are inferred, not a browser dump."
}
},
"D-pad up": {
"kind": "button",
"index": 12,
"pressedThreshold": 0,
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://github.com/YARC-Official/YARG/blob/f5bfbe6996b4af35722d4204a93c6cc5c8f52a45/Assets/Script/Input/Bindings/Defaults/BindingCollection.Gamepad.cs",
"says": "CrkdGuitar_Mode1 maps Green/Red/Yellow/Blue/Orange to A/B/Y/X/LB, strum up/down to D-pad, star power to Select, whammy to leftTrigger; no dedicated tilt axis. Menu includes left-stick and D-pad."
},
{
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
}
],
"reason": "YARG's CRKD Mode 1 D-pad up binding is translated through Chromium's XInput button order. Factory button aliases are inferred, not a browser dump."
}
},
"D-pad down": {
"kind": "button",
"index": 13,
"pressedThreshold": 0,
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://github.com/YARC-Official/YARG/blob/f5bfbe6996b4af35722d4204a93c6cc5c8f52a45/Assets/Script/Input/Bindings/Defaults/BindingCollection.Gamepad.cs",
"says": "CrkdGuitar_Mode1 maps Green/Red/Yellow/Blue/Orange to A/B/Y/X/LB, strum up/down to D-pad, star power to Select, whammy to leftTrigger; no dedicated tilt axis. Menu includes left-stick and D-pad."
},
{
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
}
],
"reason": "YARG's CRKD Mode 1 D-pad down binding is translated through Chromium's XInput button order. Factory button aliases are inferred, not a browser dump."
}
},
"D-pad left": {
"kind": "button",
"index": 14,
"pressedThreshold": 0,
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://github.com/YARC-Official/YARG/blob/f5bfbe6996b4af35722d4204a93c6cc5c8f52a45/Assets/Script/Input/Bindings/Defaults/BindingCollection.Gamepad.cs",
"says": "CrkdGuitar_Mode1 maps Green/Red/Yellow/Blue/Orange to A/B/Y/X/LB, strum up/down to D-pad, star power to Select, whammy to leftTrigger; no dedicated tilt axis. Menu includes left-stick and D-pad."
},
{
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
}
],
"reason": "YARG's CRKD Mode 1 D-pad left binding is translated through Chromium's XInput button order. Factory button aliases are inferred, not a browser dump."
}
},
"D-pad right": {
"kind": "button",
"index": 15,
"pressedThreshold": 0,
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://github.com/YARC-Official/YARG/blob/f5bfbe6996b4af35722d4204a93c6cc5c8f52a45/Assets/Script/Input/Bindings/Defaults/BindingCollection.Gamepad.cs",
"says": "CrkdGuitar_Mode1 maps Green/Red/Yellow/Blue/Orange to A/B/Y/X/LB, strum up/down to D-pad, star power to Select, whammy to leftTrigger; no dedicated tilt axis. Menu includes left-stick and D-pad."
},
{
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
}
],
"reason": "YARG's CRKD Mode 1 D-pad right binding is translated through Chromium's XInput button order. Factory button aliases are inferred, not a browser dump."
}
},
"Whammy": {
"kind": "button",
"index": 6,
"steps": 255,
"pressedThreshold": 0.11764705882352941,
"pressedInclusive": true,
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://github.com/YARC-Official/YARG/blob/f5bfbe6996b4af35722d4204a93c6cc5c8f52a45/Assets/Script/Input/Bindings/Defaults/BindingCollection.Gamepad.cs",
"says": "CrkdGuitar_Mode1 maps Green/Red/Yellow/Blue/Orange to A/B/Y/X/LB, strum up/down to D-pad, star power to Select, whammy to leftTrigger; no dedicated tilt axis. Menu includes left-stick and D-pad."
},
{
"url": "https://github.com/sanjay900/xinputemu/pull/1",
"says": "Patch author tested two CRKD guitars; VID/PID match a first-party 360 gamepad (045e:028e in patch); whammy is Z/LT, converted to signed right-stick range for old PC guitar games."
},
{
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
}
],
"reason": "YARG binds CRKD Mode 1 whammy to LT; Chromium exposes LT as analog button 6, divided by 255, pressed inclusively at byte 30. No -1 whammy axis is claimed."
},
"analog": true
},
"Left stick": {
"kind": "stick",
"x": 0,
"y": 1,
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://github.com/YARC-Official/YARG/blob/f5bfbe6996b4af35722d4204a93c6cc5c8f52a45/Assets/Script/Input/Bindings/Defaults/BindingCollection.Gamepad.cs",
"says": "CrkdGuitar_Mode1 maps Green/Red/Yellow/Blue/Orange to A/B/Y/X/LB, strum up/down to D-pad, star power to Select, whammy to leftTrigger; no dedicated tilt axis. Menu includes left-stick and D-pad."
},
{
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
}
],
"reason": "YARG's CRKD Mode 1 menu accepts the left stick; Chromium's XInput axes 0/1 carry LX/inverted LY."
}
},
"Right stick": {
"kind": "stick",
"x": 2,
"y": 3,
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://cdn.shopify.com/s/files/1/0679/3392/9791/files/Guitar_User_Guide_Multi_V1.4_08Jan26.pdf?v=1767902895",
"says": "CK25GM PC connections include USB cable and included USB wireless receiver; PC slider and mode dial select game preset; the hardware includes both sticks, View/Capture/Menu, CTRL, Home and D-pad."
},
{
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
}
],
"reason": "Manufacturer identifies a right analog stick. Standard XInput axes 2/3 carry RX/inverted RY; its factory operation in this earlier Mode 1 preset is inferred."
}
},
"L3": {
"kind": "button",
"index": 10,
"pressedThreshold": 0,
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://cdn.shopify.com/s/files/1/0679/3392/9791/files/Guitar_User_Guide_Multi_V1.4_08Jan26.pdf?v=1767902895",
"says": "CK25GM PC connections include USB cable and included USB wireless receiver; PC slider and mode dial select game preset; the hardware includes both sticks, View/Capture/Menu, CTRL, Home and D-pad."
},
{
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
}
],
"reason": "Manufacturer names this physical control; its normal XInput button correspondence is inferred from the standard browser path, not observed on hardware."
}
},
"R3": {
"kind": "button",
"index": 11,
"pressedThreshold": 0,
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://cdn.shopify.com/s/files/1/0679/3392/9791/files/Guitar_User_Guide_Multi_V1.4_08Jan26.pdf?v=1767902895",
"says": "CK25GM PC connections include USB cable and included USB wireless receiver; PC slider and mode dial select game preset; the hardware includes both sticks, View/Capture/Menu, CTRL, Home and D-pad."
},
{
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
}
],
"reason": "Manufacturer names this physical control; its normal XInput button correspondence is inferred from the standard browser path, not observed on hardware."
}
},
"CRKD Home": {
"kind": "button",
"index": 16,
"pressedThreshold": 0,
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://cdn.shopify.com/s/files/1/0679/3392/9791/files/Guitar_User_Guide_Multi_V1.4_08Jan26.pdf?v=1767902895",
"says": "CK25GM PC connections include USB cable and included USB wireless receiver; PC slider and mode dial select game preset; the hardware includes both sticks, View/Capture/Menu, CTRL, Home and D-pad."
},
{
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
}
],
"reason": "Manufacturer names this physical control; its normal XInput button correspondence is inferred from the standard browser path, not observed on hardware."
}
},
"Capture": {
"kind": "unreported",
"reason": "No independent XInput capture bit exists; the factory action or alias was not found.",
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://cdn.shopify.com/s/files/1/0679/3392/9791/files/Guitar_User_Guide_Multi_V1.4_08Jan26.pdf?v=1767902895",
"says": "CK25GM PC connections include USB cable and included USB wireless receiver; PC slider and mode dial select game preset; the hardware includes both sticks, View/Capture/Menu, CTRL, Home and D-pad."
},
{
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
}
],
"reason": "The manual confirms Capture exists physically but its PC report is not established; no browser index is invented."
}
}
},
"switches": {
"Mode dial": {
"kind": "unreported",
"reason": "Only the earlier PC Mode 1 mapping is sourced. Factory presets, firmware 3 and legacy Mode 8 change reports and need separately researched profiles.",
"evidence": {
"value": null,
"status": "confirmed",
"sources": [
{
"url": "https://cdn.shopify.com/s/files/1/0679/3392/9791/files/Guitar_User_Guide_Multi_V1.4_08Jan26.pdf?v=1767902895",
"says": "CK25GM PC connections include USB cable and included USB wireless receiver; PC slider and mode dial select game preset; the hardware includes both sticks, View/Capture/Menu, CTRL, Home and D-pad."
},
{
"url": "https://github.com/YARC-Official/YARG/blob/f5bfbe6996b4af35722d4204a93c6cc5c8f52a45/Assets/Script/Input/Bindings/Defaults/BindingCollection.Gamepad.cs",
"says": "CrkdGuitar_Mode1 maps Green/Red/Yellow/Blue/Orange to A/B/Y/X/LB, strum up/down to D-pad, star power to Select, whammy to leftTrigger; no dedicated tilt axis. Menu includes left-stick and D-pad."
}
]
}
},
"Platform slider": {
"kind": "unreported",
"reason": "PC Mode 1 only. NS, AN and P3 identifiers and raw reports have not been established.",
"evidence": {
"value": null,
"status": "confirmed",
"sources": [
{
"url": "https://cdn.shopify.com/s/files/1/0679/3392/9791/files/Guitar_User_Guide_Multi_V1.4_08Jan26.pdf?v=1767902895",
"says": "CK25GM PC connections include USB cable and included USB wireless receiver; PC slider and mode dial select game preset; the hardware includes both sticks, View/Capture/Menu, CTRL, Home and D-pad."
}
]
}
}
},
"notes": [
"Local source-derived emulation, not a hardware/browser capture. This profile models the earlier CRKD Mode 1 preset identified by YARG as CrkdGuitar_Mode1, before firmware 3 changed star power to D-pad left.",
"Use the Multi-platform Gibson Les Paul CK25GM; Nitro Deck is a separate Switch controller product.",
"Stock Windows XInputGetStateEx availability is assumed for button 16. Without it Chromium exposes 16 buttons; that separate variant is omitted.",
"All unpressed buttons, including whammy and unused RT, are modeled at zero; those physical resting bytes are inferred. The tested conversion patch supports whammy LT with rest zero, but no raw browser rest dump was located.",
"Whammy is analog button 6 (0..1), not an axis. D-pad and strum share standard button indices; there is no numeric hat axis or independent tilt axis in this preset.",
"Browser indices: 0 A/Green; 1 B/Red; 2 X/Blue; 3 Y/Yellow; 4 LB/Orange; 5 RB (physical alias unconfirmed); 6 LT/Whammy; 7 RT (physical alias unconfirmed); 8 View/Select/CTRL inferred; 9 Menu/Start; 10 L3; 11 R3; 12 D-pad/Strum up; 13 D-pad/Strum down; 14 D-pad left; 15 D-pad right; 16 Home/Guide. Axes 0/1 Left stick; 2/3 Right stick, nominal rest inferred.",
"The dual-rumble object is Chromium's unconditional XInput API exposure. It does not confirm browser-controlled physical vibration or equate to the guitar's local strum haptics.",
"Chromium XInput id hides VID/PID. Hardware-tested old Mode 1 uses 045e:028e; newer native CRKD IDs from Linux/SDL are not transplanted into this earlier profile.",
"Factory names whose report is unknown are omitted or explicitly unreported. Capture, firmware/current platform modes and remapped presets need real captures."
],
"environment": {
"browser": "Chrome / Edge (Chromium XInput path)",
"os": "Windows 10 / 11",
"connection": "2.4 GHz dongle",
"mode": "PC slider; Mode 1; earlier firmware preset"
}
}
@@ -0,0 +1,545 @@
{
"name": "crkd-les-paul-chrome-win-usb-mode1-pre3",
"label": "CRKD Gibson Les Paul — PC Mode 1 (earlier firmware, inferred)",
"gamepad": {
"id": {
"value": "Xbox 360 Controller (XInput STANDARD GAMEPAD)",
"status": "inferred",
"sources": [
{
"url": "https://github.com/sanjay900/xinputemu/pull/1",
"says": "Patch author tested two CRKD guitars; VID/PID match a first-party 360 gamepad (045e:028e in patch); whammy is Z/LT, converted to signed right-stick range for old PC guitar games."
},
{
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
}
],
"reason": "The hardware-tested original Mode 1 report uses the first-party 360 VID/PID and YARG identifies it as an XInput gamepad. Applying Chromium's subtype-1 name is a source-derived candidate, not a raw CRKD browser capture."
},
"mapping": {
"value": "standard",
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
}
]
},
"buttons": {
"value": 17,
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
}
]
},
"axesRest": {
"value": [
1.52587890625E-05,
-1.52587890625E-05,
1.52587890625E-05,
-1.52587890625E-05
],
"status": "inferred",
"sources": [
{
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
},
{
"url": "https://cdn.shopify.com/s/files/1/0679/3392/9791/files/Guitar_User_Guide_Multi_V1.4_08Jan26.pdf?v=1767902895",
"says": "CK25GM PC connections include USB cable and included USB wireless receiver; PC slider and mode dial select game preset; the hardware includes both sticks, View/Capture/Menu, CTRL, Home and D-pad."
}
],
"reason": "Nominal neutral signed XInput stick words are assumed to be zero. Chromium normalizes signed zero to +0.0000152587890625 and negates Y; physical CRKD neutral raw words were not captured."
},
"timestampPolicy": {
"value": "poll",
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
"says": "GetXInputPadData assigns CurrentTimeInMicroseconds to pad.timestamp after every successful XInput state poll, even if the input values did not change."
}
]
},
"vibration": {
"presence": "object",
"value": {
"type": "dual-rumble",
"effects": [
"dual-rumble"
]
},
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
}
]
}
},
"controls": {
"Green fret": {
"kind": "button",
"index": 0,
"pressedThreshold": 0,
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://github.com/YARC-Official/YARG/blob/f5bfbe6996b4af35722d4204a93c6cc5c8f52a45/Assets/Script/Input/Bindings/Defaults/BindingCollection.Gamepad.cs",
"says": "CrkdGuitar_Mode1 maps Green/Red/Yellow/Blue/Orange to A/B/Y/X/LB, strum up/down to D-pad, star power to Select, whammy to leftTrigger; no dedicated tilt axis. Menu includes left-stick and D-pad."
},
{
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
}
],
"reason": "YARG's CRKD Mode 1 Green fret binding is translated through Chromium's XInput button order. Factory button aliases are inferred, not a browser dump."
}
},
"Red fret": {
"kind": "button",
"index": 1,
"pressedThreshold": 0,
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://github.com/YARC-Official/YARG/blob/f5bfbe6996b4af35722d4204a93c6cc5c8f52a45/Assets/Script/Input/Bindings/Defaults/BindingCollection.Gamepad.cs",
"says": "CrkdGuitar_Mode1 maps Green/Red/Yellow/Blue/Orange to A/B/Y/X/LB, strum up/down to D-pad, star power to Select, whammy to leftTrigger; no dedicated tilt axis. Menu includes left-stick and D-pad."
},
{
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
}
],
"reason": "YARG's CRKD Mode 1 Red fret binding is translated through Chromium's XInput button order. Factory button aliases are inferred, not a browser dump."
}
},
"Yellow fret": {
"kind": "button",
"index": 3,
"pressedThreshold": 0,
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://github.com/YARC-Official/YARG/blob/f5bfbe6996b4af35722d4204a93c6cc5c8f52a45/Assets/Script/Input/Bindings/Defaults/BindingCollection.Gamepad.cs",
"says": "CrkdGuitar_Mode1 maps Green/Red/Yellow/Blue/Orange to A/B/Y/X/LB, strum up/down to D-pad, star power to Select, whammy to leftTrigger; no dedicated tilt axis. Menu includes left-stick and D-pad."
},
{
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
}
],
"reason": "YARG's CRKD Mode 1 Yellow fret binding is translated through Chromium's XInput button order. Factory button aliases are inferred, not a browser dump."
}
},
"Blue fret": {
"kind": "button",
"index": 2,
"pressedThreshold": 0,
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://github.com/YARC-Official/YARG/blob/f5bfbe6996b4af35722d4204a93c6cc5c8f52a45/Assets/Script/Input/Bindings/Defaults/BindingCollection.Gamepad.cs",
"says": "CrkdGuitar_Mode1 maps Green/Red/Yellow/Blue/Orange to A/B/Y/X/LB, strum up/down to D-pad, star power to Select, whammy to leftTrigger; no dedicated tilt axis. Menu includes left-stick and D-pad."
},
{
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
}
],
"reason": "YARG's CRKD Mode 1 Blue fret binding is translated through Chromium's XInput button order. Factory button aliases are inferred, not a browser dump."
}
},
"Orange fret": {
"kind": "button",
"index": 4,
"pressedThreshold": 0,
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://github.com/YARC-Official/YARG/blob/f5bfbe6996b4af35722d4204a93c6cc5c8f52a45/Assets/Script/Input/Bindings/Defaults/BindingCollection.Gamepad.cs",
"says": "CrkdGuitar_Mode1 maps Green/Red/Yellow/Blue/Orange to A/B/Y/X/LB, strum up/down to D-pad, star power to Select, whammy to leftTrigger; no dedicated tilt axis. Menu includes left-stick and D-pad."
},
{
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
}
],
"reason": "YARG's CRKD Mode 1 Orange fret binding is translated through Chromium's XInput button order. Factory button aliases are inferred, not a browser dump."
}
},
"Strum up": {
"kind": "button",
"index": 12,
"pressedThreshold": 0,
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://github.com/YARC-Official/YARG/blob/f5bfbe6996b4af35722d4204a93c6cc5c8f52a45/Assets/Script/Input/Bindings/Defaults/BindingCollection.Gamepad.cs",
"says": "CrkdGuitar_Mode1 maps Green/Red/Yellow/Blue/Orange to A/B/Y/X/LB, strum up/down to D-pad, star power to Select, whammy to leftTrigger; no dedicated tilt axis. Menu includes left-stick and D-pad."
},
{
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
}
],
"reason": "YARG's CRKD Mode 1 Strum up binding is translated through Chromium's XInput button order. Factory button aliases are inferred, not a browser dump."
}
},
"Strum down": {
"kind": "button",
"index": 13,
"pressedThreshold": 0,
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://github.com/YARC-Official/YARG/blob/f5bfbe6996b4af35722d4204a93c6cc5c8f52a45/Assets/Script/Input/Bindings/Defaults/BindingCollection.Gamepad.cs",
"says": "CrkdGuitar_Mode1 maps Green/Red/Yellow/Blue/Orange to A/B/Y/X/LB, strum up/down to D-pad, star power to Select, whammy to leftTrigger; no dedicated tilt axis. Menu includes left-stick and D-pad."
},
{
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
}
],
"reason": "YARG's CRKD Mode 1 Strum down binding is translated through Chromium's XInput button order. Factory button aliases are inferred, not a browser dump."
}
},
"CTRL": {
"kind": "button",
"index": 8,
"pressedThreshold": 0,
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://github.com/YARC-Official/YARG/blob/f5bfbe6996b4af35722d4204a93c6cc5c8f52a45/Assets/Script/Input/Bindings/Defaults/BindingCollection.Gamepad.cs",
"says": "CrkdGuitar_Mode1 maps Green/Red/Yellow/Blue/Orange to A/B/Y/X/LB, strum up/down to D-pad, star power to Select, whammy to leftTrigger; no dedicated tilt axis. Menu includes left-stick and D-pad."
},
{
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
}
],
"reason": "YARG's CRKD Mode 1 star-power/select binding is translated through Chromium's XInput button order. Factory button aliases are inferred, not a browser dump."
}
},
"View": {
"kind": "button",
"index": 8,
"pressedThreshold": 0,
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://github.com/YARC-Official/YARG/blob/f5bfbe6996b4af35722d4204a93c6cc5c8f52a45/Assets/Script/Input/Bindings/Defaults/BindingCollection.Gamepad.cs",
"says": "CrkdGuitar_Mode1 maps Green/Red/Yellow/Blue/Orange to A/B/Y/X/LB, strum up/down to D-pad, star power to Select, whammy to leftTrigger; no dedicated tilt axis. Menu includes left-stick and D-pad."
},
{
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
}
],
"reason": "YARG's CRKD Mode 1 View binding is translated through Chromium's XInput button order. Factory button aliases are inferred, not a browser dump."
}
},
"Menu": {
"kind": "button",
"index": 9,
"pressedThreshold": 0,
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://github.com/YARC-Official/YARG/blob/f5bfbe6996b4af35722d4204a93c6cc5c8f52a45/Assets/Script/Input/Bindings/Defaults/BindingCollection.Gamepad.cs",
"says": "CrkdGuitar_Mode1 maps Green/Red/Yellow/Blue/Orange to A/B/Y/X/LB, strum up/down to D-pad, star power to Select, whammy to leftTrigger; no dedicated tilt axis. Menu includes left-stick and D-pad."
},
{
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
}
],
"reason": "YARG's CRKD Mode 1 Menu binding is translated through Chromium's XInput button order. Factory button aliases are inferred, not a browser dump."
}
},
"D-pad up": {
"kind": "button",
"index": 12,
"pressedThreshold": 0,
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://github.com/YARC-Official/YARG/blob/f5bfbe6996b4af35722d4204a93c6cc5c8f52a45/Assets/Script/Input/Bindings/Defaults/BindingCollection.Gamepad.cs",
"says": "CrkdGuitar_Mode1 maps Green/Red/Yellow/Blue/Orange to A/B/Y/X/LB, strum up/down to D-pad, star power to Select, whammy to leftTrigger; no dedicated tilt axis. Menu includes left-stick and D-pad."
},
{
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
}
],
"reason": "YARG's CRKD Mode 1 D-pad up binding is translated through Chromium's XInput button order. Factory button aliases are inferred, not a browser dump."
}
},
"D-pad down": {
"kind": "button",
"index": 13,
"pressedThreshold": 0,
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://github.com/YARC-Official/YARG/blob/f5bfbe6996b4af35722d4204a93c6cc5c8f52a45/Assets/Script/Input/Bindings/Defaults/BindingCollection.Gamepad.cs",
"says": "CrkdGuitar_Mode1 maps Green/Red/Yellow/Blue/Orange to A/B/Y/X/LB, strum up/down to D-pad, star power to Select, whammy to leftTrigger; no dedicated tilt axis. Menu includes left-stick and D-pad."
},
{
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
}
],
"reason": "YARG's CRKD Mode 1 D-pad down binding is translated through Chromium's XInput button order. Factory button aliases are inferred, not a browser dump."
}
},
"D-pad left": {
"kind": "button",
"index": 14,
"pressedThreshold": 0,
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://github.com/YARC-Official/YARG/blob/f5bfbe6996b4af35722d4204a93c6cc5c8f52a45/Assets/Script/Input/Bindings/Defaults/BindingCollection.Gamepad.cs",
"says": "CrkdGuitar_Mode1 maps Green/Red/Yellow/Blue/Orange to A/B/Y/X/LB, strum up/down to D-pad, star power to Select, whammy to leftTrigger; no dedicated tilt axis. Menu includes left-stick and D-pad."
},
{
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
}
],
"reason": "YARG's CRKD Mode 1 D-pad left binding is translated through Chromium's XInput button order. Factory button aliases are inferred, not a browser dump."
}
},
"D-pad right": {
"kind": "button",
"index": 15,
"pressedThreshold": 0,
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://github.com/YARC-Official/YARG/blob/f5bfbe6996b4af35722d4204a93c6cc5c8f52a45/Assets/Script/Input/Bindings/Defaults/BindingCollection.Gamepad.cs",
"says": "CrkdGuitar_Mode1 maps Green/Red/Yellow/Blue/Orange to A/B/Y/X/LB, strum up/down to D-pad, star power to Select, whammy to leftTrigger; no dedicated tilt axis. Menu includes left-stick and D-pad."
},
{
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
}
],
"reason": "YARG's CRKD Mode 1 D-pad right binding is translated through Chromium's XInput button order. Factory button aliases are inferred, not a browser dump."
}
},
"Whammy": {
"kind": "button",
"index": 6,
"steps": 255,
"pressedThreshold": 0.11764705882352941,
"pressedInclusive": true,
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://github.com/YARC-Official/YARG/blob/f5bfbe6996b4af35722d4204a93c6cc5c8f52a45/Assets/Script/Input/Bindings/Defaults/BindingCollection.Gamepad.cs",
"says": "CrkdGuitar_Mode1 maps Green/Red/Yellow/Blue/Orange to A/B/Y/X/LB, strum up/down to D-pad, star power to Select, whammy to leftTrigger; no dedicated tilt axis. Menu includes left-stick and D-pad."
},
{
"url": "https://github.com/sanjay900/xinputemu/pull/1",
"says": "Patch author tested two CRKD guitars; VID/PID match a first-party 360 gamepad (045e:028e in patch); whammy is Z/LT, converted to signed right-stick range for old PC guitar games."
},
{
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
}
],
"reason": "YARG binds CRKD Mode 1 whammy to LT; Chromium exposes LT as analog button 6, divided by 255, pressed inclusively at byte 30. No -1 whammy axis is claimed."
},
"analog": true
},
"Left stick": {
"kind": "stick",
"x": 0,
"y": 1,
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://github.com/YARC-Official/YARG/blob/f5bfbe6996b4af35722d4204a93c6cc5c8f52a45/Assets/Script/Input/Bindings/Defaults/BindingCollection.Gamepad.cs",
"says": "CrkdGuitar_Mode1 maps Green/Red/Yellow/Blue/Orange to A/B/Y/X/LB, strum up/down to D-pad, star power to Select, whammy to leftTrigger; no dedicated tilt axis. Menu includes left-stick and D-pad."
},
{
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
}
],
"reason": "YARG's CRKD Mode 1 menu accepts the left stick; Chromium's XInput axes 0/1 carry LX/inverted LY."
}
},
"Right stick": {
"kind": "stick",
"x": 2,
"y": 3,
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://cdn.shopify.com/s/files/1/0679/3392/9791/files/Guitar_User_Guide_Multi_V1.4_08Jan26.pdf?v=1767902895",
"says": "CK25GM PC connections include USB cable and included USB wireless receiver; PC slider and mode dial select game preset; the hardware includes both sticks, View/Capture/Menu, CTRL, Home and D-pad."
},
{
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
}
],
"reason": "Manufacturer identifies a right analog stick. Standard XInput axes 2/3 carry RX/inverted RY; its factory operation in this earlier Mode 1 preset is inferred."
}
},
"L3": {
"kind": "button",
"index": 10,
"pressedThreshold": 0,
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://cdn.shopify.com/s/files/1/0679/3392/9791/files/Guitar_User_Guide_Multi_V1.4_08Jan26.pdf?v=1767902895",
"says": "CK25GM PC connections include USB cable and included USB wireless receiver; PC slider and mode dial select game preset; the hardware includes both sticks, View/Capture/Menu, CTRL, Home and D-pad."
},
{
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
}
],
"reason": "Manufacturer names this physical control; its normal XInput button correspondence is inferred from the standard browser path, not observed on hardware."
}
},
"R3": {
"kind": "button",
"index": 11,
"pressedThreshold": 0,
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://cdn.shopify.com/s/files/1/0679/3392/9791/files/Guitar_User_Guide_Multi_V1.4_08Jan26.pdf?v=1767902895",
"says": "CK25GM PC connections include USB cable and included USB wireless receiver; PC slider and mode dial select game preset; the hardware includes both sticks, View/Capture/Menu, CTRL, Home and D-pad."
},
{
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
}
],
"reason": "Manufacturer names this physical control; its normal XInput button correspondence is inferred from the standard browser path, not observed on hardware."
}
},
"CRKD Home": {
"kind": "button",
"index": 16,
"pressedThreshold": 0,
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://cdn.shopify.com/s/files/1/0679/3392/9791/files/Guitar_User_Guide_Multi_V1.4_08Jan26.pdf?v=1767902895",
"says": "CK25GM PC connections include USB cable and included USB wireless receiver; PC slider and mode dial select game preset; the hardware includes both sticks, View/Capture/Menu, CTRL, Home and D-pad."
},
{
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
}
],
"reason": "Manufacturer names this physical control; its normal XInput button correspondence is inferred from the standard browser path, not observed on hardware."
}
},
"Capture": {
"kind": "unreported",
"reason": "No independent XInput capture bit exists; the factory action or alias was not found.",
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://cdn.shopify.com/s/files/1/0679/3392/9791/files/Guitar_User_Guide_Multi_V1.4_08Jan26.pdf?v=1767902895",
"says": "CK25GM PC connections include USB cable and included USB wireless receiver; PC slider and mode dial select game preset; the hardware includes both sticks, View/Capture/Menu, CTRL, Home and D-pad."
},
{
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
}
],
"reason": "The manual confirms Capture exists physically but its PC report is not established; no browser index is invented."
}
}
},
"switches": {
"Mode dial": {
"kind": "unreported",
"reason": "Only the earlier PC Mode 1 mapping is sourced. Factory presets, firmware 3 and legacy Mode 8 change reports and need separately researched profiles.",
"evidence": {
"value": null,
"status": "confirmed",
"sources": [
{
"url": "https://cdn.shopify.com/s/files/1/0679/3392/9791/files/Guitar_User_Guide_Multi_V1.4_08Jan26.pdf?v=1767902895",
"says": "CK25GM PC connections include USB cable and included USB wireless receiver; PC slider and mode dial select game preset; the hardware includes both sticks, View/Capture/Menu, CTRL, Home and D-pad."
},
{
"url": "https://github.com/YARC-Official/YARG/blob/f5bfbe6996b4af35722d4204a93c6cc5c8f52a45/Assets/Script/Input/Bindings/Defaults/BindingCollection.Gamepad.cs",
"says": "CrkdGuitar_Mode1 maps Green/Red/Yellow/Blue/Orange to A/B/Y/X/LB, strum up/down to D-pad, star power to Select, whammy to leftTrigger; no dedicated tilt axis. Menu includes left-stick and D-pad."
}
]
}
},
"Platform slider": {
"kind": "unreported",
"reason": "PC Mode 1 only. NS, AN and P3 identifiers and raw reports have not been established.",
"evidence": {
"value": null,
"status": "confirmed",
"sources": [
{
"url": "https://cdn.shopify.com/s/files/1/0679/3392/9791/files/Guitar_User_Guide_Multi_V1.4_08Jan26.pdf?v=1767902895",
"says": "CK25GM PC connections include USB cable and included USB wireless receiver; PC slider and mode dial select game preset; the hardware includes both sticks, View/Capture/Menu, CTRL, Home and D-pad."
}
]
}
}
},
"notes": [
"Local source-derived emulation, not a hardware/browser capture. This profile models the earlier CRKD Mode 1 preset identified by YARG as CrkdGuitar_Mode1, before firmware 3 changed star power to D-pad left.",
"Use the Multi-platform Gibson Les Paul CK25GM; Nitro Deck is a separate Switch controller product.",
"Stock Windows XInputGetStateEx availability is assumed for button 16. Without it Chromium exposes 16 buttons; that separate variant is omitted.",
"All unpressed buttons, including whammy and unused RT, are modeled at zero; those physical resting bytes are inferred. The tested conversion patch supports whammy LT with rest zero, but no raw browser rest dump was located.",
"Whammy is analog button 6 (0..1), not an axis. D-pad and strum share standard button indices; there is no numeric hat axis or independent tilt axis in this preset.",
"Browser indices: 0 A/Green; 1 B/Red; 2 X/Blue; 3 Y/Yellow; 4 LB/Orange; 5 RB (physical alias unconfirmed); 6 LT/Whammy; 7 RT (physical alias unconfirmed); 8 View/Select/CTRL inferred; 9 Menu/Start; 10 L3; 11 R3; 12 D-pad/Strum up; 13 D-pad/Strum down; 14 D-pad left; 15 D-pad right; 16 Home/Guide. Axes 0/1 Left stick; 2/3 Right stick, nominal rest inferred.",
"The dual-rumble object is Chromium's unconditional XInput API exposure. It does not confirm browser-controlled physical vibration or equate to the guitar's local strum haptics.",
"Chromium XInput id hides VID/PID. Hardware-tested old Mode 1 uses 045e:028e; newer native CRKD IDs from Linux/SDL are not transplanted into this earlier profile.",
"Factory names whose report is unknown are omitted or explicitly unreported. Capture, firmware/current platform modes and remapped presets need real captures."
],
"environment": {
"browser": "Chrome / Edge (Chromium XInput path)",
"os": "Windows 10 / 11",
"connection": "USB",
"mode": "PC slider; Mode 1; earlier firmware preset"
}
}
@@ -0,0 +1,566 @@
{
"name": "elite2-chromium-win-adapter-wgi-conditional",
"label": "Xbox Elite Series 2 — Xbox Wireless Adapter / Chromium WGI (conditional paddles)",
"environment": {
"browser": "Chrome / Edge (Chromium WGI backend)",
"os": "Windows 10 RS1+ / Windows 11",
"connection": "Xbox Wireless Adapter",
"mode": "Default hardware profile; four WGI paddle labels assumed",
"backend": "Windows.Gaming.Input",
"status": "inferred",
"sources": [
{
"url": "https://www.xbox.com/en-US/accessories/controllers/elite-wireless-controller-series-2",
"says": "Windows 10/11 supports USB, Bluetooth and Xbox Wireless Adapter."
}
],
"reason": "Browser source is confirmed; controller-specific WGI paddle labels were not captured."
},
"hardwareIds": {
"value": {
"vendor": null,
"product": null
},
"status": "inferred",
"sources": [
{
"url": "https://www.xbox.com/en-US/accessories/controllers/elite-wireless-controller-series-2",
"says": "Xbox Wireless Adapter is a supported connection."
}
],
"reason": "No RawGameController VID/PID dump for adapter was found; adapter USB PID is not asserted to be the gamepad PID."
},
"gamepad": {
"timestampPolicy": {
"value": "poll",
"status": "confirmed",
"sources": [
{
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
"says": "GetGamepadData updates timestamp on each successful WGI reading."
}
]
},
"id": {
"value": "Xbox 360 Controller (XInput STANDARD GAMEPAD)",
"status": "inferred",
"sources": [
{
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
"says": "Known XInput devices retain this literal ID."
},
{
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/gamepad_id_list.cc",
"says": "045e:0b00 has XInput type XboxOne."
}
],
"reason": "Assumes the adapter exposes the physical Elite via a known-XInput product ID, as expected for GIP. No adapter browser dump was found."
},
"mapping": {
"value": "standard",
"status": "confirmed",
"sources": [
{
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
"says": "WGI fetcher always sets standard mapping."
}
]
},
"buttons": {
"value": 21,
"status": "inferred",
"sources": [
{
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
"says": "Length is standard count (17) plus number of non-None paddle labels."
},
{
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/gamepad_standard_mappings.h",
"says": "Standard button count is 17."
},
{
"url": "https://www.xbox.com/en-US/accessories/controllers/elite-wireless-controller-series-2",
"says": "Controller has four paddles."
}
],
"reason": "Conditional 17+4 branch. Four physical paddles do not prove four WGI labels; this is not an exact Elite hardware clone."
},
"axesRest": {
"value": [
0,
0,
0,
0
],
"status": "inferred",
"sources": [
{
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
"says": "Four axes contain the two sticks."
},
{
"url": "https://learn.microsoft.com/en-us/windows/uwp/gaming/gamepad-and-vibration",
"says": "Centered sticks approximately report zero and can drift."
}
],
"reason": "Ideal zero-center fixture; an actual resting sample varies and was not captured. Triggers are buttons 6/7, not extra axes."
},
"vibration": {
"value": {
"type": "dual-rumble",
"effects": [
"trigger-rumble",
"dual-rumble"
]
},
"status": "inferred",
"sources": [
{
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
"says": "WGI actuator is non-null, with internal trigger rumble for supported IDs."
},
{
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/gamepad_id_list.cc",
"says": "Elite Series 2 USB/Bluetooth IDs are in trigger-rumble support set."
},
{
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/third_party/blink/renderer/modules/gamepad/gamepad_haptic_actuator.cc",
"says": "Trigger rumble publishes type dual-rumble and both supported effects."
}
],
"reason": "Conditional on the adapter exposing a recognized Elite controller ID in Chromium's trigger-rumble set. The adapter's own USB PID is not used as a controller PID; an exact adapter browser dump was not found.",
"presence": "object"
}
},
"controls": {
"A": {
"kind": "button",
"index": 0,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
"says": "Browser backend sends A to standard button 0."
}
]
}
},
"B": {
"kind": "button",
"index": 1,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
"says": "Browser backend sends B to standard button 1."
}
]
}
},
"X": {
"kind": "button",
"index": 2,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
"says": "Browser backend sends X to standard button 2."
}
]
}
},
"Y": {
"kind": "button",
"index": 3,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
"says": "Browser backend sends Y to standard button 3."
}
]
}
},
"Left bumper": {
"kind": "button",
"index": 4,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
"says": "Browser backend sends Left bumper to standard button 4."
}
]
}
},
"Right bumper": {
"kind": "button",
"index": 5,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
"says": "Browser backend sends Right bumper to standard button 5."
}
]
}
},
"Left trigger": {
"kind": "button",
"index": 6,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
"says": "Trigger 6 copies WGI 0..1 reading; pressed above 30/255."
},
{
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/public/cpp/gamepad.h",
"says": "Chromium default pressed threshold is 30/255."
}
]
},
"analog": true,
"pressedThreshold": 0.11764705882352941,
"pressedInclusive": false
},
"Right trigger": {
"kind": "button",
"index": 7,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
"says": "Trigger 7 copies WGI 0..1 reading; pressed above 30/255."
},
{
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/public/cpp/gamepad.h",
"says": "Chromium default pressed threshold is 30/255."
}
]
},
"analog": true,
"pressedThreshold": 0.11764705882352941,
"pressedInclusive": false
},
"View": {
"kind": "button",
"index": 8,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
"says": "Browser backend sends View to standard button 8."
}
]
}
},
"Menu": {
"kind": "button",
"index": 9,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
"says": "Browser backend sends Menu to standard button 9."
}
]
}
},
"Left stick click": {
"kind": "button",
"index": 10,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
"says": "Browser backend sends Left stick click to standard button 10."
}
]
}
},
"Right stick click": {
"kind": "button",
"index": 11,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
"says": "Browser backend sends Right stick click to standard button 11."
}
]
}
},
"D-pad up": {
"kind": "button",
"index": 12,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
"says": "Browser backend sends D-pad up to standard button 12."
}
]
}
},
"D-pad down": {
"kind": "button",
"index": 13,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
"says": "Browser backend sends D-pad down to standard button 13."
}
]
}
},
"D-pad left": {
"kind": "button",
"index": 14,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
"says": "Browser backend sends D-pad left to standard button 14."
}
]
}
},
"D-pad right": {
"kind": "button",
"index": 15,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
"says": "Browser backend sends D-pad right to standard button 15."
}
]
}
},
"Xbox": {
"kind": "button",
"index": 16,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
"says": "Browser backend sends Xbox to standard button 16."
}
]
}
},
"Left stick": {
"kind": "stick",
"x": 0,
"y": 1,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
"says": "Axes 0/1 are left X/Y; Y is inverted for down-positive."
}
]
}
},
"Right stick": {
"kind": "stick",
"x": 2,
"y": 3,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
"says": "Axes 2/3 are right X/Y; Y is inverted for down-positive."
}
]
}
},
"P1": {
"kind": "button",
"index": 17,
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
"says": "Paddle1 is read at button 17 when exposed by WGI."
},
{
"url": "https://www.xbox.com/en-US/accessories/controllers/elite-wireless-controller-series-2",
"says": "Controller has four removable paddles."
}
],
"reason": "Conditional: physical Elite Series 2 paddle labels/readings from IGamepad2 were not found; this exercises the browser branch, not a verified hardware report."
}
},
"P2": {
"kind": "button",
"index": 18,
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
"says": "Paddle2 is read at button 18 when exposed by WGI."
},
{
"url": "https://www.xbox.com/en-US/accessories/controllers/elite-wireless-controller-series-2",
"says": "Controller has four removable paddles."
}
],
"reason": "Conditional: physical Elite Series 2 paddle labels/readings from IGamepad2 were not found; this exercises the browser branch, not a verified hardware report."
}
},
"P3": {
"kind": "button",
"index": 19,
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
"says": "Paddle3 is read at button 19 when exposed by WGI."
},
{
"url": "https://www.xbox.com/en-US/accessories/controllers/elite-wireless-controller-series-2",
"says": "Controller has four removable paddles."
}
],
"reason": "Conditional: physical Elite Series 2 paddle labels/readings from IGamepad2 were not found; this exercises the browser branch, not a verified hardware report."
}
},
"P4": {
"kind": "button",
"index": 20,
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
"says": "Paddle4 is read at button 20 when exposed by WGI."
},
{
"url": "https://www.xbox.com/en-US/accessories/controllers/elite-wireless-controller-series-2",
"says": "Controller has four removable paddles."
}
],
"reason": "Conditional: physical Elite Series 2 paddle labels/readings from IGamepad2 were not found; this exercises the browser branch, not a verified hardware report."
}
},
"Profile button": {
"kind": "unreported",
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
"says": "Backend maps standard controls, not an Elite profile selector."
},
{
"url": "https://news.xbox.com/en-us/2019/06/09/meet-the-xbox-elite-wireless-controller-series-2-over-30-new-ways-to-play-like-a-pro/",
"says": "Dedicated button switches default and three saved custom profiles."
}
]
},
"reason": "Stored profiles are programmable and their contents are unknown. The selector has no distinct button index here."
}
},
"switches": {
"Left trigger lock": {
"kind": "state",
"values": [
"full",
"medium",
"short"
],
"effect": "none",
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://news.xbox.com/en-us/2019/11/07/xbox-elite-wireless-controller-series-2-available-now/",
"says": "Elite Series 2 has three levels of trigger depth."
},
{
"url": "https://news.xbox.com/en-us/2019/06/09/meet-the-xbox-elite-wireless-controller-series-2-over-30-new-ways-to-play-like-a-pro/",
"says": "Controller automatically recognizes trigger-lock adjustments."
},
{
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
"says": "Browser exposes normalized trigger values, with no trigger-lock state field."
}
],
"reason": "This switch records physical travel only. set() supplies the browser's normalized value, so no guessed travel-to-value curve is applied."
}
},
"Right trigger lock": {
"kind": "state",
"values": [
"full",
"medium",
"short"
],
"effect": "none",
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://news.xbox.com/en-us/2019/11/07/xbox-elite-wireless-controller-series-2-available-now/",
"says": "Elite Series 2 has three levels of trigger depth."
},
{
"url": "https://news.xbox.com/en-us/2019/06/09/meet-the-xbox-elite-wireless-controller-series-2-over-30-new-ways-to-play-like-a-pro/",
"says": "Controller automatically recognizes trigger-lock adjustments."
},
{
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
"says": "Browser exposes normalized trigger values, with no trigger-lock state field."
}
],
"reason": "This switch records physical travel only. set() supplies the browser's normalized value, so no guessed travel-to-value curve is applied."
}
},
"Profile button": {
"kind": "unreported",
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://news.xbox.com/en-us/2019/06/09/meet-the-xbox-elite-wireless-controller-series-2-over-30-new-ways-to-play-like-a-pro/",
"says": "Dedicated button selects default or one of three programmable profiles."
}
]
},
"reason": "The stored remapping slots and firmware are not known. No invented paddle aliases or profile-button signal is emitted."
}
},
"notes": [
"Source-derived conditional browser fixture; no real-device Gamepad API dump was found. Never treat passing tests as hardware confirmation.",
"Buttons 0–16 and axes 0–3 follow the standard layout; P1–P4 conditionally occupy 17–20. No hat axis: D-pad is buttons 12–15.",
"All released button values are 0, pressed/touched false. Trigger value 0 is fully released and 1 fully pulled.",
"The Xbox/Guide button is redirected to the lowest-index WGI gamepad using XInput; tests connect this profile first.",
"Hardware trigger stops change travel; exact analog transfer curves and stored profile contents remain unknown.",
"Chromium source default enables WGI; Windows GameInput fetcher is feature-gated and disabled by default in the inspected source. Edge-specific overrides were not found."
]
}
@@ -0,0 +1,563 @@
{
"name": "elite2-chromium-win-usb-wgi-conditional",
"label": "Xbox Elite Series 2 — USB / Chromium WGI (conditional paddles)",
"environment": {
"browser": "Chrome / Edge (Chromium WGI backend)",
"os": "Windows 10 RS1+ / Windows 11",
"connection": "USB",
"mode": "Default hardware profile; four WGI paddle labels assumed",
"backend": "Windows.Gaming.Input",
"status": "inferred",
"sources": [
{
"url": "https://www.xbox.com/en-US/accessories/controllers/elite-wireless-controller-series-2",
"says": "Windows 10/11 supports USB, Bluetooth and Xbox Wireless Adapter."
}
],
"reason": "Browser source is confirmed; controller-specific WGI paddle labels were not captured."
},
"hardwareIds": {
"value": {
"vendor": "045e",
"product": "0b00"
},
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/gamepad_id_list.cc",
"says": "045e:0b00 is classified as Xbox One XInput; trigger-rumble list identifies Elite Series 2 USB."
}
]
},
"gamepad": {
"timestampPolicy": {
"value": "poll",
"status": "confirmed",
"sources": [
{
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
"says": "GetGamepadData updates timestamp on each successful WGI reading."
}
]
},
"id": {
"value": "Xbox 360 Controller (XInput STANDARD GAMEPAD)",
"status": "confirmed",
"sources": [
{
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
"says": "Known XInput devices retain this literal ID."
},
{
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/gamepad_id_list.cc",
"says": "045e:0b00 has XInput type XboxOne."
}
]
},
"mapping": {
"value": "standard",
"status": "confirmed",
"sources": [
{
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
"says": "WGI fetcher always sets standard mapping."
}
]
},
"buttons": {
"value": 21,
"status": "inferred",
"sources": [
{
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
"says": "Length is standard count (17) plus number of non-None paddle labels."
},
{
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/gamepad_standard_mappings.h",
"says": "Standard button count is 17."
},
{
"url": "https://www.xbox.com/en-US/accessories/controllers/elite-wireless-controller-series-2",
"says": "Controller has four paddles."
}
],
"reason": "Conditional 17+4 branch. Four physical paddles do not prove four WGI labels; this is not an exact Elite hardware clone."
},
"axesRest": {
"value": [
0,
0,
0,
0
],
"status": "inferred",
"sources": [
{
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
"says": "Four axes contain the two sticks."
},
{
"url": "https://learn.microsoft.com/en-us/windows/uwp/gaming/gamepad-and-vibration",
"says": "Centered sticks approximately report zero and can drift."
}
],
"reason": "Ideal zero-center fixture; an actual resting sample varies and was not captured. Triggers are buttons 6/7, not extra axes."
},
"vibration": {
"value": {
"type": "dual-rumble",
"effects": [
"trigger-rumble",
"dual-rumble"
]
},
"status": "confirmed",
"sources": [
{
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
"says": "WGI actuator is non-null, with internal trigger rumble for supported IDs."
},
{
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/gamepad_id_list.cc",
"says": "Elite Series 2 USB/Bluetooth IDs are in trigger-rumble support set."
},
{
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/third_party/blink/renderer/modules/gamepad/gamepad_haptic_actuator.cc",
"says": "Trigger rumble publishes type dual-rumble and both supported effects."
}
],
"presence": "object"
}
},
"controls": {
"A": {
"kind": "button",
"index": 0,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
"says": "Browser backend sends A to standard button 0."
}
]
}
},
"B": {
"kind": "button",
"index": 1,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
"says": "Browser backend sends B to standard button 1."
}
]
}
},
"X": {
"kind": "button",
"index": 2,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
"says": "Browser backend sends X to standard button 2."
}
]
}
},
"Y": {
"kind": "button",
"index": 3,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
"says": "Browser backend sends Y to standard button 3."
}
]
}
},
"Left bumper": {
"kind": "button",
"index": 4,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
"says": "Browser backend sends Left bumper to standard button 4."
}
]
}
},
"Right bumper": {
"kind": "button",
"index": 5,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
"says": "Browser backend sends Right bumper to standard button 5."
}
]
}
},
"Left trigger": {
"kind": "button",
"index": 6,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
"says": "Trigger 6 copies WGI 0..1 reading; pressed above 30/255."
},
{
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/public/cpp/gamepad.h",
"says": "Chromium default pressed threshold is 30/255."
}
]
},
"analog": true,
"pressedThreshold": 0.11764705882352941,
"pressedInclusive": false
},
"Right trigger": {
"kind": "button",
"index": 7,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
"says": "Trigger 7 copies WGI 0..1 reading; pressed above 30/255."
},
{
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/public/cpp/gamepad.h",
"says": "Chromium default pressed threshold is 30/255."
}
]
},
"analog": true,
"pressedThreshold": 0.11764705882352941,
"pressedInclusive": false
},
"View": {
"kind": "button",
"index": 8,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
"says": "Browser backend sends View to standard button 8."
}
]
}
},
"Menu": {
"kind": "button",
"index": 9,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
"says": "Browser backend sends Menu to standard button 9."
}
]
}
},
"Left stick click": {
"kind": "button",
"index": 10,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
"says": "Browser backend sends Left stick click to standard button 10."
}
]
}
},
"Right stick click": {
"kind": "button",
"index": 11,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
"says": "Browser backend sends Right stick click to standard button 11."
}
]
}
},
"D-pad up": {
"kind": "button",
"index": 12,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
"says": "Browser backend sends D-pad up to standard button 12."
}
]
}
},
"D-pad down": {
"kind": "button",
"index": 13,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
"says": "Browser backend sends D-pad down to standard button 13."
}
]
}
},
"D-pad left": {
"kind": "button",
"index": 14,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
"says": "Browser backend sends D-pad left to standard button 14."
}
]
}
},
"D-pad right": {
"kind": "button",
"index": 15,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
"says": "Browser backend sends D-pad right to standard button 15."
}
]
}
},
"Xbox": {
"kind": "button",
"index": 16,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
"says": "Browser backend sends Xbox to standard button 16."
}
]
}
},
"Left stick": {
"kind": "stick",
"x": 0,
"y": 1,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
"says": "Axes 0/1 are left X/Y; Y is inverted for down-positive."
}
]
}
},
"Right stick": {
"kind": "stick",
"x": 2,
"y": 3,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
"says": "Axes 2/3 are right X/Y; Y is inverted for down-positive."
}
]
}
},
"P1": {
"kind": "button",
"index": 17,
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
"says": "Paddle1 is read at button 17 when exposed by WGI."
},
{
"url": "https://www.xbox.com/en-US/accessories/controllers/elite-wireless-controller-series-2",
"says": "Controller has four removable paddles."
}
],
"reason": "Conditional: physical Elite Series 2 paddle labels/readings from IGamepad2 were not found; this exercises the browser branch, not a verified hardware report."
}
},
"P2": {
"kind": "button",
"index": 18,
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
"says": "Paddle2 is read at button 18 when exposed by WGI."
},
{
"url": "https://www.xbox.com/en-US/accessories/controllers/elite-wireless-controller-series-2",
"says": "Controller has four removable paddles."
}
],
"reason": "Conditional: physical Elite Series 2 paddle labels/readings from IGamepad2 were not found; this exercises the browser branch, not a verified hardware report."
}
},
"P3": {
"kind": "button",
"index": 19,
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
"says": "Paddle3 is read at button 19 when exposed by WGI."
},
{
"url": "https://www.xbox.com/en-US/accessories/controllers/elite-wireless-controller-series-2",
"says": "Controller has four removable paddles."
}
],
"reason": "Conditional: physical Elite Series 2 paddle labels/readings from IGamepad2 were not found; this exercises the browser branch, not a verified hardware report."
}
},
"P4": {
"kind": "button",
"index": 20,
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
"says": "Paddle4 is read at button 20 when exposed by WGI."
},
{
"url": "https://www.xbox.com/en-US/accessories/controllers/elite-wireless-controller-series-2",
"says": "Controller has four removable paddles."
}
],
"reason": "Conditional: physical Elite Series 2 paddle labels/readings from IGamepad2 were not found; this exercises the browser branch, not a verified hardware report."
}
},
"Profile button": {
"kind": "unreported",
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
"says": "Backend maps standard controls, not an Elite profile selector."
},
{
"url": "https://news.xbox.com/en-us/2019/06/09/meet-the-xbox-elite-wireless-controller-series-2-over-30-new-ways-to-play-like-a-pro/",
"says": "Dedicated button switches default and three saved custom profiles."
}
]
},
"reason": "Stored profiles are programmable and their contents are unknown. The selector has no distinct button index here."
}
},
"switches": {
"Left trigger lock": {
"kind": "state",
"values": [
"full",
"medium",
"short"
],
"effect": "none",
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://news.xbox.com/en-us/2019/11/07/xbox-elite-wireless-controller-series-2-available-now/",
"says": "Elite Series 2 has three levels of trigger depth."
},
{
"url": "https://news.xbox.com/en-us/2019/06/09/meet-the-xbox-elite-wireless-controller-series-2-over-30-new-ways-to-play-like-a-pro/",
"says": "Controller automatically recognizes trigger-lock adjustments."
},
{
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
"says": "Browser exposes normalized trigger values, with no trigger-lock state field."
}
],
"reason": "This switch records physical travel only. set() supplies the browser's normalized value, so no guessed travel-to-value curve is applied."
}
},
"Right trigger lock": {
"kind": "state",
"values": [
"full",
"medium",
"short"
],
"effect": "none",
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://news.xbox.com/en-us/2019/11/07/xbox-elite-wireless-controller-series-2-available-now/",
"says": "Elite Series 2 has three levels of trigger depth."
},
{
"url": "https://news.xbox.com/en-us/2019/06/09/meet-the-xbox-elite-wireless-controller-series-2-over-30-new-ways-to-play-like-a-pro/",
"says": "Controller automatically recognizes trigger-lock adjustments."
},
{
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
"says": "Browser exposes normalized trigger values, with no trigger-lock state field."
}
],
"reason": "This switch records physical travel only. set() supplies the browser's normalized value, so no guessed travel-to-value curve is applied."
}
},
"Profile button": {
"kind": "unreported",
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://news.xbox.com/en-us/2019/06/09/meet-the-xbox-elite-wireless-controller-series-2-over-30-new-ways-to-play-like-a-pro/",
"says": "Dedicated button selects default or one of three programmable profiles."
}
]
},
"reason": "The stored remapping slots and firmware are not known. No invented paddle aliases or profile-button signal is emitted."
}
},
"notes": [
"Source-derived conditional browser fixture; no real-device Gamepad API dump was found. Never treat passing tests as hardware confirmation.",
"Buttons 0–16 and axes 0–3 follow the standard layout; P1–P4 conditionally occupy 17–20. No hat axis: D-pad is buttons 12–15.",
"All released button values are 0, pressed/touched false. Trigger value 0 is fully released and 1 fully pulled.",
"The Xbox/Guide button is redirected to the lowest-index WGI gamepad using XInput; tests connect this profile first.",
"Hardware trigger stops change travel; exact analog transfer curves and stored profile contents remain unknown.",
"Chromium source default enables WGI; Windows GameInput fetcher is feature-gated and disabled by default in the inspected source. Edge-specific overrides were not found."
]
}
@@ -0,0 +1,530 @@
{
"name": "elite2-firefox-win-adapter-xinput",
"label": "Xbox Elite Series 2 — Xbox Wireless Adapter / Firefox XInput",
"environment": {
"browser": "Firefox",
"os": "Windows 10 / Windows 11",
"connection": "Xbox Wireless Adapter",
"mode": "XInput with unknown onboard paddle assignments",
"backend": "XInput",
"status": "inferred",
"sources": [
{
"url": "https://www.xbox.com/en-US/accessories/controllers/elite-wireless-controller-series-2",
"says": "All three connections are supported on Windows 10/11."
},
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Windows XInput backend handles any successful XInputGetState device."
}
],
"reason": "Conditional on Windows routing this connection through XInput. No per-connection physical browser dump was found."
},
"gamepad": {
"timestampPolicy": {
"value": "change",
"status": "inferred",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "The XInput poll emits events only for changed buttons/axes."
},
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/Gamepad.cpp",
"says": "Construction, input events and SyncState update the DOM timestamp using performance.now."
},
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/base/nsGlobalWindowInner.cpp",
"says": "GetGamepads returns existing objects without calling SyncState."
},
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/base/nsGlobalWindowOuter.cpp",
"says": "SetIsBackground invokes SyncGamepadState when returning to foreground and also some unchanged background-state calls."
}
],
"reason": "Change policy models ordinary foreground polling; construction starts at performance.now. Browser background/foreground synchronization and timer precision reduction are outside this synthetic fixture."
},
"id": {
"value": "xinput",
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "ScanForXInputDevices passes literal xinput to AddGamepad, with no VID/PID."
}
]
},
"mapping": {
"value": "standard",
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "XInput AddGamepad specifies Standard mapping."
}
]
},
"buttons": {
"value": 17,
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "XInput adds kStandardGamepadButtons and only standard masks, including Guide."
}
]
},
"axesRest": {
"value": [
0,
0,
0,
0
],
"status": "inferred",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "XInput exposes four normalized stick axes."
},
{
"url": "https://learn.microsoft.com/en-us/windows/uwp/gaming/gamepad-and-vibration",
"says": "Centered sticks approximately report zero and can drift."
}
],
"reason": "Ideal zero center, not a captured resting sample. Trigger release is button value 0 and no trigger axes are returned."
},
"vibration": {
"value": null,
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Windows backend supplies zero haptics and leaves a haptics TODO."
},
{
"url": "https://searchfox.org/firefox-main/source/dom/webidl/Gamepad.webidl",
"says": "Gamepad has hapticActuators but no vibrationActuator attribute."
}
],
"presence": "absent"
}
},
"controls": {
"A": {
"kind": "button",
"index": 0,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Browser backend sends A to standard button 0."
}
]
}
},
"B": {
"kind": "button",
"index": 1,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Browser backend sends B to standard button 1."
}
]
}
},
"X": {
"kind": "button",
"index": 2,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Browser backend sends X to standard button 2."
}
]
}
},
"Y": {
"kind": "button",
"index": 3,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Browser backend sends Y to standard button 3."
}
]
}
},
"Left bumper": {
"kind": "button",
"index": 4,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Browser backend sends Left bumper to standard button 4."
}
]
}
},
"Right bumper": {
"kind": "button",
"index": 5,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Browser backend sends Right bumper to standard button 5."
}
]
}
},
"Left trigger": {
"kind": "button",
"index": 6,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Trigger 6 copies XInput byte /255; pressed at byte >=30."
},
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/GamepadPlatformService.cpp",
"says": "The pressed/value overload copies pressed into touched, including XInput triggers below the threshold."
}
]
},
"analog": true,
"pressedThreshold": 0.11764705882352941,
"pressedInclusive": true,
"steps": 255,
"touchedPolicy": "pressed"
},
"Right trigger": {
"kind": "button",
"index": 7,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Trigger 7 copies XInput byte /255; pressed at byte >=30."
},
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/GamepadPlatformService.cpp",
"says": "The pressed/value overload copies pressed into touched, including XInput triggers below the threshold."
}
]
},
"analog": true,
"pressedThreshold": 0.11764705882352941,
"pressedInclusive": true,
"steps": 255,
"touchedPolicy": "pressed"
},
"View": {
"kind": "button",
"index": 8,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Browser backend sends View to standard button 8."
}
]
}
},
"Menu": {
"kind": "button",
"index": 9,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Browser backend sends Menu to standard button 9."
}
]
}
},
"Left stick click": {
"kind": "button",
"index": 10,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Browser backend sends Left stick click to standard button 10."
}
]
}
},
"Right stick click": {
"kind": "button",
"index": 11,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Browser backend sends Right stick click to standard button 11."
}
]
}
},
"D-pad up": {
"kind": "button",
"index": 12,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Browser backend sends D-pad up to standard button 12."
}
]
}
},
"D-pad down": {
"kind": "button",
"index": 13,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Browser backend sends D-pad down to standard button 13."
}
]
}
},
"D-pad left": {
"kind": "button",
"index": 14,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Browser backend sends D-pad left to standard button 14."
}
]
}
},
"D-pad right": {
"kind": "button",
"index": 15,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Browser backend sends D-pad right to standard button 15."
}
]
}
},
"Xbox": {
"kind": "button",
"index": 16,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Browser backend sends Xbox to standard button 16."
}
]
}
},
"Left stick": {
"kind": "stick",
"x": 0,
"y": 1,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Axes 0/1 are left X/Y; Y is inverted for down-positive."
}
]
}
},
"Right stick": {
"kind": "stick",
"x": 2,
"y": 3,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Axes 2/3 are right X/Y; Y is inverted for down-positive."
}
]
}
},
"P1": {
"kind": "unreported",
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "XInput backend only maps the standard 17 buttons; it has no paddle mask."
}
]
},
"reason": "No independent paddle index in this backend. An onboard assignment may repeat another control; this fixture does not guess those assignments."
},
"P2": {
"kind": "unreported",
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "XInput backend only maps the standard 17 buttons; it has no paddle mask."
}
]
},
"reason": "No independent paddle index in this backend. An onboard assignment may repeat another control; this fixture does not guess those assignments."
},
"P3": {
"kind": "unreported",
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "XInput backend only maps the standard 17 buttons; it has no paddle mask."
}
]
},
"reason": "No independent paddle index in this backend. An onboard assignment may repeat another control; this fixture does not guess those assignments."
},
"P4": {
"kind": "unreported",
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "XInput backend only maps the standard 17 buttons; it has no paddle mask."
}
]
},
"reason": "No independent paddle index in this backend. An onboard assignment may repeat another control; this fixture does not guess those assignments."
},
"Profile button": {
"kind": "unreported",
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Backend maps standard controls, not an Elite profile selector."
},
{
"url": "https://news.xbox.com/en-us/2019/06/09/meet-the-xbox-elite-wireless-controller-series-2-over-30-new-ways-to-play-like-a-pro/",
"says": "Dedicated button switches default and three saved custom profiles."
}
]
},
"reason": "Stored profiles are programmable and their contents are unknown. The selector has no distinct button index here."
}
},
"switches": {
"Left trigger lock": {
"kind": "state",
"values": [
"full",
"medium",
"short"
],
"effect": "none",
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://news.xbox.com/en-us/2019/11/07/xbox-elite-wireless-controller-series-2-available-now/",
"says": "Elite Series 2 has three levels of trigger depth."
},
{
"url": "https://news.xbox.com/en-us/2019/06/09/meet-the-xbox-elite-wireless-controller-series-2-over-30-new-ways-to-play-like-a-pro/",
"says": "Controller automatically recognizes trigger-lock adjustments."
},
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Browser exposes normalized trigger values, with no trigger-lock state field."
}
],
"reason": "This switch records physical travel only. set() supplies the browser's normalized value, so no guessed travel-to-value curve is applied."
}
},
"Right trigger lock": {
"kind": "state",
"values": [
"full",
"medium",
"short"
],
"effect": "none",
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://news.xbox.com/en-us/2019/11/07/xbox-elite-wireless-controller-series-2-available-now/",
"says": "Elite Series 2 has three levels of trigger depth."
},
{
"url": "https://news.xbox.com/en-us/2019/06/09/meet-the-xbox-elite-wireless-controller-series-2-over-30-new-ways-to-play-like-a-pro/",
"says": "Controller automatically recognizes trigger-lock adjustments."
},
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Browser exposes normalized trigger values, with no trigger-lock state field."
}
],
"reason": "This switch records physical travel only. set() supplies the browser's normalized value, so no guessed travel-to-value curve is applied."
}
},
"Profile button": {
"kind": "unreported",
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://news.xbox.com/en-us/2019/06/09/meet-the-xbox-elite-wireless-controller-series-2-over-30-new-ways-to-play-like-a-pro/",
"says": "Dedicated button selects default or one of three programmable profiles."
}
]
},
"reason": "The stored remapping slots and firmware are not known. No invented paddle aliases or profile-button signal is emitted."
}
},
"notes": [
"Exact browser backend layout, with connection routing inferred; no real-device Gamepad API dump was found.",
"Buttons 0–16 and axes 0–3 follow the standard layout. No hat axis; D-pad is buttons 12–15.",
"All released button values are 0, pressed/touched false. Trigger values are quantized byte/255.",
"P1–P4 cannot be bound as independent browser inputs here. Onboard remappings may repeat existing controls, but their stored assignments are deliberately unknown.",
"Profile button and trigger-lock switches have no independent Gamepad fields. Physical travel is recorded, not transformed with invented ratios.",
"vibrationActuator is absent; null in profile data means omit the property. Firefox hapticActuators is not implemented by this Windows backend.",
"Firefox trigger touched mirrors pressed: byte values 1 through 29 have value greater than zero, pressed false and touched false.",
"Timestamp starts at performance.now and changes with input during ordinary foreground polling. Firefox also updates on window synchronization; background/foreground scheduling and clock precision reduction are not reproduced."
]
}
@@ -0,0 +1,530 @@
{
"name": "elite2-firefox-win-bt-xinput",
"label": "Xbox Elite Series 2 — Bluetooth / Firefox XInput",
"environment": {
"browser": "Firefox",
"os": "Windows 10 / Windows 11",
"connection": "Bluetooth",
"mode": "XInput with unknown onboard paddle assignments",
"backend": "XInput",
"status": "inferred",
"sources": [
{
"url": "https://www.xbox.com/en-US/accessories/controllers/elite-wireless-controller-series-2",
"says": "All three connections are supported on Windows 10/11."
},
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Windows XInput backend handles any successful XInputGetState device."
}
],
"reason": "Conditional on Windows routing this connection through XInput. No per-connection physical browser dump was found."
},
"gamepad": {
"timestampPolicy": {
"value": "change",
"status": "inferred",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "The XInput poll emits events only for changed buttons/axes."
},
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/Gamepad.cpp",
"says": "Construction, input events and SyncState update the DOM timestamp using performance.now."
},
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/base/nsGlobalWindowInner.cpp",
"says": "GetGamepads returns existing objects without calling SyncState."
},
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/base/nsGlobalWindowOuter.cpp",
"says": "SetIsBackground invokes SyncGamepadState when returning to foreground and also some unchanged background-state calls."
}
],
"reason": "Change policy models ordinary foreground polling; construction starts at performance.now. Browser background/foreground synchronization and timer precision reduction are outside this synthetic fixture."
},
"id": {
"value": "xinput",
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "ScanForXInputDevices passes literal xinput to AddGamepad, with no VID/PID."
}
]
},
"mapping": {
"value": "standard",
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "XInput AddGamepad specifies Standard mapping."
}
]
},
"buttons": {
"value": 17,
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "XInput adds kStandardGamepadButtons and only standard masks, including Guide."
}
]
},
"axesRest": {
"value": [
0,
0,
0,
0
],
"status": "inferred",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "XInput exposes four normalized stick axes."
},
{
"url": "https://learn.microsoft.com/en-us/windows/uwp/gaming/gamepad-and-vibration",
"says": "Centered sticks approximately report zero and can drift."
}
],
"reason": "Ideal zero center, not a captured resting sample. Trigger release is button value 0 and no trigger axes are returned."
},
"vibration": {
"value": null,
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Windows backend supplies zero haptics and leaves a haptics TODO."
},
{
"url": "https://searchfox.org/firefox-main/source/dom/webidl/Gamepad.webidl",
"says": "Gamepad has hapticActuators but no vibrationActuator attribute."
}
],
"presence": "absent"
}
},
"controls": {
"A": {
"kind": "button",
"index": 0,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Browser backend sends A to standard button 0."
}
]
}
},
"B": {
"kind": "button",
"index": 1,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Browser backend sends B to standard button 1."
}
]
}
},
"X": {
"kind": "button",
"index": 2,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Browser backend sends X to standard button 2."
}
]
}
},
"Y": {
"kind": "button",
"index": 3,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Browser backend sends Y to standard button 3."
}
]
}
},
"Left bumper": {
"kind": "button",
"index": 4,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Browser backend sends Left bumper to standard button 4."
}
]
}
},
"Right bumper": {
"kind": "button",
"index": 5,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Browser backend sends Right bumper to standard button 5."
}
]
}
},
"Left trigger": {
"kind": "button",
"index": 6,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Trigger 6 copies XInput byte /255; pressed at byte >=30."
},
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/GamepadPlatformService.cpp",
"says": "The pressed/value overload copies pressed into touched, including XInput triggers below the threshold."
}
]
},
"analog": true,
"pressedThreshold": 0.11764705882352941,
"pressedInclusive": true,
"steps": 255,
"touchedPolicy": "pressed"
},
"Right trigger": {
"kind": "button",
"index": 7,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Trigger 7 copies XInput byte /255; pressed at byte >=30."
},
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/GamepadPlatformService.cpp",
"says": "The pressed/value overload copies pressed into touched, including XInput triggers below the threshold."
}
]
},
"analog": true,
"pressedThreshold": 0.11764705882352941,
"pressedInclusive": true,
"steps": 255,
"touchedPolicy": "pressed"
},
"View": {
"kind": "button",
"index": 8,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Browser backend sends View to standard button 8."
}
]
}
},
"Menu": {
"kind": "button",
"index": 9,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Browser backend sends Menu to standard button 9."
}
]
}
},
"Left stick click": {
"kind": "button",
"index": 10,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Browser backend sends Left stick click to standard button 10."
}
]
}
},
"Right stick click": {
"kind": "button",
"index": 11,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Browser backend sends Right stick click to standard button 11."
}
]
}
},
"D-pad up": {
"kind": "button",
"index": 12,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Browser backend sends D-pad up to standard button 12."
}
]
}
},
"D-pad down": {
"kind": "button",
"index": 13,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Browser backend sends D-pad down to standard button 13."
}
]
}
},
"D-pad left": {
"kind": "button",
"index": 14,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Browser backend sends D-pad left to standard button 14."
}
]
}
},
"D-pad right": {
"kind": "button",
"index": 15,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Browser backend sends D-pad right to standard button 15."
}
]
}
},
"Xbox": {
"kind": "button",
"index": 16,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Browser backend sends Xbox to standard button 16."
}
]
}
},
"Left stick": {
"kind": "stick",
"x": 0,
"y": 1,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Axes 0/1 are left X/Y; Y is inverted for down-positive."
}
]
}
},
"Right stick": {
"kind": "stick",
"x": 2,
"y": 3,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Axes 2/3 are right X/Y; Y is inverted for down-positive."
}
]
}
},
"P1": {
"kind": "unreported",
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "XInput backend only maps the standard 17 buttons; it has no paddle mask."
}
]
},
"reason": "No independent paddle index in this backend. An onboard assignment may repeat another control; this fixture does not guess those assignments."
},
"P2": {
"kind": "unreported",
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "XInput backend only maps the standard 17 buttons; it has no paddle mask."
}
]
},
"reason": "No independent paddle index in this backend. An onboard assignment may repeat another control; this fixture does not guess those assignments."
},
"P3": {
"kind": "unreported",
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "XInput backend only maps the standard 17 buttons; it has no paddle mask."
}
]
},
"reason": "No independent paddle index in this backend. An onboard assignment may repeat another control; this fixture does not guess those assignments."
},
"P4": {
"kind": "unreported",
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "XInput backend only maps the standard 17 buttons; it has no paddle mask."
}
]
},
"reason": "No independent paddle index in this backend. An onboard assignment may repeat another control; this fixture does not guess those assignments."
},
"Profile button": {
"kind": "unreported",
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Backend maps standard controls, not an Elite profile selector."
},
{
"url": "https://news.xbox.com/en-us/2019/06/09/meet-the-xbox-elite-wireless-controller-series-2-over-30-new-ways-to-play-like-a-pro/",
"says": "Dedicated button switches default and three saved custom profiles."
}
]
},
"reason": "Stored profiles are programmable and their contents are unknown. The selector has no distinct button index here."
}
},
"switches": {
"Left trigger lock": {
"kind": "state",
"values": [
"full",
"medium",
"short"
],
"effect": "none",
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://news.xbox.com/en-us/2019/11/07/xbox-elite-wireless-controller-series-2-available-now/",
"says": "Elite Series 2 has three levels of trigger depth."
},
{
"url": "https://news.xbox.com/en-us/2019/06/09/meet-the-xbox-elite-wireless-controller-series-2-over-30-new-ways-to-play-like-a-pro/",
"says": "Controller automatically recognizes trigger-lock adjustments."
},
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Browser exposes normalized trigger values, with no trigger-lock state field."
}
],
"reason": "This switch records physical travel only. set() supplies the browser's normalized value, so no guessed travel-to-value curve is applied."
}
},
"Right trigger lock": {
"kind": "state",
"values": [
"full",
"medium",
"short"
],
"effect": "none",
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://news.xbox.com/en-us/2019/11/07/xbox-elite-wireless-controller-series-2-available-now/",
"says": "Elite Series 2 has three levels of trigger depth."
},
{
"url": "https://news.xbox.com/en-us/2019/06/09/meet-the-xbox-elite-wireless-controller-series-2-over-30-new-ways-to-play-like-a-pro/",
"says": "Controller automatically recognizes trigger-lock adjustments."
},
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Browser exposes normalized trigger values, with no trigger-lock state field."
}
],
"reason": "This switch records physical travel only. set() supplies the browser's normalized value, so no guessed travel-to-value curve is applied."
}
},
"Profile button": {
"kind": "unreported",
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://news.xbox.com/en-us/2019/06/09/meet-the-xbox-elite-wireless-controller-series-2-over-30-new-ways-to-play-like-a-pro/",
"says": "Dedicated button selects default or one of three programmable profiles."
}
]
},
"reason": "The stored remapping slots and firmware are not known. No invented paddle aliases or profile-button signal is emitted."
}
},
"notes": [
"Exact browser backend layout, with connection routing inferred; no real-device Gamepad API dump was found.",
"Buttons 0–16 and axes 0–3 follow the standard layout. No hat axis; D-pad is buttons 12–15.",
"All released button values are 0, pressed/touched false. Trigger values are quantized byte/255.",
"P1–P4 cannot be bound as independent browser inputs here. Onboard remappings may repeat existing controls, but their stored assignments are deliberately unknown.",
"Profile button and trigger-lock switches have no independent Gamepad fields. Physical travel is recorded, not transformed with invented ratios.",
"vibrationActuator is absent; null in profile data means omit the property. Firefox hapticActuators is not implemented by this Windows backend.",
"Firefox trigger touched mirrors pressed: byte values 1 through 29 have value greater than zero, pressed false and touched false.",
"Timestamp starts at performance.now and changes with input during ordinary foreground polling. Firefox also updates on window synchronization; background/foreground scheduling and clock precision reduction are not reproduced."
]
}
@@ -0,0 +1,530 @@
{
"name": "elite2-firefox-win-usb-xinput",
"label": "Xbox Elite Series 2 — USB / Firefox XInput",
"environment": {
"browser": "Firefox",
"os": "Windows 10 / Windows 11",
"connection": "USB",
"mode": "XInput with unknown onboard paddle assignments",
"backend": "XInput",
"status": "inferred",
"sources": [
{
"url": "https://www.xbox.com/en-US/accessories/controllers/elite-wireless-controller-series-2",
"says": "All three connections are supported on Windows 10/11."
},
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Windows XInput backend handles any successful XInputGetState device."
}
],
"reason": "Conditional on Windows routing this connection through XInput. No per-connection physical browser dump was found."
},
"gamepad": {
"timestampPolicy": {
"value": "change",
"status": "inferred",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "The XInput poll emits events only for changed buttons/axes."
},
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/Gamepad.cpp",
"says": "Construction, input events and SyncState update the DOM timestamp using performance.now."
},
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/base/nsGlobalWindowInner.cpp",
"says": "GetGamepads returns existing objects without calling SyncState."
},
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/base/nsGlobalWindowOuter.cpp",
"says": "SetIsBackground invokes SyncGamepadState when returning to foreground and also some unchanged background-state calls."
}
],
"reason": "Change policy models ordinary foreground polling; construction starts at performance.now. Browser background/foreground synchronization and timer precision reduction are outside this synthetic fixture."
},
"id": {
"value": "xinput",
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "ScanForXInputDevices passes literal xinput to AddGamepad, with no VID/PID."
}
]
},
"mapping": {
"value": "standard",
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "XInput AddGamepad specifies Standard mapping."
}
]
},
"buttons": {
"value": 17,
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "XInput adds kStandardGamepadButtons and only standard masks, including Guide."
}
]
},
"axesRest": {
"value": [
0,
0,
0,
0
],
"status": "inferred",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "XInput exposes four normalized stick axes."
},
{
"url": "https://learn.microsoft.com/en-us/windows/uwp/gaming/gamepad-and-vibration",
"says": "Centered sticks approximately report zero and can drift."
}
],
"reason": "Ideal zero center, not a captured resting sample. Trigger release is button value 0 and no trigger axes are returned."
},
"vibration": {
"value": null,
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Windows backend supplies zero haptics and leaves a haptics TODO."
},
{
"url": "https://searchfox.org/firefox-main/source/dom/webidl/Gamepad.webidl",
"says": "Gamepad has hapticActuators but no vibrationActuator attribute."
}
],
"presence": "absent"
}
},
"controls": {
"A": {
"kind": "button",
"index": 0,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Browser backend sends A to standard button 0."
}
]
}
},
"B": {
"kind": "button",
"index": 1,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Browser backend sends B to standard button 1."
}
]
}
},
"X": {
"kind": "button",
"index": 2,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Browser backend sends X to standard button 2."
}
]
}
},
"Y": {
"kind": "button",
"index": 3,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Browser backend sends Y to standard button 3."
}
]
}
},
"Left bumper": {
"kind": "button",
"index": 4,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Browser backend sends Left bumper to standard button 4."
}
]
}
},
"Right bumper": {
"kind": "button",
"index": 5,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Browser backend sends Right bumper to standard button 5."
}
]
}
},
"Left trigger": {
"kind": "button",
"index": 6,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Trigger 6 copies XInput byte /255; pressed at byte >=30."
},
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/GamepadPlatformService.cpp",
"says": "The pressed/value overload copies pressed into touched, including XInput triggers below the threshold."
}
]
},
"analog": true,
"pressedThreshold": 0.11764705882352941,
"pressedInclusive": true,
"steps": 255,
"touchedPolicy": "pressed"
},
"Right trigger": {
"kind": "button",
"index": 7,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Trigger 7 copies XInput byte /255; pressed at byte >=30."
},
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/GamepadPlatformService.cpp",
"says": "The pressed/value overload copies pressed into touched, including XInput triggers below the threshold."
}
]
},
"analog": true,
"pressedThreshold": 0.11764705882352941,
"pressedInclusive": true,
"steps": 255,
"touchedPolicy": "pressed"
},
"View": {
"kind": "button",
"index": 8,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Browser backend sends View to standard button 8."
}
]
}
},
"Menu": {
"kind": "button",
"index": 9,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Browser backend sends Menu to standard button 9."
}
]
}
},
"Left stick click": {
"kind": "button",
"index": 10,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Browser backend sends Left stick click to standard button 10."
}
]
}
},
"Right stick click": {
"kind": "button",
"index": 11,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Browser backend sends Right stick click to standard button 11."
}
]
}
},
"D-pad up": {
"kind": "button",
"index": 12,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Browser backend sends D-pad up to standard button 12."
}
]
}
},
"D-pad down": {
"kind": "button",
"index": 13,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Browser backend sends D-pad down to standard button 13."
}
]
}
},
"D-pad left": {
"kind": "button",
"index": 14,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Browser backend sends D-pad left to standard button 14."
}
]
}
},
"D-pad right": {
"kind": "button",
"index": 15,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Browser backend sends D-pad right to standard button 15."
}
]
}
},
"Xbox": {
"kind": "button",
"index": 16,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Browser backend sends Xbox to standard button 16."
}
]
}
},
"Left stick": {
"kind": "stick",
"x": 0,
"y": 1,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Axes 0/1 are left X/Y; Y is inverted for down-positive."
}
]
}
},
"Right stick": {
"kind": "stick",
"x": 2,
"y": 3,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Axes 2/3 are right X/Y; Y is inverted for down-positive."
}
]
}
},
"P1": {
"kind": "unreported",
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "XInput backend only maps the standard 17 buttons; it has no paddle mask."
}
]
},
"reason": "No independent paddle index in this backend. An onboard assignment may repeat another control; this fixture does not guess those assignments."
},
"P2": {
"kind": "unreported",
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "XInput backend only maps the standard 17 buttons; it has no paddle mask."
}
]
},
"reason": "No independent paddle index in this backend. An onboard assignment may repeat another control; this fixture does not guess those assignments."
},
"P3": {
"kind": "unreported",
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "XInput backend only maps the standard 17 buttons; it has no paddle mask."
}
]
},
"reason": "No independent paddle index in this backend. An onboard assignment may repeat another control; this fixture does not guess those assignments."
},
"P4": {
"kind": "unreported",
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "XInput backend only maps the standard 17 buttons; it has no paddle mask."
}
]
},
"reason": "No independent paddle index in this backend. An onboard assignment may repeat another control; this fixture does not guess those assignments."
},
"Profile button": {
"kind": "unreported",
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Backend maps standard controls, not an Elite profile selector."
},
{
"url": "https://news.xbox.com/en-us/2019/06/09/meet-the-xbox-elite-wireless-controller-series-2-over-30-new-ways-to-play-like-a-pro/",
"says": "Dedicated button switches default and three saved custom profiles."
}
]
},
"reason": "Stored profiles are programmable and their contents are unknown. The selector has no distinct button index here."
}
},
"switches": {
"Left trigger lock": {
"kind": "state",
"values": [
"full",
"medium",
"short"
],
"effect": "none",
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://news.xbox.com/en-us/2019/11/07/xbox-elite-wireless-controller-series-2-available-now/",
"says": "Elite Series 2 has three levels of trigger depth."
},
{
"url": "https://news.xbox.com/en-us/2019/06/09/meet-the-xbox-elite-wireless-controller-series-2-over-30-new-ways-to-play-like-a-pro/",
"says": "Controller automatically recognizes trigger-lock adjustments."
},
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Browser exposes normalized trigger values, with no trigger-lock state field."
}
],
"reason": "This switch records physical travel only. set() supplies the browser's normalized value, so no guessed travel-to-value curve is applied."
}
},
"Right trigger lock": {
"kind": "state",
"values": [
"full",
"medium",
"short"
],
"effect": "none",
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://news.xbox.com/en-us/2019/11/07/xbox-elite-wireless-controller-series-2-available-now/",
"says": "Elite Series 2 has three levels of trigger depth."
},
{
"url": "https://news.xbox.com/en-us/2019/06/09/meet-the-xbox-elite-wireless-controller-series-2-over-30-new-ways-to-play-like-a-pro/",
"says": "Controller automatically recognizes trigger-lock adjustments."
},
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Browser exposes normalized trigger values, with no trigger-lock state field."
}
],
"reason": "This switch records physical travel only. set() supplies the browser's normalized value, so no guessed travel-to-value curve is applied."
}
},
"Profile button": {
"kind": "unreported",
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://news.xbox.com/en-us/2019/06/09/meet-the-xbox-elite-wireless-controller-series-2-over-30-new-ways-to-play-like-a-pro/",
"says": "Dedicated button selects default or one of three programmable profiles."
}
]
},
"reason": "The stored remapping slots and firmware are not known. No invented paddle aliases or profile-button signal is emitted."
}
},
"notes": [
"Exact browser backend layout, with connection routing inferred; no real-device Gamepad API dump was found.",
"Buttons 0–16 and axes 0–3 follow the standard layout. No hat axis; D-pad is buttons 12–15.",
"All released button values are 0, pressed/touched false. Trigger values are quantized byte/255.",
"P1–P4 cannot be bound as independent browser inputs here. Onboard remappings may repeat existing controls, but their stored assignments are deliberately unknown.",
"Profile button and trigger-lock switches have no independent Gamepad fields. Physical travel is recorded, not transformed with invented ratios.",
"vibrationActuator is absent; null in profile data means omit the property. Firefox hapticActuators is not implemented by this Windows backend.",
"Firefox trigger touched mirrors pressed: byte values 1 through 29 have value greater than zero, pressed false and touched false.",
"Timestamp starts at performance.now and changes with input during ordinary foreground polling. Firefox also updates on window synchronization; background/foreground scheduling and clock precision reduction are not reproduced."
]
}
+12
View File
@@ -0,0 +1,12 @@
{
"profiles": [
"crkd-les-paul-chrome-win-dongle-mode1-pre3.json",
"crkd-les-paul-chrome-win-usb-mode1-pre3.json",
"elite2-chromium-win-adapter-wgi-conditional.json",
"elite2-chromium-win-usb-wgi-conditional.json",
"elite2-firefox-win-adapter-xinput.json",
"elite2-firefox-win-bt-xinput.json",
"elite2-firefox-win-usb-xinput.json",
"wheel-g920-chrome-win-usb.json"
]
}
@@ -0,0 +1,575 @@
{
"name": "wheel-g920-chrome-win-usb",
"label": "Logitech G920 — Chrome Windows USB, pedals unplugged (inferred)",
"environment": {
"browser": "Chrome",
"os": "Windows",
"connection": "USB",
"mode": "Native HID, separate pedals; driver version unknown"
},
"environmentEvidence": {
"status": "inferred",
"sources": [
{
"url": "https://www.reddit.com/r/Stadia/comments/fdc7sh/",
"says": "A G920 owner pasted a Chrome Gamepad object on 2020-03-04: this exact ID, empty mapping, 18 buttons, 10 axes with the saved resting vector, and vibrationActuator null. The report does not specify the OS, driver version, or whether pedals were disconnected."
},
{
"url": "https://github.com/chromium/chromium/blob/main/device/gamepad/raw_input_gamepad_device_win.cc",
"says": "Windows RawInput assigns a button index from HID button usage minus one, and an axis index from HID Generic Desktop usage minus 0x30. It normalizes to [-1,+1], preserving an out-of-range hat null state."
},
{
"url": "https://www.logitech.com/assets/54557/2/g920-driving-forcetm-racing-wheel.pdf",
"says": "The G920 manual identifies A/B/X/Y, directional pad, left/right bumpers and stick buttons, View/Menu, Xbox, USB, and separate pedal/shifter connectors; its numbered callouts are physical labels, not browser indices."
}
],
"reason": "Chrome is explicit in the dump; Windows is inferred from its sparse 10-axis HID usage layout. USB is the wheel's physical host connection. This historical report does not prove current Edge/Chrome behavior under every Logitech driver."
},
"gamepad": {
"id": {
"value": "G920 Driving Force Racing Wheel for Xbox One (Vendor: 046d Product: c262)",
"status": "confirmed",
"sources": [
{
"url": "https://www.reddit.com/r/Stadia/comments/fdc7sh/",
"says": "A G920 owner pasted a Chrome Gamepad object on 2020-03-04: this exact ID, empty mapping, 18 buttons, 10 axes with the saved resting vector, and vibrationActuator null. The report does not specify the OS, driver version, or whether pedals were disconnected."
}
]
},
"mapping": {
"value": "",
"status": "confirmed",
"sources": [
{
"url": "https://www.reddit.com/r/Stadia/comments/fdc7sh/",
"says": "A G920 owner pasted a Chrome Gamepad object on 2020-03-04: this exact ID, empty mapping, 18 buttons, 10 axes with the saved resting vector, and vibrationActuator null. The report does not specify the OS, driver version, or whether pedals were disconnected."
}
]
},
"buttons": {
"value": 18,
"status": "confirmed",
"sources": [
{
"url": "https://www.reddit.com/r/Stadia/comments/fdc7sh/",
"says": "A G920 owner pasted a Chrome Gamepad object on 2020-03-04: this exact ID, empty mapping, 18 buttons, 10 axes with the saved resting vector, and vibrationActuator null. The report does not specify the OS, driver version, or whether pedals were disconnected."
},
{
"url": "https://forum.scssoft.com/viewtopic.php?p=1313821",
"says": "A real 046d:c262 G920 HID dump has 18 button usages, X usage 0x30 (0..65535), Y 0x31/Z 0x32/Rz 0x35 (0..255), and hat 0x39 (0..7). The dump is from macOS native HID, not Safari."
}
]
},
"axesRest": {
"value": [
-0.0013580322265625,
1,
1,
0,
0,
1,
0,
0,
0,
1.2857143878936768
],
"status": "inferred",
"sources": [
{
"url": "https://www.reddit.com/r/Stadia/comments/fdc7sh/",
"says": "A G920 owner pasted a Chrome Gamepad object on 2020-03-04: this exact ID, empty mapping, 18 buttons, 10 axes with the saved resting vector, and vibrationActuator null. The report does not specify the OS, driver version, or whether pedals were disconnected."
},
{
"url": "https://spenibus.net/b/p/F/PC-steering-wheel-viewer-prototype-in-html-javascript",
"says": "The author's 2018 G920 Firefox/Chrome browser prototype reads steering axis 0, throttle 1, brake 2, and clutch 3 or 5; its pedal conversion treats +1 as released and -1 as pressed."
},
{
"url": "https://forum.scssoft.com/viewtopic.php?p=1313821",
"says": "A real 046d:c262 G920 HID dump has 18 button usages, X usage 0x30 (0..65535), Y 0x31/Z 0x32/Rz 0x35 (0..255), and hat 0x39 (0..7). The dump is from macOS native HID, not Safari."
}
],
"reason": "The complete vector is copied exactly from the real Chrome snapshot, including zeros at HID axis gaps and steering sensor offset. The snapshot never says pedals were unplugged; using its released-pedal +1 values for unplugged pedals is explicitly inferred."
},
"vibration": {
"value": null,
"status": "confirmed",
"sources": [
{
"url": "https://www.reddit.com/r/Stadia/comments/fdc7sh/",
"says": "A G920 owner pasted a Chrome Gamepad object on 2020-03-04: this exact ID, empty mapping, 18 buttons, 10 axes with the saved resting vector, and vibrationActuator null. The report does not specify the OS, driver version, or whether pedals were disconnected."
}
],
"presence": "null"
}
},
"controls": {
"A": {
"kind": "button",
"index": 0,
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://steamcommunity.com/sharedfiles/filedetails/?id=2489644810",
"says": "The author's G920 Windows DirectInput setup lists raw indices 0 A, 1 B, 2 X, 3 Y, 4 right paddle, 5 left paddle, 6 Menu, 7 View, 8 RSB, and 9 LSB. The guide warns the G HUB product name differs."
},
{
"url": "https://github.com/chromium/chromium/blob/main/device/gamepad/raw_input_gamepad_device_win.cc",
"says": "Windows RawInput assigns a button index from HID button usage minus one, and an axis index from HID Generic Desktop usage minus 0x30. It normalizes to [-1,+1], preserving an out-of-range hat null state."
}
],
"reason": "The G920 owner's native Windows indices are projected to Chrome's unremapped HID button order; no Chrome dump of this individual button was found."
}
},
"B": {
"kind": "button",
"index": 1,
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://steamcommunity.com/sharedfiles/filedetails/?id=2489644810",
"says": "The author's G920 Windows DirectInput setup lists raw indices 0 A, 1 B, 2 X, 3 Y, 4 right paddle, 5 left paddle, 6 Menu, 7 View, 8 RSB, and 9 LSB. The guide warns the G HUB product name differs."
},
{
"url": "https://github.com/chromium/chromium/blob/main/device/gamepad/raw_input_gamepad_device_win.cc",
"says": "Windows RawInput assigns a button index from HID button usage minus one, and an axis index from HID Generic Desktop usage minus 0x30. It normalizes to [-1,+1], preserving an out-of-range hat null state."
}
],
"reason": "The G920 owner's native Windows indices are projected to Chrome's unremapped HID button order; no Chrome dump of this individual button was found."
}
},
"X": {
"kind": "button",
"index": 2,
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://steamcommunity.com/sharedfiles/filedetails/?id=2489644810",
"says": "The author's G920 Windows DirectInput setup lists raw indices 0 A, 1 B, 2 X, 3 Y, 4 right paddle, 5 left paddle, 6 Menu, 7 View, 8 RSB, and 9 LSB. The guide warns the G HUB product name differs."
},
{
"url": "https://github.com/chromium/chromium/blob/main/device/gamepad/raw_input_gamepad_device_win.cc",
"says": "Windows RawInput assigns a button index from HID button usage minus one, and an axis index from HID Generic Desktop usage minus 0x30. It normalizes to [-1,+1], preserving an out-of-range hat null state."
}
],
"reason": "The G920 owner's native Windows indices are projected to Chrome's unremapped HID button order; no Chrome dump of this individual button was found."
}
},
"Y": {
"kind": "button",
"index": 3,
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://steamcommunity.com/sharedfiles/filedetails/?id=2489644810",
"says": "The author's G920 Windows DirectInput setup lists raw indices 0 A, 1 B, 2 X, 3 Y, 4 right paddle, 5 left paddle, 6 Menu, 7 View, 8 RSB, and 9 LSB. The guide warns the G HUB product name differs."
},
{
"url": "https://github.com/chromium/chromium/blob/main/device/gamepad/raw_input_gamepad_device_win.cc",
"says": "Windows RawInput assigns a button index from HID button usage minus one, and an axis index from HID Generic Desktop usage minus 0x30. It normalizes to [-1,+1], preserving an out-of-range hat null state."
}
],
"reason": "The G920 owner's native Windows indices are projected to Chrome's unremapped HID button order; no Chrome dump of this individual button was found."
}
},
"Right paddle shifter": {
"kind": "button",
"index": 4,
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://steamcommunity.com/sharedfiles/filedetails/?id=2489644810",
"says": "The author's G920 Windows DirectInput setup lists raw indices 0 A, 1 B, 2 X, 3 Y, 4 right paddle, 5 left paddle, 6 Menu, 7 View, 8 RSB, and 9 LSB. The guide warns the G HUB product name differs."
},
{
"url": "https://github.com/chromium/chromium/blob/main/device/gamepad/raw_input_gamepad_device_win.cc",
"says": "Windows RawInput assigns a button index from HID button usage minus one, and an axis index from HID Generic Desktop usage minus 0x30. It normalizes to [-1,+1], preserving an out-of-range hat null state."
}
],
"reason": "The G920 owner's native Windows indices are projected to Chrome's unremapped HID button order; no Chrome dump of this individual button was found."
}
},
"Left paddle shifter": {
"kind": "button",
"index": 5,
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://steamcommunity.com/sharedfiles/filedetails/?id=2489644810",
"says": "The author's G920 Windows DirectInput setup lists raw indices 0 A, 1 B, 2 X, 3 Y, 4 right paddle, 5 left paddle, 6 Menu, 7 View, 8 RSB, and 9 LSB. The guide warns the G HUB product name differs."
},
{
"url": "https://github.com/chromium/chromium/blob/main/device/gamepad/raw_input_gamepad_device_win.cc",
"says": "Windows RawInput assigns a button index from HID button usage minus one, and an axis index from HID Generic Desktop usage minus 0x30. It normalizes to [-1,+1], preserving an out-of-range hat null state."
}
],
"reason": "The G920 owner's native Windows indices are projected to Chrome's unremapped HID button order; no Chrome dump of this individual button was found."
}
},
"Menu": {
"kind": "button",
"index": 6,
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://steamcommunity.com/sharedfiles/filedetails/?id=2489644810",
"says": "The author's G920 Windows DirectInput setup lists raw indices 0 A, 1 B, 2 X, 3 Y, 4 right paddle, 5 left paddle, 6 Menu, 7 View, 8 RSB, and 9 LSB. The guide warns the G HUB product name differs."
},
{
"url": "https://github.com/chromium/chromium/blob/main/device/gamepad/raw_input_gamepad_device_win.cc",
"says": "Windows RawInput assigns a button index from HID button usage minus one, and an axis index from HID Generic Desktop usage minus 0x30. It normalizes to [-1,+1], preserving an out-of-range hat null state."
}
],
"reason": "The G920 owner's native Windows indices are projected to Chrome's unremapped HID button order; no Chrome dump of this individual button was found."
}
},
"View": {
"kind": "button",
"index": 7,
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://steamcommunity.com/sharedfiles/filedetails/?id=2489644810",
"says": "The author's G920 Windows DirectInput setup lists raw indices 0 A, 1 B, 2 X, 3 Y, 4 right paddle, 5 left paddle, 6 Menu, 7 View, 8 RSB, and 9 LSB. The guide warns the G HUB product name differs."
},
{
"url": "https://github.com/chromium/chromium/blob/main/device/gamepad/raw_input_gamepad_device_win.cc",
"says": "Windows RawInput assigns a button index from HID button usage minus one, and an axis index from HID Generic Desktop usage minus 0x30. It normalizes to [-1,+1], preserving an out-of-range hat null state."
}
],
"reason": "The G920 owner's native Windows indices are projected to Chrome's unremapped HID button order; no Chrome dump of this individual button was found."
}
},
"RSB": {
"kind": "button",
"index": 8,
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://steamcommunity.com/sharedfiles/filedetails/?id=2489644810",
"says": "The author's G920 Windows DirectInput setup lists raw indices 0 A, 1 B, 2 X, 3 Y, 4 right paddle, 5 left paddle, 6 Menu, 7 View, 8 RSB, and 9 LSB. The guide warns the G HUB product name differs."
},
{
"url": "https://github.com/chromium/chromium/blob/main/device/gamepad/raw_input_gamepad_device_win.cc",
"says": "Windows RawInput assigns a button index from HID button usage minus one, and an axis index from HID Generic Desktop usage minus 0x30. It normalizes to [-1,+1], preserving an out-of-range hat null state."
}
],
"reason": "The G920 owner's native Windows indices are projected to Chrome's unremapped HID button order; no Chrome dump of this individual button was found."
}
},
"LSB": {
"kind": "button",
"index": 9,
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://steamcommunity.com/sharedfiles/filedetails/?id=2489644810",
"says": "The author's G920 Windows DirectInput setup lists raw indices 0 A, 1 B, 2 X, 3 Y, 4 right paddle, 5 left paddle, 6 Menu, 7 View, 8 RSB, and 9 LSB. The guide warns the G HUB product name differs."
},
{
"url": "https://github.com/chromium/chromium/blob/main/device/gamepad/raw_input_gamepad_device_win.cc",
"says": "Windows RawInput assigns a button index from HID button usage minus one, and an axis index from HID Generic Desktop usage minus 0x30. It normalizes to [-1,+1], preserving an out-of-range hat null state."
}
],
"reason": "The G920 owner's native Windows indices are projected to Chrome's unremapped HID button order; no Chrome dump of this individual button was found."
}
},
"Xbox": {
"kind": "button",
"index": 10,
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://www.logitech.com/assets/54557/2/g920-driving-forcetm-racing-wheel.pdf",
"says": "The G920 manual identifies A/B/X/Y, directional pad, left/right bumpers and stick buttons, View/Menu, Xbox, USB, and separate pedal/shifter connectors; its numbered callouts are physical labels, not browser indices."
},
{
"url": "https://www.reddit.com/r/LogitechG/comments/lzmpf3/",
"says": "A G920 and G HUB owner reports Windows joy.cpl buttons 1-11 on the wheel, button 12 for reverse, and buttons 13-18 for forward gears; pushing the lever down and moving it bottom right selects reverse."
},
{
"url": "https://github.com/chromium/chromium/blob/main/device/gamepad/raw_input_gamepad_device_win.cc",
"says": "Windows RawInput assigns a button index from HID button usage minus one, and an axis index from HID Generic Desktop usage minus 0x30. It normalizes to [-1,+1], preserving an out-of-range hat null state."
}
],
"reason": "The manual identifies Xbox as the remaining wheel button; the owner's Windows report puts wheel buttons at one-based 1..11. Assigning Xbox to zero-based 10 is inferred."
}
},
"Reverse gear": {
"kind": "button",
"index": 11,
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://www.reddit.com/r/LogitechG/comments/lzmpf3/",
"says": "A G920 and G HUB owner reports Windows joy.cpl buttons 1-11 on the wheel, button 12 for reverse, and buttons 13-18 for forward gears; pushing the lever down and moving it bottom right selects reverse."
},
{
"url": "https://github.com/chromium/chromium/blob/main/device/gamepad/raw_input_gamepad_device_win.cc",
"says": "Windows RawInput assigns a button index from HID button usage minus one, and an axis index from HID Generic Desktop usage minus 0x30. It normalizes to [-1,+1], preserving an out-of-range hat null state."
}
],
"reason": "One-based Windows button 12 is projected to unremapped browser index 11. LGS documentation uses a conflicting 19-button order; this is only a G HUB-style candidate."
}
},
"Gear 1": {
"kind": "button",
"index": 12,
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://www.reddit.com/r/LogitechG/comments/lzmpf3/",
"says": "A G920 and G HUB owner reports Windows joy.cpl buttons 1-11 on the wheel, button 12 for reverse, and buttons 13-18 for forward gears; pushing the lever down and moving it bottom right selects reverse."
},
{
"url": "https://github.com/chromium/chromium/blob/main/device/gamepad/raw_input_gamepad_device_win.cc",
"says": "Windows RawInput assigns a button index from HID button usage minus one, and an axis index from HID Generic Desktop usage minus 0x30. It normalizes to [-1,+1], preserving an out-of-range hat null state."
}
],
"reason": "The owner's one-based Windows forward-gear buttons 13..18 are projected to browser indices 12..17. The optional shifter is assumed disconnected by default."
}
},
"Gear 2": {
"kind": "button",
"index": 13,
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://www.reddit.com/r/LogitechG/comments/lzmpf3/",
"says": "A G920 and G HUB owner reports Windows joy.cpl buttons 1-11 on the wheel, button 12 for reverse, and buttons 13-18 for forward gears; pushing the lever down and moving it bottom right selects reverse."
},
{
"url": "https://github.com/chromium/chromium/blob/main/device/gamepad/raw_input_gamepad_device_win.cc",
"says": "Windows RawInput assigns a button index from HID button usage minus one, and an axis index from HID Generic Desktop usage minus 0x30. It normalizes to [-1,+1], preserving an out-of-range hat null state."
}
],
"reason": "The owner's one-based Windows forward-gear buttons 13..18 are projected to browser indices 12..17. The optional shifter is assumed disconnected by default."
}
},
"Gear 3": {
"kind": "button",
"index": 14,
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://www.reddit.com/r/LogitechG/comments/lzmpf3/",
"says": "A G920 and G HUB owner reports Windows joy.cpl buttons 1-11 on the wheel, button 12 for reverse, and buttons 13-18 for forward gears; pushing the lever down and moving it bottom right selects reverse."
},
{
"url": "https://github.com/chromium/chromium/blob/main/device/gamepad/raw_input_gamepad_device_win.cc",
"says": "Windows RawInput assigns a button index from HID button usage minus one, and an axis index from HID Generic Desktop usage minus 0x30. It normalizes to [-1,+1], preserving an out-of-range hat null state."
}
],
"reason": "The owner's one-based Windows forward-gear buttons 13..18 are projected to browser indices 12..17. The optional shifter is assumed disconnected by default."
}
},
"Gear 4": {
"kind": "button",
"index": 15,
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://www.reddit.com/r/LogitechG/comments/lzmpf3/",
"says": "A G920 and G HUB owner reports Windows joy.cpl buttons 1-11 on the wheel, button 12 for reverse, and buttons 13-18 for forward gears; pushing the lever down and moving it bottom right selects reverse."
},
{
"url": "https://github.com/chromium/chromium/blob/main/device/gamepad/raw_input_gamepad_device_win.cc",
"says": "Windows RawInput assigns a button index from HID button usage minus one, and an axis index from HID Generic Desktop usage minus 0x30. It normalizes to [-1,+1], preserving an out-of-range hat null state."
}
],
"reason": "The owner's one-based Windows forward-gear buttons 13..18 are projected to browser indices 12..17. The optional shifter is assumed disconnected by default."
}
},
"Gear 5": {
"kind": "button",
"index": 16,
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://www.reddit.com/r/LogitechG/comments/lzmpf3/",
"says": "A G920 and G HUB owner reports Windows joy.cpl buttons 1-11 on the wheel, button 12 for reverse, and buttons 13-18 for forward gears; pushing the lever down and moving it bottom right selects reverse."
},
{
"url": "https://github.com/chromium/chromium/blob/main/device/gamepad/raw_input_gamepad_device_win.cc",
"says": "Windows RawInput assigns a button index from HID button usage minus one, and an axis index from HID Generic Desktop usage minus 0x30. It normalizes to [-1,+1], preserving an out-of-range hat null state."
}
],
"reason": "The owner's one-based Windows forward-gear buttons 13..18 are projected to browser indices 12..17. The optional shifter is assumed disconnected by default."
}
},
"Gear 6": {
"kind": "button",
"index": 17,
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://www.reddit.com/r/LogitechG/comments/lzmpf3/",
"says": "A G920 and G HUB owner reports Windows joy.cpl buttons 1-11 on the wheel, button 12 for reverse, and buttons 13-18 for forward gears; pushing the lever down and moving it bottom right selects reverse."
},
{
"url": "https://github.com/chromium/chromium/blob/main/device/gamepad/raw_input_gamepad_device_win.cc",
"says": "Windows RawInput assigns a button index from HID button usage minus one, and an axis index from HID Generic Desktop usage minus 0x30. It normalizes to [-1,+1], preserving an out-of-range hat null state."
}
],
"reason": "The owner's one-based Windows forward-gear buttons 13..18 are projected to browser indices 12..17. The optional shifter is assumed disconnected by default."
}
},
"Wheel": {
"kind": "axis",
"index": 0,
"rest": -0.0013580322265625,
"min": -1,
"max": 1,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://www.reddit.com/r/Stadia/comments/fdc7sh/",
"says": "A G920 owner pasted a Chrome Gamepad object on 2020-03-04: this exact ID, empty mapping, 18 buttons, 10 axes with the saved resting vector, and vibrationActuator null. The report does not specify the OS, driver version, or whether pedals were disconnected."
},
{
"url": "https://spenibus.net/b/p/F/PC-steering-wheel-viewer-prototype-in-html-javascript",
"says": "The author's 2018 G920 Firefox/Chrome browser prototype reads steering axis 0, throttle 1, brake 2, and clutch 3 or 5; its pedal conversion treats +1 as released and -1 as pressed."
}
],
"reason": "The dump preserves a small observed steering offset; the author's browser code identifies axis 0 as steering. Positive values turn right by the browser axis convention."
}
},
"Accelerator pedal": {
"kind": "axis",
"index": 1,
"rest": 1,
"min": -1,
"max": 1,
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://www.reddit.com/r/Stadia/comments/fdc7sh/",
"says": "A G920 owner pasted a Chrome Gamepad object on 2020-03-04: this exact ID, empty mapping, 18 buttons, 10 axes with the saved resting vector, and vibrationActuator null. The report does not specify the OS, driver version, or whether pedals were disconnected."
},
{
"url": "https://spenibus.net/b/p/F/PC-steering-wheel-viewer-prototype-in-html-javascript",
"says": "The author's 2018 G920 Firefox/Chrome browser prototype reads steering axis 0, throttle 1, brake 2, and clutch 3 or 5; its pedal conversion treats +1 as released and -1 as pressed."
},
{
"url": "https://forum.scssoft.com/viewtopic.php?p=1313821",
"says": "A real 046d:c262 G920 HID dump has 18 button usages, X usage 0x30 (0..65535), Y 0x31/Z 0x32/Rz 0x35 (0..255), and hat 0x39 (0..7). The dump is from macOS native HID, not Safari."
}
],
"reason": "The real Chrome snapshot has +1 here and the owner's browser prototype supplies the pedal identity/range. Retaining +1 with the pedal connector unplugged is a scenario assumption, not an observed disconnected-pedal capture."
}
},
"Brake pedal": {
"kind": "axis",
"index": 2,
"rest": 1,
"min": -1,
"max": 1,
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://www.reddit.com/r/Stadia/comments/fdc7sh/",
"says": "A G920 owner pasted a Chrome Gamepad object on 2020-03-04: this exact ID, empty mapping, 18 buttons, 10 axes with the saved resting vector, and vibrationActuator null. The report does not specify the OS, driver version, or whether pedals were disconnected."
},
{
"url": "https://spenibus.net/b/p/F/PC-steering-wheel-viewer-prototype-in-html-javascript",
"says": "The author's 2018 G920 Firefox/Chrome browser prototype reads steering axis 0, throttle 1, brake 2, and clutch 3 or 5; its pedal conversion treats +1 as released and -1 as pressed."
},
{
"url": "https://forum.scssoft.com/viewtopic.php?p=1313821",
"says": "A real 046d:c262 G920 HID dump has 18 button usages, X usage 0x30 (0..65535), Y 0x31/Z 0x32/Rz 0x35 (0..255), and hat 0x39 (0..7). The dump is from macOS native HID, not Safari."
}
],
"reason": "The real Chrome snapshot has +1 here and the owner's browser prototype supplies the pedal identity/range. Retaining +1 with the pedal connector unplugged is a scenario assumption, not an observed disconnected-pedal capture."
}
},
"Clutch pedal": {
"kind": "axis",
"index": 5,
"rest": 1,
"min": -1,
"max": 1,
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://www.reddit.com/r/Stadia/comments/fdc7sh/",
"says": "A G920 owner pasted a Chrome Gamepad object on 2020-03-04: this exact ID, empty mapping, 18 buttons, 10 axes with the saved resting vector, and vibrationActuator null. The report does not specify the OS, driver version, or whether pedals were disconnected."
},
{
"url": "https://spenibus.net/b/p/F/PC-steering-wheel-viewer-prototype-in-html-javascript",
"says": "The author's 2018 G920 Firefox/Chrome browser prototype reads steering axis 0, throttle 1, brake 2, and clutch 3 or 5; its pedal conversion treats +1 as released and -1 as pressed."
},
{
"url": "https://forum.scssoft.com/viewtopic.php?p=1313821",
"says": "A real 046d:c262 G920 HID dump has 18 button usages, X usage 0x30 (0..65535), Y 0x31/Z 0x32/Rz 0x35 (0..255), and hat 0x39 (0..7). The dump is from macOS native HID, not Safari."
}
],
"reason": "The real Chrome snapshot has +1 here and the owner's browser prototype supplies the pedal identity/range. Retaining +1 with the pedal connector unplugged is a scenario assumption, not an observed disconnected-pedal capture."
}
},
"D-pad": {
"kind": "hat",
"index": 9,
"neutral": 1.2857143878936768,
"directions": {
"up": -1,
"up-right": -0.7142857313156128,
"right": -0.4285714030265808,
"down-right": -0.1428571343421936,
"down": 0.14285719394683838,
"down-left": 0.4285714626312256,
"left": 0.7142857313156128,
"up-left": 1
},
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://www.reddit.com/r/Stadia/comments/fdc7sh/",
"says": "A G920 owner pasted a Chrome Gamepad object on 2020-03-04: this exact ID, empty mapping, 18 buttons, 10 axes with the saved resting vector, and vibrationActuator null. The report does not specify the OS, driver version, or whether pedals were disconnected."
},
{
"url": "https://github.com/chromium/chromium/blob/main/device/gamepad/raw_input_gamepad_device_win.cc",
"says": "Windows RawInput assigns a button index from HID button usage minus one, and an axis index from HID Generic Desktop usage minus 0x30. It normalizes to [-1,+1], preserving an out-of-range hat null state."
},
{
"url": "https://forum.scssoft.com/viewtopic.php?p=1313821",
"says": "A real 046d:c262 G920 HID dump has 18 button usages, X usage 0x30 (0..65535), Y 0x31/Z 0x32/Rz 0x35 (0..255), and hat 0x39 (0..7). The dump is from macOS native HID, not Safari."
}
],
"reason": "Hat axis 9 and neutral 1.2857143878936768 are confirmed by the Chrome dump. Direction values are derived using Chromium float normalization of the G920 HID hat range 0..7, clockwise from north."
}
}
},
"switches": {},
"notes": [
"This profile is a historical Chrome/HID report plus named-control inferences, not a claim of hardware compatibility.",
"Axes 3,4,6,7,8 are preserved zero-valued gaps in the reported array, not imaginary sticks.",
"The hat neutral is 1.2857143878936768 for this dump; do not replace it with the often-seen 3.2857.",
"Pedal axes remain in the fixed HID report descriptor when the separate pedal unit is absent. Their +1 unplugged rests are unconfirmed and intentionally inferred.",
"Buttons rest at value 0, pressed false, touched false, using Chromium's binary raw HID button semantics; pedals are axes, not analog buttons.",
"No user-operated platform/mode switch is documented for G920. Driver-generated XInput and combined-pedal variants are outside this profile.",
"Optional shifter indices use a firsthand G HUB native report; Logitech's LGS shifter guide disagrees. Do not silently apply that LGS layout to this 18-button snapshot.",
"No Firefox, Safari, Edge, current-G-HUB Chrome, or second-wheel profile is emitted without a full matching browser report."
]
}
+59
View File
@@ -0,0 +1,59 @@
// Local-only manual lab: static files plus a fixed loopback service relay.
// Run in Docker after tests/run.py has built build/web-host-test.
const http = require('node:http'), net = require('node:net'), fs = require('node:fs'), path = require('node:path');
const { spawn } = require('node:child_process');
const root = path.resolve(__dirname, '..');
function createLabServer() {
const server = http.createServer((request, response) => {
const url = new URL(request.url, 'http://127.0.0.1:8000');
let pathname = url.pathname === '/' ? '/tests/fake_gamepads.html' : url.pathname;
if (['/manifest.webmanifest', '/icon-192.png'].includes(pathname)) pathname = '/client' + pathname;
const allowed = pathname === '/client/index.html' || ['/client/manifest.webmanifest', '/client/icon-192.png',
'/tests/fake_gamepads.html', '/tests/fake_gamepads.js'].includes(pathname) || /^\/tests\/fake_gamepads\/[a-z0-9-]+\.json$/.test(pathname);
if (request.method !== 'GET' || !allowed) { response.writeHead(404).end(); return; }
fs.readFile(path.join(root, pathname), (error, data) => {
if (error) { response.writeHead(404).end(); return; }
const mime = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.json': 'application/json',
'.png': 'image/png', '.webmanifest': 'application/manifest+json' }[path.extname(pathname)];
response.writeHead(200, { 'Content-Type': mime, 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff' }); response.end(data);
});
});
const sockets = new Set();
server.on('connection', socket => { sockets.add(socket); socket.on('close', () => sockets.delete(socket)); });
server.on('upgrade', (request, socket, head) => {
const expected = 'http://127.0.0.1:' + server.address().port;
if (request.url !== '/local-service' || request.headers.origin !== expected || request.headers.upgrade?.toLowerCase() !== 'websocket') {
socket.end('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n'); return;
}
// No caller-specified destination: the host stub is the only upstream.
const upstream = net.connect(4264, '127.0.0.1'); sockets.add(upstream);
upstream.on('close', () => { sockets.delete(upstream); socket.destroy(); });
upstream.on('error', () => socket.destroy()); socket.on('error', () => upstream.destroy()); socket.on('close', () => upstream.destroy());
upstream.on('connect', () => {
const headers = ['GET /ws HTTP/1.1', 'Host: 127.0.0.1:4264', 'Origin: http://127.0.0.1:4264'];
for (const name of ['upgrade', 'connection', 'sec-websocket-key', 'sec-websocket-version', 'sec-websocket-protocol']) {
if (request.headers[name]) headers.push(name + ': ' + request.headers[name]);
}
upstream.write(headers.join('\r\n') + '\r\n\r\n');
if (head.length) upstream.write(head);
socket.pipe(upstream); upstream.pipe(socket);
});
});
server.stop = () => { for (const socket of sockets) socket.destroy(); return new Promise(resolve => server.close(resolve)); };
return server;
}
module.exports = { createLabServer };
if (require.main === module) {
const binary = path.join(root, 'build', 'web-host-test');
if (!fs.existsSync(binary)) { console.error('Run tests/run.py in Docker first.'); process.exitCode = 1; }
else {
fs.copyFileSync(binary, '/tmp/c4f-gamepad-lab-service');
const service = spawn('/tmp/c4f-gamepad-lab-service', [], { stdio: ['ignore', 'ignore', 'inherit'] });
const server = createLabServer();
server.listen(8000, process.env.C4F_LAB_BIND || '127.0.0.1', () => console.log('Local gamepad lab: http://127.0.0.1:8000/'));
const stop = () => { service.kill(); server.stop().then(() => process.exit()); };
process.on('SIGTERM', stop); process.on('SIGINT', stop);
service.on('error', error => { console.error(error.message); stop(); });
server.on('error', error => { console.error(error.message); stop(); });
}
}
+466
View File
@@ -0,0 +1,466 @@
// Researched Gamepad API emulations, run only in the Playwright Docker image.
// The controller page is instrumented in memory; the shipped HTML is unchanged.
const { chromium } = require('playwright');
const fs = require('node:fs');
const path = require('node:path');
const assert = require('node:assert/strict');
const { spawn } = require('node:child_process');
const { createLabServer } = require('./fake_gamepads_server.cjs');
const root = '/src', profileDir = path.join(root, 'tests/fake_gamepads');
const profiles = fs.readdirSync(profileDir).filter(file => file.endsWith('.json') && file !== 'manifest.json')
.sort().map(file => JSON.parse(fs.readFileSync(path.join(profileDir, file), 'utf8')));
assert(profiles.length, 'No researched profiles found');
const fakeScript = fs.readFileSync(path.join(root, 'tests/fake_gamepads.js'), 'utf8');
const hook = '\nwindow.c4fTest = { ControllerMapping, mapper, profileStore, gamepadSources, pollGamepads, setGamepadPad, mergedState, renderGamepads, renderPads, openSettings, closeSettings, openPad, profileFor, loadProfiles, persistProfiles, clearDevice };\n';
const originalHtml = fs.readFileSync(path.join(root, 'client/index.html'), 'utf8');
const html = originalHtml.replace('\n })();', hook + ' })();');
assert.notEqual(html, originalHtml, 'In-memory controller-page hook was not inserted');
const outputs = {
CROSS: ['Cross', 0x4000], CIRCLE: ['Circle', 0x2000], SQUARE: ['Square', 0x8000],
TRIANGLE: ['Triangle', 0x1000], L1: ['L1', 0x400], R1: ['R1', 0x800],
L2: ['L2', 0x100], R2: ['R2', 0x200], L3: ['L3', 0x2], R3: ['R3', 0x4],
SHARE: ['Share', 0x1], OPTIONS: ['Options', 0x8], PS: ['PS button', 0x10000],
TOUCH: ['Touchpad click', 0x100000], UP: ['D-pad up', 0x10], RIGHT: ['D-pad right', 0x20],
DOWN: ['D-pad down', 0x40], LEFT: ['D-pad left', 0x80],
lx: ['Left stick, left and right'], ly: ['Left stick, up and down'],
rx: ['Right stick, left and right'], ry: ['Right stick, up and down']
};
const lab = createLabServer();
fs.copyFileSync(path.join(root, 'build/web-host-test'), '/tmp/c4f-fake-gamepad-service');
const service = spawn('/tmp/c4f-fake-gamepad-service', [], { stdio: ['ignore', 'ignore', 'pipe'] });
let serviceError;
service.on('error', error => { serviceError = error; });
service.stderr.on('data', data => console.error(data.toString().trim()));
function resolvedControl(profile, name) {
const seen = new Set();
let control = profile.controls[name];
while (control?.kind === 'alias') {
assert(!seen.has(control.target), 'Circular control alias');
seen.add(control.target); control = profile.controls[control.target];
}
return control;
}
const buttonNames = profile => Object.keys(profile.controls).filter(name => resolvedControl(profile, name)?.kind === 'button');
const controlAt = (profile, index) => buttonNames(profile).find(name => resolvedControl(profile, name).index === index);
// Click a control on the mapping view's picture, then Change: it listens for the gamepad.
const pictureTarget = output => ({ lx: 'LS', ly: 'LS', rx: 'RS', ry: 'RS' })[output] || output;
async function changeControl(owner, output) {
await owner.click('#mapChips [data-target="' + pictureTarget(output) + '"]');
await owner.click('#mapPanel button:has-text("Change")');
}
const poll = page => page.evaluate(() => c4fTest.pollGamepads(performance.now()));
const press = (page, name) => page.evaluate(name => { fakePads.press(0, name); c4fTest.pollGamepads(performance.now()); }, name);
const release = (page, name) => page.evaluate(name => { fakePads.release(0, name); c4fTest.pollGamepads(performance.now()); }, name);
const hat = (page, direction, name) => page.evaluate(({ direction, name }) => {
fakePads.hat(0, direction, name); c4fTest.pollGamepads(performance.now());
}, { direction, name });
async function mapButton(page, profile, name, output) {
await changeControl(page, output);
await page.waitForFunction(() => c4fTest.mapper.capture?.ready);
await press(page, name);
await page.waitForFunction(() => !c4fTest.mapper.capture);
const inputs = await page.evaluate(output => c4fTest.mapper.draft.bindings
.filter(binding => binding.output === output).map(binding => binding.input), output);
assert.deepEqual(inputs, [{ kind: 'button', index: resolvedControl(profile, name).index, mode: 'digital' }], name + ' captured through Change');
await release(page, name);
}
async function openEditor(page) {
await page.click('.gp-row[data-index="0"] [data-focus="configure"]');
await page.waitForFunction(() => c4fTest.mapper.draft !== null);
}
async function sourceReady(page, profile) {
await page.waitForFunction(id => window.c4fTest?.gamepadSources.get(0)?.signature.id === id, profile.gamepad.id.value);
}
async function checkApi(page, profile) {
const snapshot = await page.evaluate(() => {
const slots = navigator.getGamepads(), first = slots[0], timestamp = first.timestamp, next = navigator.getGamepads()[0];
return {
slotCount: slots.length, holes: slots.slice(1),
id: next.id, index: next.index, mapping: next.mapping, connected: next.connected,
axes: next.axes, buttons: next.buttons.map(button => ({ value: button.value, pressed: button.pressed, touched: button.touched })),
actuator: next.vibrationActuator ? { type: next.vibrationActuator.type, effects: [...next.vibrationActuator.effects] } : null,
actuatorPresent: Object.hasOwn(next, 'vibrationActuator'), stableObject: first === next,
timestampIncreased: next.timestamp > timestamp,
gamepadTag: Object.prototype.toString.call(next), buttonTag: Object.prototype.toString.call(next.buttons[0]),
events: window.fakeEvents
};
});
assert.equal(snapshot.slotCount, 4);
assert.deepEqual(snapshot.holes, [null, null, null]);
assert.equal(snapshot.id, profile.gamepad.id.value);
assert.equal(snapshot.index, 0);
assert.equal(snapshot.mapping, profile.gamepad.mapping.value);
assert.equal(snapshot.connected, true);
assert.deepEqual(snapshot.axes, profile.gamepad.axesRest.value, 'exact documented resting values');
assert.deepEqual(snapshot.buttons, Array.from({ length: profile.gamepad.buttons.value }, () => ({ value: 0, pressed: false, touched: false })));
assert.deepEqual(snapshot.actuator, profile.gamepad.vibration.value);
assert.equal(snapshot.actuatorPresent, profile.gamepad.vibration.presence ?
profile.gamepad.vibration.presence !== 'absent' : !!profile.gamepad.vibration.value);
assert(snapshot.stableObject, 'polls refresh the browser slot object');
assert.equal(snapshot.timestampIncreased, profile.gamepad.timestampPolicy?.value !== 'change', 'idle timestamp follows the documented backend policy');
assert.equal(snapshot.gamepadTag, '[object Gamepad]');
assert.equal(snapshot.buttonTag, '[object GamepadButton]');
assert.deepEqual(snapshot.events, [{ type: 'gamepadconnected', index: 0, id: profile.gamepad.id.value, connected: true }]);
const analog = buttonNames(profile).find(name => resolvedControl(profile, name).analog);
if (analog) {
const control = resolvedControl(profile, analog);
const changes = await page.evaluate(({ name, index, threshold, inclusive, steps }) => {
const old = navigator.getGamepads()[0];
fakePads.set(0, name, .05);
const beforePoll = old.buttons[index].value;
const light = { ...navigator.getGamepads()[0].buttons[index] };
const refreshedOld = old.buttons[index].value;
fakePads.set(0, name, .75);
const heavy = { ...navigator.getGamepads()[0].buttons[index] };
fakePads.release(0, name);
const expected = value => steps ? Math.round(value * steps) / steps : value;
return { beforePoll, refreshedOld, light: { value: light.value, touched: light.touched, pressed: light.pressed },
heavy: { value: heavy.value, touched: heavy.touched, pressed: heavy.pressed },
expectedLight: expected(.05), expectedHeavy: expected(.75), threshold, inclusive };
}, { name: analog, index: control.index, threshold: control.pressedThreshold ?? .5, inclusive: !!control.pressedInclusive, steps: control.steps });
assert.equal(changes.beforePoll, 0, 'the browser object keeps its previous sample until polled');
assert.equal(changes.refreshedOld, changes.expectedLight, 'polling refreshes retained Gamepad references');
for (const [state, expected] of [[changes.light, changes.expectedLight], [changes.heavy, changes.expectedHeavy]]) {
const pressed = changes.inclusive ? expected >= changes.threshold : expected > changes.threshold;
assert.equal(state.value, expected);
assert.equal(state.pressed, pressed);
assert.equal(state.touched, control.touchedPolicy === 'pressed' ? pressed : expected > 0);
}
}
const digital = controlAt(profile, 0);
const digitalCheck = await page.evaluate(name => {
let error = '';
try { fakePads.set(0, name, .25); } catch (caught) { error = caught.message; }
fakePads.press(0, name);
const pressed = navigator.getGamepads()[0].buttons[0].value;
fakePads.release(0, name);
return { error, pressed };
}, digital);
assert(digitalCheck.error, 'digital buttons reject invented analog pressure');
assert.equal(digitalCheck.pressed, 1);
const stickName = Object.keys(profile.controls).find(name => resolvedControl(profile, name)?.kind === 'stick');
if (stickName) {
const stick = resolvedControl(profile, stickName);
const moved = await page.evaluate(name => { fakePads.tilt(0, name, .6, -.7); return [...navigator.getGamepads()[0].axes]; }, stickName);
assert.equal(moved[stick.x], .6); assert.equal(moved[stick.y], -.7);
await release(page, stickName);
}
if (profile.gamepad.vibration.value) {
const feedback = await page.evaluate(async () => {
const actuator = navigator.getGamepads()[0].vibrationActuator, results = [];
for (const effect of actuator.effects) results.push(await actuator.playEffect(effect, { duration: 20, strongMagnitude: .25 }));
results.push(await actuator.reset());
let rejected = '';
try { await actuator.playEffect('unresearched-effect'); } catch (error) { rejected = error.name; }
const invalid = await actuator.playEffect(actuator.effects[0], { duration: -1 });
const unsupportedEffect = ['dual-rumble', 'trigger-rumble'].find(effect => !actuator.effects.includes(effect));
const unsupported = unsupportedEffect ? await actuator.playEffect(unsupportedEffect) : null;
return { results, rejected, invalid, unsupported, calls: fakePads.feedback() };
});
assert(feedback.results.every(result => result === 'complete'));
assert.equal(feedback.rejected, 'TypeError', 'unknown haptic enum values match browser rejection');
assert.equal(feedback.invalid, 'invalid-parameter');
if (profile.gamepad.vibration.value.effects.length === 1) assert.equal(feedback.unsupported, 'not-supported');
assert.equal(feedback.calls.length, profile.gamepad.vibration.value.effects.length + 1);
}
const strum = Object.keys(profile.controls).find(name => /^strum up$/i.test(name));
const dpadUp = Object.keys(profile.controls).find(name => /^d-pad up$/i.test(name));
if (strum && dpadUp && resolvedControl(profile, strum).index === resolvedControl(profile, dpadUp).index) {
const values = await page.evaluate(({ strum, dpadUp, index }) => {
fakePads.press(0, strum); fakePads.press(0, dpadUp);
fakePads.release(0, strum);
const stillHeld = navigator.getGamepads()[0].buttons[index].value;
fakePads.release(0, dpadUp);
return [stillHeld, navigator.getGamepads()[0].buttons[index].value];
}, { strum, dpadUp, index: resolvedControl(profile, strum).index });
assert.deepEqual(values, [1, 0], 'releasing strum preserves the shared D-pad contribution until both controls release');
}
for (const [name, spec] of Object.entries(profile.switches || {})) {
if (spec.kind === 'unreported') {
const error = await page.evaluate(name => { try { fakePads.switch(0, name, 'unknown'); return ''; } catch (error) { return error.message; } }, name);
assert(error, name + ' must not invent a browser signal');
} else if (spec.kind === 'state') {
for (const value of spec.values) {
const state = await page.evaluate(({ name, value }) => {
const before = navigator.getGamepads()[0]; fakePads.switch(0, name, value);
return { value: fakePads.switchState(0)[name], id: navigator.getGamepads()[0].id, axes: navigator.getGamepads()[0].axes, before: before.axes };
}, { name, value });
assert.equal(state.value, value); assert.equal(state.id, profile.gamepad.id.value);
assert.deepEqual(state.axes, state.before, 'physical switch with no documented browser effect adds no guessed input');
}
} else if (spec.kind === 'profile') {
for (const [value, target] of Object.entries(spec.values)) {
const expected = profiles.find(profile => profile.name === target);
assert(expected, 'Mode targets must have researched profiles');
const id = await page.evaluate(({ name, value }) => { fakePads.switch(0, name, value); return navigator.getGamepads()[0].id; }, { name, value });
assert.equal(id, expected.gamepad.id.value);
await page.evaluate(name => { fakePads.disconnect(0); fakePads.connect(name); }, profile.name);
}
}
}
await page.evaluate(() => { fakePads.reset(0); c4fTest.pollGamepads(performance.now()); });
}
async function runProfile(browser, profile) {
console.log('START fake profile: ' + profile.name);
const context = await browser.newContext({ viewport: { width: 1440, height: 1000 } });
await context.grantPermissions(['local-network-access']);
// A reload starts the same physical signature, with saved mappings retained.
await context.addInitScript({ content: fakeScript + '\n' +
'window.fakePads.loadProfiles(' + JSON.stringify(profiles).replace(/</g, '\\u003c') + ');\n' +
'window.fakeEvents = [];\n' +
'for (const type of ["gamepadconnected","gamepaddisconnected"]) window.addEventListener(type, event => fakeEvents.push({type,index:event.gamepad.index,id:event.gamepad.id,connected:event.gamepad.connected}));\n' +
'window.fakePads.connect(' + JSON.stringify(profile.name) + ');\n' });
const page = await context.newPage(), errors = [];
page.on('pageerror', error => errors.push(error.message));
await page.route(/^http:\/\/127\.0\.0\.1:4264\/(?:\?.*)?$/, async route => route.fulfill({ response: await route.fetch(), body: html }));
try {
await page.goto('http://127.0.0.1:4264/');
await sourceReady(page, profile);
await page.waitForFunction(() => document.querySelector('#conn').dataset.conn === 'open');
await checkApi(page, profile);
const row = page.locator('.gp-row[data-index="0"]');
assert.equal(await row.locator('.gp-name').textContent(), profile.gamepad.id.value.replace(/\s*\(.*?\)\s*/g, ' ').trim());
assert.equal((await row.textContent()).includes('buttons may be mixed up'), profile.gamepad.mapping.value === '');
assert((await row.textContent()).includes('Standard layout'));
await row.locator('[data-focus="pad-0"]').click();
await poll(page);
const button0 = controlAt(profile, 0);
assert(button0, profile.name + ' has no real name for raw button 0');
await press(page, button0);
await page.waitForFunction(() => !!(c4fTest.mergedState(0).buttons & 0x4000));
await release(page, button0);
await page.waitForFunction(() => !(c4fTest.mergedState(0).buttons & 0x4000));
await page.screenshot({ path: root + '/build/fake-' + profile.name + '-home.png', fullPage: true });
await openEditor(page);
await page.fill('#mapName', 'Local ' + profile.name);
const special = Object.keys(profile.controls).filter(name => /^(P[1-4])$|paddle shifter|fret|strum/i.test(name));
const availableOutputs = ['CIRCLE', 'SQUARE', 'TRIANGLE', 'L1', 'R1', 'L3', 'R3', 'SHARE', 'OPTIONS', 'PS', 'TOUCH'];
let assigned = 0;
for (const name of special) {
const control = resolvedControl(profile, name);
if (control.kind === 'unreported') {
const error = await page.evaluate(name => { try { fakePads.press(0, name); return ''; } catch (error) { return error.message; } }, name);
assert.match(error, /not independently exposed/, name + ' is honestly unavailable');
} else {
assert.equal(control.kind, 'button', 'Special hardware control needs an explicit tested capture action');
const output = availableOutputs[assigned++];
assert(output, 'Add output targets for additional distinct hardware controls');
await mapButton(page, profile, name, output);
}
}
if (!assigned) {
const second = controlAt(profile, 1);
assert(second); await mapButton(page, profile, second, 'CIRCLE');
}
if (profile.controls.Wheel) {
await changeControl(page, 'lx');
await page.waitForFunction(() => c4fTest.mapper.capture?.ready);
await page.evaluate(() => { fakePads.set(0, 'Wheel', .8); c4fTest.pollGamepads(performance.now()); });
// A stick is two moves; a wheel has only the first.
await page.waitForFunction(() => c4fTest.mapper.capture?.output === 'ly');
await page.click('#mapPanel button:has-text("Cancel")');
await page.waitForFunction(() => !c4fTest.mapper.capture);
const binding = await page.evaluate(() => c4fTest.mapper.draft.bindings.find(binding => binding.output === 'lx'));
assert.deepEqual(binding.input, { kind: 'axis', index: profile.controls.Wheel.index, mode: 'center' });
assert.equal(binding.invert, false, 'moving the wheel right binds positive lx');
await release(page, 'Wheel');
}
const hatName = Object.keys(profile.controls).find(name => resolvedControl(profile, name)?.kind === 'hat');
const hasDirections = ['up', 'right', 'down', 'left'].every(direction => Object.keys(profile.controls).some(name => name.toLowerCase() === 'd-pad ' + direction));
if (hatName || hasDirections) {
for (const direction of ['up', 'right', 'down', 'left']) {
await changeControl(page, direction.toUpperCase());
await page.waitForFunction(() => c4fTest.mapper.capture?.ready);
await hat(page, direction, hatName);
await page.waitForFunction(() => !c4fTest.mapper.capture);
const binding = await page.evaluate(output => c4fTest.mapper.draft.bindings.find(binding => binding.output === output), direction.toUpperCase());
if (hatName) {
assert.deepEqual(binding.input, { kind: 'hat', index: resolvedControl(profile, hatName).index, direction: direction.toUpperCase() });
const calibration = await page.evaluate(index => c4fTest.mapper.draft.calibration[index], resolvedControl(profile, hatName).index);
const slot = { up: 1, right: 3, down: 5, left: 7 }[direction];
assert.equal(calibration.values[0], resolvedControl(profile, hatName).neutral);
assert.equal(calibration.values[slot], resolvedControl(profile, hatName).directions[direction]);
} else assert.equal(binding.input.index, resolvedControl(profile, Object.keys(profile.controls).find(name => name.toLowerCase() === 'd-pad ' + direction)).index);
await hat(page, 'none', hatName);
}
}
const draft = await page.evaluate(() => c4fTest.ControllerMapping.copy(c4fTest.mapper.draft));
const heldBinding = draft.bindings.find(binding => binding.input.kind === 'button' && binding.input.mode === 'digital' && outputs[binding.output]?.[1] && controlAt(profile, binding.input.index));
assert(heldBinding, 'A named digital mapping is required for the hold gate');
const heldName = controlAt(profile, heldBinding.input.index), heldMask = outputs[heldBinding.output][1];
const otherBinding = draft.bindings.find(binding => binding.input.kind === 'button' && binding.input.mode === 'digital' &&
binding.input.index !== heldBinding.input.index && binding.output !== heldBinding.output && outputs[binding.output]?.[1] && controlAt(profile, binding.input.index));
assert(otherBinding, 'A second digital input is required to check independent release gates');
const otherName = controlAt(profile, otherBinding.input.index), otherMask = outputs[otherBinding.output][1];
await page.evaluate(() => document.querySelector('#toast').classList.remove('show'));
await page.locator('#mapView').evaluate(element => { element.scrollTop = 0; });
await page.screenshot({ path: root + '/build/fake-' + profile.name + '-mapping.png', fullPage: true });
if (profile.controls.Wheel) {
await page.setViewportSize({ width: 390, height: 844 });
await page.screenshot({ path: root + '/build/fake-' + profile.name + '-mapping-mobile.png', fullPage: true });
assert(await page.evaluate(() => document.querySelector('#mapView').scrollWidth <= innerWidth), 'wheel mapping has no mobile horizontal overflow');
await page.setViewportSize({ width: 1440, height: 1000 });
}
await press(page, heldName);
assert.equal(await page.evaluate(mask => c4fTest.mergedState(0).buttons & mask, heldMask), 0, 'editing never plays a draft');
await page.click('#mapSave');
await page.click('#mapBack');
await page.waitForFunction(() => c4fTest.mapper.index === null);
await poll(page);
assert.equal(await page.evaluate(mask => c4fTest.mergedState(0).buttons & mask, heldMask), 0, 'held mapped input is suppressed immediately after Save');
await press(page, otherName);
await page.waitForFunction(mask => !!(c4fTest.mergedState(0).buttons & mask), otherMask);
assert.equal(await page.evaluate(mask => c4fTest.mergedState(0).buttons & mask, heldMask), 0, 'another input cannot release the held binding');
const rests = await page.evaluate(() => [...navigator.getGamepads()[0].axes]);
assert.deepEqual(rests, profile.gamepad.axesRest.value, 'nonzero trigger/whammy/pedal rests do not block another input');
await release(page, otherName);
await release(page, heldName);
await press(page, heldName);
await page.waitForFunction(mask => !!(c4fTest.mergedState(0).buttons & mask), heldMask);
await release(page, heldName);
if (profile.controls.Wheel) {
await page.evaluate(() => { fakePads.set(0, 'Wheel', .8); c4fTest.pollGamepads(performance.now()); });
assert((await page.evaluate(() => c4fTest.mergedState(0).lx)) > 180, 'quick-mapped wheel drives the merged left-stick X');
await release(page, 'Wheel');
}
if (hatName || hasDirections) {
for (const direction of ['up', 'right', 'down', 'left']) {
await hat(page, direction, hatName);
assert(await page.evaluate(mask => !!(c4fTest.mergedState(0).buttons & mask), outputs[direction.toUpperCase()][1]), 'mapped D-pad ' + direction + ' reaches mergedState');
await hat(page, 'none', hatName);
}
}
const saved = await page.evaluate(() => c4fTest.ControllerMapping.copy(c4fTest.profileFor(c4fTest.gamepadSources.get(0))));
assert.notEqual(saved.id, 'default');
await page.reload();
await sourceReady(page, profile);
const reloaded = await page.evaluate(() => c4fTest.ControllerMapping.copy(c4fTest.profileFor(c4fTest.gamepadSources.get(0))));
assert.deepEqual(reloaded, saved, 'same browser ID/signature reloads the saved full bindings and calibration');
await page.locator('.gp-row[data-index="0"] [data-focus="pad-0"]').click();
await poll(page);
await press(page, heldName);
await page.waitForFunction(mask => !!(c4fTest.mergedState(0).buttons & mask), heldMask);
await release(page, heldName);
if (profile.controls.Wheel) {
// Intentional stress input: this is not a measured unplugged-pedal rest.
// The researched default remains +1; exercise the opposite axis endpoint.
const pedal = Object.keys(profile.controls).find(name => /pedal/i.test(name) && resolvedControl(profile, name)?.kind === 'axis');
assert(pedal);
await page.evaluate(name => { fakePads.set(0, name, -1); c4fTest.pollGamepads(performance.now()); }, pedal);
await openEditor(page);
await press(page, heldName);
await page.click('#mapBack');
await poll(page);
assert.equal(await page.evaluate(mask => c4fTest.mergedState(0).buttons & mask, heldMask), 0, 'held input waits after Save with a persistent -1 stress axis');
await press(page, otherName);
assert(await page.evaluate(mask => !!(c4fTest.mergedState(0).buttons & mask), otherMask), 'a persistent -1 stress axis never blocks another mapped button');
await release(page, otherName);
await release(page, heldName);
await press(page, heldName);
assert(await page.evaluate(mask => !!(c4fTest.mergedState(0).buttons & mask), heldMask), 'held input works after release despite the -1 stress axis');
await release(page, heldName);
await release(page, pedal);
}
await openEditor(page);
await changeControl(page, 'CROSS');
await page.waitForFunction(() => c4fTest.mapper.capture?.ready);
const disconnectedReference = await page.evaluate(() => {
const retained = navigator.getGamepads()[0]; fakePads.disconnect(0); c4fTest.pollGamepads(performance.now());
return retained.connected;
});
assert.equal(disconnectedReference, false, 'a retained Gamepad reports its disconnection');
await page.waitForFunction(() => c4fTest.gamepadSources.size === 0 && c4fTest.mapper.index === null && !c4fTest.mapper.capture);
assert.equal(await page.evaluate(() => navigator.getGamepads()[0]), null, 'disconnect retains a null slot');
assert.equal(await page.evaluate(() => fakeEvents.at(-1).type), 'gamepaddisconnected');
assert.equal(await page.evaluate(() => fakeEvents.at(-1).connected), false);
assert.equal(await page.evaluate(name => fakePads.connect(name), profile.name), 0, 'the browser slot is reused');
await sourceReady(page, profile);
assert.deepEqual(await page.evaluate(() => c4fTest.profileFor(c4fTest.gamepadSources.get(0)).bindings), saved.bindings);
assert.deepEqual(errors, [], 'no page errors, including disconnect mid-edit');
console.log('PASS fake profile: ' + profile.name + ' — exact snapshots, controls, quick mapping, release gates, persistence, disconnect');
} catch (error) {
await page.screenshot({ path: root + '/build/fake-' + profile.name + '-failure.png', fullPage: true }).catch(() => {});
error.message = profile.name + ': ' + error.message;
throw error;
} finally { await context.close(); }
}
async function runManualHarness(browser) {
const context = await browser.newContext({ viewport: { width: 1440, height: 1000 } });
await context.grantPermissions(['local-network-access']);
const page = await context.newPage(), errors = [];
page.on('pageerror', error => errors.push(error.message));
await page.route('http://127.0.0.1:8000/client/index.html', async route => route.fulfill({ response: await route.fetch(), body: html }));
try {
await page.goto('http://127.0.0.1:8000/tests/fake_gamepads.html');
await page.waitForFunction(() => !document.querySelector('#profile').disabled);
assert.equal(await page.locator('#profile option').count(), profiles.length);
const chosen = profiles.find(profile => profile.controls.Wheel) || profiles[0];
await page.selectOption('#profile', chosen.name);
await page.click('#connect');
const frame = page.frames().find(frame => frame.parentFrame());
assert(frame, 'Manual harness needs its controller iframe');
await frame.waitForFunction(() => window.c4fTest?.gamepadSources.size === 1);
await frame.waitForFunction(() => document.querySelector('#conn').dataset.conn === 'open');
assert.equal(await frame.evaluate(() => navigator.getGamepads()[0].id), chosen.gamepad.id.value);
const refused = await frame.evaluate(() => {
try { new WebSocket('wss://example.invalid/ws'); return ''; } catch (error) { return error.message; }
});
assert.match(refused, /only connects to the local test service/, 'the local manual harness rejects external WebSocket destinations');
await frame.click('.gp-row [data-focus="configure"]');
await changeControl(frame, 'CROSS');
await frame.waitForFunction(() => c4fTest.mapper.capture?.ready);
const name = controlAt(chosen, 0), index = Object.keys(chosen.controls).indexOf(name);
const hold = page.locator('#controls .control').nth(index).getByRole('button', { name: 'Hold', exact: true });
await hold.focus(); await page.keyboard.down('Space');
await frame.waitForFunction(() => !c4fTest.mapper.capture);
await page.keyboard.up('Space');
assert.equal(await frame.evaluate(() => c4fTest.mapper.draft.bindings.find(binding => binding.output === 'CROSS').input.index), 0, 'manual Hold drives the actual quick capture');
await page.click('#reset');
assert.deepEqual(await frame.evaluate(() => [...navigator.getGamepads()[0].axes]), chosen.gamepad.axesRest.value);
await page.screenshot({ path: root + '/build/fake-manual-harness.png', fullPage: true });
await page.click('#disconnect');
await frame.waitForFunction(() => c4fTest.gamepadSources.size === 0);
assert.equal(await frame.evaluate(() => navigator.getGamepads()[0]), null);
assert.deepEqual(errors, []);
console.log('PASS fake manual harness: local profile loading, iframe connection, Hold capture, reset and disconnect');
} finally { await context.close(); }
}
async function screenshotOverview(browser) {
const page = await browser.newPage({ viewport: { width: 1600, height: 1000 }, deviceScaleFactor: 1 });
try {
const cards = profiles.flatMap(profile => ['home', 'mapping'].map(screen => {
const image = fs.readFileSync(root + '/build/fake-' + profile.name + '-' + screen + '.png').toString('base64');
const label = (profile.name + ' — ' + screen).replace(/[&<>"']/g, character =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[character]);
return '<figure><figcaption>' + label + '</figcaption><img src="data:image/png;base64,' + image + '"></figure>';
})).join('');
await page.setContent('<!doctype html><html><head><meta charset="utf-8"><title>Fake gamepad screenshots</title>' +
'<style>*{box-sizing:border-box}body{margin:0;background:#101827;color:#f3f6fa;font:13px system-ui}' +
'main{display:grid;grid-template-columns:repeat(4,400px)}figure{margin:0;padding:0 0 12px}' +
'figcaption{padding:10px;min-height:54px;overflow-wrap:anywhere}img{display:block;width:400px;height:auto;object-fit:contain}</style>' +
'</head><body><main>' + cards + '</main></body></html>');
await page.evaluate(() => Promise.all([...document.images].map(image => image.decode())));
await page.screenshot({ path: root + '/build/fake-profiles-overview.png', fullPage: true });
} finally { await page.close(); }
}
(async () => {
let browser;
try {
await new Promise((resolve, reject) => { lab.once('error', reject); lab.listen(8000, '127.0.0.1', resolve); });
browser = await chromium.launch({ headless: true });
if (serviceError) throw serviceError;
for (const profile of profiles) await runProfile(browser, profile);
await runManualHarness(browser);
await screenshotOverview(browser);
if (!profiles.some(profile => /crkd|guitar/i.test(profile.name))) console.log('NOT FOUND: no sourced CRKD guitar profile; fret/strum hardware scenarios were not fabricated');
} finally {
if (browser) await browser.close();
await lab.stop();
service.kill();
}
})().catch(error => { console.error(error); process.exitCode = 1; });
+44 -3
View File
@@ -15,7 +15,7 @@ const sendClaimSource = extract(" let lastClaim = '';", ' const lastSent =
const pollSource = extract(' function pollGamepads(t)', ' function setGamepadPad(');
function padStatus(fields) {
return Object.assign({ known: true, mine: false, open: false }, fields);
return Object.assign({ known: true, mine: false, open: false, clients: 0 }, fields);
}
(async () => {
@@ -26,7 +26,7 @@ function padStatus(fields) {
conn: { state: 'open' },
keepAwake: { update() {} },
primaryPad: -1,
status: [padStatus({ open: true }), padStatus({ mine: true, open: true }), padStatus({}), padStatus({})],
status: [padStatus({ open: true, clients: 1 }), padStatus({ mine: true, open: true, clients: 1 }), padStatus({}), padStatus({})],
claimedPads: () => [...ctx.gamepadSources.values()].map(s => s.pad).filter(p => p >= 0).sort(),
gamepadSources: new Map([[0, { pad: 0 }], [1, { pad: 1 }]]),
call(method, params) {
@@ -47,6 +47,45 @@ function padStatus(fields) {
'the remaining controller is claimed again');
console.log('PASS a refused claim gives up only the controllers owned elsewhere');
// Rapid selections serialize one claim and retain the latest desired set,
// including an empty selection. Creation-busy is not another owner.
const serialClaims = [], deferred = [];
const serial = {
conn: { state: 'open' }, keepAwake: { update() {} }, primaryPad: -1,
status: [padStatus({ open: true, clients: 0 }), padStatus({ mine: true, clients: 1 }), padStatus({}), padStatus({})],
gamepadSources: new Map([[0, { pad: 0 }], [1, { pad: 1 }]]),
claimedPads: () => [...new Set([...serial.gamepadSources.values()].map(s => s.pad).filter(p => p >= 0))].sort(),
call(method, params) {
if (method === 'status') return Promise.resolve({ pads: [] });
serialClaims.push(params); return new Promise((resolve, reject) => deferred.push({ resolve, reject }));
}, applyStatus() {}, toast() {}, closePad() {}, renderGamepads() {},
};
vm.createContext(serial); vm.runInContext(sendClaimSource, serial);
vm.runInContext('sendClaim(true)', serial);
serial.gamepadSources.get(0).pad = 2; vm.runInContext('sendClaim()', serial);
for (const s of serial.gamepadSources.values()) s.pad = -1;
vm.runInContext('sendClaim()', serial);
assert.equal(serialClaims.length, 1);
deferred.shift().resolve({ pads: [] });
for (let i = 0; i < 4; i++) await new Promise(setImmediate);
assert.deepEqual(serialClaims, [[0, 1], []]);
deferred.shift().resolve({ pads: [] });
for (let i = 0; i < 4; i++) await new Promise(setImmediate);
serial.gamepadSources.get(0).pad = 0; serial.gamepadSources.get(1).pad = 1;
vm.runInContext('sendClaim(true)', serial);
deferred.shift().reject({ code: 409, message: 'Creation busy' });
for (let i = 0; i < 4; i++) await new Promise(setImmediate);
assert.equal(serial.gamepadSources.get(0).pad, 0);
assert.equal(serial.gamepadSources.get(1).pad, 1);
assert.equal(serialClaims.length, 3, 'busy refusal cannot loop');
vm.runInContext('sendClaim(true); resetClaims(); sendClaim(true)', serial);
assert.equal(deferred.length, 2);
deferred.shift().reject({ message: 'old connection' });
deferred.shift().resolve({ pads: [] });
for (let i = 0; i < 4; i++) await new Promise(setImmediate);
assert.equal(serial.gamepadSources.get(1).pad, 1, 'old connection failure cannot drop new selections');
console.log('PASS serialized claims, latest empty selection and unowned-busy preservation');
// getGamepads() can start throwing after it has worked. Held buttons must not
// stay in the source: nothing would be left to release them.
const pads = {
@@ -55,7 +94,9 @@ function padStatus(fields) {
navigator: { getGamepads() { throw Error('SecurityError'); } },
gamepadSources: new Map([[0, { pad: 0, buttons: 0x4000, lx: 0, ly: 255, rx: 0, ry: 0, l2: 255, r2: 255, touches: [{}] }]]),
blankSource: pad => ({ pad, buttons: 0, lx: 128, ly: 128, rx: 128, ry: 128, l2: 0, r2: 0, touches: [] }),
renderGamepads() {}, sendClaim() {}, $: () => null,
renderGamepads() {}, sendClaim() {}, $: () => ({ textContent: '' }),
clearDevice(src) { Object.assign(src, pads.blankSource(src.pad)); },
mapper: { index: null, cancelCapture() {}, status() {} },
};
vm.createContext(pads);
vm.runInContext(pollSource, pads);
+6
View File
@@ -0,0 +1,6 @@
#ifndef C4F_TEST_ORBIS_KERNEL_H
#define C4F_TEST_ORBIS_KERNEL_H
#include <stdint.h>
typedef struct { int Size; char VersionString[28]; uint32_t Version; } OrbisKernelSwVersion;
int sceKernelGetSystemSwVersion(OrbisKernelSwVersion *version);
#endif
+11
View File
@@ -3,6 +3,7 @@
#include <stdarg.h>
#include <string.h>
#include <time.h>
#include <sys/stat.h>
#include <unistd.h>
#include "c4f_log.h"
@@ -32,5 +33,15 @@ int main(void)
file = fopen(C4F_LOG_PATH ".previous", "r"); assert(file);
char previous[8192]; size_t m = fread(previous, 1, sizeof(previous), file); fclose(file);
assert(m == n && memcmp(data, previous, n) == 0);
c4fLogOpen();
for (int i = 0; i < 2400; i++) c4fLog("%s", large);
c4fLogClose();
struct stat active, rollover;
assert(!stat(C4F_LOG_PATH, &active) && !stat(C4F_LOG_PATH ".1", &rollover));
assert(active.st_size <= 1024 * 1024 && rollover.st_size <= 1024 * 1024);
assert(active.st_size > 0 && rollover.st_size > 0);
c4fLogOpen(); c4fLogClose();
assert(access(C4F_LOG_PATH ".1", F_OK) != 0);
puts("PASS bounded active/rollover segments and stale rollover removal on restart");
puts("PASS elapsed timestamps, multiline/truncated messages and previous-log preservation");
}
+137
View File
@@ -0,0 +1,137 @@
// Synthetic device snapshots exercise the shipped pure mapper, not a test copy.
const fs = require('node:fs'), vm = require('node:vm'), assert = require('node:assert/strict');
const page = fs.readFileSync('client/index.html', 'utf8');
const ctx = {};
vm.createContext(ctx);
const extract = (start, end) => page.slice(page.indexOf(start), page.indexOf(end, page.indexOf(start)));
vm.runInContext(extract(' const B = {', ' const VALID_BUTTONS') +
extract(' const ControllerMapping =', ' const profileStore =') + '\nthis.M = ControllerMapping; this.B = B;', ctx);
const { M, B } = ctx;
const device = (buttons = 24, axes = 6) => ({ id: 'Synthetic controller', mapping: '', buttons: Array.from({ length: buttons }, () => ({ value: 0, pressed: false })), axes: Array(axes).fill(0) });
const gp = device(), sig = M.signature(gp);
const fresh = () => ({ id: 'test', name: 'Test', signature: sig, calibration: {}, bindings: [] });
const button = (i, output, mode = 'digital', options = {}) => M.binding({ kind: 'button', index: i, mode }, output, options);
const axis = (i, output, mode = 'center', options = {}) => M.binding({ kind: 'axis', index: i, mode }, output, options);
const evaluate = (p, g = gp, state) => M.toSource(M.evaluate(M.read(g), p, state));
let p = fresh();
p.bindings = [button(0, 'CROSS'), button(23, 'CROSS'), button(23, 'SQUARE')];
for (const ordering of [[0, 23], [23, 0]]) {
gp.buttons.forEach(b => { b.value = 0; b.pressed = false; });
for (const i of ordering) { gp.buttons[i].value = 1; assert(evaluate(p).buttons & B.CROSS); }
gp.buttons[ordering[0]].value = 0; assert(evaluate(p).buttons & B.CROSS);
gp.buttons[ordering[1]].value = 0; assert.equal(evaluate(p).buttons, 0);
}
gp.buttons[23].value = 1; assert.equal(evaluate(p).buttons, B.CROSS | B.SQUARE); gp.buttons[23].value = 0;
console.log('PASS extra buttons, duplicate press/release orderings and one-to-many bindings');
p = M.browserDefault(sig);
for (let n = -1000; n <= 1000; n++) {
gp.axes[0] = n / 1000;
const expected = Math.abs(gp.axes[0]) < .06 ? 128 : Math.round((gp.axes[0] + 1) / 2 * 255);
assert.equal(evaluate(p).lx, expected);
}
for (let i = 0; i < 18; i++) {
gp.buttons.forEach(b => b.value = 0); gp.buttons[i].value = 1;
const s = evaluate(p);
if (i === 6 || i === 7) assert.equal(s[i === 6 ? 'l2' : 'r2'], 255);
else assert(s.buttons & B[p.bindings[i].output]);
}
gp.buttons.forEach(b => b.value = 0); gp.axes.fill(0);
gp.buttons[6].value = .25; assert.equal(evaluate(p).l2, 64); assert.equal(evaluate(p).buttons & B.L2, 0);
gp.buttons[6].value = 0;
const legacyConflict = M.copy(p); legacyConflict.bindings = legacyConflict.bindings.filter(b => b.input.kind === 'axis');
legacyConflict.bindings[1].output = 'lx'; gp.axes[0] = .5; gp.axes[1] = -.5;
assert.equal(evaluate(legacyConflict).lx, 128, 'legacy transfer must also merge before quantization'); gp.axes.fill(0);
console.log('PASS standard layout and original stick transfer parity; analog trigger pressure stays independent');
for (let byte = 0; byte <= 255; byte++) {
const original = { buttons: 0, lx: byte, ly: byte, rx: byte, ry: byte, l2: byte, r2: byte };
const result = M.toSource(M.fromSource(original));
assert.equal(result.lx, byte); assert.equal(result.l2, byte);
}
p = fresh(); p.calibration = { 0: { type: 'center', min: -.8, center: .2, max: .9 },
1: { type: 'pedal', released: -1, full: 1 }, 2: { type: 'pedal', released: 1, full: -1 } };
p.bindings = [axis(0, 'lx'), axis(1, 'R2', 'pedal'), axis(2, 'L2', 'pedal')];
gp.axes = [.2, -1, 1, 0, 0, 0]; assert.equal(evaluate(p).lx, 128); assert.equal(evaluate(p).r2, 0);
gp.axes[0] = -.8; assert.equal(evaluate(p).lx, 0); gp.axes[0] = .9; assert.equal(evaluate(p).lx, 255);
gp.axes[1] = 0; gp.axes[2] = -1; assert.equal(evaluate(p).r2, 128); assert.equal(evaluate(p).l2, 255);
p = fresh(); p.bindings = [axis(0, 'R2', 'positive'), axis(0, 'L2', 'negative')];
gp.axes[0] = -.5; assert.equal(evaluate(p).l2, 128); assert.equal(evaluate(p).r2, 0);
gp.axes[0] = .5; assert.equal(evaluate(p).l2, 0); assert.equal(evaluate(p).r2, 128);
p = fresh(); p.bindings = [axis(0, 'lx', 'center', { deadzone: .1, saturation: .9, exponent: 2, invert: true })];
gp.axes[0] = .5; assert.equal(evaluate(p).lx, 96); gp.axes[0] = .05; assert.equal(evaluate(p).lx, 128);
console.log('PASS asymmetric wheels, separate/reversed/combined pedals, inversion, deadzones, saturation and curves');
p = fresh(); p.bindings = [axis(0, 'CROSS', 'positive')]; const transient = {};
for (const [v, expected] of [[.49, 0], [.51, B.CROSS], [.45, B.CROSS], [.39, 0], [.45, 0]]) {
gp.axes[0] = v; assert.equal(evaluate(p, gp, transient).buttons, expected);
}
gp.axes[0] = NaN; assert.equal(evaluate(p, gp, transient).buttons, 0); assert.equal(transient[0], undefined);
p = fresh(); p.calibration[5] = { type: 'hat', values: [3, -1, -.75, -.5, -.25, 0, .25, .5, .75], tolerance: .05 };
p.bindings = ['UP', 'RIGHT', 'DOWN', 'LEFT'].map(direction => M.binding({ kind: 'hat', index: 5, direction }, direction));
assert.equal(M.validate(p), '');
for (let i = 0; i < 9; i++) { gp.axes[5] = p.calibration[5].values[i]; assert.equal(evaluate(p).buttons, M.hats[i].reduce((n, key) => n | B[key], 0)); }
gp.axes[5] = 2; assert.equal(evaluate(p).buttons, 0);
console.log('PASS hysteresis, invalid readings and all nine encoded-hat positions');
p = fresh(); p.bindings = [button(0, 'LS_LEFT'), button(1, 'LS_RIGHT')];
gp.buttons[0].value = 1; gp.buttons[1].value = 1; assert.equal(evaluate(p).lx, 128);
const other = device(); other.buttons[1].value = 1; gp.buttons[1].value = 0;
assert.equal(M.toSource(M.merge([M.evaluate(M.read(gp), p), M.evaluate(M.read(other), p)])).lx, 128);
p = fresh(); p.bindings = [axis(0, 'lx'), axis(1, 'lx')]; gp.axes[0] = -.5; gp.axes[1] = .5; assert.equal(evaluate(p).lx, 128);
gp.axes[1] = .8; assert.equal(evaluate(p).lx, 230);
p = fresh(); p.bindings = [button(2, 'L2', 'pressure'), button(3, 'L2', 'pressure')]; gp.buttons[2].value = .3; gp.buttons[3].value = .6; assert.equal(evaluate(p).l2, 153);
p = fresh(); p.bindings = [button(0, 'CROSS'), button(1, 'SQUARE'), axis(4, 'ry'), button(2, 'UP'), button(3, 'DOWN')];
gp.axes[4] = .75; assert.equal(evaluate(p).ry, 223); assert(evaluate(p).buttons & B.CROSS);
console.log('PASS digital conflicts, analog conflicts, cross-device merging, trigger maximum and guitar-style sources');
const exported = M.exportProfile(p), imported = M.importProfile(JSON.parse(JSON.stringify(exported)));
assert.equal(JSON.stringify(imported), JSON.stringify(p)); imported.name = 'Independent draft'; assert.notEqual(imported.name, p.name);
for (const bad of [null, {}, { ...exported, schema: 99 }, { ...exported, profile: { ...p, bindings: [{}] } }]) assert.throws(() => M.importProfile(bad));
const badCalibration = M.copy(p); badCalibration.calibration[0] = { type: 'pedal', released: 1, full: 1 }; assert(M.validate(badCalibration));
const mismatch = { ...sig, buttons: 1, axes: 0 }; assert(M.unresolved(p, mismatch));
gp.buttons.forEach(b => b.value = 0); gp.axes.fill(0);
p = fresh(); p.calibration[0] = { type: 'pedal', released: -1, full: 1 }; p.bindings = [axis(0, 'R2', 'pedal')];
const held = (p, values) => { gp.axes.splice(0, values.length, ...values); const hold = M.holdBack(p); return M.letGo(M.read(gp), p, hold); };
assert.deepEqual(Object.keys(held(p, [-1])), [], 'a released pedal plays at once');
assert.deepEqual(Object.keys(held(p, [-.5])), ['0'], 'a half-pressed pedal waits');
assert.deepEqual(Object.keys(held(p, [null])), ['0'], 'an unreadable input waits');
console.log('PASS JSON round-trip, draft copying, schema/data rejection, unavailable inputs and held-input gating');
// Only the binding whose input is still held waits. A worn stick resting off
// centre, or an axis resting at one end, never silences the rest of the gamepad.
gp.buttons.forEach(b => b.value = 0); gp.axes.fill(0);
p = M.browserDefault(sig);
gp.axes[0] = .12; gp.axes[2] = -1; gp.buttons[1].value = 1;
let hold = M.letGo(M.read(gp), p, M.holdBack(p));
assert.deepEqual(Object.keys(hold).map(i => p.bindings[i].output), ['CIRCLE'], 'only the held button waits');
gp.buttons[0].value = 1;
let out = M.toSource(M.evaluate(M.read(gp), p, {}, hold));
assert.equal(out.buttons, B.CROSS, 'other buttons play while one is held');
assert.notEqual(out.lx, 128, 'a stick off centre still plays');
gp.buttons[1].value = 0; M.letGo(M.read(gp), p, hold); gp.buttons[1].value = 1;
assert.equal(M.toSource(M.evaluate(M.read(gp), p, {}, hold)).buttons, B.CROSS | B.CIRCLE, 'a let-go button plays again');
const inverted = fresh(); inverted.bindings = [button(0, 'CROSS', 'digital', { invert: true })];
gp.buttons[0].value = 0;
assert.deepEqual(Object.keys(M.letGo(M.read(gp), inverted, M.holdBack(inverted))), [], 'a reversed button at rest is not held');
gp.buttons.forEach(b => b.value = 0); gp.axes.fill(0);
console.log('PASS a held input waits on its own; drifting sticks and resting axes never block the gamepad');
// Parse the entire script to catch errors outside the extracted functions.
new vm.Script(page.match(/<script>([\s\S]*?)<\/script>/)[1]);
// Setting a range a step at a time: a stick from its rest and both ends, a hat
// from its rest and four directions, with the diagonals between them.
const range = M.centeredRange(.02, .97, -.93);
assert.deepEqual({ ...range }, { type: 'center', min: -.93, center: .02, max: .97 });
assert.equal(M.centeredRange(0, .05, -1), null, 'one end too close to the rest');
const hat = M.hatFromCardinals(3.2857, -1, -3 / 7, 1 / 7, 5 / 7);
const usual = [3.2857, -1, -5 / 7, -3 / 7, -1 / 7, 1 / 7, 3 / 7, 5 / 7, 1];
hat.values.forEach((v, i) => assert(Math.abs(v - usual[i]) < 1e-9, 'hat position ' + i));
p = fresh(); p.calibration[0] = { ...hat, values: [...hat.values] };
assert.equal(M.validate(Object.assign(p, { bindings: ['UP', 'RIGHT', 'DOWN', 'LEFT'].map(direction => M.binding({ kind: 'hat', index: 0, direction }, direction)) })), '');
gp.axes[0] = -5 / 7;
assert.equal(evaluate(p).buttons, B.UP | B.RIGHT, 'a recorded hat reads its diagonal');
gp.axes[0] = 0;
assert.equal(M.hatFromCardinals(3, 0, 0, 1, -1), null, 'directions that read the same are refused');
console.log('PASS ranges set a step at a time: sticks, and hats with their diagonals');
+23
View File
@@ -0,0 +1,23 @@
"""Tag-specific release notes keep documentation links usable on GitHub Releases."""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'tools'))
from release_notes import release_notes
text = '''# Changelog
## 2.3.4 — Unreleased
[Guide](docs/playing.md#gamepads) [External](https://example.com/) [Anchor](#top)
## 2.3.3 — Before
older notes
'''
result = release_notes(text, '2.3.4', 'example/project')
assert '[Guide](https://github.com/example/project/blob/v2.3.4/docs/playing.md#gamepads)' in result
assert '[External](https://example.com/)' in result and '[Anchor](#top)' in result
assert 'older notes' not in result
try:
release_notes(text, 'missing', 'example/project')
except ValueError:
pass
else:
raise AssertionError('missing changelog version accepted')
print('PASS release-note extraction and tag-specific links')
+2
View File
@@ -4,6 +4,8 @@ import subprocess
def main():
subprocess.run(['node', 'tests/frontend_test.cjs'], check=True, timeout=30)
subprocess.run(['node', 'tests/mapping_test.cjs'], check=True, timeout=30)
subprocess.run(['python3', '-B', 'tests/release_notes_test.py'], check=True, timeout=10)
if __name__ == '__main__':
+13 -1
View File
@@ -47,6 +47,11 @@ def one_reply(address, reply, check=None):
def main():
web.build()
subprocess.run(['clang-18', '-std=gnu11', '-D_DEFAULT_SOURCE', '-Wall', '-Wextra', '-Werror',
'-ffunction-sections', '-fdata-sections', '-Ilauncher', '-Itests/include',
'tests/diag_test.c', '-Wl,--gc-sections,--wrap=write,--wrap=fsync', '-pthread',
'-o', 'build/diag-host-test'], check=True)
subprocess.run(['build/diag-host-test'], check=True)
subprocess.run(['clang-18', '-std=gnu11', '-D_DEFAULT_SOURCE', '-Wall', '-Wextra', '-Werror',
'-Ilauncher', '-Ivendor/jsmn', 'launcher/service.c', 'launcher/autorun.c', 'launcher/sandbox.c',
'tests/launcher_cli.c', '-o', str(CLI)], check=True)
@@ -143,6 +148,13 @@ def main():
assert text.count('[AutoRun]') == 1 and '/user/data/payloads/other.bin = 1' in text and ours in text, text
assert (root / 'payloads/control4free.elf').read_bytes() == BUNDLED.read_bytes()
assert autorun('autorun-check', str(BUNDLED)).startswith('1|')
(root / 'payloads/control4free.elf').unlink()
assert autorun('autorun-check', str(BUNDLED)).startswith('2|Enabled, but the payload is missing')
autorun('autorun-on', str(BUNDLED))
original_ini = ini.read_bytes()
ini.unlink(); ini.mkdir()
assert autorun('autorun-check', str(BUNDLED)).startswith('-1|Could not inspect AutoRun')
ini.rmdir(); ini.write_bytes(original_ini)
autorun('autorun-on', str(BUNDLED))
assert ini.read_text().count('control4free.elf') == 1
(root / 'payloads/control4free.elf').write_bytes(BUNDLED.read_bytes()[:-1] + b'\x01')
@@ -165,7 +177,7 @@ def main():
sys.path.insert(0, str(ROOT / 'tools'))
import package_launcher as package
package.build_art_tool()
for state in ('setup', 'running', 'stopped', 'outdated', 'confirm', 'busy', 'locked', 'unknown'):
for state in ('setup', 'running', 'stopped', 'outdated', 'mismatch', 'confirm', 'busy', 'locked', 'unknown'):
png = ROOT / f'build/launcher-preview-{state}.png'
package.render(['screen', state], png, 1920, 1080)
assert png.stat().st_size > 50000, png
+106 -7
View File
@@ -59,12 +59,17 @@ class Client:
self.sock.sendall(head + (mask if masked else b'') + bytes(b ^ (mask[i % 4] if masked else 0) for i, b in enumerate(payload)))
def receive(self):
first, second = self.exact(2)
size = second & 127
if size == 126:
size = struct.unpack('!H', self.exact(2))[0]
assert not second & 128
return first & 15, self.exact(size)
while True:
first, second = self.exact(2)
size = second & 127
if size == 126:
size = struct.unpack('!H', self.exact(2))[0]
assert not second & 128
data = self.exact(size)
if first & 15 == 9:
self.frame(data, op=10)
continue
return first & 15, data
def request(self, method, params=()):
ident = self.next_id
@@ -378,7 +383,12 @@ def main():
c.wait_for(rumble(0, 200, 40))
time.sleep(.05)
assert c.request('status')['result']['pads'][0]['color'] == [255, 0, 0], 'player 2 red, at full'
assert 'web controller 1 light bar 40 00 00' in server.rows(), 'the colour is logged for diagnosis'
# Latest colour is logged within a second, while feedback itself stays fast.
end = time.monotonic() + 1.2
while 'web controller 1 light bar 40 00 00' not in server.rows():
assert time.monotonic() < end, 'latest colour never logged'
c.input(0)
time.sleep(.02)
watcher = Client()
os.write(server.log, b'C4F-TEST-FEEDBACK 11030d 0 0 0 64 0\n')
c.wait_for(rumble(0, 0, 0))
@@ -480,5 +490,94 @@ def main():
server.close()
def reliability():
# Reserve retained and newly-created slots throughout a multi-device claim.
server = Server(C4F_TEST_ADD_DELAY='1250')
clients = []
try:
first = server.c
first.request('claim', [0])
first.close()
owner = Client(); observer = Client(); clients += [owner, observer]
owner.frame(json.dumps(dict(id=80, method='claim', params=[0, 1, 2])))
time.sleep(.25)
assert observer.request('claim', [0])['error']['code'] == 409
owner.frame(json.dumps(dict(id=81, method='claim', params=[])))
owner.frame(json.dumps(dict(id=82, method='claim', params=[3])))
replies = {}
while 80 not in replies:
op, data = owner.receive()
if op == 1:
msg = json.loads(data)
if 'id' in msg: replies[msg['id']] = msg
assert replies[81]['error']['code'] == 409 and replies[82]['error']['code'] == 409
assert all(replies[80]['result']['pads'][i]['mine'] for i in (0, 1, 2))
assert len([r for r in server.rows() if r.startswith('ADD')]) == 3
assert not any(r.startswith('REMOVE') for r in server.rows()), 'retained reservation was reaped'
print('PASS full-claim reservations, retained-pad reaper protection and same-client competing claims', flush=True)
finally:
for c in clients: c.close()
server.close()
# A departing client must not cause the remaining AddDevice calls to run.
server = Server(C4F_TEST_ADD_DELAY='600')
try:
c = server.c
c.request('claim', [0])
c.frame(json.dumps(dict(id=70, method='claim', params=[0, 1, 2, 3])))
end = time.monotonic() + 2
while len([r for r in server.rows() if r.startswith('ADD')]) < 2:
assert time.monotonic() < end
time.sleep(.01)
c.close()
time.sleep(.75)
server.c = Client()
assert len([r for r in server.rows() if r.startswith('ADD')]) == 2
assert any(r == 'REMOVE 12030d' for r in server.rows())
assert not any(r == 'REMOVE 11030d' for r in server.rows()), 'pre-existing device rolled back'
assert server.c.request('claim', [0])['result']['pads'][0]['mine']
print('PASS cancellation captures issued creation, skips remaining work and preserves pre-existing pads', flush=True)
finally:
server.close()
server = Server(C4F_FAIL_INPUT='1')
try:
server.c.request('claim', [0]); server.c.input(0, 0x4000)
time.sleep(.08)
assert any('InsertData = ' in r for r in server.rows())
server.c.input(0); time.sleep(.04)
assert server.c.request('status')['result']['pads'][0]['error'] == 0
print('PASS successful input insertion clears transient errors', flush=True)
finally:
server.close()
server = Server()
abandoned = []
try:
abandoned = [Client() for _ in range(5)]
handshake = (b'GET /ws HTTP/1.1\r\nHost: 127.0.0.1:4264\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n'
b'Sec-WebSocket-Version: 13\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\r\n')
assert b'503' in http(handshake), 'seventh upgrade accepted'
assert b'200 OK' in http(b'GET /api/status HTTP/1.1\r\nHost: 127.0.0.1:4264\r\nX-Control4Free-Launcher: 1\r\n\r\n')
start = time.monotonic()
# Unrelated text and wrong pongs cannot refresh a challenge deadline.
noisy = abandoned[0]
while time.monotonic() - start < 15.6:
assert server.c.request('ping')['result'] == {}
try: noisy.frame(b'wrong-token', op=10); noisy.frame('{"method":"ping","params":[]}')
except OSError: pass
time.sleep(.15)
for c in abandoned:
c.sock.settimeout(1)
while c.sock.recv(65536): pass
newcomer = Client(); newcomer.close()
assert server.c.request('info')['result']['pads'] == 4
print('PASS six-WebSocket cap preserves HTTP; matching pong keeps live client; abandoned/noisy sockets reclaimed', flush=True)
finally:
for c in abandoned: c.close()
server.close()
if __name__ == '__main__':
main()
reliability()
+2 -1
View File
@@ -54,9 +54,10 @@ int main(int argc, char **argv)
snprintf(s.address, sizeof(s.address), "http://192.168.1.20:4264");
const char *state = argv[2], *message = "";
s.autorun = C4F_AUTORUN_ON;
if (!strcmp(state, "running") || !strcmp(state, "confirm") || !strcmp(state, "outdated")) {
if (!strcmp(state, "running") || !strcmp(state, "confirm") || !strcmp(state, "outdated") || !strcmp(state, "mismatch")) {
s.running = 1; s.controllers = 2; s.confirmStop = !strcmp(state, "confirm");
if (!strcmp(state, "outdated")) s.autorun = C4F_AUTORUN_OUTDATED;
if (!strcmp(state, "mismatch")) snprintf(s.runningVersion, sizeof(s.runningVersion), "older build");
message = "Ready. Open the address on your phone or PC.";
} else if (!strcmp(state, "setup")) {
s.autorun = C4F_AUTORUN_OFF; message = "Not running.";
+37
View File
@@ -0,0 +1,37 @@
#!/usr/bin/env python3
"""Extract release notes and make repository-relative Markdown links tag-specific."""
import re
import sys
from pathlib import Path
from urllib.parse import urljoin, urlsplit
def release_notes(changelog, version, repository):
lines = changelog.splitlines(keepends=True)
active = False
notes = []
for line in lines:
if line.startswith('## '):
if active:
break
active = line.startswith(f'## {version} ')
continue
if active:
notes.append(line)
text = ''.join(notes).strip()
if not text:
raise ValueError(f'CHANGELOG.md has no notes for {version}')
base = f'https://github.com/{repository}/blob/v{version}/'
def absolute(match):
label, target = match.groups()
if target.startswith('#') or urlsplit(target).scheme or target.startswith('//'):
return match.group(0)
return f'[{label}]({urljoin(base, target)})'
return re.sub(r'\[([^\]]+)\]\(([^\s)]+)\)', absolute, text) + '\n'
if __name__ == '__main__':
path, version, repository = sys.argv[1:]
print(release_notes(Path(path).read_text(), version, repository), end='')