Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
252e26d2b7 | ||
|
|
41c476b2e2 | ||
|
|
017b8e2473 | ||
|
|
96326c71b0 | ||
|
|
f57224ee39 | ||
|
|
9d0c3d6a7d | ||
|
|
b583d149d8 | ||
|
|
eceeb7bf46 | ||
|
|
056a32f6c9 | ||
|
|
745c16decf | ||
|
|
94e4a7ecd7 | ||
|
|
7d3eb00e52 | ||
|
|
1b4ee87294 | ||
|
|
200e2bfc0b | ||
|
|
be950bf7b5 | ||
|
|
028cb9f2f4 | ||
|
|
6d9949f88b | ||
|
|
627e2cec63 | ||
|
|
baf36d648e | ||
|
|
183f9dfaee | ||
|
|
1cc59c12dc |
No files matched your search
@@ -40,10 +40,11 @@ body:
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: log
|
||||
id: diagnostics
|
||||
attributes:
|
||||
label: Log (optional)
|
||||
label: Diagnostic report
|
||||
description: |
|
||||
`/data/control4free/control4free.log` on the PS4, over GoldHEN's FTP server (port 2121). If the problem was before a restart, it's in `control4free.log.previous`.
|
||||
The log contains your console's network address: remove it if you prefer.
|
||||
render: text
|
||||
Install the diagnostic package, `Control4Free-<version>-diag.pkg` from the [latest release](https://github.com/MoHadiShibli/Control4Free/releases/latest), over the app. Open **Control4Free (diagnostic)**, make the problem happen again, then attach a screenshot of each of its 5 pages here (**R1** changes the page). Or attach the report it saves: `control4free-diag.txt` on a USB stick that was plugged in, or `/data/control4free/diag-report.txt` over GoldHEN's FTP server. [Step by step](https://github.com/MoHadiShibli/Control4Free/blob/main/docs/troubleshooting.md#reporting-a-problem).
|
||||
The report shows your console's local network address. It only works inside your home network, but you can blur it.
|
||||
validations:
|
||||
required: true
|
||||
@@ -1,7 +1,8 @@
|
||||
name: build
|
||||
|
||||
# Builds both toolchain images, the payload, the installable package, and runs
|
||||
# every host test. On a vX.Y.Z tag it also publishes the release.
|
||||
# Builds both toolchain images, the payload, the installable package and its
|
||||
# diagnostic version, and runs every host test. On a vX.Y.Z tag it also
|
||||
# publishes the release.
|
||||
on:
|
||||
push:
|
||||
branches: [main, next]
|
||||
@@ -23,21 +24,31 @@ jobs:
|
||||
docker build -t control4free-build -f docker/Dockerfile docker
|
||||
docker build -t control4free-launcher-build -f docker/Dockerfile.launcher docker
|
||||
|
||||
- name: Build the payload and the package
|
||||
- name: Build the payload, the package and the diagnostic package
|
||||
run: |
|
||||
docker run --rm --network none -v "$PWD:/src" -w /src \
|
||||
control4free-launcher-build bash -lc 'make && make -C launcher'
|
||||
control4free-launcher-build bash -lc 'make && make -C launcher && make C4F_DIAG=1 && make -C launcher C4F_DIAG=1'
|
||||
|
||||
- name: Host tests
|
||||
run: |
|
||||
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)
|
||||
mkdir -p artifacts
|
||||
cp build/control4free.elf "build/Control4Free-$version.pkg" artifacts/
|
||||
cp build/control4free.elf "build/Control4Free-$version.pkg" "build/Control4Free-$version-diag.pkg" artifacts/
|
||||
cd artifacts && sha256sum * > SHA256SUMS && cat SHA256SUMS
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
@@ -59,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
|
||||
@@ -3,6 +3,70 @@
|
||||
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-09
|
||||
|
||||
Gamepad mapping.
|
||||
|
||||
- Map a gamepad on a DualShock 4: each control shows what drives it and lights up when pressed.
|
||||
Click a control, then press the gamepad button for it. Map browser-exposed inputs from gamepads, wheels,
|
||||
guitars, pedals and arcade sticks to DualShock 4 buttons, sticks and triggers. Wheel and guitar model
|
||||
compatibility remains unverified, and native accessory identity is not provided. Sticks and
|
||||
triggers show their position with a dead zone and a curve. **Gamepad Controls** 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 screen.
|
||||
- Mappings are saved as named profiles in the browser. Save updates every connected gamepad using that
|
||||
profile; Save as new gives the selected gamepad a separate profile. Profiles can be exported to a file
|
||||
and imported on another device for review before saving.
|
||||
- Several gamepads can play as the same controller, for example a wheel and its pedals.
|
||||
- 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.
|
||||
- Signing out of a PS4 user disconnects their virtual controllers and clears their names and browser
|
||||
selections. Other signed-in players stay connected.
|
||||
- The mapping header aligns its controls, and gamepad rows group the device name, controller choice and
|
||||
Mapping button. The PS button uses the PlayStation mark and shows press feedback.
|
||||
- Documented a Windows GameInput workaround for Xbox buttons crossing gamepads in affected Chrome/Edge
|
||||
builds. A two-controller Chrome/Windows 11 test confirmed it; it is not a general requirement. See
|
||||
[Gamepad troubleshooting](docs/troubleshooting.md#gamepads).
|
||||
- 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.
|
||||
- Active service logs are bounded to two 1 MiB files. The last segment of the previous run is kept separately.
|
||||
- The diagnostic app says whether it managed to save its report.
|
||||
|
||||
## 1.1.1 — 2026-10-07
|
||||
|
||||
Fixes.
|
||||
|
||||
- Control4Free starts on firmware 13.52. Before, it stopped before doing anything on that firmware:
|
||||
GoldHEN said the payload launched, but the app said it never answered. The payload's start-up code now
|
||||
comes from a newer ps4-payload-sdk that knows 13.52's kernel.
|
||||
- When a start fails, press **Cross** to try again. The app no longer asks you to restart the PS4: a second
|
||||
copy of Control4Free quits on its own, so two can't end up running.
|
||||
- Each release now includes a diagnostic package, `Control4Free-<version>-diag.pkg`. Its app shows what the
|
||||
PS4 reports (firmware, GoldHEN, the logs, each connection from a phone or PC) on pages you can screenshot
|
||||
for a bug report. See [Reporting a problem](docs/troubleshooting.md#reporting-a-problem).
|
||||
|
||||
## 1.1.0 — 2026-10-06
|
||||
|
||||
Rumble and the light bar.
|
||||
|
||||
- When a game rumbles a controller, the device driving it feels it: an Android phone buzzes, and a gamepad
|
||||
playing as that controller rumbles too, though not every controller supports vibration in the browser. A
|
||||
gamepad that can't be rumbled says so on its row.
|
||||
- The page glows in the controller's light-bar colour, the player colour the PS4 gives each user or whatever
|
||||
the game sets, brightened from the quarter strength the PS4 uses.
|
||||
- Each controller shows the name of the PS4 user signed in on it.
|
||||
- An **Invite** button shows the page's address as a QR code, so friends can scan it and join. The console
|
||||
makes the code, so the page still needs no libraries.
|
||||
- The page says "Choose user on PS4" rather than "on TV": not everyone plays on a TV.
|
||||
- Protocol: the status reports the light bar's colour in `color` and the signed-in user in `user`; the
|
||||
service sends rumble as `{"method": "v", "params": [pad, large, small]}`; a new `invite` method returns the
|
||||
QR code. See [docs/protocol.md](docs/protocol.md).
|
||||
|
||||
## 1.0.0 — 2026-10-05
|
||||
|
||||
First release.
|
||||
@@ -26,4 +90,4 @@ the PS4's own *"Who's using this controller?"* screen deciding who each one is.
|
||||
- Open on the local network by design, with no pairing. What that does and does not protect is written down in
|
||||
[SECURITY.md](SECURITY.md).
|
||||
|
||||
Tested on firmware 10.01 with GoldHEN v2.4b18.10. Rumble, light bar and motion are not passed on yet.
|
||||
Tested on firmware 10.01 with GoldHEN v2.4b18.10. Rumble, light bar and motion are not passed on.
|
||||
@@ -11,7 +11,7 @@ details:
|
||||
- the phone or computer and browser you used;
|
||||
- what you did and what happened;
|
||||
- the service's log, if you can get it: `/data/control4free/control4free.log` on the PS4, over GoldHEN's FTP
|
||||
server (port 2121). The previous run is kept in `control4free.log.previous`.
|
||||
server (port 2121). The last segment of the previous run is kept in `control4free.log.previous`.
|
||||
|
||||
The log contains your console's network address. Remove it before posting if you prefer.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -65,7 +67,7 @@ address, or add `?host=<ps4-ip>:4264` to the page's URL.
|
||||
Match the code around your change:
|
||||
- C with GNU extensions (gnu11), 4-space indent, `camelCase` functions with a `c4f` prefix, `C4f` types and
|
||||
`C4F_` macros;
|
||||
- log with `c4fLog`. `c4fNotify` shows a notification on the TV: keep those rare;
|
||||
- log with `c4fLog`. `c4fNotify` shows a notification on the PS4's screen: keep those rare;
|
||||
- comments explain *why*, not *what*;
|
||||
- only button bits confirmed on hardware go in `include/c4f_sce.h`;
|
||||
- the page is one file with no build step and no libraries: keep it that way;
|
||||
@@ -76,15 +78,21 @@ Build without new warnings; the app is built with `-Werror`.
|
||||
|
||||
## Releasing
|
||||
|
||||
1. Update `VERSION`, and add a section to `CHANGELOG.md`.
|
||||
2. Commit, tag `vX.Y.Z`, and push the tag.
|
||||
3. CI builds both images, runs the tests, and publishes the release with the ELF, the package and
|
||||
`SHA256SUMS`. It refuses a tag that doesn't match `VERSION`.
|
||||
1. Update `VERSION` and the changelog's release date and notes. Review mapping, compatibility and
|
||||
diagnostic documentation; distinguish hardware reports from emulated-input tests.
|
||||
2. Run all five Docker host suites and the browser/editor and emulated-gamepad checks. Build normal and
|
||||
diagnostic packages without new warnings. Inspect launcher previews and browser layouts.
|
||||
3. Validate standard controllers on real hardware: simultaneous devices, source mixing, rumble,
|
||||
reconnect, logout and rest/wake. Recheck firmware 10.01 and a modern tested firmware, and record the
|
||||
results. Wheel/guitar model compatibility remains unverified until device reports establish it.
|
||||
4. Commit, tag `vX.Y.Z`, and push the tag when the release gates pass.
|
||||
5. CI checks the tag against `VERSION` and publishes `control4free.elf`, the normal package, the diagnostic
|
||||
package and `SHA256SUMS`. Release-note documentation links point to that tag.
|
||||
|
||||
## Reviewing AI-written code
|
||||
|
||||
Much of Control4Free was written with the help of AI models (Claude by Anthropic and Codex by OpenAI). It has
|
||||
been tested, on the console as well, but AI-written code can look right and still be wrong. Please read it
|
||||
Much of Control4Free was written with the help of AI models (Claude Opus 5.5 by Anthropic and Astra GPT-6 by
|
||||
OpenAI). It has been tested, on the console as well, but AI-written code can look right and still be wrong. Please read it
|
||||
critically: if you find dead code, wrong assumptions or needless complexity,
|
||||
[open an issue](https://github.com/MoHadiShibli/Control4Free/issues) or send a pull request.
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
# Build:
|
||||
# make # build/control4free.elf
|
||||
# make C4F_PROBE=1 # plus the rumble/light-bar research probe (dev only)
|
||||
# make C4F_DIAG=1 # diagnostic build for a console it fails on
|
||||
# make clean
|
||||
#
|
||||
# A build needs the ps4-payload-sdk; docker/Dockerfile has it, and
|
||||
@@ -19,7 +20,7 @@ VERSION := $(shell cat VERSION)
|
||||
NAME := control4free
|
||||
ELF := build/$(NAME).elf
|
||||
OBJDIR := build/payload
|
||||
SOURCES := src/main.c src/log.c src/vda.c src/klog_line.c src/web.c src/net.c
|
||||
SOURCES := src/main.c src/log.c src/vda.c src/klog_line.c src/feedback.c src/web.c src/net.c
|
||||
|
||||
# Research build: logs what scePadVirtualDeviceGetRemoteSetting returns. Its ELF
|
||||
# gets its own name, so it can never be mistaken for a release.
|
||||
@@ -29,11 +30,20 @@ ifdef C4F_PROBE
|
||||
OBJDIR := build/probe
|
||||
endif
|
||||
|
||||
OBJECTS := $(SOURCES:src/%.c=$(OBJDIR)/%.o) $(OBJDIR)/client.o $(OBJDIR)/assets.o
|
||||
# Diagnostic build: logs what the SDK found in the kernel and the host process,
|
||||
# and says on screen that it started. The launcher's C4F_DIAG build carries it.
|
||||
ifdef C4F_DIAG
|
||||
EXTRA_CFLAGS += -DC4F_DIAG
|
||||
VERSION := $(VERSION)-diag
|
||||
ELF := build/$(NAME)-diag.elf
|
||||
OBJDIR := build/diag
|
||||
endif
|
||||
|
||||
OBJECTS := $(SOURCES:src/%.c=$(OBJDIR)/%.o) $(OBJDIR)/client.o $(OBJDIR)/assets.o $(OBJDIR)/qrcodegen.o
|
||||
|
||||
CFLAGS += -std=gnu11 -Wall -Wextra -Wpointer-arith -g -O2
|
||||
CFLAGS += -MMD -MP
|
||||
CFLAGS += -Iinclude -Ivendor/jsmn -DC4F_VERSION='"$(VERSION)"' $(EXTRA_CFLAGS)
|
||||
CFLAGS += -Iinclude -Ivendor/jsmn -Ivendor/qrcodegen -DC4F_VERSION='"$(VERSION)"' $(EXTRA_CFLAGS)
|
||||
LDFLAGS += -lScePad -lSceUserService -ldl -lpthread
|
||||
|
||||
.PHONY: all clean
|
||||
@@ -46,12 +56,18 @@ all: $(ELF)
|
||||
$(OBJDIR) build:
|
||||
mkdir -p $@
|
||||
|
||||
$(OBJDIR)/%.o: src/%.c | $(OBJDIR)
|
||||
# VERSION is compiled into the objects, and make doesn't notice a changed flag,
|
||||
# so a new version has to rebuild them.
|
||||
$(OBJDIR)/%.o: src/%.c VERSION | $(OBJDIR)
|
||||
$(CC) $(CFLAGS) -c $< -o $@
|
||||
|
||||
build/client.c: client/index.html tools/embed_client.py | build
|
||||
python3 tools/embed_client.py $< $@
|
||||
|
||||
# The page's Invite QR code (MIT, the same copy the app uses).
|
||||
$(OBJDIR)/qrcodegen.o: vendor/qrcodegen/qrcodegen.c | $(OBJDIR)
|
||||
$(CC) $(CFLAGS) -c $< -o $@
|
||||
|
||||
$(OBJDIR)/client.o: build/client.c | $(OBJDIR)
|
||||
$(CC) $(CFLAGS) -c $< -o $@
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<a href="https://ko-fi.com/mohadishibli"><img src="https://img.shields.io/badge/Ko--fi-support%20this%20project-FF5E5B?logo=ko-fi&logoColor=white" alt="Support on Ko-fi"></a>
|
||||
</p>
|
||||
|
||||
**Use your phone or PC as a PS4 controller everywhere: on the home screen, at sign-in, and in every game.**
|
||||
**Use your phone or PC as a PS4 controller: on the home screen, at sign-in, and in games.**
|
||||
|
||||
Control4Free adds up to four extra DualShock 4 controllers to a jailbroken PS4. Open its page in a browser on
|
||||
your network, pick a controller, and choose who's playing on the PS4's own *"Who's using this controller?"*
|
||||
@@ -22,20 +22,30 @@ others work through the phone or PC. There's nothing to install on the phone or
|
||||
|
||||
Controller plugins live inside games, so they can't reach the home screen or the sign-in screen. Control4Free
|
||||
is a GoldHEN payload that goes through Sony's own virtual-device system, the one Remote Play uses. The PS4
|
||||
sees a real controller, so it works wherever a DualShock does:
|
||||
sees a virtual DualShock 4, which provides:
|
||||
|
||||
- **on the home screen and in every menu**, PS button included;
|
||||
- **at sign-in**, through the PS4's own user picker, for real users and guests alike;
|
||||
- **in every game**, with no per-game setup.
|
||||
- **in games**, with DS4 input and no per-game plugin setup. See the limits below for motion and accessories.
|
||||
|
||||
## Features
|
||||
|
||||
- **Up to four controllers**, from as many phones, tablets or computers as you like.
|
||||
- **See who's playing.** Each controller shows the name of the PS4 user signed in on it.
|
||||
- **Invite friends with a QR code.** One button on the page shows its address as a QR code: friends scan it
|
||||
and they're in.
|
||||
- **A full DualShock 4 on the screen**: analog sticks, a two-finger touchpad, L3/R3, PS, Share and Options.
|
||||
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. Map browser-exposed buttons and axes from gamepads, wheels, guitars and pedals to DS4
|
||||
controls; wheel and guitar model compatibility remains unverified. 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.
|
||||
- **Fast.** Input reaches the console the moment it arrives, at a DualShock 4's own rate while you play.
|
||||
- **An app on the PS4** that shows the address and a QR code, how many controllers are connected, and starts
|
||||
or stops Control4Free.
|
||||
@@ -47,13 +57,13 @@ sees a real controller, so it works wherever a DualShock does:
|
||||
|
||||
## Quick start
|
||||
|
||||
1. Download `Control4Free-1.0.0.pkg` from the [latest release](https://github.com/MoHadiShibli/Control4Free/releases/latest).
|
||||
1. Download `Control4Free-<version>.pkg` from the [latest release](https://github.com/MoHadiShibli/Control4Free/releases/latest).
|
||||
2. Install it with GoldHEN's Package Installer: **Settings → Debug Settings → Game → Package Installer**, from a
|
||||
USB drive or `/data/pkg/`.
|
||||
3. Open **Control4Free** from **Library → Applications** and press **Cross**. It sets up auto-start and
|
||||
starts Control4Free.
|
||||
4. On your phone or PC, scan the QR code or open the address shown, for example `http://192.168.1.20:4264`.
|
||||
Pick a controller, then choose your user on the TV with the D-pad and Cross.
|
||||
Pick a controller, then choose your user on the PS4.
|
||||
|
||||
New to GoldHEN packages? The **[installation guide](docs/installation.md)** walks through every step.
|
||||
|
||||
@@ -67,6 +77,7 @@ New to GoldHEN packages? The **[installation guide](docs/installation.md)** walk
|
||||
|
||||
- **[Installation](docs/installation.md)**: requirements, installing, auto-start, updating, uninstalling.
|
||||
- **[Playing](docs/playing.md)**: signing in, touch, keyboard and gamepads, the layout editor, settings.
|
||||
- **[Gamepad mapping](docs/gamepad-mapping.md)**: profiles, calibration and using several input devices together.
|
||||
- **[Troubleshooting](docs/troubleshooting.md)**: the messages you might see and what to do about each.
|
||||
- **[How it works](docs/how-it-works.md)**: the virtual-device API, sign-in, and what runs where.
|
||||
- **[Protocol](docs/protocol.md)**: the WebSocket protocol between the page and the console.
|
||||
@@ -76,8 +87,9 @@ New to GoldHEN packages? The **[installation guide](docs/installation.md)** walk
|
||||
|
||||
## Requirements and compatibility
|
||||
|
||||
- A PS4 with GoldHEN. Tested on firmware 10.01 with GoldHEN v2.4b18.10. Auto-start and starting Control4Free
|
||||
from GoldHEN's menu need v2.4b18.10 or later.
|
||||
- A PS4 with GoldHEN v2.4b18.10 or later. Tested on firmware 10.01, 13.02, 13.04 and 13.52; the
|
||||
[compatibility table](docs/installation.md#compatibility) lists every firmware and the GoldHEN version it
|
||||
needs.
|
||||
- A phone, tablet or computer with a browser, on the same network as the PS4.
|
||||
- Turn off other GoldHEN controller plugins in games you play with Control4Free. A plugin that takes over a
|
||||
signed-in user's controller can stop games from reading the virtual ones.
|
||||
@@ -85,8 +97,17 @@ New to GoldHEN packages? The **[installation guide](docs/installation.md)** walk
|
||||
|
||||
## Limitations
|
||||
|
||||
- **No rumble, light bar or motion yet.** The PS4 does send rumble and light-bar changes to the virtual
|
||||
controllers; passing them to the phone is planned for 1.1.
|
||||
- **The PS4 always sees a DualShock 4.** Mapping does not provide native wheel or guitar identity, or wheel
|
||||
force feedback. Games requiring native accessories are outside this release; native accessory emulation
|
||||
remains a separate, unproven research question.
|
||||
- **Browser input limits apply.** Hidden buttons and lost input identity cannot be recovered by mapping.
|
||||
With several Xbox controllers, some Chrome/Edge Windows backends combine their Xbox buttons; see the
|
||||
[GameInput workaround](docs/troubleshooting.md#gamepads).
|
||||
- **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
|
||||
iPhone can still rumble, if the browser supports it.
|
||||
- **One controller is added at a time.** If two people pick at once, the second is asked to try again a
|
||||
moment later.
|
||||
- **Rest mode signs everyone out**, so controllers have to be picked again after the PS4 wakes up, just like
|
||||
@@ -96,36 +117,24 @@ New to GoldHEN packages? The **[installation guide](docs/installation.md)** walk
|
||||
|
||||
## Support
|
||||
|
||||
Found a bug? [Open an issue](https://github.com/MoHadiShibli/Control4Free/issues/new/choose); I maintain this
|
||||
project and read every report. If Control4Free is useful to you, you can help keep it going on
|
||||
Found a bug? Install the diagnostic package from the latest release first, then
|
||||
[open an issue](https://github.com/MoHadiShibli/Control4Free/issues/new/choose) with its screenshots (see
|
||||
[Reporting a problem](docs/troubleshooting.md#reporting-a-problem)). I maintain this project and read every
|
||||
report. If Control4Free is useful to you, you can help keep it going on
|
||||
[Ko-fi](https://ko-fi.com/mohadishibli).
|
||||
|
||||
## Credits
|
||||
|
||||
- [seregonwar/SplashDown](https://github.com/seregonwar/SplashDown): Control4Free's virtual-device code is
|
||||
ported from its `psbutton.c`, the first working use of this API on a PS4.
|
||||
- [GoldHEN](https://github.com/GoldHEN/GoldHEN), its PayLoader and AutoRun, and the
|
||||
[GoldHEN Plugins SDK](https://github.com/GoldHEN/GoldHEN_Plugins_SDK).
|
||||
- [ps4-payload-sdk](https://github.com/ps4-payload-dev/sdk) by John Törnblom builds the payload, and the
|
||||
[OpenOrbis PS4 Toolchain](https://github.com/OpenOrbis/OpenOrbis-PS4-Toolchain) builds the app and its package.
|
||||
- [Ghostcontrol](https://github.com/srbraboo/Ghostcontrol-PS5-USB-Controller-Patcher) was a research reference.
|
||||
- [Apollo Save Tool](https://github.com/bucanero/apollo-ps4) showed the package settings that file an app under
|
||||
Applications.
|
||||
- [jsmn](https://github.com/zserge/jsmn), Project Nayuki's
|
||||
[QR Code generator](https://github.com/nayuki/QR-Code-generator),
|
||||
[stb_truetype](https://github.com/nothings/stb) and the [Roboto](https://github.com/googlefonts/roboto) font.
|
||||
- The on-screen buttons are drawn after the
|
||||
[DualShock 4 layout diagram](https://commons.wikimedia.org/wiki/File:Dualshock_4_Layout.svg) by Tokyoship
|
||||
(CC BY 3.0).
|
||||
|
||||
Full licence details are in [THIRD_PARTY.md](THIRD_PARTY.md).
|
||||
Control4Free's virtual-device code is ported from [SplashDown](https://github.com/seregonwar/SplashDown) by
|
||||
seregonwar. The other code, fonts and artwork it uses, and their licenses, are listed in
|
||||
[THIRD_PARTY.md](THIRD_PARTY.md).
|
||||
|
||||
### Built with AI help
|
||||
|
||||
Control4Free was built with the help of AI models, Claude by Anthropic and Codex by OpenAI, which worked on the
|
||||
code, tests and documentation with me. AI-written code can look right and still be wrong. Reviews are very
|
||||
welcome: if you spot AI slop (dead code, wrong assumptions, needless complexity), please
|
||||
[open an issue](https://github.com/MoHadiShibli/Control4Free/issues) or send a pull request.
|
||||
Control4Free was built with the help of AI models, **Claude Opus 5.5** by Anthropic and **Astra GPT-6** by
|
||||
OpenAI, which worked on the code, tests and documentation with me. AI-written code can look right and still be
|
||||
wrong. Reviews are very welcome: if you spot AI slop (dead code, wrong assumptions, needless complexity),
|
||||
please [open an issue](https://github.com/MoHadiShibli/Control4Free/issues) or send a pull request.
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -16,10 +16,12 @@ can reach your PS4 over the network can:
|
||||
|
||||
- take a free controller and play, on the home screen, at sign-in and in games;
|
||||
- press PS and Share, which reach the system menus;
|
||||
- stop Control4Free, which disconnects every controller.
|
||||
- stop Control4Free, which disconnects every controller;
|
||||
- see the names of the users signed in on the controllers.
|
||||
|
||||
They cannot sign in as one of your users without someone at the TV choosing that
|
||||
user on the PS4's own screen, and they cannot see your screen.
|
||||
Controller input can operate the PS4's user-selection screen, so someone on your
|
||||
network can select an available user. The PS4's own login restrictions still apply.
|
||||
Control4Free does not stream the screen.
|
||||
|
||||
Run Control4Free on a network you trust. Do not forward port 4264 through your
|
||||
router, and do not run it on open or guest Wi-Fi. When you are finished, stop it
|
||||
@@ -50,8 +52,8 @@ for a controller page opened from a saved file, and the saved file is how gamepa
|
||||
work in browsers that only allow the Gamepad API on secure pages. A website can
|
||||
produce the same `Origin: null` from a sandboxed frame, so a page you have open
|
||||
while on your home network could take a free controller and press buttons, as
|
||||
anyone on your network can. It cannot take a controller someone is using, sign in
|
||||
as a user, or see your screen.
|
||||
anyone on your network can. It cannot take a controller someone is using or stream
|
||||
your screen, but its input can operate the user-selection screen.
|
||||
|
||||
**One controller has one owner.** A slot is claimed by one WebSocket connection.
|
||||
Another device asking for the same slot is refused (409), and input never claims a
|
||||
|
||||
@@ -34,9 +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/).
|
||||
- The package settings that file the app under Applications (`CATEGORY`,
|
||||
`ATTRIBUTE`) follow Apollo Save Tool (https://github.com/bucanero/apollo-ps4).
|
||||
(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.
|
||||
|
||||
|
||||
@@ -26,6 +26,17 @@ RUN curl -fsSL -o /tmp/sdk.zip \
|
||||
&& rm /tmp/sdk.zip \
|
||||
&& chmod -R +x /opt/ps4-payload-sdk/bin
|
||||
|
||||
# The payload's start-up code (crt1.o) comes from a later SDK commit than the
|
||||
# release: v0.9 has no kernel offsets for firmware 13.52 or 14.00, so a payload
|
||||
# built with it quits before main() on those consoles. Same exports, built with
|
||||
# the SDK's own crt/Makefile.
|
||||
ARG SDK_CRT_COMMIT=573b4a0d0d5fa8695cfeee5f5e2f0447c550035d
|
||||
RUN mkdir /tmp/sdk-src \
|
||||
&& curl -fsSL "https://github.com/ps4-payload-dev/sdk/archive/${SDK_CRT_COMMIT}.tar.gz" \
|
||||
| tar xz -C /tmp/sdk-src --strip-components=1 \
|
||||
&& make -C /tmp/sdk-src/crt LLVM_CONFIG=llvm-config-18 DESTDIR=/opt/ps4-payload-sdk install \
|
||||
&& rm -rf /tmp/sdk-src
|
||||
|
||||
# orbis-clang execs $SCRIPT_DIR/clang, and orbis-ld execs $SCRIPT_DIR/ld.lld.
|
||||
RUN ln -sf /usr/bin/clang-18 /opt/ps4-payload-sdk/bin/clang \
|
||||
&& ln -sf /usr/bin/clang++-18 /opt/ps4-payload-sdk/bin/clang++ \
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
# Gamepad mapping
|
||||
|
||||
Map the buttons and axes a browser exposes from gamepads, wheels, guitars and other input devices to one of
|
||||
the four virtual DualShock 4 controllers. Wheel and guitar model compatibility remains unverified; these
|
||||
are mapping capabilities, not native accessory emulation. You map on a picture of a DualShock 4. The
|
||||
keyboard is mapped the same way. Mappings are kept in the browser, like your layout.
|
||||
|
||||
With several gamepads on Chrome or Edge for Windows, Xbox/Guide presses can be reported on the wrong
|
||||
gamepad by the browser. Check **Gamepad Controls** first; see the
|
||||
[Xbox-button workaround](troubleshooting.md#gamepads). Mapping cannot restore an input's missing source.
|
||||
|
||||

|
||||
|
||||
## 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 DS4 control another input, for example a back paddle that also presses Cross.
|
||||
Cross stays pressed until both inputs are released. **Remove** takes a binding away. Choosing **Change**
|
||||
moves the selected input from its previous control. The engine also accepts profiles with one input
|
||||
driving multiple outputs; see [Advanced profiles](#advanced-profiles).
|
||||
|
||||
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.
|
||||
Press a button or move a stick to identify its tile or bar. This view starts with the physical input;
|
||||
**DS4 Mapping** starts with the DS4 control you want to change. The guide panel follows the selected tab.
|
||||
The screenshots use an emulated gamepad to illustrate the editor.
|
||||
|
||||

|
||||
|
||||
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).
|
||||
|
||||
The hat wizard infers diagonal values from the four directions. Test every diagonal afterward. A hat with
|
||||
different encoding needs its actual neutral and eight direction values in an advanced profile.
|
||||
|
||||
## Profiles
|
||||
|
||||
A mapping is saved as a profile, named in the **Name** box. Choose that profile for each connected gamepad
|
||||
that should use it. **Save** updates every connected gamepad already using that profile; the editor lists
|
||||
the others affected. **Save as a new profile** branches a profile for only the selected gamepad.
|
||||
The browser remembers the last saved profile choice for matching device IDs, mapping types and input
|
||||
counts when a gamepad reconnects. The **Profile** list switches between your profiles and the
|
||||
**Standard layout**, which is immutable: editing and saving it creates a named profile.
|
||||
|
||||
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.
|
||||
|
||||
Imports open as drafts and do not overwrite an existing profile. Review bindings and calibration before
|
||||
**Save**, especially after fitting a different device. Missing inputs must be removed or corrected.
|
||||
Profiles use browser storage under `c4f.gamepadProfiles`; export them as a backup. Invalid data falls back
|
||||
safely, and a storage warning means changes will last only for this session.
|
||||
|
||||
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.
|
||||
|
||||
## Advanced profiles
|
||||
|
||||
Export a profile, edit its JSON, then import it for review. The file has
|
||||
`format: "control4free-controller-profile"`, `schema: 1`, and a `profile` containing its ID, name,
|
||||
device signature, bindings and calibration. Unknown file versions are rejected. Unknown versions already
|
||||
in browser storage are preserved, with changes limited to the session.
|
||||
|
||||
Bindings can use a digital or pressure-sensitive button, a full axis, its positive or negative half,
|
||||
a calibrated pedal, or a hat direction. Outputs are DS4 buttons, stick axes or directions, and L2/R2
|
||||
pressure. This permits button-to-stick, button-to-trigger, axis-to-button and axis-to-trigger mapping.
|
||||
Duplicate a binding with a different `output` to make one input drive multiple actions.
|
||||
|
||||
Each binding includes `invert`, `deadzone`, `saturation`, `exponent`, `activate` and `release`.
|
||||
The response curve is a power curve with exponent 1 for linear response. Analog-to-button bindings default
|
||||
to 50% activation and 40% release to avoid jitter. Default stick bindings retain the previous transfer
|
||||
behavior through `legacy`; remove that property when opting into tuning.
|
||||
|
||||
Centered-axis calibration stores `min`, `center` and `max`, including asymmetric ranges. Pedal calibration
|
||||
stores `released` and `full`, which may be reversed. For combined pedals, use opposite halves of the axis.
|
||||
Hat calibration stores `values` in the order neutral, up, up-right, right, down-right, down, down-left,
|
||||
left, up-left, plus a `tolerance` that separates those values.
|
||||
|
||||
Inputs sharing a controller are merged: buttons use OR, triggers use maximum pressure, opposing digital
|
||||
stick directions cancel, and analog sticks use the strongest displacement. Equal opposing analog
|
||||
contributions cancel. Values are converted to DS4 bytes after merging.
|
||||
|
||||
## 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. Native wheel/guitar identity and wheel force feedback are not provided.
|
||||
Native accessory emulation is unproven and outside this release; existing supported gamepad rumble remains.
|
||||
- A combined pedal axis can drive separate outputs, but simultaneous pedal positions lost by the device
|
||||
cannot be recovered.
|
||||
- The page reads gamepads about 60 times a second, so a press shorter than that can be missed.
|
||||
- DS4 output uses 8-bit stick and trigger values; extra precision in a physical device is reduced.
|
||||
- Macros, turbo, toggles, chords and shifted layers are outside this release.
|
||||
|
||||
When reporting compatibility, include the controller model, browser and OS versions, connection mode,
|
||||
exposed button/axis counts, working mappings and remaining limitations. Emulated-input tests are not
|
||||
real-device compatibility reports.
|
||||
@@ -50,10 +50,16 @@ If no line arrives, it stops creating controllers until it's restarted, rather t
|
||||
it can no longer address.
|
||||
|
||||
The device is created for user 1, so it arrives with no user, and the PS4 asks *"Who's using this
|
||||
controller?"*. The service never presses anything itself: the choice is made at the TV. When a user is picked,
|
||||
controller?"*. The service never presses anything itself: the choice is made on the PS4. When a user is picked,
|
||||
the login manager logs `DEVICE_OWNER_CHANGED [DeviceId:…][UserId:…]`, which is how the page learns the
|
||||
controller is signed in.
|
||||
|
||||
UserService logout events and the current local login list are checked independently of the assignment
|
||||
log. When a bound user signs out, their controllers are neutralized and removed, rumble is stopped, and
|
||||
status clears the user and ownership. A failed login-list read leaves controllers alone; a newly assigned
|
||||
user is first observed in the list before absence counts as logout. The browser clears affected gamepad
|
||||
selections and returns to its home screen, requiring an explicit choice to reconnect.
|
||||
|
||||
None of this blocks. Creating a controller is a small state machine the main loop advances: drain the log's
|
||||
backlog, write a marker and wait for it to come back (proof that the reader really delivers), call
|
||||
`AddDevice`, wait for the line. The other players keep playing and keep being answered meanwhile.
|
||||
@@ -77,8 +83,30 @@ turns it into a `ScePadData` sample and hands it to `scePadVirtualDeviceInsertDa
|
||||
- a short queue keeps every button press and release, so a quick tap can't fall between two reports, while
|
||||
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.
|
||||
When a connection closes, its buttons are released at once. With no new input for 3 seconds the controller
|
||||
goes neutral, and 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
|
||||
|
||||
A game talks to a virtual controller the way it talks to a real one, and the system keeps what it asked for.
|
||||
`scePadVirtualDeviceGetRemoteSetting` reads it back, in the layout of a DualShock 4's own output report: the
|
||||
two motors, then the light bar's red, green and blue. The service reads it every 16 ms for each controller,
|
||||
because games pulse rumble for only tens of milliseconds:
|
||||
|
||||
- a change in rumble is sent to the controller's owner, whose page buzzes the phone and rumbles any gamepad
|
||||
playing as that controller. Someone who takes a controller over is told its current state at once;
|
||||
- the light bar goes into the status every page sees. The PS4 sets player colours at a quarter of full
|
||||
brightness, so the page brightens them to full and keeps the hue.
|
||||
|
||||
## Rest mode and failures
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 535 KiB After Width: | Height: | Size: 504 KiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 840 KiB After Width: | Height: | Size: 840 KiB |
|
After Width: | Height: | Size: 561 KiB |
|
After Width: | Height: | Size: 495 KiB |
@@ -10,11 +10,32 @@ Control4Free comes in two pieces:
|
||||
|
||||
## Requirements
|
||||
|
||||
- A PS4 with [GoldHEN](https://github.com/GoldHEN/GoldHEN). Tested on firmware 10.01 with GoldHEN v2.4b18.10.
|
||||
Auto-start and GoldHEN's Payloader LaunchPad need v2.4b18.10 or later.
|
||||
- A PS4 with [GoldHEN](https://github.com/GoldHEN/GoldHEN) v2.4b18.10 or later. Older GoldHEN versions don't
|
||||
run ELF payloads like Control4Free reliably, and have no auto-start. See [Compatibility](#compatibility) for
|
||||
each firmware.
|
||||
- **Debug Settings** turned on in GoldHEN, for the Package Installer.
|
||||
- A phone, tablet or computer with a modern browser, on the same network as the PS4.
|
||||
|
||||
## Compatibility
|
||||
|
||||
Every firmware GoldHEN supports has a GoldHEN version of v2.4b18.10 or later, so updating GoldHEN is all it
|
||||
takes. **Tested** means someone confirmed it on a console; the other rows have GoldHEN support and support in
|
||||
Control4Free's start-up code, but nobody has tried them yet.
|
||||
|
||||
| Firmware | GoldHEN | Status |
|
||||
|---|---|---|
|
||||
| 10.01 | v2.4b18.10 or later | **Tested** (v2.4b18.10) |
|
||||
| 13.02, 13.04 | v2.4b18.12 or later | **Tested** (v2.4b18.12) |
|
||||
| 13.52 | v2.4b18.11 or later | **Tested** (v2.4b18.12), with Control4Free 1.1.1 or later |
|
||||
| 13.50 | v2.4b18.12 or later | Not tested |
|
||||
| 13.00 | v2.4b18.10 or later | Not tested |
|
||||
| 12.00, 12.02, 12.50, 12.52 | v2.4b18.10 or later | Not tested |
|
||||
| 5.05, 6.71, 6.72, 7.0x, 7.5x, 8.0x, 8.5x, 9.0x, 9.5x, 9.60, 10.00, 10.50, 10.70, 10.71, 11.0x, 11.5x | v2.4b18.10 or later | Not tested |
|
||||
|
||||
The older a firmware, the less certain it is that the PS4's virtual-controller API behaves as on the tested
|
||||
ones. If you try an untested firmware, an issue saying whether it worked helps others, with a
|
||||
[diagnostic report](troubleshooting.md#reporting-a-problem) if it didn't.
|
||||
|
||||
## Install the app
|
||||
|
||||
1. Download `Control4Free-<version>.pkg` from the
|
||||
@@ -34,19 +55,25 @@ Open **Control4Free** and press **Cross**. The app:
|
||||
1. copies the service to `/data/payloads/control4free.elf`;
|
||||
2. adds it to GoldHEN's AutoRun list, `/data/GoldHEN/payloads.ini`, so GoldHEN starts it every time it
|
||||
loads;
|
||||
3. starts it straight away if GoldHEN's **PayLoader** is on. If it isn't, restart the PS4 and run the
|
||||
jailbreak: AutoRun starts Control4Free from then on.
|
||||
3. starts it straight away if GoldHEN's **PayLoader** is on. If it isn't, turn PayLoader on in GoldHEN's
|
||||
settings and press **Cross** again. AutoRun also starts Control4Free each time GoldHEN loads.
|
||||
|
||||
Once it's running, the app shows the address to open, for example `http://192.168.1.20:4264`, and a QR code
|
||||
for your phone's camera. The PS4 also shows the address in a notification when the service starts.
|
||||
|
||||
AutoRun keeps other entries in `payloads.ini` as they are.
|
||||
|
||||
## After a restart
|
||||
|
||||
Run the GoldHEN jailbreak as usual. With auto-start on, Control4Free starts by itself and shows its address in
|
||||
a notification, so you don't need to open the app. If you do open it and press **PS** to go back to the home
|
||||
screen, that's fine too: the app doesn't get in the way of Control4Free.
|
||||
|
||||
## The app's buttons
|
||||
|
||||
| Button | What it does |
|
||||
|---|---|
|
||||
| **Cross** | Starts Control4Free when it isn't running. Until auto-start is on, it sets that up first. |
|
||||
| **Cross** | Starts Control4Free when it isn't running. Until auto-start is on with this app's version, it sets that up first. |
|
||||
| **Triangle** | Turns auto-start on or off. After you install a newer package, it updates the auto-start copy. |
|
||||
| **Square**, then **Cross** | Stops Control4Free and disconnects every controller. |
|
||||
| **Circle** | Closes the app. Control4Free keeps running without it. |
|
||||
@@ -54,12 +81,15 @@ AutoRun keeps other entries in `payloads.ini` as they are.
|
||||
## Updating
|
||||
|
||||
1. Install the new package over the old one. Delete the old app first if the installer refuses.
|
||||
You don't need to stop Control4Free first: deleting or replacing the app leaves the running service and
|
||||
its auto-start copy alone.
|
||||
2. Open Control4Free. It says the auto-start copy is older than the one in the app: press **Triangle** to
|
||||
update it.
|
||||
3. The old version is still the one running. Press **Square**, then **Cross** to stop it, and **Cross**
|
||||
again to start the new one (this needs GoldHEN's PayLoader). Or restart the PS4 and run the jailbreak.
|
||||
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
|
||||
|
||||
@@ -86,11 +116,17 @@ It runs until the PS4 restarts.
|
||||
2. Delete the app from the Library.
|
||||
3. Delete `/data/payloads/control4free.elf`, and `/data/control4free/` if you want the logs gone too.
|
||||
|
||||
Do step 1 before deleting the app. Deleting the app alone doesn't stop the service, and GoldHEN keeps
|
||||
starting it after every restart. If the app is already gone, either install it again and start from step 1,
|
||||
or remove the `control4free.elf` line from `/data/GoldHEN/payloads.ini` by hand and restart the PS4.
|
||||
|
||||
## Files on the console
|
||||
|
||||
| Path | What it is |
|
||||
|---|---|
|
||||
| `/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` | The current service log, bounded to 1 MiB. |
|
||||
| `/data/control4free/control4free.log.1` | One older 1 MiB segment from the current run. |
|
||||
| `/data/control4free/control4free.log.previous` | The last segment of the previous run. |
|
||||
| `/data/control4free/instance.lock` | Keeps a second copy from starting next to the first. |
|
||||
@@ -11,17 +11,27 @@
|
||||
Connect one controller at a time, and finish the user screen before the next person picks theirs. **Don't
|
||||
press PS while that screen is up**: it cancels the choice. PS works normally once you're signed in.
|
||||
|
||||
Signing out of that user on the PS4 disconnects their virtual controllers. The page clears the user's name,
|
||||
returns to the home screen, and switches gamepads assigned to those controllers to **Off**. Pick a
|
||||
controller again to reconnect. Other signed-in players keep playing.
|
||||
|
||||
The home screen shows all four controllers live:
|
||||
|
||||
| State | Meaning |
|
||||
|---|---|
|
||||
| **Free** | Nobody is using it. Pick it to play. |
|
||||
| **Connecting** | The PS4 is setting it up. Takes a moment. |
|
||||
| **Choose user on TV** | Waiting for someone to pick a user on the PS4's screen. |
|
||||
| **Connected** | Signed in and ready. |
|
||||
| **Choose user on PS4** | Waiting for someone to pick a user on the PS4's screen. |
|
||||
| **Connected** | Signed in and ready. The tile shows the user's name. |
|
||||
| **Paused** | Its phone stopped sending input, for example because the screen locked. |
|
||||
| **This device** / **+1 device** | Who's driving it: this browser, or other ones. |
|
||||
|
||||
## Inviting someone
|
||||
|
||||
Press the **QR code button** at the top of the home screen, or **☰ → Invite someone** on the controller
|
||||
screen. The page shows its own address as a QR code: a friend scans it with their phone's camera and gets the
|
||||
controller page, as long as they're on the same network as the PS4. Tap outside the panel to close it.
|
||||
|
||||
## Touch controls
|
||||
|
||||
The controller screen is a DualShock 4 laid out for your screen, best held sideways:
|
||||
@@ -40,8 +50,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 |
|
||||
|---|---|---|---|
|
||||
@@ -59,12 +70,18 @@ Keyboard**, and turn on **Show keyboard keys** to see them on the buttons.
|
||||
Connect an Xbox, DualSense, Switch Pro or other gamepad to the phone or computer and press a button on it. It
|
||||
appears under **Gamepads on this device**:
|
||||
|
||||
- a gamepad that turns up while a controller is open on the page drives that controller;
|
||||
- if a controller is open and has no gamepad assigned yet, the first new gamepad drives it;
|
||||
- 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.
|
||||
|
||||
With several Xbox controllers on Chrome or Edge for Windows, check that each Xbox button appears on its
|
||||
own gamepad in **Gamepad Controls**. Some browser backends combine those presses; see the
|
||||
[GameInput workaround](troubleshooting.md#gamepads). This is not a general Control4Free requirement.
|
||||
|
||||
**"This browser only allows gamepads on secure pages."** Some browsers only offer gamepads on `https`
|
||||
pages, and the console's page is plain `http`. Save the page to the device (**Save page as** on a computer),
|
||||
open the saved file, and enter the console's address in its connection box. Touch and the keyboard work either
|
||||
@@ -73,11 +90,12 @@ 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 |
|
||||
|---|---|
|
||||
| **Keep the screen on** | The phone's screen doesn't turn off while the page drives a controller. |
|
||||
| **Vibration** | The phone buzzes briefly when you press a button (Android). |
|
||||
| **Vibration** | The phone buzzes when the game rumbles, and briefly when you press a button (Android). |
|
||||
| **Floating sticks** | A stick starts wherever your thumb lands in its area. |
|
||||
| **Tap the touchpad to click** | A quick tap presses the touchpad button. |
|
||||
| **Double-tap a stick for L3 / R3** | Off: L3 and R3 get their own buttons. |
|
||||
@@ -100,11 +118,27 @@ for the PS4 in your router so it doesn't change.
|
||||
**Full screen**: the **⛶** button in the corner. Safari on iPhone doesn't let pages go full screen, so there
|
||||
it explains how to use the home screen instead.
|
||||
|
||||
## Rumble and light bar
|
||||
|
||||
When a game rumbles the controller, the device driving it feels it:
|
||||
|
||||
- **an Android phone** buzzes, with **Vibration** turned on in the settings. Safari on iPhone doesn't let pages
|
||||
vibrate the phone;
|
||||
- **a gamepad** playing as that controller rumbles too, though not every controller supports vibration in
|
||||
the browser. When the browser knows it can't, the gamepad's row says *no rumble in this browser*.
|
||||
|
||||
A gamepad's own light bar stays as it is: browsers don't let pages change it.
|
||||
|
||||
The page also glows in the controller's **light-bar colour**: the player colour the PS4 gives each user, or
|
||||
whatever colour the game sets. Until one is set, a controller shows its own colour (1 blue, 2 red, 3 green,
|
||||
4 pink).
|
||||
|
||||
## Leaving
|
||||
|
||||
- **☰ → 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.
|
||||
|
||||
|
||||
@@ -37,16 +37,19 @@ handled.
|
||||
an `Invalid request` error.
|
||||
|
||||
Controllers are numbered 0 to 3 in the protocol and 1 to 4 on screen.
|
||||
`<version>` in the examples is a placeholder for the service's version, read from `VERSION`.
|
||||
The control protocol remains version 2 and the launcher API remains version 1.
|
||||
|
||||
| Method | Params | Reply |
|
||||
|---|---|---|
|
||||
| `info` | `[]` | `{"version": "1.0.0", "protocol": 2, "pads": 4}` |
|
||||
| `info` | `[]` | `{"version": "<version>", "protocol": 2, "pads": 4}` |
|
||||
| `status` | `[]` | A status object, below. |
|
||||
| `claim` | the controllers this connection wants, for example `[0]` or `[0, 2]` | A status object, once every controller in the claim exists. |
|
||||
| `u` | input, below | None. |
|
||||
| `leave` | `[pad]` | A status object. |
|
||||
| `stop` | `[]` | None; the service stops and the connection closes. |
|
||||
| `ping` | `[]` | `{}` |
|
||||
| `invite` | `[a, b, c, d, port]`, the address the page reached, or `[]` | `{"text": "http://192.168.1.20:4264/", "size": 29, "rows": […]}` |
|
||||
|
||||
**`claim`** is the whole set this connection wants: controllers missing from it that it owned are removed,
|
||||
and free ones in it are created. A claim that needs a new controller is answered when the controller exists,
|
||||
@@ -85,18 +88,31 @@ 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.
|
||||
|
||||
**`invite`** returns a QR code of the page's address: `[]` uses the console's own address. `rows` holds one
|
||||
hex string per row of modules, most significant bit first, `size` modules wide; add a light border of 4
|
||||
modules around it when you draw it.
|
||||
|
||||
### The status object
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "1.0.0",
|
||||
"version": "<version>",
|
||||
"protocol": 2,
|
||||
"pads": [
|
||||
{"pad": 0, "name": "Controller 1", "enabled": true, "open": true, "connected": true, "clients": 1,
|
||||
{"pad": 0, "name": "Controller 1", "user": "Alex", "enabled": true, "open": true, "connected": true, "clients": 1,
|
||||
"mine": true, "state": "ready", "uid": "1a2b3c4d", "color": [32, 96, 255], "reports": 5120, "error": 0}
|
||||
]
|
||||
}
|
||||
@@ -104,21 +120,28 @@ a connection claims them again; the page does so when it reconnects.
|
||||
|
||||
| Field | Meaning |
|
||||
|---|---|
|
||||
| `user` | The name of the PS4 user signed in on it, or `""` (not signed in, or not known yet). |
|
||||
| `open` | The virtual controller exists. |
|
||||
| `connected` | A connection owns it and is sending input. |
|
||||
| `clients` | 1 if a connection owns it, else 0. |
|
||||
| `mine` | This connection owns it. |
|
||||
| `state` | `free`, `connecting`, `select` (waiting for a user on the TV), `ready` (signed in), `paused` (no input lately, or nobody connected), or `error` (the console refused input). |
|
||||
| `state` | `free`, `connecting`, `select` (waiting for a user to be chosen on the PS4), `ready` (signed in), `paused` (no input lately, or nobody connected), or `error` (the console refused input). |
|
||||
| `uid` | The signed-in user's ID in hex, or `unassigned-…` before sign-in. |
|
||||
| `color` | The controller's colour on the page. |
|
||||
| `color` | The light bar's colour, brightened so its strongest channel is 255; the controller's own colour until one is set. |
|
||||
| `reports` | Samples given to the console so far. |
|
||||
| `error` | The console's error code for the last refused sample, or 0. |
|
||||
|
||||
### Messages from the service
|
||||
|
||||
PS4 user logout removes that user's virtual controllers. Their status becomes `free`, with `open`,
|
||||
`connected` and `mine` false, `clients: 0`, and an empty `user`. Owners receive `[pad, 0, 0]` to stop rumble
|
||||
and a disconnection message. Pages clear the affected selections instead of automatically claiming them.
|
||||
|
||||
- `{"method": "s", "params": <status>}`: the status, whenever something changes and at least once a second.
|
||||
- `{"method": "v", "params": [pad, large, small]}`: rumble, 0 to 255 for each motor, sent to the controller's
|
||||
owner when it changes and when a connection takes the controller over. `[pad, 0, 0]` stops it.
|
||||
- `{"method": "error", "params": {"message": "…"}}`: a problem that isn't the reply to a request: a controller
|
||||
removed for sitting unused, a request that couldn't be parsed, or an error for a request sent without an
|
||||
removed for sitting unused or PS4 user logout, a request that couldn't be parsed, or an error for a request sent without an
|
||||
`id`.
|
||||
|
||||
Errors in reply to a request:
|
||||
@@ -141,7 +164,7 @@ if they carry an `Origin`, an `Upgrade`, a body or `Transfer-Encoding`. A browse
|
||||
cross-origin after a CORS preflight, which the service never answers, so websites can't use these.
|
||||
|
||||
```
|
||||
GET /api/status → {"application": "Control4Free", "api": 1, "version": "1.0.0", "controllers": 2, "stopping": false}
|
||||
GET /api/status → {"application": "Control4Free", "api": 1, "version": "<version>", "controllers": 2, "stopping": false}
|
||||
POST /api/stop → {"application": "Control4Free", "stopping": true}
|
||||
```
|
||||
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
# Troubleshooting
|
||||
|
||||
Find the message you see, or the symptom, below. If nothing here helps,
|
||||
[open an issue](https://github.com/MoHadiShibli/Control4Free/issues/new/choose) with the log described at the
|
||||
end of this page.
|
||||
[open an issue](https://github.com/MoHadiShibli/Control4Free/issues/new/choose) with a diagnostic report, as
|
||||
described at the [end of this page](#reporting-a-problem).
|
||||
|
||||
## On the controller page
|
||||
|
||||
**"Signing a controller in needs the PS4's kernel log, and something else has it."**
|
||||
A klog viewer is connected to GoldHEN's log server (port 3232). Close it and pick the controller again. Only
|
||||
signing a controller in needs the log, so this never interrupts play.
|
||||
A klog viewer is connected to GoldHEN's log server (port 3232). Close it and pick the controller again. A
|
||||
diagnostic app from a test build before 1.1.1 can also hold the log while it sits in the background: close it
|
||||
(press **PS**, highlight the app, press **OPTIONS**, choose **Close Application**) and update. Only signing a
|
||||
controller in needs the log, so this never interrupts play.
|
||||
|
||||
**"Another controller is being connected; try again in a moment."**
|
||||
Controllers are added one at a time. Wait a second and pick yours again. Everyone already playing carries on
|
||||
@@ -54,7 +56,7 @@ controller. If it still doesn't, disconnect the controller from the page's menu
|
||||
**The screen went away without a user.** PS cancels it. Pick the controller again and use only the D-pad and
|
||||
Cross until you're signed in.
|
||||
|
||||
**The page keeps saying "Choose user on TV" after signing in.** The page learns about sign-ins from the PS4's
|
||||
**The page keeps saying "Choose user on PS4" after signing in.** The page learns about sign-ins from the PS4's
|
||||
kernel log. If a klog viewer was connected at that moment, it missed it. Play on: the controller works either
|
||||
way.
|
||||
|
||||
@@ -66,8 +68,10 @@ that takes over a signed-in user's controller can stop games from reading Contro
|
||||
**Buttons feel slow.** The number next to the version at the bottom of the home screen is the round trip to
|
||||
the console. Above about 30 ms, your Wi-Fi is the slow part: move closer to the router or use 5 GHz.
|
||||
|
||||
**No vibration from the game.** Rumble isn't passed to the phone or gamepad yet. It's planned for the next
|
||||
version.
|
||||
**No vibration from the game.** Check **Vibration** is on in the settings. Only Android phones can vibrate:
|
||||
Safari on iPhone doesn't let pages do it. Not every controller supports vibration in the browser, and one
|
||||
whose row says *no rumble in this browser* can't be rumbled from the page at all. Keep the page's window in
|
||||
front. And not every game rumbles every player.
|
||||
|
||||
## Gamepads
|
||||
|
||||
@@ -75,8 +79,50 @@ version.
|
||||
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 Xbox button presses PS for another controller in Chrome or Edge on Windows.** The browser's
|
||||
Windows.Gaming.Input backend combines Xbox/Guide presses from several controllers and reports them on one
|
||||
gamepad. Other buttons can still belong to the correct gamepad. Check **Mapping → Gamepad Controls** on
|
||||
each device: if the wrong device's **B16** lights up, the browser has already lost the physical source.
|
||||
Control4Free cannot recover that source from the reported input. See the
|
||||
[Chromium implementation](https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc#419).
|
||||
|
||||
On Windows 11, newer Chrome builds offer an experimental **Windows GameInput** backend with
|
||||
per-device Guide-button callbacks. If your browser offers it, open
|
||||
`chrome://flags/#enable-windows-gameinput-data-fetcher` (or search `edge://flags` in Edge), set it to
|
||||
**Enabled**, and relaunch. Reconnect the gamepads and repeat the **Gamepad Controls** check: each Xbox
|
||||
button should light **B16** only on its own gamepad. Confirm both PS buttons after assigning controllers.
|
||||
This resolved combined Xbox/Guide reporting in a two-controller user test on Chrome 154 and Windows 11.
|
||||
Controller models and connection modes were not recorded, and other input and haptic capabilities were
|
||||
not revalidated. The option remains experimental; input counts and haptics may change. Compare
|
||||
**Gamepad Controls**, including any extra paddles, and review saved profiles after changing the backend.
|
||||
If gamepads stop appearing, return the flag to **Default**.
|
||||
See Chromium's [backend selection](https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/gamepad_platform_data_fetcher.h)
|
||||
and [per-device Guide handling](https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/gameinput_data_fetcher.cc).
|
||||
|
||||
GameInput is a workaround for this browser's multi-controller Guide reporting, not a general
|
||||
Control4Free requirement. The controller page cannot select a Windows input backend or change browser
|
||||
flags through the [Gamepad API](https://w3c.github.io/gamepad/). A permanent correction to this browser
|
||||
path requires a browser fix; a future PC app could choose its own per-device reader. The existing mapper
|
||||
can also use an independently reported button for PS.
|
||||
|
||||
In older browser builds that offer **Enable Windows.Gaming.Input**, open
|
||||
`chrome://flags/#enable-windows-gaming-input-data-fetcher` (or search `edge://flags` in Edge), set it to
|
||||
**Disabled**, and relaunch the browser. Reconnect the gamepads, select their controllers again, and check
|
||||
both Xbox buttons independently. This switches those builds to per-device XInput; extra paddles, input
|
||||
counts and haptic capabilities may change, so review saved mappings. The flag is unavailable in some
|
||||
newer builds.
|
||||
|
||||
If these options are unavailable or the raw Guide input still crosses devices, try Firefox on Windows
|
||||
or map an independently reported button to **PS button**
|
||||
using **DS4 Mapping → PS button → Change**. That replaces the Xbox/Guide binding. Remapping or swapping
|
||||
controller numbers alone cannot separate the combined Xbox-button signal.
|
||||
|
||||
**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**.
|
||||
@@ -89,8 +135,9 @@ run the jailbreak: with auto-start on, Control4Free starts by itself.
|
||||
**"Control4Free is not responding."** Wait a few seconds after the PS4 wakes from rest mode, then press
|
||||
**Cross** again. If it stays stuck, restart the PS4 and run the jailbreak.
|
||||
|
||||
**"Sent, but Control4Free never answered."** or **"The transfer to PayLoader broke off."** Restart the PS4
|
||||
before trying again. The app won't send a second copy until then, so two can't end up running.
|
||||
**"Sent, but Control4Free did not start."** or **"The transfer to PayLoader broke off."** Press **Cross** to
|
||||
try again. Two copies can't end up running: a second one quits at once. If it never starts, open an issue
|
||||
with your firmware and GoldHEN versions.
|
||||
|
||||
**"Something answers on port 4264 but not the way this app expects."** or **"No answer the app
|
||||
understands."** Something else is answering on port 4264, often an older version of Control4Free. Stop it
|
||||
@@ -104,10 +151,37 @@ the app and open it again; if that doesn't help, restart the PS4 and run the jai
|
||||
**Control4Free is listed under Games instead of Applications.** That's a package from before 1.0.0. Delete the
|
||||
app and install the current package.
|
||||
|
||||
## The log
|
||||
## Reporting a problem
|
||||
|
||||
The service writes a log to `/data/control4free/control4free.log` on the PS4, and keeps the previous run in
|
||||
`control4free.log.previous`. Turn on GoldHEN's FTP server and download them from port 2121, or connect to
|
||||
GoldHEN's log server on port 3232 and look for lines starting with `[c4f]`.
|
||||
Every release comes with a diagnostic package, `Control4Free-<version>-diag.pkg`. It's the same Control4Free,
|
||||
except that its app shows on screen what the PS4 reports, so you can send screenshots instead of digging out
|
||||
log files. Please use it before you open an issue:
|
||||
|
||||
The log contains your console's network address. Remove it before posting the log if you prefer.
|
||||
1. Download `Control4Free-<version>-diag.pkg` from the
|
||||
[latest release](https://github.com/MoHadiShibli/Control4Free/releases/latest) and install it over the app.
|
||||
It replaces the app and shows up as **Control4Free (diagnostic)**.
|
||||
2. Open it. If it says RUNNING, the normal version is still the one running: press **Square**, then **Cross**
|
||||
to stop it. Then press **Cross** to start the diagnostic version, and wait until the top stops saying
|
||||
"WORKING" (up to 30 seconds).
|
||||
3. Make the problem happen again, for example open the page on your phone or pick a controller. Then go back
|
||||
to the app.
|
||||
4. Press **R1** to go through its 5 pages, and take a screenshot of each one: press **SHARE**, then
|
||||
**Triangle**. To copy them to a USB stick, open **Capture Gallery**, press **OPTIONS** and choose **Copy
|
||||
to USB Storage Device**.
|
||||
5. [Open an issue](https://github.com/MoHadiShibli/Control4Free/issues/new/choose) and attach the
|
||||
screenshots.
|
||||
|
||||
The app also attempts to save the same report as text: `control4free-diag.txt` on a plugged-in USB stick, and
|
||||
`/data/control4free/diag-report.txt` on the PS4. Its first page reports which saves succeeded. You can attach
|
||||
a saved report instead of the screenshots. The report
|
||||
shows your console's local network address. It only works inside your home network, but you can blur it.
|
||||
|
||||
When you're done, install the normal package again over the diagnostic one, open it, press **Square**, then
|
||||
**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, readable over GoldHEN's FTP
|
||||
server (port 2121). Each active segment is bounded to 1 MiB: rollover moves it to `control4free.log.1` and
|
||||
starts a new one, replacing the older segment. The last segment of 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.
|
||||
@@ -5,16 +5,26 @@
|
||||
#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];
|
||||
const unsigned char *body;
|
||||
size_t bodySize, bodySent;
|
||||
void *user;
|
||||
#ifdef C4F_DIAG
|
||||
char peer[16]; /* the other end's address, for the diagnostic log */
|
||||
#endif
|
||||
} C4fNetClient;
|
||||
|
||||
enum { C4F_NET_OPEN, C4F_NET_MESSAGE_EVENT, C4F_NET_CLOSE,
|
||||
|
||||
@@ -14,6 +14,13 @@
|
||||
|
||||
/* ---- libSceUserService ---- */
|
||||
int32_t sceUserServiceInitialize(void *params);
|
||||
/* The user's name as the PS4 shows it: an online ID, or a local account's name. */
|
||||
int32_t sceUserServiceGetUserName(int32_t userId, char *name, size_t size);
|
||||
typedef struct { int32_t userId[4]; } C4fUserServiceLoginList;
|
||||
typedef struct { int32_t event, userId; } C4fUserServiceEvent;
|
||||
int32_t sceUserServiceGetLoginUserIdList(C4fUserServiceLoginList *list);
|
||||
int32_t sceUserServiceGetEvent(C4fUserServiceEvent *event);
|
||||
#define C4F_USER_EVENT_LOGOUT 1
|
||||
|
||||
#define C4F_USER_ID_INVALID (-1)
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
#ifndef C4F_VDA_H
|
||||
#define C4F_VDA_H
|
||||
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
#include "c4f_sce.h"
|
||||
|
||||
@@ -59,6 +60,28 @@ int32_t c4fVirtualPadAdd(int32_t vdaUser);
|
||||
/* Completes a pad from the DeviceId that came out of the log. */
|
||||
void c4fVirtualPadAdopt(C4fVirtualPad *out, int32_t userId, int32_t vdaUser, uint64_t deviceId);
|
||||
|
||||
/* ---- what the game asks of a controller ---- */
|
||||
|
||||
typedef struct {
|
||||
uint8_t large, small; /* rumble motors, 0..255 */
|
||||
uint8_t r, g, b; /* light bar; the PS4 uses 0x40 for full */
|
||||
} C4fPadFeedback;
|
||||
|
||||
/* Reads the buffer scePadVirtualDeviceGetRemoteSetting fills (feedback.c). */
|
||||
void c4fPadFeedbackParse(const uint8_t *buf, size_t size, C4fPadFeedback *out);
|
||||
|
||||
/* The rumble and light bar the system wants on this controller right now.
|
||||
* Returns 0, the call's error code, or -1 if the call isn't exported. */
|
||||
int32_t c4fVirtualPadFeedback(const C4fVirtualPad *pad, C4fPadFeedback *out);
|
||||
|
||||
/* The PS4's name for a user, as it shows it. Returns 0, or the call's error.
|
||||
* Not written to the log: people post logs in bug reports. */
|
||||
int32_t c4fUserName(uint32_t userId, char *out, size_t size);
|
||||
/* Current local login sessions, independently of cached names or pad assignment.
|
||||
* Failed reads never mean a user logged out. Events are nonblocking. */
|
||||
int32_t c4fLoginUsers(int32_t userIds[4]);
|
||||
int32_t c4fUserEvent(int32_t *type, uint32_t *userId);
|
||||
|
||||
/* A neutral sample: sticks centred, identity quaternion, connected. */
|
||||
void c4fPadDataNeutral(ScePadData *data);
|
||||
|
||||
|
||||
@@ -4,12 +4,23 @@ endif
|
||||
SDK := $(OO_PS4_TOOLCHAIN)
|
||||
VERSION := $(shell cat ../VERSION)
|
||||
OUT := ../build/launcher
|
||||
PAYLOAD := ../build/control4free.elf
|
||||
OBJECTS :=
|
||||
# Diagnostic build: pages of everything that says why Control4Free does not
|
||||
# start, in place of the normal screen, with the diagnostic payload inside.
|
||||
ifdef C4F_DIAG
|
||||
VERSION := $(VERSION)-diag
|
||||
OUT := ../build/launcher-diag
|
||||
PAYLOAD := ../build/control4free-diag.elf
|
||||
DIAG_FLAGS := -DC4F_DIAG
|
||||
OBJECTS += $(OUT)/diag.o
|
||||
endif
|
||||
PKGDIR := $(OUT)/pkg
|
||||
CC := clang-18
|
||||
CXX := clang++-18
|
||||
LD := ld.lld-18
|
||||
CFLAGS := --target=x86_64-pc-freebsd12-elf -fPIC -funwind-tables -O2 -Wall -Wextra -Werror -MMD -MP -D_DEFAULT_SOURCE -isysroot $(SDK) -isystem $(SDK)/include -I../vendor/jsmn -I../vendor/qrcodegen -I../vendor/stb -DC4F_LAUNCHER_VERSION='"$(VERSION)"'
|
||||
OBJECTS := $(OUT)/main.o $(OUT)/screen.o $(OUT)/draw.o $(OUT)/service.o $(OUT)/autorun.o $(OUT)/sandbox.o $(OUT)/logo.o $(OUT)/qrcodegen.o $(OUT)/stb_truetype.o $(OUT)/fonts.o
|
||||
CFLAGS := --target=x86_64-pc-freebsd12-elf -fPIC -funwind-tables -O2 -Wall -Wextra -Werror -MMD -MP -D_DEFAULT_SOURCE -isysroot $(SDK) -isystem $(SDK)/include -I../vendor/jsmn -I../vendor/qrcodegen -I../vendor/stb -DC4F_LAUNCHER_VERSION='"$(VERSION)"' $(DIAG_FLAGS)
|
||||
OBJECTS += $(OUT)/main.o $(OUT)/screen.o $(OUT)/draw.o $(OUT)/service.o $(OUT)/autorun.o $(OUT)/sandbox.o $(OUT)/logo.o $(OUT)/qrcodegen.o $(OUT)/stb_truetype.o $(OUT)/fonts.o
|
||||
LIBS := -lc -lkernel -lSceVideoOut -lScePad -lSceUserService -lSceSystemService -lSceNetCtl
|
||||
FONTS := ../vendor/roboto/Roboto-Light.ttf ../vendor/roboto/Roboto-Regular.ttf
|
||||
.PHONY: all package
|
||||
@@ -18,9 +29,10 @@ FONTS := ../vendor/roboto/Roboto-Light.ttf ../vendor/roboto/Roboto-Regular.ttf
|
||||
all: package
|
||||
$(OUT):
|
||||
mkdir -p $@
|
||||
$(OUT)/%.o: %.c | $(OUT)
|
||||
# The version is compiled in; a new one must rebuild everything that shows it.
|
||||
$(OUT)/%.o: %.c ../VERSION | $(OUT)
|
||||
$(CC) $(CFLAGS) -std=gnu11 -c $< -o $@
|
||||
$(OUT)/main.o: main.cpp | $(OUT)
|
||||
$(OUT)/main.o: main.cpp ../VERSION | $(OUT)
|
||||
$(CXX) $(CFLAGS) -std=c++17 -fno-exceptions -fno-rtti -c $< -o $@
|
||||
$(OUT)/qrcodegen.o: ../vendor/qrcodegen/qrcodegen.c | $(OUT)
|
||||
$(CC) $(CFLAGS) -std=gnu11 -c $< -o $@
|
||||
@@ -33,5 +45,5 @@ $(OUT)/fonts.o: $(OUT)/fonts.c
|
||||
$(CC) $(CFLAGS) -std=gnu11 -c $< -o $@
|
||||
$(OUT)/launcher.elf: $(OBJECTS)
|
||||
$(LD) -m elf_x86_64 -pie --script $(SDK)/link.x --eh-frame-hdr -o $@ $(OBJECTS) -L$(SDK)/lib $(LIBS) $(SDK)/lib/crt1.o
|
||||
package: $(OUT)/launcher.elf ../build/control4free.elf
|
||||
python3 ../tools/package_launcher.py --sdk $(SDK)
|
||||
package: $(OUT)/launcher.elf $(PAYLOAD)
|
||||
python3 ../tools/package_launcher.py --sdk $(SDK) $(if $(C4F_DIAG),--diag)
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,636 @@
|
||||
/* Diagnostic build of the launcher: see diag.h.
|
||||
*
|
||||
* Everything is shown on pages a user can screenshot, and also saved to
|
||||
* /data/control4free/diag-report.txt (and a USB stick when one is plugged in).
|
||||
* The kernel log has a single reader, and Control4Free needs it to sign a
|
||||
* controller in. So the app reads it only in short windows, and never while
|
||||
* Control4Free runs: an app suspended in the background keeps its descriptor
|
||||
* open, and every controller would then fail to sign in (seen on 13.52). */
|
||||
#include "diag.h"
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
#include <pthread.h>
|
||||
#include <stdarg.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/select.h>
|
||||
#include <sys/stat.h>
|
||||
#include <unistd.h>
|
||||
#include <orbis/libkernel.h>
|
||||
#include "autorun.h"
|
||||
#include "draw.h"
|
||||
#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"
|
||||
#define C4F_DIAG_PAYLOAD "/user/data/payloads/control4free.elf"
|
||||
#define C4F_DIAG_INI "/user/data/GoldHEN/payloads.ini"
|
||||
#define C4F_LOG_TAIL (64 * 1024)
|
||||
#define C4F_KLOG_KEEP 1000
|
||||
#define C4F_KLOG_WIDTH 192
|
||||
#define C4F_KLOG_FILE_MAX (4u << 20)
|
||||
|
||||
#define C4F_D_BG C4F_RGBA(6, 14, 34, 1)
|
||||
#define C4F_D_TEXT C4F_RGBA(235, 240, 255, 1)
|
||||
#define C4F_D_FAINT C4F_RGBA(160, 175, 205, 1)
|
||||
#define C4F_D_ACCENT C4F_RGBA(90, 160, 255, 1)
|
||||
#define C4F_D_WARN C4F_RGBA(255, 208, 77, 1)
|
||||
#define C4F_D_LINE C4F_RGBA(255, 255, 255, .2f)
|
||||
#define C4F_D_SIZE 20.0f
|
||||
#define C4F_D_TITLE 28.0f
|
||||
#define C4F_D_ROW 24
|
||||
#define C4F_D_LEFT 40
|
||||
#define C4F_D_TOP 186
|
||||
#define C4F_D_BOTTOM 1052
|
||||
|
||||
static const char *const c4fPageNames[C4F_DIAG_PAGES] = {
|
||||
"Summary", "Control4Free log", "Previous Control4Free log", "Kernel log (filtered)", "Kernel log (everything)",
|
||||
};
|
||||
|
||||
static pthread_mutex_t c4fLock = PTHREAD_MUTEX_INITIALIZER;
|
||||
static pthread_t c4fKlogReader;
|
||||
static int c4fReaderStarted, c4fQuit;
|
||||
static uint64_t c4fBootMs, c4fCaptureUntil;
|
||||
static int c4fServiceUp, c4fRefreshed;
|
||||
|
||||
/* Pages, rebuilt by the launcher's worker and drawn by its main loop. */
|
||||
static char *c4fPages[C4F_DIAG_PAGES];
|
||||
static unsigned c4fGeneration = 1;
|
||||
static int c4fMaxScroll[C4F_DIAG_PAGES];
|
||||
|
||||
/* What start-up found. */
|
||||
static char c4fFirmware[96], c4fGoldHen[96], c4fSandbox[96], c4fBundled[96];
|
||||
|
||||
/* The kernel log: the newest lines, and how reading it went. */
|
||||
static char c4fKlog[C4F_KLOG_KEEP][C4F_KLOG_WIDTH];
|
||||
static unsigned c4fKlogTotal, c4fKlogOpens;
|
||||
static int c4fKlogError;
|
||||
static unsigned long long c4fKlogBytes;
|
||||
|
||||
/* The last start. */
|
||||
static int c4fStarted, c4fStartResult, c4fLogChanged;
|
||||
static uint64_t c4fStartedAt, c4fStartTook;
|
||||
static char c4fStartMessage[192];
|
||||
static char *c4fLogBefore;
|
||||
|
||||
static uint64_t c4fLastReport;
|
||||
static char c4fReportNote[160];
|
||||
|
||||
/* ---- helpers ---- */
|
||||
|
||||
typedef struct { char *data; size_t used, size; } C4fText;
|
||||
|
||||
static void c4fAdd(C4fText *t, const char *fmt, ...) __attribute__((format(printf, 2, 3)));
|
||||
static void c4fAdd(C4fText *t, const char *fmt, ...)
|
||||
{
|
||||
for (;;) {
|
||||
size_t room = t->data ? t->size - t->used : 0;
|
||||
va_list ap;
|
||||
va_start(ap, fmt);
|
||||
int n = vsnprintf(t->data ? t->data + t->used : NULL, room, fmt, ap);
|
||||
va_end(ap);
|
||||
if (n < 0) return;
|
||||
if (t->data && (size_t)n < room) { t->used += (size_t)n; return; }
|
||||
size_t grown = t->size ? t->size * 2 : 4096;
|
||||
while (grown < t->used + (size_t)n + 1) grown *= 2;
|
||||
char *next = realloc(t->data, grown);
|
||||
if (!next) return;
|
||||
t->data = next; t->size = grown;
|
||||
}
|
||||
}
|
||||
|
||||
static char *c4fTake(C4fText *t) { return t->data ? t->data : strdup(""); }
|
||||
|
||||
/* The last `limit` bytes of a file, NUL-terminated, NULs shown as spaces;
|
||||
* NULL with errno when it cannot be read. */
|
||||
static char *c4fReadTail(const char *path, size_t limit, long long *total)
|
||||
{
|
||||
int fd = open(path, O_RDONLY);
|
||||
if (fd < 0) return NULL;
|
||||
off_t size = lseek(fd, 0, SEEK_END);
|
||||
off_t start = size > (off_t)limit ? size - (off_t)limit : 0;
|
||||
if (size < 0 || lseek(fd, start, SEEK_SET) < 0) { int saved = errno; close(fd); errno = saved; return NULL; }
|
||||
size_t want = (size_t)(size - start), used = 0;
|
||||
char *data = malloc(want + 1);
|
||||
if (!data) { close(fd); errno = ENOMEM; return NULL; }
|
||||
while (used < want) {
|
||||
ssize_t n = read(fd, data + used, want - used);
|
||||
if (n < 0 && errno == EINTR) continue;
|
||||
if (n <= 0) break;
|
||||
used += (size_t)n;
|
||||
}
|
||||
close(fd);
|
||||
for (size_t i = 0; i < used; i++) if (!data[i]) data[i] = ' ';
|
||||
data[used] = 0;
|
||||
if (total) *total = (long long)size;
|
||||
return data;
|
||||
}
|
||||
|
||||
static uint32_t c4fCrc32Update(uint32_t crc, const unsigned char *data, size_t size)
|
||||
{
|
||||
crc = ~crc;
|
||||
for (size_t i = 0; i < size; i++) {
|
||||
crc ^= data[i];
|
||||
for (int bit = 0; bit < 8; bit++) crc = (crc >> 1) ^ (0xedb88320u & (0u - (crc & 1)));
|
||||
}
|
||||
return ~crc;
|
||||
}
|
||||
|
||||
static uint32_t c4fCrc32(const unsigned char *data, size_t size) { return c4fCrc32Update(0, data, size); }
|
||||
|
||||
/* A file's size and CRC-32; -1 with errno when it cannot be read. */
|
||||
static int c4fCrcFile(const char *path, size_t *size, uint32_t *crc)
|
||||
{
|
||||
int fd = open(path, O_RDONLY);
|
||||
if (fd < 0) return -1;
|
||||
unsigned char buf[16384];
|
||||
*size = 0; *crc = 0;
|
||||
for (;;) {
|
||||
ssize_t n = read(fd, buf, sizeof(buf));
|
||||
if (n < 0 && errno == EINTR) continue;
|
||||
if (n < 0) { int saved = errno; close(fd); errno = saved; return -1; }
|
||||
if (n == 0) break;
|
||||
*crc = c4fCrc32Update(*crc, buf, (size_t)n);
|
||||
*size += (size_t)n;
|
||||
}
|
||||
close(fd);
|
||||
return 0;
|
||||
}
|
||||
|
||||
static const char *c4fLastLine(const char *text, char *out, size_t size)
|
||||
{
|
||||
const char *end = text + strlen(text);
|
||||
while (end > text && (end[-1] == '\n' || end[-1] == '\r' || end[-1] == ' ')) end--;
|
||||
const char *start = end;
|
||||
while (start > text && start[-1] != '\n') start--;
|
||||
snprintf(out, size, "%.*s", (int)(end - start), start);
|
||||
return out;
|
||||
}
|
||||
|
||||
static int c4fContains(const char *haystack, const char *needle)
|
||||
{
|
||||
size_t n = strlen(needle);
|
||||
for (; *haystack; haystack++) {
|
||||
size_t i = 0;
|
||||
while (i < n && haystack[i] && (haystack[i] | 0x20) == (needle[i] | 0x20)) i++;
|
||||
if (i == n) return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
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 -1;
|
||||
size_t size = strlen(text);
|
||||
while (size) {
|
||||
ssize_t n = write(fd, text, size);
|
||||
if (n < 0 && errno == EINTR) continue;
|
||||
if (n <= 0) { int saved = n < 0 ? errno : EIO; close(fd); errno = saved; return -1; }
|
||||
text += n; size -= (size_t)n;
|
||||
}
|
||||
if (fsync(fd)) { int saved = errno; close(fd); errno = saved; return -1; }
|
||||
return close(fd);
|
||||
}
|
||||
|
||||
/* ---- the kernel log ---- */
|
||||
|
||||
/* One line, without terminal colour codes (GoldHEN's lines carry them). */
|
||||
static void c4fKlogAdd(const char *line)
|
||||
{
|
||||
char clean[C4F_KLOG_WIDTH];
|
||||
size_t used = 0;
|
||||
for (const char *p = line; *p && used + 1 < sizeof(clean); p++) {
|
||||
if (*p == '\033') {
|
||||
if (p[1] == '[') { p += 2; while (*p && !(*p >= '@' && *p <= '~')) p++; if (!*p) break; }
|
||||
continue;
|
||||
}
|
||||
if (*p != '\r') clean[used++] = *p;
|
||||
}
|
||||
clean[used] = 0;
|
||||
pthread_mutex_lock(&c4fLock);
|
||||
snprintf(c4fKlog[c4fKlogTotal % C4F_KLOG_KEEP], C4F_KLOG_WIDTH, "%s", clean);
|
||||
c4fKlogTotal++;
|
||||
pthread_mutex_unlock(&c4fLock);
|
||||
}
|
||||
|
||||
static void *c4fKlogThread(void *unused)
|
||||
{
|
||||
(void)unused;
|
||||
int fd = -1;
|
||||
size_t used = 0, written = 0;
|
||||
char line[C4F_KLOG_WIDTH * 2];
|
||||
for (;;) {
|
||||
pthread_mutex_lock(&c4fLock);
|
||||
int quit = c4fQuit, want = !c4fServiceUp && c4fLauncherTimeMs() < c4fCaptureUntil;
|
||||
pthread_mutex_unlock(&c4fLock);
|
||||
if (quit) break;
|
||||
if (want && fd < 0) {
|
||||
fd = open("/dev/klog", O_RDONLY | O_NONBLOCK);
|
||||
int error = fd < 0 ? errno : 0;
|
||||
if (fd >= (int)FD_SETSIZE) { close(fd); fd = -1; error = EMFILE; }
|
||||
pthread_mutex_lock(&c4fLock);
|
||||
c4fKlogError = error;
|
||||
if (fd < 0) c4fCaptureUntil = 0; else c4fKlogOpens++;
|
||||
pthread_mutex_unlock(&c4fLock);
|
||||
continue;
|
||||
}
|
||||
if (!want && fd >= 0) {
|
||||
close(fd); fd = -1;
|
||||
if (used) { line[used] = 0; c4fKlogAdd(line); used = 0; }
|
||||
}
|
||||
if (fd < 0) { usleep(100000); continue; }
|
||||
fd_set set; FD_ZERO(&set); FD_SET(fd, &set);
|
||||
struct timeval tv = { 0, 100000 };
|
||||
if (select(fd + 1, &set, NULL, NULL, &tv) <= 0) continue;
|
||||
char buf[4096];
|
||||
ssize_t n = read(fd, buf, sizeof(buf) - 1);
|
||||
if (n < 0 && (errno == EINTR || errno == EAGAIN || errno == EWOULDBLOCK)) continue;
|
||||
if (n <= 0) {
|
||||
pthread_mutex_lock(&c4fLock);
|
||||
c4fKlogError = n < 0 ? errno : EPIPE; c4fCaptureUntil = 0;
|
||||
pthread_mutex_unlock(&c4fLock);
|
||||
close(fd); fd = -1;
|
||||
continue;
|
||||
}
|
||||
buf[n] = 0;
|
||||
if (written < C4F_KLOG_FILE_MAX) { c4fWriteAll(C4F_DIAG_KLOG, buf, 1); written += (size_t)n; }
|
||||
pthread_mutex_lock(&c4fLock);
|
||||
c4fKlogBytes += (unsigned long long)n;
|
||||
pthread_mutex_unlock(&c4fLock);
|
||||
for (ssize_t i = 0; i < n; i++) {
|
||||
if (buf[i] == '\n' || used + 1 >= sizeof(line)) {
|
||||
line[used] = 0; c4fKlogAdd(line); used = 0;
|
||||
if (buf[i] == '\n') continue;
|
||||
}
|
||||
line[used++] = buf[i];
|
||||
}
|
||||
}
|
||||
if (fd >= 0) close(fd);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
void c4fDiagCapture(unsigned milliseconds)
|
||||
{
|
||||
pthread_mutex_lock(&c4fLock);
|
||||
uint64_t until = c4fLauncherTimeMs() + milliseconds;
|
||||
if (until > c4fCaptureUntil) c4fCaptureUntil = until;
|
||||
pthread_mutex_unlock(&c4fLock);
|
||||
}
|
||||
|
||||
/* ---- start-up and starts ---- */
|
||||
|
||||
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));
|
||||
version.Size = sizeof(version);
|
||||
int ret = sceKernelGetSystemSwVersion(&version);
|
||||
version.VersionString[sizeof(version.VersionString) - 1] = 0;
|
||||
snprintf(c4fFirmware, sizeof(c4fFirmware), "\"%s\" = 0x%08x (call returned 0x%08x)",
|
||||
version.VersionString, version.Version, (uint32_t)ret);
|
||||
/* GoldHEN returns the version as the call's error value (seen on 13.52:
|
||||
* 256 = SDK 1.00), so only ENOSYS (78) means there is no GoldHEN call. */
|
||||
long goldHen = c4fGoldHenCommand(0, NULL);
|
||||
if (goldHen == -78) snprintf(c4fGoldHen, sizeof(c4fGoldHen), "no answer (errno 78: no GoldHEN SDK call)");
|
||||
else snprintf(c4fGoldHen, sizeof(c4fGoldHen), "answers, SDK version 0x%lx", goldHen < 0 ? -goldHen : goldHen);
|
||||
if (sandboxResult == 0) snprintf(c4fSandbox, sizeof(c4fSandbox), "left through GoldHEN");
|
||||
else snprintf(c4fSandbox, sizeof(c4fSandbox), "still inside, errno %d", sandboxError);
|
||||
size_t size = 0;
|
||||
const unsigned char *payload = c4fAutorunBundled(&size);
|
||||
if (payload) snprintf(c4fBundled, sizeof(c4fBundled), "%zu bytes, crc32 %08x", size, c4fCrc32(payload, size));
|
||||
else snprintf(c4fBundled, sizeof(c4fBundled), "could not be read");
|
||||
c4fWriteAll(C4F_DIAG_KLOG, "", 0);
|
||||
c4fReaderStarted = pthread_create(&c4fKlogReader, NULL, c4fKlogThread, NULL) == 0;
|
||||
/* The backlog (GoldHEN, AutoRun, earlier runs) is read at the first
|
||||
* refresh, once it is known whether Control4Free runs. */
|
||||
}
|
||||
|
||||
void c4fDiagStop(void)
|
||||
{
|
||||
pthread_mutex_lock(&c4fLock);
|
||||
c4fQuit = 1;
|
||||
pthread_mutex_unlock(&c4fLock);
|
||||
if (c4fReaderStarted) pthread_join(c4fKlogReader, NULL);
|
||||
}
|
||||
|
||||
void c4fDiagBeforeStart(void)
|
||||
{
|
||||
free(c4fLogBefore);
|
||||
c4fLogBefore = c4fReadTail(C4F_DIAG_LOG, C4F_LOG_TAIL, NULL);
|
||||
c4fStartedAt = c4fLauncherTimeMs();
|
||||
c4fDiagCapture(30000);
|
||||
}
|
||||
|
||||
void c4fDiagAfterStart(int result, const char *message)
|
||||
{
|
||||
char *after = c4fReadTail(C4F_DIAG_LOG, C4F_LOG_TAIL, NULL);
|
||||
c4fLogChanged = after && (!c4fLogBefore || strcmp(after, c4fLogBefore));
|
||||
free(after);
|
||||
c4fStarted = 1;
|
||||
c4fStartResult = result;
|
||||
c4fStartTook = c4fLauncherTimeMs() - c4fStartedAt;
|
||||
snprintf(c4fStartMessage, sizeof(c4fStartMessage), "%s", message);
|
||||
c4fLastReport = 0; /* save the report straight away */
|
||||
if (result) c4fDiagCapture(3000);
|
||||
else { pthread_mutex_lock(&c4fLock); c4fCaptureUntil = 0; pthread_mutex_unlock(&c4fLock); }
|
||||
}
|
||||
|
||||
/* ---- pages ---- */
|
||||
|
||||
static void c4fFileSummary(C4fText *t, const char *label, const char *path)
|
||||
{
|
||||
long long size = 0;
|
||||
char *text = c4fReadTail(path, C4F_LOG_TAIL, &size);
|
||||
if (!text) { c4fAdd(t, " %s: not there (errno %d)\n", label, errno); return; }
|
||||
char last[200];
|
||||
const char *firstEnd = strchr(text, '\n');
|
||||
c4fAdd(t, " %s: %lld bytes\n first: %.*s\n last: %s\n", label, size,
|
||||
(int)(firstEnd ? firstEnd - text : (long)strlen(text)), text, c4fLastLine(text, last, sizeof(last)));
|
||||
free(text);
|
||||
}
|
||||
|
||||
static char *c4fSummary(const C4fDiagState *s)
|
||||
{
|
||||
C4fText t = {0};
|
||||
char last[200] = "";
|
||||
char *log = c4fReadTail(C4F_DIAG_LOG, C4F_LOG_TAIL, NULL);
|
||||
if (log) c4fLastLine(log, last, sizeof(last));
|
||||
|
||||
if (s->running == 1)
|
||||
c4fAdd(&t, ">> Control4Free is RUNNING (%s). Now open %s on your phone or PC. Whatever happens, come back\n"
|
||||
">> here and take screenshots of pages 1 and 2: page 2 lists every connection it saw.\n",
|
||||
s->version, s->address[0] ? s->address : "the PS4's address with :4264");
|
||||
else if (c4fStarted && c4fContains(c4fStartMessage, "PayLoader did not answer"))
|
||||
c4fAdd(&t, ">> GoldHEN's PayLoader (port 9090) did not take the payload. Turn PayLoader on in GoldHEN's\n"
|
||||
">> settings, then press Cross again.\n");
|
||||
else if (c4fStarted && c4fStartResult == -2 && !c4fLogChanged)
|
||||
c4fAdd(&t, ">> The payload was handed to PayLoader, but Control4Free's own code never ran (it wrote no log).\n"
|
||||
">> PayLoader did not run it, or it stopped while starting up on this firmware. The kernel log\n"
|
||||
">> pages (4 and 5) show what the PS4 said meanwhile.\n");
|
||||
else if (c4fStarted && c4fLogChanged)
|
||||
c4fAdd(&t, ">> Control4Free started but does not answer. Its log (page 2) shows how far it got. Last line:\n"
|
||||
">> %s\n", last);
|
||||
else if (c4fStarted)
|
||||
c4fAdd(&t, ">> The start did not work: %s\n", c4fStartMessage);
|
||||
else
|
||||
c4fAdd(&t, ">> Not running. Press Cross to start it, wait for the result (up to 30 seconds), then take a\n"
|
||||
">> screenshot of every page.\n");
|
||||
free(log);
|
||||
|
||||
c4fAdd(&t, "\nConsole\n");
|
||||
c4fAdd(&t, " system software: %s\n", c4fFirmware);
|
||||
c4fAdd(&t, " GoldHEN SDK call: %s\n", c4fGoldHen);
|
||||
c4fAdd(&t, " app sandbox: %s\n", c4fSandbox);
|
||||
c4fAdd(&t, " network address: %s\n", s->address[0] ? s->address : "(none)");
|
||||
|
||||
c4fAdd(&t, "\nThis app\n");
|
||||
c4fAdd(&t, " version %s, bundled payload %s, open for %llu s\n", C4F_LAUNCHER_VERSION, c4fBundled,
|
||||
(unsigned long long)((c4fLauncherTimeMs() - c4fBootMs) / 1000));
|
||||
|
||||
c4fAdd(&t, "\nService (port 4264)\n");
|
||||
if (s->running == 1) c4fAdd(&t, " running, version %s, %d controller(s)\n", s->version, s->controllers);
|
||||
else if (s->running == 0) c4fAdd(&t, " not running (nothing listens)\n");
|
||||
else c4fAdd(&t, " unclear: %s\n", s->problem[0] ? s->problem : "(no detail)");
|
||||
c4fAdd(&t, " screen message: %s%s\n", s->message, s->locked ? " [app error: reopen the app]" : "");
|
||||
|
||||
c4fAdd(&t, "\nLast start from this app\n");
|
||||
if (!c4fStarted) c4fAdd(&t, " none yet\n");
|
||||
else c4fAdd(&t, " result %d after %llu ms; service log %s\n message: %s\n", c4fStartResult,
|
||||
(unsigned long long)c4fStartTook, c4fLogChanged ? "CHANGED (it ran)" : "unchanged (it did not run)",
|
||||
c4fStartMessage);
|
||||
|
||||
c4fAdd(&t, "\nAuto-start\n");
|
||||
static const char *const states[] = { "unknown", "off", "on", "on, but an older copy" };
|
||||
int state = s->autorun + 1;
|
||||
c4fAdd(&t, " state: %s%s%s\n", state >= 0 && state < 4 ? states[state] : "?", s->autorunNote[0] ? "; " : "",
|
||||
s->autorunNote);
|
||||
size_t installedSize = 0;
|
||||
uint32_t installedCrc = 0;
|
||||
if (!c4fCrcFile(C4F_DIAG_PAYLOAD, &installedSize, &installedCrc))
|
||||
c4fAdd(&t, " /data/payloads/control4free.elf: %zu bytes, crc32 %08x\n", installedSize, installedCrc);
|
||||
else c4fAdd(&t, " /data/payloads/control4free.elf: not there (errno %d)\n", errno);
|
||||
char *ini = c4fReadTail(C4F_DIAG_INI, 2048, NULL);
|
||||
if (ini) {
|
||||
c4fAdd(&t, " /data/GoldHEN/payloads.ini:\n");
|
||||
for (char *line = strtok(ini, "\n"); line; line = strtok(NULL, "\n")) c4fAdd(&t, " %s\n", line);
|
||||
free(ini);
|
||||
} else c4fAdd(&t, " /data/GoldHEN/payloads.ini: not there (errno %d)\n", errno);
|
||||
|
||||
c4fAdd(&t, "\nLogs\n");
|
||||
c4fFileSummary(&t, "control4free.log", C4F_DIAG_LOG);
|
||||
c4fFileSummary(&t, "control4free.log.previous", C4F_DIAG_LOG ".previous");
|
||||
|
||||
pthread_mutex_lock(&c4fLock);
|
||||
unsigned total = c4fKlogTotal, opens = c4fKlogOpens;
|
||||
int error = c4fKlogError;
|
||||
unsigned long long bytes = c4fKlogBytes;
|
||||
pthread_mutex_unlock(&c4fLock);
|
||||
c4fAdd(&t, " kernel log: read %u time(s), %llu bytes, %u lines", opens, bytes, total);
|
||||
if (s->running == 1) c4fAdd(&t, "; not read while Control4Free runs, which needs it to sign controllers in\n");
|
||||
else if (error == 16) c4fAdd(&t, "; BUSY: a klog viewer is connected to GoldHEN (port 3232). Close it, press Options.\n");
|
||||
else if (error) c4fAdd(&t, "; last error errno %d\n", error);
|
||||
else c4fAdd(&t, "\n");
|
||||
c4fAdd(&t, " report: %s\n", c4fReportNote[0] ? c4fReportNote : "not saved yet");
|
||||
return c4fTake(&t);
|
||||
}
|
||||
|
||||
static char *c4fLogPage(const char *path)
|
||||
{
|
||||
long long size = 0;
|
||||
char *text = c4fReadTail(path, C4F_LOG_TAIL, &size);
|
||||
if (!text) {
|
||||
C4fText t = {0};
|
||||
c4fAdd(&t, "%s is not there (errno %d).\n", path, errno);
|
||||
return c4fTake(&t);
|
||||
}
|
||||
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. */
|
||||
static int c4fInteresting(const char *line)
|
||||
{
|
||||
static const char *const words[] = {
|
||||
"c4f", "control4free", "unsupported", "firmware", "kexec", "payload", "elf", "goldhen", "signal",
|
||||
"crash", "fault", "panic", "trap", "segv", "party", "rtld", "dlsym", "unable", "fail", "error",
|
||||
"denied", "9090", "4264",
|
||||
};
|
||||
for (size_t i = 0; i < sizeof(words) / sizeof(words[0]); i++) if (c4fContains(line, words[i])) return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static char *c4fKlogPage(int filtered)
|
||||
{
|
||||
C4fText t = {0};
|
||||
pthread_mutex_lock(&c4fLock);
|
||||
unsigned total = c4fKlogTotal, first = total > C4F_KLOG_KEEP ? total - C4F_KLOG_KEEP : 0;
|
||||
if (!total) c4fAdd(&t, "Nothing read from the kernel log yet (see the summary page). Options reads it again.\n");
|
||||
for (unsigned i = first; i < total; i++) {
|
||||
const char *line = c4fKlog[i % C4F_KLOG_KEEP];
|
||||
if (!filtered || c4fInteresting(line)) c4fAdd(&t, "%s\n", line);
|
||||
}
|
||||
pthread_mutex_unlock(&c4fLock);
|
||||
return c4fTake(&t);
|
||||
}
|
||||
|
||||
static void c4fSaveReport(void)
|
||||
{
|
||||
C4fText t = {0};
|
||||
pthread_mutex_lock(&c4fLock);
|
||||
for (int i = 0; i < C4F_DIAG_PAGES; i++)
|
||||
c4fAdd(&t, "===== %s =====\n%s\n", c4fPageNames[i], c4fPages[i] ? c4fPages[i] : "");
|
||||
pthread_mutex_unlock(&c4fLock);
|
||||
char *text = c4fTake(&t);
|
||||
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]);
|
||||
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);
|
||||
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);
|
||||
}
|
||||
|
||||
unsigned c4fDiagRefresh(const C4fDiagState *state)
|
||||
{
|
||||
pthread_mutex_lock(&c4fLock);
|
||||
c4fServiceUp = state->running == 1;
|
||||
if (c4fServiceUp) c4fCaptureUntil = 0;
|
||||
pthread_mutex_unlock(&c4fLock);
|
||||
if (!c4fRefreshed++ && state->running != 1) c4fDiagCapture(4000);
|
||||
char *pages[C4F_DIAG_PAGES] = {
|
||||
c4fSummary(state), c4fCurrentLogPage(), c4fLogPage(C4F_DIAG_LOG ".previous"),
|
||||
c4fKlogPage(1), c4fKlogPage(0),
|
||||
};
|
||||
int changed = 0;
|
||||
pthread_mutex_lock(&c4fLock);
|
||||
for (int i = 0; i < C4F_DIAG_PAGES; i++) {
|
||||
if (c4fPages[i] && !strcmp(c4fPages[i], pages[i])) { free(pages[i]); continue; }
|
||||
free(c4fPages[i]);
|
||||
c4fPages[i] = pages[i];
|
||||
changed = 1;
|
||||
}
|
||||
if (changed) c4fGeneration++;
|
||||
unsigned generation = c4fGeneration;
|
||||
pthread_mutex_unlock(&c4fLock);
|
||||
uint64_t now = c4fLauncherTimeMs();
|
||||
if (changed && (!c4fLastReport || now - c4fLastReport >= 10000)) { c4fSaveReport(); c4fLastReport = now; }
|
||||
return generation;
|
||||
}
|
||||
|
||||
/* ---- drawing ---- */
|
||||
|
||||
typedef struct { const char *text; int length, highlight; } C4fRow;
|
||||
|
||||
/* How many characters of a line fit in the width. */
|
||||
static int c4fFit(const char *text, int length, float width)
|
||||
{
|
||||
char line[400];
|
||||
int n = length < (int)sizeof(line) - 1 ? length : (int)sizeof(line) - 1;
|
||||
memcpy(line, text, (size_t)n); line[n] = 0;
|
||||
float measured = c4fTextWidth(C4F_FONT_REGULAR, C4F_D_SIZE, line);
|
||||
if (measured <= width) return n;
|
||||
n = (int)(n * width / measured);
|
||||
if (n < 1) n = 1;
|
||||
line[n] = 0;
|
||||
while (n > 1 && c4fTextWidth(C4F_FONT_REGULAR, C4F_D_SIZE, line) > width) line[--n] = 0;
|
||||
return n;
|
||||
}
|
||||
|
||||
void c4fDrawDiag(uint32_t *pixels, const C4fLauncherScreen *s)
|
||||
{
|
||||
C4fCanvas c = { pixels, C4F_SCREEN_WIDTH, C4F_SCREEN_HEIGHT };
|
||||
const float width = C4F_SCREEN_WIDTH - 2 * C4F_D_LEFT;
|
||||
int page = s->diagPage >= 0 && s->diagPage < C4F_DIAG_PAGES ? s->diagPage : 0;
|
||||
char line[320];
|
||||
c4fFillRect(&c, 0, 0, c.w, c.h, C4F_D_BG);
|
||||
|
||||
/* The build time tells one diagnostic build from the next in a screenshot. */
|
||||
snprintf(line, sizeof(line), "Control4Free DIAGNOSTIC BUILD %s (%s %s) page %d of %d: %s", C4F_LAUNCHER_VERSION,
|
||||
__DATE__, __TIME__, page + 1, C4F_DIAG_PAGES, c4fPageNames[page]);
|
||||
c4fText(&c, C4F_FONT_REGULAR, C4F_D_TITLE, C4F_D_LEFT, 48, C4F_D_ACCENT, line);
|
||||
c4fText(&c, C4F_FONT_REGULAR, C4F_D_SIZE, C4F_D_LEFT, 84, C4F_D_FAINT,
|
||||
"L1 / R1: change page Up / Down: scroll Options: read the kernel log again "
|
||||
"Cross: start Triangle: auto-start on/off Square: stop Circle: close");
|
||||
c4fText(&c, C4F_FONT_REGULAR, C4F_D_SIZE, C4F_D_LEFT, 114, C4F_D_WARN,
|
||||
"Please take a screenshot of EVERY page (hold SHARE, or press SHARE then Triangle) and send them all.");
|
||||
if (s->confirmStop) snprintf(line, sizeof(line), "Press Cross to stop Control4Free, or Circle to cancel.");
|
||||
else snprintf(line, sizeof(line), "%s%s", s->busy ? "WORKING: " : "Now: ", s->message);
|
||||
c4fText(&c, C4F_FONT_REGULAR, C4F_D_SIZE, C4F_D_LEFT, 146, C4F_D_TEXT, line);
|
||||
c4fFillRect(&c, C4F_D_LEFT, 160, (int)width, 1, C4F_D_LINE);
|
||||
|
||||
pthread_mutex_lock(&c4fLock);
|
||||
char *text = strdup(c4fPages[page] ? c4fPages[page] : "(not read yet)");
|
||||
pthread_mutex_unlock(&c4fLock);
|
||||
if (!text) return;
|
||||
|
||||
/* Wrap every line, then show the window the scroll position picks. */
|
||||
int count = 0, capacity = 256;
|
||||
C4fRow *rows = malloc(sizeof(*rows) * (size_t)capacity);
|
||||
for (char *p = text; rows && *p; ) {
|
||||
char *end = strchr(p, '\n');
|
||||
int length = end ? (int)(end - p) : (int)strlen(p);
|
||||
int highlight = !strncmp(p, ">> ", 3);
|
||||
if (highlight) { p += 3; length -= 3; }
|
||||
do {
|
||||
int fit = length ? c4fFit(p, length, width) : 0;
|
||||
if (count == capacity) {
|
||||
C4fRow *grown = realloc(rows, sizeof(*rows) * (size_t)(capacity *= 2));
|
||||
if (!grown) break;
|
||||
rows = grown;
|
||||
}
|
||||
rows[count++] = (C4fRow){ p, fit, highlight };
|
||||
p += fit; length -= fit;
|
||||
} while (length > 0);
|
||||
p = end ? end + 1 : p + strlen(p);
|
||||
}
|
||||
int visible = (C4F_D_BOTTOM - C4F_D_TOP) / C4F_D_ROW;
|
||||
int maxScroll = count > visible ? count - visible : 0;
|
||||
c4fMaxScroll[page] = maxScroll;
|
||||
int first = s->diagScroll[page] >= C4F_DIAG_END ? maxScroll : s->diagScroll[page];
|
||||
if (first > maxScroll) first = maxScroll;
|
||||
if (first < 0) first = 0;
|
||||
for (int i = 0; rows && i < visible && first + i < count; i++) {
|
||||
const C4fRow *row = &rows[first + i];
|
||||
c4fTextN(&c, C4F_FONT_REGULAR, C4F_D_SIZE, C4F_D_LEFT, C4F_D_TOP + i * C4F_D_ROW,
|
||||
row->highlight ? C4F_D_WARN : C4F_D_TEXT, row->text, row->length);
|
||||
}
|
||||
snprintf(line, sizeof(line), "lines %d-%d of %d", count ? first + 1 : 0, first + visible < count ? first + visible : count, count);
|
||||
c4fText(&c, C4F_FONT_REGULAR, C4F_D_SIZE, C4F_D_LEFT, 1072, C4F_D_FAINT, line);
|
||||
free(rows);
|
||||
free(text);
|
||||
}
|
||||
|
||||
int c4fDiagMaxScroll(int page)
|
||||
{
|
||||
return page >= 0 && page < C4F_DIAG_PAGES ? c4fMaxScroll[page] : 0;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
#ifndef C4F_LAUNCHER_DIAG_H
|
||||
#define C4F_LAUNCHER_DIAG_H
|
||||
/* Diagnostic build (make -C launcher C4F_DIAG=1): plain pages of everything
|
||||
* that says why Control4Free does not start on a console, in place of the
|
||||
* normal screen, so a user can send screenshots instead of log files. */
|
||||
#include <stdint.h>
|
||||
#include "screen.h"
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
#define C4F_DIAG_PAGES 5
|
||||
/* A page's scroll position meaning "show the end". */
|
||||
#define C4F_DIAG_END 1000000
|
||||
|
||||
typedef struct {
|
||||
int running, controllers, autorun, locked, busy, confirmStop;
|
||||
const char *version, *problem, *message, *address, *autorunNote;
|
||||
} C4fDiagState;
|
||||
|
||||
/* Records what is known at start-up and reads the kernel log's backlog. */
|
||||
void c4fDiagStart(int sandboxResult, int sandboxError);
|
||||
void c4fDiagStop(void);
|
||||
/* Holds the kernel log open for this long, reading it. */
|
||||
void c4fDiagCapture(unsigned milliseconds);
|
||||
/* Around a start: what the service log looked like before, and the result. */
|
||||
void c4fDiagBeforeStart(void);
|
||||
void c4fDiagAfterStart(int result, const char *message);
|
||||
/* Rebuilds the pages; returns their generation, which changes with them. */
|
||||
unsigned c4fDiagRefresh(const C4fDiagState *state);
|
||||
void c4fDrawDiag(uint32_t *pixels, const C4fLauncherScreen *screen);
|
||||
/* The largest useful scroll position of a page, as of its last drawing. */
|
||||
int c4fDiagMaxScroll(int page);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
@@ -16,6 +16,9 @@
|
||||
#include "sandbox.h"
|
||||
#include "screen.h"
|
||||
#include "service.h"
|
||||
#ifdef C4F_DIAG
|
||||
#include "diag.h"
|
||||
#endif
|
||||
|
||||
#define C4F_BUNDLED_PAYLOAD "/app0/assets/control4free.elf"
|
||||
|
||||
@@ -54,7 +57,7 @@ static void *c4fWorker(void *)
|
||||
if (quit) return NULL;
|
||||
if (command || c4fLauncherTimeMs() >= nextCheck) {
|
||||
char message[160] = {0}, address[64] = {0};
|
||||
int result = 0;
|
||||
[[maybe_unused]] int result = 0; /* read by the diagnostic build */
|
||||
/* The console's own address: shown for the phone or PC, and the
|
||||
* app's second way to reach Control4Free from its sandbox. */
|
||||
OrbisNetCtlInfo info;
|
||||
@@ -66,6 +69,9 @@ static void *c4fWorker(void *)
|
||||
} else {
|
||||
c4fLauncherSetHost(NULL);
|
||||
}
|
||||
#ifdef C4F_DIAG
|
||||
if (command == C4F_DO_START || command == C4F_DO_SET_UP) c4fDiagBeforeStart();
|
||||
#endif
|
||||
if (command == C4F_DO_START) {
|
||||
size_t payloadSize;
|
||||
const unsigned char *payload = c4fAutorunBundled(&payloadSize);
|
||||
@@ -73,6 +79,9 @@ static void *c4fWorker(void *)
|
||||
}
|
||||
if (command == C4F_DO_STOP) result = c4fLauncherStop(message, sizeof(message));
|
||||
if (command == C4F_DO_SET_UP) result = c4fSetUp(message, sizeof(message));
|
||||
#ifdef C4F_DIAG
|
||||
if (command == C4F_DO_START || command == C4F_DO_SET_UP) c4fDiagAfterStart(result, message);
|
||||
#endif
|
||||
if (command == C4F_DO_AUTORUN_ON) result = c4fAutorunEnable(message, sizeof(message));
|
||||
if (command == C4F_DO_AUTORUN_OFF) result = c4fAutorunDisable(message, sizeof(message));
|
||||
if (first || command >= C4F_DO_SET_UP) autorun = c4fAutorunCheck(autorunNote, sizeof(autorunNote));
|
||||
@@ -80,7 +89,6 @@ static void *c4fWorker(void *)
|
||||
int running = c4fLauncherProbe(&status);
|
||||
const char *problem = c4fLauncherProblem();
|
||||
pthread_mutex_lock(&c4fMutex);
|
||||
if (result == -2) c4fScreen.locked = 1;
|
||||
if (command) {
|
||||
snprintf(c4fScreen.message, sizeof(c4fScreen.message), "%s", message);
|
||||
} else if (!c4fScreen.locked &&
|
||||
@@ -93,11 +101,21 @@ 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);
|
||||
/* A queued click while a periodic probe was in flight remains busy. */
|
||||
if (!c4fCommand) c4fScreen.busy = 0;
|
||||
#ifdef C4F_DIAG
|
||||
C4fLauncherScreen copy = c4fScreen;
|
||||
pthread_mutex_unlock(&c4fMutex);
|
||||
C4fDiagState diag = { running, status.controllers, autorun, copy.locked, copy.busy, copy.confirmStop,
|
||||
status.version, problem, copy.message, copy.address, copy.autorunNote };
|
||||
unsigned generation = c4fDiagRefresh(&diag);
|
||||
pthread_mutex_lock(&c4fMutex);
|
||||
c4fScreen.diagGeneration = generation;
|
||||
#endif
|
||||
pthread_mutex_unlock(&c4fMutex);
|
||||
first = 0; nextCheck = c4fLauncherTimeMs() + 2000;
|
||||
}
|
||||
@@ -175,7 +193,12 @@ int main(void)
|
||||
if (c4fAutorunLoadBundled(C4F_BUNDLED_PAYLOAD)) printf("[c4f-launcher] bundled payload unreadable\n");
|
||||
/* The sandbox refuses connections to the console itself (EACCES), so the
|
||||
* app could reach neither Control4Free nor PayLoader from inside it. */
|
||||
if (c4fSandboxLeave()) printf("[c4f-launcher] could not leave the sandbox, errno %d\n", errno);
|
||||
int sandbox = c4fSandboxLeave(), sandboxError = errno;
|
||||
if (sandbox) printf("[c4f-launcher] could not leave the sandbox, errno %d\n", sandboxError);
|
||||
#ifdef C4F_DIAG
|
||||
c4fDiagStart(sandbox, sandboxError);
|
||||
for (int i = 0; i < C4F_DIAG_PAGES; i++) c4fScreen.diagScroll[i] = i ? C4F_DIAG_END : 0;
|
||||
#endif
|
||||
pthread_t worker;
|
||||
int workerStarted = pthread_create(&worker, NULL, c4fWorker, NULL) == 0;
|
||||
if (!workerStarted || pad < 0) {
|
||||
@@ -220,11 +243,28 @@ int main(void)
|
||||
off ? "Turning auto-start off..." : "Turning auto-start on...");
|
||||
} else if ((pressed & ORBIS_PAD_BUTTON_SQUARE) && c4fScreen.running == 1) c4fScreen.confirmStop = 1;
|
||||
}
|
||||
#ifdef C4F_DIAG
|
||||
/* Pages and scrolling work while busy too. */
|
||||
int page = c4fScreen.diagPage;
|
||||
if (pressed & (ORBIS_PAD_BUTTON_R1 | ORBIS_PAD_BUTTON_RIGHT)) c4fScreen.diagPage = (page + 1) % C4F_DIAG_PAGES;
|
||||
if (pressed & (ORBIS_PAD_BUTTON_L1 | ORBIS_PAD_BUTTON_LEFT)) c4fScreen.diagPage = (page + C4F_DIAG_PAGES - 1) % C4F_DIAG_PAGES;
|
||||
if (pressed & (ORBIS_PAD_BUTTON_UP | ORBIS_PAD_BUTTON_DOWN)) {
|
||||
int max = c4fDiagMaxScroll(page), at = c4fScreen.diagScroll[page];
|
||||
if (at >= C4F_DIAG_END) at = max;
|
||||
at += pressed & ORBIS_PAD_BUTTON_UP ? -10 : 10;
|
||||
c4fScreen.diagScroll[page] = at >= max ? C4F_DIAG_END : at < 0 ? 0 : at;
|
||||
}
|
||||
if (pressed & ORBIS_PAD_BUTTON_OPTIONS) c4fDiagCapture(4000);
|
||||
#endif
|
||||
C4fLauncherScreen snapshot = c4fScreen;
|
||||
pthread_mutex_unlock(&c4fMutex);
|
||||
/* Redraw only when something changed, then copy it to each buffer once. */
|
||||
if (!drawn || memcmp(&snapshot, &drawnState, sizeof(snapshot))) {
|
||||
#ifdef C4F_DIAG
|
||||
c4fDrawDiag(canvas, &snapshot);
|
||||
#else
|
||||
c4fDrawLauncher(canvas, &snapshot);
|
||||
#endif
|
||||
drawnState = snapshot; drawn = 1; shown[0] = shown[1] = 0;
|
||||
}
|
||||
if (!shown[buffer]) { memcpy(c4fBuffers[buffer], canvas, frameBytes); shown[buffer] = 1; }
|
||||
@@ -243,6 +283,9 @@ int main(void)
|
||||
}
|
||||
pthread_mutex_lock(&c4fMutex); c4fQuit = 1; pthread_mutex_unlock(&c4fMutex);
|
||||
if (workerStarted) pthread_join(worker, NULL);
|
||||
#ifdef C4F_DIAG
|
||||
c4fDiagStop();
|
||||
#endif
|
||||
if (pad >= 0) scePadClose(pad);
|
||||
sceNetCtlTerm();
|
||||
c4fCloseVideo();
|
||||
|
||||
@@ -30,21 +30,32 @@ static long c4fGoldHen(uint64_t command, void *data)
|
||||
return failed ? -ret : ret;
|
||||
}
|
||||
|
||||
long c4fGoldHenCommand(unsigned long command, void *data)
|
||||
{
|
||||
/* Without GoldHEN the call does not exist: get an error, not SIGSYS. The
|
||||
* kernel's SIGSYS is FreeBSD's 12; OpenOrbis's header has Linux's 31. */
|
||||
signal(12, SIG_IGN);
|
||||
return c4fGoldHen(command, data);
|
||||
}
|
||||
|
||||
int c4fSandboxLeave(void)
|
||||
{
|
||||
static C4fJailbreak backup;
|
||||
if (c4fOut) return 0;
|
||||
/* Without GoldHEN the call does not exist: get an error, not SIGSYS. The
|
||||
* kernel's SIGSYS is FreeBSD's 12; OpenOrbis's header has Linux's 31. */
|
||||
signal(12, SIG_IGN);
|
||||
memset(&backup, 0, sizeof(backup));
|
||||
long ret = c4fGoldHen(2, &backup);
|
||||
long ret = c4fGoldHenCommand(2, &backup);
|
||||
if (ret != 0) { errno = ret < 0 ? (int)-ret : EPERM; return -1; }
|
||||
c4fOut = 1;
|
||||
return 0;
|
||||
}
|
||||
#else
|
||||
/* Host tests: there is no sandbox. */
|
||||
/* Host tests: there is no sandbox, and no GoldHEN. */
|
||||
long c4fGoldHenCommand(unsigned long command, void *data)
|
||||
{
|
||||
(void)command; (void)data;
|
||||
return -ENOSYS;
|
||||
}
|
||||
|
||||
int c4fSandboxLeave(void)
|
||||
{
|
||||
if (getenv("C4F_TEST_NO_GOLDHEN")) { errno = ENOSYS; return -1; }
|
||||
|
||||
@@ -10,6 +10,9 @@ extern "C" {
|
||||
/* 0 when out of the sandbox (again: no-op); -1 with errno otherwise. */
|
||||
int c4fSandboxLeave(void);
|
||||
int c4fSandboxIsOut(void);
|
||||
/* GoldHEN's SDK call (syscall 500): its result, or -errno when the call
|
||||
* failed or does not exist. */
|
||||
long c4fGoldHenCommand(unsigned long command, void *data);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
|
||||
@@ -428,10 +428,10 @@ static C4fLook c4fLookFor(const C4fLauncherScreen *s, char *meta, size_t metaSiz
|
||||
"is running, stop it on its controller page or restart the PS4.",
|
||||
"Not responding", C4F_BAD, C4F_BAD, 0 };
|
||||
if (s->locked) {
|
||||
look.pill = look.meta = "Restart needed";
|
||||
look.title = "Restart the PS4";
|
||||
look.subtitle = "Control4Free was sent but never answered. Restart the console before you try again, so "
|
||||
"two copies never run at once.";
|
||||
look.pill = look.meta = "App error";
|
||||
look.title = "Reopen the app";
|
||||
look.subtitle = "The app could not start properly. Close it with the PS button and open it again. "
|
||||
"Control4Free itself is not affected.";
|
||||
} else if (s->busy) {
|
||||
look = (C4fLook){ "Working", "Please wait", s->message, "Checking", C4F_WARN, C4F_ACCENT, 0 };
|
||||
} else if (s->running == 1) {
|
||||
@@ -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.";
|
||||
|
||||
@@ -10,7 +10,11 @@
|
||||
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];
|
||||
#endif
|
||||
} C4fLauncherScreen;
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
|
||||
@@ -242,7 +242,7 @@ int c4fLauncherStart(const unsigned char *payload, size_t payloadSize, char *mes
|
||||
if (fd < 0) { snprintf(message, size, "PayLoader did not answer (errno %d). Turn it on in GoldHEN, then press Cross again.", errno); return -1; }
|
||||
int failed = c4fSendAll(fd, payload, payloadSize, c4fLauncherTimeMs() + 10000);
|
||||
shutdown(fd, SHUT_WR); close(fd);
|
||||
if (failed) { snprintf(message, size, "The transfer to PayLoader broke off. Restart the PS4 before you try again."); return -2; }
|
||||
if (failed) { snprintf(message, size, "The transfer to PayLoader broke off. Press Cross to try again."); return -2; }
|
||||
uint64_t deadline;
|
||||
deadline = c4fLauncherTimeMs() + 20000;
|
||||
while (c4fLauncherTimeMs() < deadline) {
|
||||
@@ -251,8 +251,10 @@ int c4fLauncherStart(const unsigned char *payload, size_t payloadSize, char *mes
|
||||
}
|
||||
usleep(250000);
|
||||
}
|
||||
snprintf(message, size, "Sent, but Control4Free never answered (%s). Restart the PS4 before you try again.", c4fProblem);
|
||||
return -2; /* Caller locks further sends until restart, avoiding duplicates. */
|
||||
snprintf(message, size, "Sent, but Control4Free did not start (%s). Press Cross to try again.", c4fProblem);
|
||||
/* Sending again is safe: a copy that did start holds the payload's
|
||||
* instance lock, so a second one quits at once. */
|
||||
return -2;
|
||||
}
|
||||
|
||||
int c4fLauncherStop(char *message, size_t size)
|
||||
|
||||
@@ -21,6 +21,7 @@ int c4fLauncherProbe(C4fServiceStatus *status);
|
||||
/* Whether the last probe connected anywhere, so "no answer" can be told apart
|
||||
* from "cannot reach". */
|
||||
int c4fLauncherReached(void);
|
||||
/* 0 running, -1 not sent, -2 sent but Control4Free did not answer. */
|
||||
int c4fLauncherStart(const unsigned char *payload, size_t payloadSize, char *message, size_t size);
|
||||
int c4fLauncherStop(char *message, size_t size);
|
||||
#ifdef __cplusplus
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
/* Control4Free -- what a game asks of a controller: rumble and light bar.
|
||||
*
|
||||
* Pure byte work, with no PS4 calls, so the host tests run the same code the
|
||||
* console does.
|
||||
*/
|
||||
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "c4f_vda.h"
|
||||
|
||||
/* scePadVirtualDeviceGetRemoteSetting fills a buffer laid out like the DualShock
|
||||
* 4's HID output report without its report ID. Read on firmware 10.01
|
||||
* (2026-10-05) while games rumbled and users signed in:
|
||||
*
|
||||
* [0] update flags: bit 0 rumble, bit 1 light bar, as in the DS4 report.
|
||||
* Pulses for a few milliseconds, so it is not relied on here.
|
||||
* [3] right motor, the small fast one: 0xff for ~300 ms on a game's hit.
|
||||
* [4] left motor, the large slow one.
|
||||
* [5..7] light bar red, green, blue: 0x40 at full, as the PS4 sets them
|
||||
* (40 00 00 for player 2, 00 40 00 for player 3).
|
||||
* [16] 1 once a user is assigned.
|
||||
*/
|
||||
void c4fPadFeedbackParse(const uint8_t *buf, size_t size, C4fPadFeedback *out)
|
||||
{
|
||||
(void)memset(out, 0, sizeof(*out));
|
||||
if (size < 8) return;
|
||||
out->small = buf[3];
|
||||
out->large = buf[4];
|
||||
out->r = buf[5];
|
||||
out->g = buf[6];
|
||||
out->b = buf[7];
|
||||
}
|
||||
@@ -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);
|
||||
@@ -102,5 +117,10 @@ void c4fNotify(const char *fmt, ...)
|
||||
va_start(ap, fmt);
|
||||
(void)vsnprintf(req.message, sizeof(req.message), fmt, ap);
|
||||
va_end(ap);
|
||||
#ifdef C4F_DIAG
|
||||
int ret = sceKernelSendNotificationRequest(0, &req, sizeof(req), 0);
|
||||
c4fLog("notification = 0x%08x: %s\n", (uint32_t)ret, req.message);
|
||||
#else
|
||||
(void)sceKernelSendNotificationRequest(0, &req, sizeof(req), 0);
|
||||
#endif
|
||||
}
|
||||
@@ -100,6 +100,45 @@ static void c4fRestoreCredentials(void)
|
||||
c4fLogCredentials("restored", g_pid);
|
||||
}
|
||||
|
||||
#ifdef C4F_DIAG
|
||||
#include <signal.h>
|
||||
#include <string.h>
|
||||
|
||||
/* Diagnostic build: what the SDK's start-up code found in the kernel, and
|
||||
* which process PayLoader runs us in, from GoldHEN's SDK call (syscall 500,
|
||||
* command 4; struct proc_info of the GoldHEN Plugins SDK). */
|
||||
typedef struct {
|
||||
int pid;
|
||||
char name[40], path[64], titleId[16], contentId[64], version[6];
|
||||
uint64_t baseAddress;
|
||||
} __attribute__((packed)) C4fProcInfo;
|
||||
|
||||
static void c4fDiagHost(pid_t pid)
|
||||
{
|
||||
C4fProcInfo info;
|
||||
struct sigaction ignore, previous;
|
||||
|
||||
c4fLog("diag: built %s %s\n", __DATE__, __TIME__);
|
||||
c4fLog("diag: kernel image base=0x%lx size=0x%lx allproc=0x%lx prison0=0x%lx rootvnode=0x%lx targetid=0x%lx\n",
|
||||
(unsigned long)KERNEL_ADDRESS_IMAGE_BASE, (unsigned long)KERNEL_IMAGE_SIZE,
|
||||
(unsigned long)KERNEL_ADDRESS_ALLPROC, (unsigned long)KERNEL_ADDRESS_PRISON0,
|
||||
(unsigned long)KERNEL_ADDRESS_ROOTVNODE, (unsigned long)KERNEL_ADDRESS_TARGETID);
|
||||
c4fLog("diag: proc=0x%lx ucred=0x%lx\n", (unsigned long)kernel_get_proc(pid),
|
||||
(unsigned long)kernel_get_proc_ucred(pid));
|
||||
|
||||
/* Without GoldHEN the call does not exist: get an error, not SIGSYS. */
|
||||
memset(&info, 0, sizeof(info));
|
||||
memset(&ignore, 0, sizeof(ignore));
|
||||
ignore.sa_handler = SIG_IGN;
|
||||
sigaction(SIGSYS, &ignore, &previous);
|
||||
int ret = syscall(500, 4, &info);
|
||||
sigaction(SIGSYS, &previous, NULL);
|
||||
info.name[sizeof(info.name) - 1] = info.path[sizeof(info.path) - 1] = info.titleId[sizeof(info.titleId) - 1] = 0;
|
||||
c4fLog("diag: GoldHEN process info = %d: pid=%d name=%s path=%s title=%s\n",
|
||||
ret, info.pid, info.name, info.path, info.titleId);
|
||||
}
|
||||
#endif
|
||||
|
||||
static int c4fFinish(int status)
|
||||
{
|
||||
c4fRestoreCredentials();
|
||||
@@ -127,6 +166,10 @@ int main(void)
|
||||
}
|
||||
c4fLogOpen();
|
||||
c4fLog("Control4Free %s: pid=%d firmware=0x%08x\n", C4F_VERSION, pid, kernel_get_fw_version());
|
||||
#ifdef C4F_DIAG
|
||||
c4fNotify("Control4Free diagnostic build started (firmware %08x)", kernel_get_fw_version());
|
||||
c4fDiagHost(pid);
|
||||
#endif
|
||||
|
||||
c4fLogCredentials("on entry", pid);
|
||||
if (c4fRaiseCredentials(pid) != 0) return c4fFinish(1);
|
||||
|
||||
@@ -14,6 +14,16 @@
|
||||
#include <unistd.h>
|
||||
#include "c4f_net.h"
|
||||
|
||||
#ifdef C4F_DIAG
|
||||
#include "c4f_log.h"
|
||||
/* Diagnostic build: each connection from another device, step by step. The
|
||||
* launcher's status checks over loopback come every 2 seconds and stay out. */
|
||||
#define C4F_NET_LOG(c, fmt, ...) do { if (strcmp((c)->peer, "127.0.0.1")) \
|
||||
c4fLog("net: %s " fmt "\n", (c)->peer, __VA_ARGS__); } while (0)
|
||||
#else
|
||||
#define C4F_NET_LOG(c, fmt, ...) do { } while (0)
|
||||
#endif
|
||||
|
||||
/* Which interface would carry traffic out, without sending anything: a connected
|
||||
* UDP socket picks the route, and getsockname then names the local end. */
|
||||
void c4fNetLocalAddress(char *out, size_t size)
|
||||
@@ -152,6 +162,7 @@ static void c4fHttpError(C4fNetClient *c, int code, const char *message)
|
||||
{
|
||||
char buf[512];
|
||||
int n = snprintf(buf, sizeof(buf), "HTTP/1.1 %d Error\r\nConnection: close\r\nContent-Type: text/plain\r\nContent-Length: %zu\r\n\r\n%s", code, strlen(message), message);
|
||||
C4F_NET_LOG(c, "answered %d %s", code, message);
|
||||
c4fQueue(c, buf, (size_t)n); c->closing = 2;
|
||||
}
|
||||
|
||||
@@ -165,6 +176,7 @@ static void c4fHttp(C4fNet *net, C4fNetClient *c)
|
||||
if (!end) { if (c->rxUsed == sizeof(c->rx)) c4fHttpError(c, 431, "Headers too large"); return; }
|
||||
used = (size_t)(end-request) + 4;
|
||||
request[used] = 0;
|
||||
C4F_NET_LOG(c, "asked: %.*s", (int)strcspn(request, "\r\n") > 100 ? 100 : (int)strcspn(request, "\r\n"), request);
|
||||
int statusRequest = !strncmp(request, "GET /api/status HTTP/1.1\r\n", 26);
|
||||
int stopRequest = !strncmp(request, "POST /api/stop HTTP/1.1\r\n", 25);
|
||||
/* The two files a phone needs to keep this page on its home screen. */
|
||||
@@ -229,19 +241,26 @@ 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) {
|
||||
char reply[256];
|
||||
int n = snprintf(reply, sizeof(reply), "HTTP/1.1 200 OK\r\nContent-Type: %s\r\nContent-Length: %zu\r\nConnection: close\r\nCache-Control: max-age=86400\r\nX-Content-Type-Options: nosniff\r\n\r\n", assetType, assetSize);
|
||||
C4F_NET_LOG(c, "sending %s, %zu bytes", assetType, assetSize);
|
||||
c4fQueue(c, reply, (size_t)n);
|
||||
c->body = asset; c->bodySize = assetSize; c->rxUsed = 0;
|
||||
} else {
|
||||
char reply[768];
|
||||
int n = snprintf(reply, sizeof(reply), "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Encoding: gzip\r\nContent-Length: %zu\r\nConnection: close\r\nCache-Control: no-store\r\nX-Content-Type-Options: nosniff\r\nReferrer-Policy: no-referrer\r\nContent-Security-Policy: default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src 'self' data:; media-src data:; connect-src ws:; manifest-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'\r\n\r\n", c4fPageSize);
|
||||
C4F_NET_LOG(c, "sending the page, %zu bytes", c4fPageSize);
|
||||
c4fQueue(c, reply, (size_t)n);
|
||||
c->body = c4fPage; c->bodySize = c4fPageSize; c->rxUsed = 0;
|
||||
}
|
||||
@@ -266,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;
|
||||
@@ -323,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);
|
||||
@@ -343,6 +377,12 @@ int c4fNetPoll(C4fNet *net, int timeoutMs)
|
||||
return -1;
|
||||
for (index = 0; index < C4F_NET_CLIENTS && net->clients[index].fd >= 0; index++) {}
|
||||
if (fd >= 0) {
|
||||
#ifdef C4F_DIAG
|
||||
char peerName[16] = "?";
|
||||
inet_ntop(AF_INET, &peer.sin_addr, peerName, sizeof(peerName));
|
||||
if (index == C4F_NET_CLIENTS || fd >= (int)FD_SETSIZE)
|
||||
c4fLog("net: %s turned away: fd %d, %s\n", peerName, fd, index == C4F_NET_CLIENTS ? "all slots taken" : "fd too high");
|
||||
#endif
|
||||
if (index == C4F_NET_CLIENTS || fd >= (int)FD_SETSIZE || fcntl(fd, F_SETFL, O_NONBLOCK)) close(fd);
|
||||
else {
|
||||
int one = 1;
|
||||
@@ -351,6 +391,10 @@ int c4fNetPoll(C4fNet *net, int timeoutMs)
|
||||
setsockopt(fd, SOL_SOCKET, SO_NOSIGPIPE, &one, sizeof(one));
|
||||
#endif
|
||||
net->clients[index].fd = fd; net->clients[index].openedMs = c4fTimeMs();
|
||||
#ifdef C4F_DIAG
|
||||
memcpy(net->clients[index].peer, peerName, sizeof(peerName));
|
||||
C4F_NET_LOG(&net->clients[index], "connected: slot %d, fd %d", index, fd);
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -359,8 +403,10 @@ int c4fNetPoll(C4fNet *net, int timeoutMs)
|
||||
if (c->fd < 0) continue;
|
||||
if (FD_ISSET(c->fd, &rd)) {
|
||||
ssize_t n = recv(c->fd, c->rx+c->rxUsed, sizeof(c->rx)-c->rxUsed, 0);
|
||||
if (n == 0 || (n < 0 && errno != EAGAIN && errno != EWOULDBLOCK && errno != EINTR)) c->closing = 1;
|
||||
else if (n > 0) {
|
||||
if (n == 0 || (n < 0 && errno != EAGAIN && errno != EWOULDBLOCK && errno != EINTR)) {
|
||||
C4F_NET_LOG(c, "recv = %zd, errno %d: it closed its end", n, n < 0 ? errno : 0);
|
||||
c->closing = 1;
|
||||
} else if (n > 0) {
|
||||
c->rxUsed += (size_t)n;
|
||||
if (!c->websocket) c4fHttp(net, c);
|
||||
if (c->websocket) c4fWs(net, c);
|
||||
@@ -376,15 +422,23 @@ int c4fNetPoll(C4fNet *net, int timeoutMs)
|
||||
flags = MSG_NOSIGNAL;
|
||||
#endif
|
||||
ssize_t n = send(c->fd, data, left, flags);
|
||||
if (n < 0 && errno != EAGAIN && errno != EWOULDBLOCK && errno != EINTR) c->closing = 1;
|
||||
else if (n > 0) {
|
||||
if (n < 0 && errno != EAGAIN && errno != EWOULDBLOCK && errno != EINTR) {
|
||||
C4F_NET_LOG(c, "send of %zu bytes failed, errno %d (%s, %zu sent)", left, errno,
|
||||
body ? "body" : "header", body ? c->bodySent : c->txSent);
|
||||
c->closing = 1;
|
||||
} else if (n > 0) {
|
||||
if (body) {
|
||||
c->bodySent += (size_t)n;
|
||||
if (c->bodySent == c->bodySize) { c->body = NULL; c->closing = 1; }
|
||||
if (c->bodySent == c->bodySize) { C4F_NET_LOG(c, "sent all %zu bytes", c->bodySize); c->body = NULL; c->closing = 1; }
|
||||
} else { c->txSent += (size_t)n; if (c->txSent == c->txUsed) c->txSent = c->txUsed = 0; }
|
||||
}
|
||||
}
|
||||
if (!c->websocket && c4fTimeMs()-c->openedMs > 10000) c->closing = 1;
|
||||
if (!c->websocket && c4fTimeMs()-c->openedMs > 10000) {
|
||||
/* A first byte of 0x16 is a TLS handshake: the browser tried https. */
|
||||
C4F_NET_LOG(c, "timed out after 10 s with %zu bytes unanswered, first byte 0x%02x", c->rxUsed,
|
||||
c->rxUsed ? c->rx[0] : 0);
|
||||
c->closing = 1;
|
||||
}
|
||||
if (c->closing == 1 || (c->closing == 2 && c->txSent == c->txUsed)) c4fDrop(net, c);
|
||||
}
|
||||
return 0;
|
||||
|
||||
@@ -147,6 +147,46 @@ void c4fVirtualPadAdopt(C4fVirtualPad *out, int32_t userId, int32_t vdaUser, uin
|
||||
(unsigned long long)deviceId, (uint32_t)out->handle);
|
||||
}
|
||||
|
||||
int32_t c4fVirtualPadFeedback(const C4fVirtualPad *pad, C4fPadFeedback *out)
|
||||
{
|
||||
uint8_t buf[256]; /* the call's size is unknown; it has only ever used 17 */
|
||||
|
||||
(void)memset(out, 0, sizeof(*out));
|
||||
if (!scePadVirtualDeviceGetRemoteSetting) return -1;
|
||||
(void)memset(buf, 0, sizeof(buf));
|
||||
int32_t ret = scePadVirtualDeviceGetRemoteSetting(pad->handle, buf);
|
||||
if (ret == 0) c4fPadFeedbackParse(buf, sizeof(buf), out);
|
||||
return ret;
|
||||
}
|
||||
|
||||
int32_t c4fUserName(uint32_t userId, char *out, size_t size)
|
||||
{
|
||||
char name[64]; /* the system's own limit is 16 characters */
|
||||
|
||||
if (size) out[0] = 0;
|
||||
(void)memset(name, 0, sizeof(name));
|
||||
int32_t ret = sceUserServiceGetUserName((int32_t)userId, name, sizeof(name) - 1);
|
||||
if (ret == 0) snprintf(out, size, "%s", name);
|
||||
return ret;
|
||||
}
|
||||
|
||||
int32_t c4fLoginUsers(int32_t userIds[4])
|
||||
{
|
||||
C4fUserServiceLoginList list = {{-1, -1, -1, -1}};
|
||||
for (int i = 0; i < 4; i++) userIds[i] = C4F_USER_ID_INVALID;
|
||||
int32_t ret = sceUserServiceGetLoginUserIdList(&list);
|
||||
if (ret == 0) memcpy(userIds, list.userId, sizeof(list.userId));
|
||||
return ret;
|
||||
}
|
||||
|
||||
int32_t c4fUserEvent(int32_t *type, uint32_t *userId)
|
||||
{
|
||||
C4fUserServiceEvent event = {-1, C4F_USER_ID_INVALID};
|
||||
int32_t ret = sceUserServiceGetEvent(&event);
|
||||
if (ret == 0) { *type = event.event; *userId = (uint32_t)event.userId; }
|
||||
return ret;
|
||||
}
|
||||
|
||||
void c4fPadDataNeutral(ScePadData *data)
|
||||
{
|
||||
(void)memset(data, 0, sizeof(*data));
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
#define JSMN_STATIC
|
||||
#define JSMN_STRICT
|
||||
#include "jsmn.h"
|
||||
#include "qrcodegen.h"
|
||||
#include "c4f_log.h"
|
||||
#include "c4f_net.h"
|
||||
#include "c4f_vda.h"
|
||||
@@ -30,6 +31,10 @@
|
||||
#define C4F_RELEASE_MS 15000
|
||||
#endif
|
||||
#define C4F_QUEUE_SIZE 32
|
||||
/* How often each controller's rumble and light bar are read back. Games pulse
|
||||
* rumble for a few tens of milliseconds, so this has to stay well under 100. */
|
||||
#define C4F_FEEDBACK_MS 16
|
||||
#define C4F_USER_POLL_MS 250
|
||||
|
||||
typedef struct {
|
||||
C4fVirtualPad device;
|
||||
@@ -37,8 +42,19 @@ typedef struct {
|
||||
ScePadData current, queue[C4F_QUEUE_SIZE];
|
||||
unsigned head, count;
|
||||
uint64_t lastInput, lastReport, nextReport, detachedAt, reports;
|
||||
int active, stale, error, assigned;
|
||||
int active, stale, error, assigned, loginSeen;
|
||||
uint32_t userId;
|
||||
/* What the game wants this controller to do, and what its owner was told. */
|
||||
C4fPadFeedback feedback;
|
||||
int feedbackKnown, rumbleSentValid, feedbackFailed;
|
||||
uint8_t rumbleSent[2];
|
||||
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;
|
||||
uint64_t nameAt;
|
||||
} C4fWebPad;
|
||||
|
||||
/* Creating a controller means issuing AddDevice and then finding its DeviceId in
|
||||
@@ -72,11 +88,16 @@ typedef struct {
|
||||
typedef struct {
|
||||
C4fNet net;
|
||||
C4fWebPad pads[C4F_MAX_PADS];
|
||||
int klogFd, stop, changed, creationBlocked;
|
||||
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. */
|
||||
uint64_t feedbackCalls, feedbackUs, feedbackMaxUs;
|
||||
char klogLine[1024];
|
||||
size_t klogUsed;
|
||||
C4fAdd add;
|
||||
uint64_t broadcastAt, stopAt, heartbeatAt;
|
||||
uint64_t broadcastAt, stopAt, heartbeatAt, usersAt;
|
||||
int usersFailed;
|
||||
} C4fWeb;
|
||||
|
||||
typedef struct {
|
||||
@@ -143,9 +164,34 @@ static void c4fError(C4fNetClient *c, const C4fRequest *r, int code, const char
|
||||
c4fNetText(c, reply);
|
||||
}
|
||||
|
||||
/* `in` as the inside of a JSON string. Quotes and backslashes are escaped, and
|
||||
* control characters and anything that isn't valid UTF-8 become '?'. A user
|
||||
* name is the one string here we don't write ourselves, and a browser drops the
|
||||
* whole WebSocket on a text frame that isn't valid UTF-8. */
|
||||
static void c4fJsonText(char *out, size_t size, const char *in)
|
||||
{
|
||||
const unsigned char *p = (const unsigned char *)in;
|
||||
size_t o = 0;
|
||||
|
||||
if (!size) return;
|
||||
while (*p && o + 7 < size) {
|
||||
unsigned c = *p;
|
||||
unsigned n = c < 0x80 ? 1 : (c & 0xe0) == 0xc0 ? 2 : (c & 0xf0) == 0xe0 ? 3 : (c & 0xf8) == 0xf0 ? 4 : 0;
|
||||
int valid = n > 0 && !(n == 2 && c < 0xc2) && c <= 0xf4;
|
||||
for (unsigned k = 1; valid && k < n; k++) valid = (p[k] & 0xc0) == 0x80;
|
||||
/* No overlong forms, no UTF-16 surrogates, nothing past U+10FFFF. */
|
||||
if (valid && n == 3 && ((c == 0xe0 && p[1] < 0xa0) || (c == 0xed && p[1] >= 0xa0))) valid = 0;
|
||||
if (valid && n == 4 && ((c == 0xf0 && p[1] < 0x90) || (c == 0xf4 && p[1] >= 0x90))) valid = 0;
|
||||
if (!valid || (n == 1 && (c < 0x20 || c == 0x7f))) { out[o++] = '?'; p++; continue; }
|
||||
if (c == '"' || c == '\\') out[o++] = '\\';
|
||||
memcpy(out + o, p, n); o += n; p += n;
|
||||
}
|
||||
out[o] = 0;
|
||||
}
|
||||
|
||||
static void c4fStatus(C4fWeb *app, C4fNetClient *c, const C4fRequest *request)
|
||||
{
|
||||
char json[3072];
|
||||
char json[4096];
|
||||
size_t pos;
|
||||
if (request && request->hasId) pos = (size_t)snprintf(json, sizeof(json), "{\"id\":%lld,\"result\":", (long long)request->id);
|
||||
else pos = (size_t)snprintf(json, sizeof(json), "{\"method\":\"s\",\"params\":");
|
||||
@@ -154,12 +200,24 @@ static void c4fStatus(C4fWeb *app, C4fNetClient *c, const C4fRequest *request)
|
||||
C4fWebPad *p = &app->pads[i];
|
||||
const char *state = p->error ? "error" : app->add.state && (app->add.pending & (1u << i)) ? "connecting"
|
||||
: !p->active ? "free" : !p->owner || p->stale ? "paused" : p->assigned ? "ready" : "select";
|
||||
unsigned colors[4][3] = {{32,96,255},{255,48,64},{48,200,96},{255,80,180}};
|
||||
static const unsigned colors[4][3] = {{32,96,255},{255,48,64},{48,200,96},{255,80,180}};
|
||||
unsigned rgb[3] = { colors[i][0], colors[i][1], colors[i][2] };
|
||||
if (p->active && p->feedbackKnown) {
|
||||
/* The PS4 sets player colours at a quarter strength (0x40), which on a
|
||||
* screen reads as near black: keep the hue, brighten it to full. An
|
||||
* unlit bar keeps the controller's own colour. */
|
||||
unsigned l[3] = { p->feedback.r, p->feedback.g, p->feedback.b };
|
||||
unsigned top = l[0] > l[1] ? l[0] : l[1];
|
||||
if (l[2] > top) top = l[2];
|
||||
if (top) for (int k = 0; k < 3; k++) rgb[k] = l[k] * 255 / top;
|
||||
}
|
||||
char user[160];
|
||||
c4fJsonText(user, sizeof(user), p->active && p->assigned ? p->userName : "");
|
||||
pos += (size_t)snprintf(json+pos, sizeof(json)-pos,
|
||||
"%s{\"pad\":%d,\"name\":\"Controller %d\",\"enabled\":true,\"open\":%s,\"connected\":%s,\"clients\":%d,\"mine\":%s,\"state\":\"%s\",\"uid\":\"%s%08x\",\"color\":[%u,%u,%u],\"reports\":%llu,\"error\":%d}",
|
||||
i ? "," : "", i, i+1, p->active ? "true" : "false", p->owner && !p->stale ? "true" : "false", p->owner ? 1 : 0,
|
||||
"%s{\"pad\":%d,\"name\":\"Controller %d\",\"user\":\"%s\",\"enabled\":true,\"open\":%s,\"connected\":%s,\"clients\":%d,\"mine\":%s,\"state\":\"%s\",\"uid\":\"%s%08x\",\"color\":[%u,%u,%u],\"reports\":%llu,\"error\":%d}",
|
||||
i ? "," : "", i, i+1, user, p->active ? "true" : "false", p->owner && !p->stale ? "true" : "false", p->owner ? 1 : 0,
|
||||
p->owner == c ? "true" : "false", state, p->assigned ? "" : "unassigned-", p->userId,
|
||||
colors[i][0], colors[i][1], colors[i][2], (unsigned long long)p->reports, p->error);
|
||||
rgb[0], rgb[1], rgb[2], (unsigned long long)p->reports, p->error);
|
||||
}
|
||||
snprintf(json+pos, sizeof(json)-pos, "]}}"); c4fNetText(c, json);
|
||||
}
|
||||
@@ -191,6 +249,60 @@ static void c4fRemove(C4fWeb *app, C4fWebPad *p)
|
||||
memset(p, 0, sizeof(*p)); app->changed = 1;
|
||||
}
|
||||
|
||||
static void c4fLogoutPad(C4fWeb *app, C4fWebPad *p, int index)
|
||||
{
|
||||
C4fNetClient *owner = p->owner;
|
||||
if (owner) {
|
||||
char message[64];
|
||||
snprintf(message, sizeof(message), "{\"method\":\"v\",\"params\":[%d,0,0]}", index);
|
||||
c4fNetText(owner, message);
|
||||
}
|
||||
c4fLog("web controller %d disconnected after PS4 user logout\n", index + 1);
|
||||
c4fRemove(app, p);
|
||||
if (owner) c4fError(owner, NULL, 410, "The PS4 user signed out. Controller disconnected. Select it again to play.");
|
||||
}
|
||||
|
||||
/* Assignment and name caches are not login state. UserService keeps working
|
||||
* after we release /dev/klog. Logout events cover quick sign-in/sign-out;
|
||||
* snapshots also recover a missed event. Never treat a failed snapshot or a
|
||||
* user still entering the login list as logout. Foreground-user changes are
|
||||
* irrelevant: several local users may play at once. */
|
||||
static void c4fPollUsers(C4fWeb *app, uint64_t now)
|
||||
{
|
||||
if (now < app->usersAt) return;
|
||||
app->usersAt = now + C4F_USER_POLL_MS;
|
||||
for (int n = 0; n < 16; n++) {
|
||||
int32_t type;
|
||||
uint32_t user;
|
||||
if (c4fUserEvent(&type, &user) != 0) break;
|
||||
if (type != C4F_USER_EVENT_LOGOUT || user == 0xffffffffu) continue;
|
||||
for (int i = 0; i < C4F_MAX_PADS; i++) {
|
||||
C4fWebPad *p = &app->pads[i];
|
||||
if (p->active && p->assigned && p->userId == user) c4fLogoutPad(app, p, i);
|
||||
}
|
||||
}
|
||||
int assigned = 0;
|
||||
for (int i = 0; i < C4F_MAX_PADS; i++) assigned |= app->pads[i].active && app->pads[i].assigned;
|
||||
if (!assigned) return;
|
||||
int32_t users[4];
|
||||
int32_t ret = c4fLoginUsers(users);
|
||||
if (ret != 0) {
|
||||
if (!app->usersFailed) c4fLog("login session read failed (0x%08x); retaining controller assignments\n", (uint32_t)ret);
|
||||
app->usersFailed = 1;
|
||||
return;
|
||||
}
|
||||
if (app->usersFailed) c4fLog("login session reads recovered\n");
|
||||
app->usersFailed = 0;
|
||||
for (int i = 0; i < C4F_MAX_PADS; i++) {
|
||||
C4fWebPad *p = &app->pads[i];
|
||||
if (!p->active || !p->assigned) continue;
|
||||
int present = 0;
|
||||
for (int j = 0; j < 4; j++) if ((uint32_t)users[j] == p->userId) present = 1;
|
||||
if (present) p->loginSeen = 1;
|
||||
else if (p->loginSeen) c4fLogoutPad(app, p, i);
|
||||
}
|
||||
}
|
||||
|
||||
/* One report for one controller, taking the next queued sample if there is one. */
|
||||
static void c4fReportPad(C4fWeb *app, C4fWebPad *p, int index, uint64_t now)
|
||||
{
|
||||
@@ -206,6 +318,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)
|
||||
@@ -222,6 +335,85 @@ static void c4fReportPads(C4fWeb *app)
|
||||
}
|
||||
}
|
||||
|
||||
static uint64_t c4fTimeUs(void)
|
||||
{
|
||||
struct timespec t;
|
||||
clock_gettime(CLOCK_MONOTONIC, &t);
|
||||
return (uint64_t)t.tv_sec * 1000000 + (uint64_t)t.tv_nsec / 1000;
|
||||
}
|
||||
|
||||
/* The names of signed-in users. The PS4 may not have finished signing a user in
|
||||
* when it reports the controller assigned, so a failed look-up is retried for a
|
||||
* few seconds. Names are kept out of the log. */
|
||||
static void c4fLookUpNames(C4fWeb *app, uint64_t now)
|
||||
{
|
||||
for (int i = 0; i < C4F_MAX_PADS; i++) {
|
||||
C4fWebPad *p = &app->pads[i];
|
||||
if (!p->active || !p->assigned || p->userName[0] || p->nameTries >= 10 || now < p->nameAt) continue;
|
||||
p->nameTries++;
|
||||
p->nameAt = now + 500;
|
||||
int32_t ret = c4fUserName(p->userId, p->userName, sizeof(p->userName));
|
||||
if (ret == 0 && p->userName[0]) {
|
||||
c4fLog("web controller %d user name found (%u bytes)\n", i + 1, (unsigned)strlen(p->userName));
|
||||
app->changed = 1;
|
||||
} else if (p->nameTries == 10) {
|
||||
p->userName[0] = 0;
|
||||
c4fLog("web controller %d user name unavailable (0x%08x)\n", i + 1, (uint32_t)ret);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Reads back what the game wants from each controller. Rumble goes to the
|
||||
* controller's owner, whose page drives the phone's vibration and any gamepad
|
||||
* playing as it; the light bar goes into the status everyone sees. */
|
||||
static void c4fPollFeedback(C4fWeb *app, uint64_t now)
|
||||
{
|
||||
if (app->feedbackMissing) return;
|
||||
for (int i = 0; i < C4F_MAX_PADS; i++) {
|
||||
C4fWebPad *p = &app->pads[i];
|
||||
C4fPadFeedback f;
|
||||
int32_t ret;
|
||||
|
||||
if (!p->active || now < p->feedbackAt) continue;
|
||||
p->feedbackAt = now + C4F_FEEDBACK_MS;
|
||||
uint64_t started = c4fTimeUs();
|
||||
ret = c4fVirtualPadFeedback(&p->device, &f);
|
||||
uint64_t took = c4fTimeUs() - started;
|
||||
app->feedbackCalls++; app->feedbackUs += took;
|
||||
if (took > app->feedbackMaxUs) app->feedbackMaxUs = took;
|
||||
if (ret == -1) {
|
||||
c4fLog("scePadVirtualDeviceGetRemoteSetting is not exported: no rumble or light bar\n");
|
||||
app->feedbackMissing = 1;
|
||||
return;
|
||||
}
|
||||
if (ret != 0) {
|
||||
if (!p->feedbackFailed) c4fLog("web controller %d GetRemoteSetting = 0x%08x\n", i + 1, (uint32_t)ret);
|
||||
p->feedbackFailed = 1;
|
||||
continue;
|
||||
}
|
||||
if (!p->feedbackKnown || f.r != p->feedback.r || f.g != p->feedback.g || f.b != p->feedback.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;
|
||||
p->feedbackKnown = 1;
|
||||
if (p->owner && (!p->rumbleSentValid || f.large != p->rumbleSent[0] || f.small != p->rumbleSent[1])) {
|
||||
char message[64];
|
||||
snprintf(message, sizeof(message), "{\"method\":\"v\",\"params\":[%d,%u,%u]}", i, f.large, f.small);
|
||||
if (c4fNetText(p->owner, message) == 0) {
|
||||
p->rumbleSent[0] = f.large; p->rumbleSent[1] = f.small; p->rumbleSentValid = 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Preserve button/touch edges for at least one report. Coalesce analogue-only
|
||||
* changes so mouse/gamepad updates cannot create seconds of input latency. */
|
||||
static void c4fEnqueue(C4fWebPad *p, const ScePadData *data)
|
||||
@@ -315,7 +507,10 @@ static void c4fKlogLine(C4fWeb *app, const char *line)
|
||||
for (int i = 0; i < C4F_MAX_PADS; i++) {
|
||||
C4fWebPad *p = &app->pads[i];
|
||||
if (p->active && p->device.deviceId == device) {
|
||||
if (user == 0xffffffffu && p->assigned) { c4fLogoutPad(app, p, i); continue; }
|
||||
if (user != p->userId) p->loginSeen = 0;
|
||||
p->assigned = user != 0xffffffffu; p->userId = user; app->changed = 1;
|
||||
p->userName[0] = 0; p->nameTries = 0; p->nameAt = 0;
|
||||
c4fLog("web controller %d assignment confirmed=%d\n", i+1, p->assigned);
|
||||
}
|
||||
}
|
||||
@@ -371,7 +566,7 @@ static void c4fApplyClaim(C4fWeb *app, C4fNetClient *c, unsigned wanted, C4fRequ
|
||||
for (int i = 0; i < C4F_MAX_PADS; i++) {
|
||||
C4fWebPad *p = &app->pads[i];
|
||||
if (wanted & (1u << i)) {
|
||||
if (p->owner != c) c4fNeutralize(p);
|
||||
if (p->owner != c) { c4fNeutralize(p); p->rumbleSentValid = 0; }
|
||||
p->owner = c; p->detachedAt = 0; p->lastInput = c4fTimeMs(); p->stale = 0;
|
||||
} else if (p->owner == c) c4fRemove(app, p);
|
||||
}
|
||||
@@ -402,6 +597,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)
|
||||
{
|
||||
@@ -463,7 +668,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) {
|
||||
@@ -472,12 +676,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);
|
||||
}
|
||||
@@ -504,6 +715,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];
|
||||
@@ -535,6 +749,51 @@ static void c4fClaim(C4fWeb *app, C4fNetClient *c, C4fRequest *r)
|
||||
app->changed = 1;
|
||||
}
|
||||
|
||||
/* A QR code of the page's address, for the page's Invite panel. The page names
|
||||
* the address it reached us on, as [a, b, c, d, port], because that's the one
|
||||
* that works from this network; without it, the console's own address is used.
|
||||
* The answer is the module grid, one hex string per row, most significant bit
|
||||
* first: the page draws it, so it needs no QR library of its own. */
|
||||
static void c4fInvite(C4fNetClient *c, const C4fRequest *r)
|
||||
{
|
||||
char text[64], address[64], reply[1024];
|
||||
uint8_t qr[qrcodegen_BUFFER_LEN_FOR_VERSION(5)], scratch[qrcodegen_BUFFER_LEN_FOR_VERSION(5)];
|
||||
size_t pos;
|
||||
|
||||
if (r->argc == 5) {
|
||||
for (int i = 0; i < 4; i++)
|
||||
if (r->args[i] < 0 || r->args[i] > 255) { c4fError(c, r, 400, "That isn't an IPv4 address"); return; }
|
||||
if (r->args[4] < 1 || r->args[4] > 65535) { c4fError(c, r, 400, "That isn't a port"); return; }
|
||||
snprintf(text, sizeof(text), "http://%d.%d.%d.%d:%d/", (int)r->args[0], (int)r->args[1],
|
||||
(int)r->args[2], (int)r->args[3], (int)r->args[4]);
|
||||
} else if (r->argc == 0) {
|
||||
c4fNetLocalAddress(address, sizeof(address));
|
||||
if (!address[0]) { c4fError(c, r, 503, "Couldn't work out the console's address"); return; }
|
||||
snprintf(text, sizeof(text), "http://%s:%d/", address, C4F_WEB_PORT);
|
||||
} else {
|
||||
c4fError(c, r, 400, "Invalid request"); return;
|
||||
}
|
||||
if (!qrcodegen_encodeText(text, scratch, qr, qrcodegen_Ecc_MEDIUM, 1, 5, qrcodegen_Mask_AUTO, true)) {
|
||||
c4fError(c, r, 500, "Couldn't make the QR code"); return;
|
||||
}
|
||||
int size = qrcodegen_getSize(qr);
|
||||
pos = (size_t)snprintf(reply, sizeof(reply), "{\"id\":%lld,\"result\":{\"text\":\"%s\",\"size\":%d,\"rows\":[",
|
||||
(long long)r->id, text, size);
|
||||
for (int y = 0; y < size && pos < sizeof(reply) - 16; y++) {
|
||||
reply[pos++] = y ? ',' : '"';
|
||||
if (y) reply[pos++] = '"';
|
||||
for (int x = 0; x < size; x += 4) {
|
||||
unsigned nibble = 0;
|
||||
for (int b = 0; b < 4; b++)
|
||||
nibble = nibble << 1 | (x + b < size && qrcodegen_getModule(qr, x + b, y));
|
||||
reply[pos++] = "0123456789abcdef"[nibble];
|
||||
}
|
||||
reply[pos++] = '"';
|
||||
}
|
||||
snprintf(reply + pos, sizeof(reply) - pos, "]}}");
|
||||
c4fNetText(c, reply);
|
||||
}
|
||||
|
||||
static void c4fUpdate(C4fWeb *app, C4fNetClient *c, C4fRequest *r)
|
||||
{
|
||||
if (r->argc < 9 || r->args[0] < 0 || r->args[0] >= C4F_MAX_PADS) { c4fError(c, r, 400, "That input was not in a form Control4Free understands"); return; }
|
||||
@@ -571,7 +830,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;
|
||||
@@ -580,7 +839,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;
|
||||
@@ -596,17 +855,21 @@ static void c4fWebEvent(C4fNetClient *c, int event, const char *text, size_t len
|
||||
} else if (!strcmp(r.method, "ping")) {
|
||||
char reply[64];
|
||||
if (r.hasId) { snprintf(reply, sizeof(reply), "{\"id\":%lld,\"result\":{}}", (long long)r.id); c4fNetText(c, reply); }
|
||||
} else if (!strcmp(r.method, "invite")) {
|
||||
if (r.hasId) c4fInvite(c, &r);
|
||||
} else if (!strcmp(r.method, "status")) c4fStatus(app, c, &r);
|
||||
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.");
|
||||
}
|
||||
|
||||
@@ -635,19 +898,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;
|
||||
@@ -660,7 +922,10 @@ int c4fWebRun(int klogFd)
|
||||
} else c4fLog("listener recovered on port %d\n", C4F_WEB_PORT);
|
||||
}
|
||||
long klogBytes = c4fReadKlog(app);
|
||||
c4fPollUsers(app, now);
|
||||
c4fReportPads(app);
|
||||
c4fPollFeedback(app, now);
|
||||
c4fLookUpNames(app, now);
|
||||
#ifdef C4F_PROBE_SETTING
|
||||
c4fProbeSetting(app, now);
|
||||
#endif
|
||||
@@ -668,7 +933,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;
|
||||
@@ -683,8 +948,14 @@ int c4fWebRun(int klogFd)
|
||||
if (now >= app->heartbeatAt) {
|
||||
int active = 0;
|
||||
for (int i = 0; i < C4F_MAX_PADS; i++) active += app->pads[i].active;
|
||||
c4fLog("heartbeat: listener=%d controllers=%d klog=%d connecting=%d\n",
|
||||
app->net.fd >= 0, active, app->klogFd >= 0, app->add.state);
|
||||
c4fLog("heartbeat: listener=%d controllers=%d klog=%d connecting=%d rumble-changes=%u "
|
||||
"feedback-reads=%llu avg=%lluus max=%lluus\n",
|
||||
app->net.fd >= 0, active, app->klogFd >= 0, app->add.state, app->rumbleChanges,
|
||||
(unsigned long long)app->feedbackCalls,
|
||||
(unsigned long long)(app->feedbackCalls ? app->feedbackUs / app->feedbackCalls : 0),
|
||||
(unsigned long long)app->feedbackMaxUs);
|
||||
app->rumbleChanges = 0;
|
||||
app->feedbackCalls = app->feedbackUs = app->feedbackMaxUs = 0;
|
||||
app->heartbeatAt = now + 60000;
|
||||
}
|
||||
if (app->changed || now >= app->broadcastAt) {
|
||||
@@ -711,9 +982,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);
|
||||
|
||||
@@ -0,0 +1,458 @@
|
||||
// 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, applyStatus, status, 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', [], { env: { ...process.env, C4F_TEST_KLOG_FD: '0' }, stdio: ['pipe', '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.testClaims = [];
|
||||
window.testInputs = [];
|
||||
const socketSend = WebSocket.prototype.send;
|
||||
WebSocket.prototype.send = function(data) {
|
||||
try {
|
||||
const message = JSON.parse(data);
|
||||
if (message.method === 'claim') testClaims.push(message.params);
|
||||
if (message.method === 'u') testInputs.push(message.params);
|
||||
} catch {}
|
||||
return socketSend.call(this, data);
|
||||
};
|
||||
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');
|
||||
// Sign the first player in, open/sign in the second, and press the first
|
||||
// gamepad's PS before, during and after that change. Assignment status and
|
||||
// the currently open controller must not move one button to another slot.
|
||||
await page.evaluate(() => { c4fTest.setGamepadPad(0, 0); c4fTest.openPad(0); });
|
||||
await page.waitForFunction(() => c4fTest.status[0].mine && c4fTest.status[0].state === 'select');
|
||||
server.stdin.write('DEVICE_OWNER_CHANGED [DeviceId:0x11030d][UserId:0x1a2b3c4d]\n');
|
||||
await page.waitForFunction(() => c4fTest.status[0].state === 'ready' && c4fTest.status[0].user === 'Alex');
|
||||
await page.waitForFunction(() => !c4fTest.gamepadSources.get(0).waitNeutral);
|
||||
await page.evaluate(() => { 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(() => !(c4fTest.mergedState(0).buttons & 0x10000));
|
||||
await page.evaluate(() => { c4fTest.setGamepadPad(1, 1); c4fTest.openPad(1); });
|
||||
await page.waitForFunction(() => c4fTest.status[1].mine && c4fTest.status[1].state === 'select');
|
||||
await page.evaluate(() => { testPads[0].buttons[16].value = 1; });
|
||||
await page.waitForFunction(() => !!(c4fTest.mergedState(0).buttons & 0x10000));
|
||||
assert.equal(await page.evaluate(() => c4fTest.mergedState(1).buttons & 0x10000), 0, 'PS moved while the second controller was choosing a user');
|
||||
server.stdin.write('DEVICE_OWNER_CHANGED [DeviceId:0x12030d][UserId:0x1a2b3c4e]\n');
|
||||
await page.waitForFunction(() => c4fTest.status[1].state === 'ready' && c4fTest.status[1].user.startsWith('Sam'));
|
||||
assert.equal(await page.evaluate(() => c4fTest.gamepadSources.get(0).pad), 0, 'signing in another user reassigned the first source');
|
||||
assert.equal(await page.evaluate(() => c4fTest.mergedState(1).buttons & 0x10000), 0, 'PS moved after the second controller signed in');
|
||||
assert.equal(await page.locator('#psBtn').evaluate(e => e.classList.contains('on')), false, 'the second controller picture displayed the first controller PS');
|
||||
await page.waitForFunction(() => !!(testInputs.findLast(p => p[0] === 0)?.[1] & 0x10000));
|
||||
assert.equal(await page.evaluate(() => testInputs.findLast(p => p[0] === 1)?.[1] & 0x10000), 0, 'the first signed-in gamepad sent PS to the second slot');
|
||||
await page.evaluate(() => { testPads[0].buttons[16].value = 0; });
|
||||
await page.waitForFunction(() => !(c4fTest.mergedState(0).buttons & 0x10000));
|
||||
await page.click('#backBtn');
|
||||
await page.evaluate(() => c4fTest.setGamepadPad(2, 0));
|
||||
console.log('PASS real browser: first PS stays with its signed-in player before, during and after the second sign-in');
|
||||
|
||||
// Identical gamepads still have independent PS inputs. The open controller
|
||||
// view and physical enumeration order must not choose where a button goes.
|
||||
await page.waitForFunction(() => c4fTest.status[0].mine && c4fTest.status[1].mine);
|
||||
await page.evaluate(() => { c4fTest.setGamepadPad(2, -1); c4fTest.openPad(1); });
|
||||
for (const targets of [[0, 1], [1, 0]]) {
|
||||
for (const index of [0, 1]) {
|
||||
const selection = await page.evaluate(({ index, pad }) => {
|
||||
c4fTest.setGamepadPad(index, pad);
|
||||
return [...new Set([1, ...[...c4fTest.gamepadSources.values()].map(s => s.pad).filter(p => p >= 0)])];
|
||||
}, { index, pad: targets[index] });
|
||||
await page.waitForFunction(selection => c4fTest.status.every(p => p.mine === selection.includes(p.pad)), selection);
|
||||
}
|
||||
await page.waitForFunction(() => c4fTest.status[0].mine && c4fTest.status[1].mine &&
|
||||
!c4fTest.gamepadSources.get(0).waitNeutral && !c4fTest.gamepadSources.get(1).waitNeutral);
|
||||
for (const index of [0, 1]) {
|
||||
const target = targets[index], other = 1 - target;
|
||||
await page.evaluate(index => { testPads[index].buttons[16].value = 1; }, index);
|
||||
await page.waitForFunction(target => !!(c4fTest.mergedState(target).buttons & 0x10000), target);
|
||||
assert.equal(await page.evaluate(other => c4fTest.mergedState(other).buttons & 0x10000, other), 0, 'PS leaked into the other virtual controller');
|
||||
await page.waitForFunction(target => document.querySelector('#psBtn').classList.contains('on') === (target === 1), target);
|
||||
await page.waitForFunction(target => !!(testInputs.findLast(p => p[0] === target)?.[1] & 0x10000), target);
|
||||
assert.equal(await page.evaluate(other => testInputs.findLast(p => p[0] === other)?.[1] & 0x10000, other), 0, 'PS went to the wrong WebSocket slot');
|
||||
await page.evaluate(index => { testPads[index].buttons[16].value = 0; }, index);
|
||||
await page.waitForFunction(() => !(c4fTest.mergedState(0).buttons & 0x10000) && !(c4fTest.mergedState(1).buttons & 0x10000));
|
||||
await page.waitForFunction(() => !document.querySelector('#psBtn').classList.contains('on'));
|
||||
}
|
||||
for (const firstReleased of [0, 1]) {
|
||||
await page.evaluate(() => { testPads[0].buttons[16].value = testPads[1].buttons[16].value = 1; });
|
||||
await page.waitForFunction(() => (c4fTest.mergedState(0).buttons & 0x10000) && (c4fTest.mergedState(1).buttons & 0x10000));
|
||||
await page.evaluate(index => { testPads[index].buttons[16].value = 0; }, firstReleased);
|
||||
await page.waitForFunction(targets => !(c4fTest.mergedState(targets[0]).buttons & 0x10000) && !!(c4fTest.mergedState(targets[1]).buttons & 0x10000),
|
||||
[targets[firstReleased], targets[1 - firstReleased]]);
|
||||
await page.evaluate(() => { testPads[0].buttons[16].value = testPads[1].buttons[16].value = 0; });
|
||||
await page.waitForFunction(() => !(c4fTest.mergedState(0).buttons & 0x10000) && !(c4fTest.mergedState(1).buttons & 0x10000));
|
||||
}
|
||||
}
|
||||
await page.evaluate(() => { c4fTest.setGamepadPad(0, 0); c4fTest.setGamepadPad(1, 1); c4fTest.setGamepadPad(2, 0); });
|
||||
await page.click('#backBtn');
|
||||
console.log('PASS real browser: independent PS buttons on identical gamepads, reversed assignments, simultaneous holds, both release orders, display and WebSocket routing');
|
||||
|
||||
// The two mapping tabs must read only the selected physical source, even
|
||||
// when identical pads arrive in a different enumeration order. Guide can
|
||||
// be exposed through pressed alone, rather than a positive analog value.
|
||||
await page.evaluate(() => { window.testPads = [testPads[1], null, testPads[0], testPads[2]]; });
|
||||
for (const selected of [0, 1]) {
|
||||
await page.evaluate(index => c4fTest.mapper.open(index), selected);
|
||||
await page.click('#mapTabs [data-tab="inputs"]');
|
||||
for (const physical of [0, 1]) {
|
||||
for (const value of [1, 0]) {
|
||||
const changedAt = await page.evaluate(({ physical, value }) => {
|
||||
const button = testPads.find(p => p?.index === physical).buttons[16];
|
||||
button.value = value; button.pressed = true;
|
||||
return performance.now();
|
||||
}, { physical, value });
|
||||
await page.waitForFunction(({ physical, changedAt }) =>
|
||||
c4fTest.gamepadSources.get(physical).snapshot.buttons[16].pressed &&
|
||||
c4fTest.mapper.renderedAt > changedAt, { physical, changedAt });
|
||||
const expected = physical === selected;
|
||||
assert.equal(await page.locator('#mapInputs [data-input="button:16"]').evaluate(e => e.classList.contains('on')), expected,
|
||||
`raw Guide from gamepad ${physical} appeared on gamepad ${selected}`);
|
||||
await page.click('#mapTabs [data-tab="pad"]');
|
||||
await page.waitForFunction(() => c4fTest.mapper.renderedAt > performance.now() - 50);
|
||||
assert.equal(await page.locator('.map-area [data-btn="PS"]').evaluate(e => e.classList.contains('on')), expected,
|
||||
`mapped Guide from gamepad ${physical} appeared on gamepad ${selected}`);
|
||||
await page.evaluate(physical => {
|
||||
const button = testPads.find(p => p?.index === physical).buttons[16];
|
||||
button.value = 0; button.pressed = false;
|
||||
}, physical);
|
||||
await page.waitForFunction(() => !document.querySelector('.map-area [data-btn="PS"]').classList.contains('on'));
|
||||
await page.click('#mapTabs [data-tab="inputs"]');
|
||||
await page.waitForFunction(() => !document.querySelector('#mapInputs [data-input="button:16"]').classList.contains('on'));
|
||||
}
|
||||
}
|
||||
await page.click('#mapBack');
|
||||
}
|
||||
await page.evaluate(() => { window.testPads = testPads.filter(Boolean).sort((a, b) => a.index - b.index); });
|
||||
console.log('PASS real browser: both mapping tabs isolate each PS input, including pressed-only Guide and sparse/reordered device enumeration');
|
||||
|
||||
// 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"]');
|
||||
assert.equal(await page.textContent('#mapPanel .map-panel-title'), 'Gamepad Controls');
|
||||
assert.match(await page.textContent('#mapPanel'), /buttons and axes your browser detects/);
|
||||
assert.match(await page.textContent('#mapStatus'), /Select a button or axis/);
|
||||
await page.click('.in-axis[data-input="axis:4"]');
|
||||
await page.click('#mapTabs [data-tab="pad"]');
|
||||
assert.equal(await page.textContent('#mapPanel .map-panel-title'), 'DS4 Mapping', 'switching tabs clears the previous selection');
|
||||
await control(page, 'CROSS');
|
||||
await page.click('#mapTabs [data-tab="inputs"]');
|
||||
assert.equal(await page.textContent('#mapPanel .map-panel-title'), 'Gamepad Controls', 'a selected DS4 control cannot hide the input guide');
|
||||
await page.waitForFunction(() => getComputedStyle(document.querySelector('#mapTabs [data-tab="inputs"]')).backgroundColor === 'rgb(255, 255, 255)');
|
||||
await page.screenshot({ path: '/src/build/gamepad-controls-guide.png' });
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.locator('#mapPanel').scrollIntoViewIfNeeded();
|
||||
assert(await page.evaluate(() => document.querySelector('#mapView').scrollWidth <= innerWidth), 'input guide overflows on mobile');
|
||||
await page.screenshot({ path: '/src/build/gamepad-controls-guide-mobile.png' });
|
||||
await page.setViewportSize({ width: 1440, height: 1000 });
|
||||
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');
|
||||
// Earlier capture/index-reuse checks release the second source. Connect it
|
||||
// again so ownership loss is tested with another player actually playing.
|
||||
await page.evaluate(() => { c4fTest.setGamepadPad(1, 1); c4fTest.setGamepadPad(2, 2); });
|
||||
await page.waitForFunction(() => c4fTest.status.slice(0, 3).every(p => p.mine) && !c4fTest.gamepadSources.get(1).waitNeutral);
|
||||
await page.evaluate(() => { testPads[0].buttons[16].value = 1; testPads[1].buttons[0].value = 1; });
|
||||
await page.waitForFunction(() => c4fTest.gamepadSources.get(0).buttons && c4fTest.gamepadSources.get(1).buttons);
|
||||
const otherButtons = await page.evaluate(() => c4fTest.gamepadSources.get(1).buttons);
|
||||
await page.evaluate(() => {
|
||||
const h = c4fTest;
|
||||
testClaims.length = 0;
|
||||
h.applyStatus({ pads: h.status.map((p, i) => i === 0 || i === 2 ? { ...p, state: 'free', mine: false, open: false, connected: false, clients: 0, user: '', uid: 'unassigned-00000000' } : { ...p }) });
|
||||
});
|
||||
assert.equal(await page.locator('#home').isVisible(), true, 'logout closes the controller screen');
|
||||
assert.equal(await page.evaluate(() => c4fTest.gamepadSources.get(0).pad), -1, 'logout clears the physical source selection');
|
||||
assert.equal(await page.evaluate(() => c4fTest.gamepadSources.get(0).buttons), 0, 'logout clears held input');
|
||||
assert.equal(await page.evaluate(() => c4fTest.gamepadSources.get(2).pad), -1, 'logout clears another affected controller');
|
||||
assert.deepEqual(await page.evaluate(() => testClaims), [[1]], 'the remaining claim must not recreate a later logged-out slot');
|
||||
assert.equal(await page.getAttribute('#gamepadList .gp-row[data-index="0"] [data-focus="pad--1"]', 'aria-checked'), 'true');
|
||||
assert.equal(await page.evaluate(() => c4fTest.gamepadSources.get(1).pad), 1, 'another player stays selected');
|
||||
assert.equal(await page.evaluate(() => c4fTest.gamepadSources.get(1).buttons), otherButtons, 'another player keeps their held input');
|
||||
await page.evaluate(() => { testPads[0].buttons[16].value = 0; testPads[1].buttons[0].value = 0; });
|
||||
console.log('PASS real browser: logout/ownership loss returns home and switches affected gamepads Off without dropping another player');
|
||||
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; });
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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."
|
||||
]
|
||||
}
|
||||
@@ -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."
|
||||
]
|
||||
}
|
||||
@@ -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(); });
|
||||
}
|
||||
}
|
||||
@@ -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(/buttons may be mixed up/i.test(await row.textContent()), 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 =>
|
||||
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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; });
|
||||
@@ -0,0 +1,44 @@
|
||||
/* c4fPadFeedbackParse against buffers read from the console (firmware 10.01). */
|
||||
#include <assert.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include "c4f_vda.h"
|
||||
|
||||
static C4fPadFeedback parse(const uint8_t *bytes, size_t n)
|
||||
{
|
||||
uint8_t buf[256] = {0};
|
||||
C4fPadFeedback f;
|
||||
memcpy(buf, bytes, n);
|
||||
c4fPadFeedbackParse(buf, sizeof(buf), &f);
|
||||
return f;
|
||||
}
|
||||
|
||||
int main(void)
|
||||
{
|
||||
/* A new controller, player 2: update flag, light bar 40 00 00. */
|
||||
static const uint8_t red[] = { 0x01, 0, 0, 0, 0, 0x40, 0, 0 };
|
||||
C4fPadFeedback f = parse(red, sizeof(red));
|
||||
assert(f.r == 0x40 && f.g == 0 && f.b == 0 && f.large == 0 && f.small == 0);
|
||||
|
||||
/* Player 3, signed in: light bar 00 40 00, assigned flag at [16]. */
|
||||
static const uint8_t green[17] = { 0, 0, 0, 0, 0, 0, 0x40, 0, [16] = 1 };
|
||||
f = parse(green, sizeof(green));
|
||||
assert(f.r == 0 && f.g == 0x40 && f.b == 0);
|
||||
|
||||
/* A game's hit: the small motor at full for ~300 ms. */
|
||||
static const uint8_t hit[] = { 0, 0, 0, 0xff, 0, 0, 0x40, 0 };
|
||||
f = parse(hit, sizeof(hit));
|
||||
assert(f.small == 0xff && f.large == 0 && f.g == 0x40);
|
||||
|
||||
/* Both motors, by the DualShock 4 layout: [3] small, [4] large. */
|
||||
static const uint8_t both[] = { 0x01, 0, 0, 0x30, 0xc0, 0x20, 0, 0x20 };
|
||||
f = parse(both, sizeof(both));
|
||||
assert(f.small == 0x30 && f.large == 0xc0 && f.r == 0x20 && f.b == 0x20);
|
||||
|
||||
/* Too short to hold the fields: nothing, rather than a misread. */
|
||||
uint8_t shortBuf[4] = { 1, 2, 3, 4 };
|
||||
c4fPadFeedbackParse(shortBuf, sizeof(shortBuf), &f);
|
||||
assert(!f.large && !f.small && !f.r && !f.g && !f.b);
|
||||
|
||||
puts("PASS rumble and light bar read from the console's buffer layout");
|
||||
}
|
||||
@@ -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);
|
||||
@@ -110,4 +151,27 @@ function padStatus(fields) {
|
||||
fs = await fullScreen({ enabled: false });
|
||||
assert.equal(fs.button.hidden, true);
|
||||
console.log('PASS full screen: real where allowed, a home-screen hint on iPhone, gone when standalone');
|
||||
|
||||
// Which gamepads the page says it can rumble.
|
||||
const rumbleCtx = {};
|
||||
vm.createContext(rumbleCtx);
|
||||
vm.runInContext(extract(' function canRumble(gp)', ' function renderGamepads()'), rumbleCtx);
|
||||
const can = gp => vm.runInContext('canRumble(gp)', Object.assign(rumbleCtx, { gp }));
|
||||
const play = () => Promise.resolve();
|
||||
assert.equal(can({ vibrationActuator: { playEffect: play, effects: ['dual-rumble', 'trigger-rumble'] } }), true);
|
||||
assert.equal(can({ vibrationActuator: { playEffect: play, type: 'dual-rumble' } }), true, 'older Chrome');
|
||||
assert.equal(can({ vibrationActuator: null }), false, 'Chrome with a DualSense');
|
||||
assert.equal(can({}), false, 'Firefox');
|
||||
assert.equal(can({ vibrationActuator: { playEffect: play, effects: [] } }), false);
|
||||
console.log('PASS gamepads that cannot rumble are named as such');
|
||||
|
||||
// The Invite panel draws the console's grid as runs of dark modules.
|
||||
const qrCtx = {};
|
||||
vm.createContext(qrCtx);
|
||||
vm.runInContext(extract(' function qrPath(size, rows, quiet)', ' // The address this page reached'), qrCtx);
|
||||
// 5 wide: row 0 = 11011, row 1 = 00100 -> hex d8, 20.
|
||||
assert.equal(vm.runInContext("qrPath(5, ['d8', '20'], 4)", qrCtx),
|
||||
'M4 4h2v1h-2zM7 4h2v1h-2zM6 5h1v1h-1z');
|
||||
assert.equal(vm.runInContext("qrPath(3, ['0', '0', '0'], 4)", qrCtx), '');
|
||||
console.log('PASS the invite QR is drawn from the console grid');
|
||||
})();
|
||||
@@ -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
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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');
|
||||
@@ -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')
|
||||
@@ -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__':
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -14,6 +14,8 @@ def main():
|
||||
subprocess.run(flags + ['src/klog_line.c', 'tests/device_id_test.c',
|
||||
'-o', 'build/device-id-host-test'], check=True)
|
||||
subprocess.run(['build/device-id-host-test'], check=True, timeout=5)
|
||||
subprocess.run(flags + ['src/feedback.c', 'tests/feedback_test.c', '-o', 'build/feedback-host-test'], check=True)
|
||||
subprocess.run(['build/feedback-host-test'], check=True, timeout=5)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
@@ -21,7 +21,7 @@ def connect_again():
|
||||
|
||||
def main():
|
||||
subprocess.run(['clang-18', '-std=gnu11', '-Wall', '-Wextra', '-Werror', '-g',
|
||||
'-Iinclude', '-Ivendor/jsmn', 'src/net.c', 'src/web.c', 'src/klog_line.c',
|
||||
'-Iinclude', '-Ivendor/jsmn', '-Ivendor/qrcodegen', 'src/net.c', 'src/web.c', 'src/klog_line.c', 'vendor/qrcodegen/qrcodegen.c',
|
||||
'tests/web_stub.c', 'tests/recovery_faults.c', 'build/client.c', 'build/assets.c', '-pthread',
|
||||
'-Wl,--wrap=select,--wrap=accept,--wrap=getsockopt', '-o', str(web.BINARY)], check=True)
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
|
||||
@@ -21,8 +21,8 @@ def build():
|
||||
subprocess.run(['python3', 'tools/embed_file.py', 'build/assets.c',
|
||||
'c4fManifest=client/manifest.webmanifest', 'c4fIcon=client/icon-192.png'], check=True)
|
||||
subprocess.run(['clang-18', '-std=gnu11', '-Wall', '-Wextra', '-Werror', '-g',
|
||||
'-Iinclude', '-Ivendor/jsmn', '-DC4F_STALE_MS=400', '-DC4F_RELEASE_MS=1800',
|
||||
'src/net.c', 'src/web.c', 'src/klog_line.c', 'tests/web_stub.c', 'build/client.c',
|
||||
'-Iinclude', '-Ivendor/jsmn', '-Ivendor/qrcodegen', '-DC4F_STALE_MS=400', '-DC4F_RELEASE_MS=1800',
|
||||
'src/net.c', 'src/web.c', 'src/klog_line.c', 'vendor/qrcodegen/qrcodegen.c', 'tests/web_stub.c', 'build/client.c',
|
||||
'build/assets.c', '-pthread', '-o', str(BINARY)], check=True)
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ class Client:
|
||||
def __init__(self, port=4264):
|
||||
self.sock = socket.create_connection(('127.0.0.1', port), timeout=3)
|
||||
self.buf = b''
|
||||
self.pushed = [] # messages the service sent on its own, oldest first
|
||||
key = 'dGhlIHNhbXBsZSBub25jZQ=='
|
||||
self.sock.sendall((f'GET /ws HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\nOrigin: http://127.0.0.1:{port}\r\n'
|
||||
f'Connection: Upgrade\r\nUpgrade: websocket\r\nSec-WebSocket-Key: {key}\r\nSec-WebSocket-Version: 13\r\n\r\n').encode())
|
||||
@@ -58,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
|
||||
@@ -76,6 +82,24 @@ class Client:
|
||||
msg = json.loads(text)
|
||||
if msg.get('id') == ident:
|
||||
return msg
|
||||
if 'id' not in msg:
|
||||
self.pushed.append(msg)
|
||||
|
||||
def wait_for(self, match, timeout=2):
|
||||
"""The first pushed message `match` accepts, seen already or still to come."""
|
||||
end = time.monotonic() + timeout
|
||||
while True:
|
||||
for i, msg in enumerate(self.pushed):
|
||||
if match(msg):
|
||||
del self.pushed[:i + 1]
|
||||
return msg
|
||||
self.pushed.clear()
|
||||
assert time.monotonic() < end, 'the expected message never came'
|
||||
op, text = self.receive()
|
||||
if op == 1:
|
||||
msg = json.loads(text)
|
||||
if 'id' not in msg:
|
||||
self.pushed.append(msg)
|
||||
|
||||
def input(self, pad, buttons=0, lx=128, ly=128, rx=128, ry=128, l2=0, r2=0, touch=()):
|
||||
params = [pad, buttons, lx, ly, rx, ry, l2, r2, len(touch)]
|
||||
@@ -347,6 +371,106 @@ def main():
|
||||
finally:
|
||||
server.close()
|
||||
|
||||
# Rumble goes to the controller's owner; the light bar, brightened, to everyone.
|
||||
def rumble(pad, large, small):
|
||||
return lambda m: m.get('method') == 'v' and m['params'] == [pad, large, small]
|
||||
server = Server()
|
||||
try:
|
||||
c = server.c
|
||||
assert c.request('claim', [0])['result']['pads'][0]['mine']
|
||||
assert c.request('status')['result']['pads'][0]['color'] == [32, 96, 255], 'unlit: its own colour'
|
||||
os.write(server.log, b'C4F-TEST-FEEDBACK 11030d 200 40 64 0 0\n')
|
||||
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'
|
||||
# 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))
|
||||
time.sleep(.05)
|
||||
assert watcher.request('status')['result']['pads'][0]['color'] == [0, 255, 0], 'everyone sees it'
|
||||
os.write(server.log, b'C4F-TEST-FEEDBACK 11030d 0 0 32 0 32\n')
|
||||
time.sleep(.1)
|
||||
assert c.request('status')['result']['pads'][0]['color'] == [255, 0, 255], 'pink keeps its hue'
|
||||
assert not any(m.get('method') == 'v' for m in watcher.pushed), 'rumble went to a non-owner'
|
||||
# Rumble holds while the game holds it; one message per change, not per poll.
|
||||
os.write(server.log, b'C4F-TEST-FEEDBACK 11030d 90 0 32 0 32\n')
|
||||
c.wait_for(rumble(0, 90, 0))
|
||||
time.sleep(.3)
|
||||
c.request('status')
|
||||
assert not any(m.get('method') == 'v' for m in c.pushed), 'unchanged rumble was sent again'
|
||||
# Whoever takes the controller over is told the current state at once.
|
||||
c.close()
|
||||
server.c = c = Client()
|
||||
assert c.request('claim', [0])['result']['pads'][0]['mine']
|
||||
c.wait_for(rumble(0, 90, 0))
|
||||
# And told when it stops, so a page never keeps buzzing on old news.
|
||||
os.write(server.log, b'C4F-TEST-FEEDBACK 11030d 0 0 0 0 0\n')
|
||||
c.wait_for(rumble(0, 0, 0))
|
||||
time.sleep(.05)
|
||||
assert c.request('status')['result']['pads'][0]['color'] == [32, 96, 255], 'unlit again: its own colour'
|
||||
watcher.close()
|
||||
print('PASS rumble to the owner on change, the light bar to everyone, the state to a new owner', flush=True)
|
||||
finally:
|
||||
server.close()
|
||||
|
||||
# Without the call, controllers work as before.
|
||||
server = Server(C4F_TEST_NO_FEEDBACK='1')
|
||||
try:
|
||||
assert server.c.request('claim', [0, 1])['result']['pads'][1]['mine']
|
||||
time.sleep(.1)
|
||||
assert server.c.request('status')['result']['pads'][0]['color'] == [32, 96, 255]
|
||||
assert len([r for r in server.rows() if 'not exported' in r]) == 1
|
||||
print('PASS no rumble call: logged once, controllers unaffected', flush=True)
|
||||
finally:
|
||||
server.close()
|
||||
|
||||
# Who's signed in on each controller, made safe for JSON and UTF-8, kept out of the log.
|
||||
server = Server()
|
||||
try:
|
||||
c = server.c
|
||||
assert c.request('claim', [0, 1])['result']['pads'][1]['mine']
|
||||
assert c.request('status')['result']['pads'][0]['user'] == ''
|
||||
os.write(server.log, b'<118>#LOGIN MGR# Receive Event : SCE_MBUS_EVENT_DEVICE_OWNER_CHANGED [DeviceId:0x11030d][UserId:0x1a2b3c4d]\n')
|
||||
os.write(server.log, b'<118>#LOGIN MGR# Receive Event : SCE_MBUS_EVENT_DEVICE_OWNER_CHANGED [DeviceId:0x12030d][UserId:0x1a2b3c4e]\n')
|
||||
time.sleep(1.2) # Sam's name only comes on the retry, half a second later
|
||||
pads = c.request('status')['result']['pads']
|
||||
assert pads[0]['user'] == 'Alex', pads[0]
|
||||
assert pads[1]['user'] == 'Sam "S" \\ ? ? \u00e9', repr(pads[1]['user'])
|
||||
assert pads[2]['user'] == '' and pads[3]['user'] == ''
|
||||
assert not any('Alex' in r or 'Sam' in r for r in server.rows()), 'a name reached the log'
|
||||
assert any('user name found (4 bytes)' in r for r in server.rows())
|
||||
print('PASS signed-in names shown, made safe for JSON and UTF-8, kept out of the log', flush=True)
|
||||
finally:
|
||||
server.close()
|
||||
|
||||
# The Invite panel's QR code: a real QR code of the address the page names.
|
||||
server = Server()
|
||||
try:
|
||||
r = server.c.request('invite', [192, 168, 1, 20, 4264])['result']
|
||||
assert r['text'] == 'http://192.168.1.20:4264/', r
|
||||
size = r['size']
|
||||
assert size in (21, 25, 29, 33, 37) and len(r['rows']) == size
|
||||
bits = [[int(row[x >> 2], 16) >> (3 - (x & 3)) & 1 for x in range(size)] for row in r['rows']]
|
||||
assert all(len(row) == (size + 3) // 4 for row in r['rows'])
|
||||
# The three finder squares: a dark ring, a light ring, a dark 3x3 core.
|
||||
finder = [[1,1,1,1,1,1,1], [1,0,0,0,0,0,1], [1,0,1,1,1,0,1], [1,0,1,1,1,0,1],
|
||||
[1,0,1,1,1,0,1], [1,0,0,0,0,0,1], [1,1,1,1,1,1,1]]
|
||||
for ox, oy in ((0, 0), (size - 7, 0), (0, size - 7)):
|
||||
assert [bits[oy + y][ox:ox + 7] for y in range(7)] == finder, (ox, oy)
|
||||
assert server.c.request('invite', [300, 1, 1, 1, 4264])['error']['code'] == 400
|
||||
assert server.c.request('invite', [192, 168, 1, 20, 0])['error']['code'] == 400
|
||||
reply = server.c.request('invite', []) # the console's own address, when it has a route
|
||||
assert reply.get('result', {}).get('text', 'http://').startswith('http://') or reply['error']['code'] == 503
|
||||
print('PASS invite: a real QR code of the page address, bad addresses refused', flush=True)
|
||||
finally:
|
||||
server.close()
|
||||
|
||||
# Two claims cannot create at once: the second is refused, not queued.
|
||||
server = Server(C4F_TEST_ADD_DELAY='600')
|
||||
try:
|
||||
@@ -366,5 +490,209 @@ def main():
|
||||
server.close()
|
||||
|
||||
|
||||
def logout_regressions():
|
||||
def users(server, result=0, ids=(0x1a2b3c4d, 0x1a2b3c4e)):
|
||||
ids = list(ids) + [0xffffffff] * (4 - len(ids))
|
||||
os.write(server.log, ('C4F-TEST-USERS ' + str(result) + ' ' + ' '.join(f'{i:x}' for i in ids) + '\n').encode())
|
||||
|
||||
def bind(server, handle, user):
|
||||
os.write(server.log, f'DEVICE_OWNER_CHANGED [DeviceId:0x{handle:x}][UserId:0x{user:x}]\n'.encode())
|
||||
|
||||
def wait(c, predicate, timeout=2):
|
||||
end = time.monotonic() + timeout
|
||||
while time.monotonic() < end:
|
||||
pads = c.request('status')['result']['pads']
|
||||
for p in pads:
|
||||
if p['mine']: c.input(p['pad'])
|
||||
if predicate(pads): return pads
|
||||
time.sleep(.03)
|
||||
raise AssertionError(pads)
|
||||
|
||||
server = Server()
|
||||
try:
|
||||
c = server.c
|
||||
c.request('claim', [0, 1])
|
||||
bind(server, 0x11030d, 0x1a2b3c4d); bind(server, 0x12030d, 0x1a2b3c4e)
|
||||
wait(c, lambda p: p[0]['user'] == 'Alex' and p[1]['state'] == 'ready')
|
||||
time.sleep(.3)
|
||||
assert 'released klog reader' in server.rows(), 'logout must work without a klog reader'
|
||||
users(server, -5, ())
|
||||
time.sleep(.3)
|
||||
pads = c.request('status')['result']['pads']
|
||||
assert pads[0]['mine'] and pads[0]['user'] == 'Alex', 'failed lookup is not logout'
|
||||
# List order/foreground changes do not disconnect another logged-in player.
|
||||
users(server, ids=(0x1a2b3c4e, 0x1a2b3c4d))
|
||||
c.input(0); c.input(1); time.sleep(.3)
|
||||
assert all(p['mine'] for p in c.request('status')['result']['pads'][:2])
|
||||
os.write(server.log, b'C4F-TEST-FEEDBACK 11030d 80 30 64 0 0\n')
|
||||
c.wait_for(lambda m: m.get('method') == 'v' and m['params'] == [0, 80, 30])
|
||||
c.input(0, 0x10000); c.input(1, 0x4000)
|
||||
adds = len([r for r in server.rows() if r.startswith('ADD ')])
|
||||
users(server, ids=(0x1a2b3c4e,))
|
||||
pads = wait(c, lambda p: not p[0]['open'])
|
||||
assert pads[0]['state'] == 'free' and not pads[0]['mine'] and pads[0]['clients'] == 0 and pads[0]['user'] == ''
|
||||
assert pads[0]['uid'].startswith('unassigned-') and pads[1]['mine'] and pads[1]['state'] == 'ready'
|
||||
c.wait_for(lambda m: m.get('method') == 'v' and m['params'] == [0, 0, 0])
|
||||
rows = server.rows(); removed = rows.index('REMOVE 11030d')
|
||||
assert rows[removed - 1].split()[2:10] == ['11030d', '0', '128', '128', '128', '128', '0', '0'], rows[removed - 1]
|
||||
c.input(0, 0x10000); c.input(1)
|
||||
time.sleep(.1)
|
||||
assert not c.request('status')['result']['pads'][0]['open']
|
||||
assert len([r for r in server.rows() if r.startswith('ADD ')]) == adds, 'late input recreated a logged-out controller'
|
||||
assert len([r for r in server.rows() if 'login session read failed' in r]) == 1
|
||||
print('PASS logout snapshot after klog release: neutral/delete, clear name/ownership, stop rumble, preserve other player; failed reads and foreground changes safe', flush=True)
|
||||
finally:
|
||||
server.close()
|
||||
|
||||
server = Server()
|
||||
try:
|
||||
c = server.c
|
||||
c.request('claim', [0, 1, 2])
|
||||
bind(server, 0x11030d, 0x1a2b3c4d); bind(server, 0x12030d, 0x1a2b3c4d); bind(server, 0x13030d, 0x1a2b3c4e)
|
||||
wait(c, lambda p: all(s['state'] == 'ready' for s in p[:3]))
|
||||
os.write(server.log, b'C4F-TEST-USER-EVENT 1 1a2b3c4d\n')
|
||||
pads = wait(c, lambda p: not p[0]['open'] and not p[1]['open'])
|
||||
assert pads[2]['mine'] and pads[2]['state'] == 'ready'
|
||||
assert all(pads[i]['user'] == '' for i in (0, 1))
|
||||
print('PASS explicit logout removes every controller for that user while a different user stays connected', flush=True)
|
||||
finally:
|
||||
server.close()
|
||||
|
||||
server = Server()
|
||||
try:
|
||||
c = server.c
|
||||
users(server, ids=())
|
||||
c.request('claim', [0, 1])
|
||||
bind(server, 0x11030d, 0x1a2b3c4d)
|
||||
wait(c, lambda p: p[0]['state'] == 'ready')
|
||||
time.sleep(.3)
|
||||
assert c.request('status')['result']['pads'][0]['mine'], 'sign-in race treated as logout'
|
||||
os.write(server.log, b'C4F-TEST-USER-EVENT 1 1a2b3c4d\n')
|
||||
pads = wait(c, lambda p: not p[0]['open'])
|
||||
assert pads[1]['mine'] and pads[1]['state'] == 'select'
|
||||
# A known assigned controller losing its device owner is also removed.
|
||||
bind(server, 0x12030d, 0x1a2b3c4e)
|
||||
wait(c, lambda p: p[1]['state'] == 'ready')
|
||||
# Keep a third unassigned pad so the assignment log reader remains open.
|
||||
c.request('claim', [1, 2])
|
||||
bind(server, 0x12030d, 0xffffffff)
|
||||
wait(c, lambda p: not p[1]['open'])
|
||||
print('PASS delayed initial login listing, fast logout event and explicit unassignment', flush=True)
|
||||
finally:
|
||||
server.close()
|
||||
|
||||
server = Server(C4F_TEST_ADD_DELAY='650')
|
||||
try:
|
||||
c = server.c
|
||||
c.request('claim', [0]); bind(server, 0x11030d, 0x1a2b3c4d)
|
||||
wait(c, lambda p: p[0]['state'] == 'ready')
|
||||
replies = []
|
||||
worker = threading.Thread(target=lambda: replies.append(c.request('claim', [0, 1])))
|
||||
worker.start()
|
||||
end = time.monotonic() + 2
|
||||
while not any(r.startswith('ADD 12030d') for r in server.rows()):
|
||||
assert time.monotonic() < end
|
||||
time.sleep(.02)
|
||||
os.write(server.log, b'C4F-TEST-USER-EVENT 1 1a2b3c4d\n')
|
||||
worker.join(timeout=4)
|
||||
assert replies and replies[0]['error']['code'] == 409, replies
|
||||
pads = c.request('status')['result']['pads']
|
||||
assert not pads[0]['open'] and not pads[1]['open'] and not pads[0]['mine']
|
||||
assert 'REMOVE 12030d' in server.rows(), 'issued creation leaked after logout invalidated its retained slot'
|
||||
print('PASS logout during pending claim: revalidation rejects it and cleans up only its new devices', flush=True)
|
||||
finally:
|
||||
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()
|
||||
logout_regressions()
|
||||
reliability()
|
||||
@@ -6,7 +6,9 @@
|
||||
* marker verification and DeviceId matching, and only scePad is absent.
|
||||
*
|
||||
* A test injects its own kernel lines by writing them to the pipe named in
|
||||
* C4F_TEST_KLOG_FD; they are relayed into the log. The line C4F-TEST-CLOSE-KLOG
|
||||
* C4F_TEST_KLOG_FD; they are relayed into the log, except two commands: the line
|
||||
* C4F-TEST-FEEDBACK <handle> <large> <small> <r> <g> <b> sets what the "game"
|
||||
* wants of that controller (handle in hex), and C4F-TEST-CLOSE-KLOG
|
||||
* kills the log instead, as GoldHEN taking the device would.
|
||||
*
|
||||
* Environment switches:
|
||||
@@ -16,6 +18,7 @@
|
||||
* C4F_TEST_ADD_DELAY milliseconds before the device-added line turns up
|
||||
* C4F_FAIL_ADD AddDevice runs but its line never arrives
|
||||
* C4F_FAIL_INPUT InsertData fails while a button is held
|
||||
* C4F_TEST_NO_FEEDBACK the rumble/light-bar call isn't exported
|
||||
* C4F_TEST_SLOW_MS milliseconds each new device takes to adopt, the way
|
||||
* real console work spends time inside one loop iteration
|
||||
*/
|
||||
@@ -32,6 +35,86 @@
|
||||
|
||||
static unsigned nextId = 0x11030d;
|
||||
static int logRead = -1, logWrite = -1;
|
||||
static pthread_mutex_t userLock = PTHREAD_MUTEX_INITIALIZER;
|
||||
static int32_t loginUsers[4] = {0x1a2b3c4d, 0x1a2b3c4e, -1, -1}, loginResult;
|
||||
static struct { int32_t type; uint32_t user; } userEvents[32];
|
||||
static unsigned userHead, userCount;
|
||||
|
||||
static void setUsers(const char *line)
|
||||
{
|
||||
int result;
|
||||
unsigned ids[4];
|
||||
if (sscanf(line, "C4F-TEST-USERS %d %x %x %x %x", &result, &ids[0], &ids[1], &ids[2], &ids[3]) != 5) return;
|
||||
pthread_mutex_lock(&userLock);
|
||||
loginResult = result;
|
||||
for (int i = 0; i < 4; i++) loginUsers[i] = (int32_t)ids[i];
|
||||
pthread_mutex_unlock(&userLock);
|
||||
printf("USERS %d\n", result);
|
||||
}
|
||||
|
||||
static void addUserEvent(const char *line)
|
||||
{
|
||||
int type;
|
||||
unsigned user;
|
||||
if (sscanf(line, "C4F-TEST-USER-EVENT %d %x", &type, &user) != 2) return;
|
||||
pthread_mutex_lock(&userLock);
|
||||
if (userCount < 32) {
|
||||
unsigned pos = (userHead + userCount++) % 32;
|
||||
userEvents[pos].type = type; userEvents[pos].user = user;
|
||||
}
|
||||
pthread_mutex_unlock(&userLock);
|
||||
printf("USER-EVENT %d\n", type);
|
||||
}
|
||||
|
||||
int32_t c4fLoginUsers(int32_t out[4])
|
||||
{
|
||||
pthread_mutex_lock(&userLock);
|
||||
int32_t ret = loginResult;
|
||||
for (int i = 0; i < 4; i++) out[i] = ret == 0 ? loginUsers[i] : -1;
|
||||
pthread_mutex_unlock(&userLock);
|
||||
return ret;
|
||||
}
|
||||
|
||||
int32_t c4fUserEvent(int32_t *type, uint32_t *user)
|
||||
{
|
||||
pthread_mutex_lock(&userLock);
|
||||
int32_t ret = userCount ? 0 : -1;
|
||||
if (userCount) {
|
||||
*type = userEvents[userHead].type; *user = userEvents[userHead].user;
|
||||
userHead = (userHead + 1) % 32; userCount--;
|
||||
}
|
||||
pthread_mutex_unlock(&userLock);
|
||||
return ret;
|
||||
}
|
||||
|
||||
/* What the "game" wants of each controller, by handle. */
|
||||
static struct { unsigned handle; C4fPadFeedback feedback; } feedbacks[8];
|
||||
static pthread_mutex_t feedbackLock = PTHREAD_MUTEX_INITIALIZER;
|
||||
|
||||
static void setFeedback(const char *line)
|
||||
{
|
||||
unsigned handle, v[5];
|
||||
if (sscanf(line, "C4F-TEST-FEEDBACK %x %u %u %u %u %u", &handle, &v[0], &v[1], &v[2], &v[3], &v[4]) != 6) return;
|
||||
pthread_mutex_lock(&feedbackLock);
|
||||
for (int i = 0; i < 8; i++) {
|
||||
if (feedbacks[i].handle && feedbacks[i].handle != handle) continue;
|
||||
feedbacks[i].handle = handle;
|
||||
feedbacks[i].feedback = (C4fPadFeedback){ (uint8_t)v[0], (uint8_t)v[1], (uint8_t)v[2], (uint8_t)v[3], (uint8_t)v[4] };
|
||||
break;
|
||||
}
|
||||
pthread_mutex_unlock(&feedbackLock);
|
||||
}
|
||||
|
||||
int32_t c4fVirtualPadFeedback(const C4fVirtualPad *p, C4fPadFeedback *out)
|
||||
{
|
||||
memset(out, 0, sizeof(*out));
|
||||
if (getenv("C4F_TEST_NO_FEEDBACK")) return -1;
|
||||
pthread_mutex_lock(&feedbackLock);
|
||||
for (int i = 0; i < 8; i++)
|
||||
if (feedbacks[i].handle == (unsigned)p->handle) *out = feedbacks[i].feedback;
|
||||
pthread_mutex_unlock(&feedbackLock);
|
||||
return 0;
|
||||
}
|
||||
static pthread_mutex_t logLock = PTHREAD_MUTEX_INITIALIZER;
|
||||
|
||||
/* The write end is non-blocking: a log nobody drains drops lines instead of
|
||||
@@ -69,6 +152,18 @@ static void *relay(void *arg)
|
||||
ssize_t n = read(source, buf, sizeof(buf) - 1);
|
||||
if (n <= 0) return NULL;
|
||||
buf[n] = 0;
|
||||
if (strstr(buf, "C4F-TEST-FEEDBACK") || strstr(buf, "C4F-TEST-USERS") || strstr(buf, "C4F-TEST-USER-EVENT")) {
|
||||
char *save = NULL;
|
||||
for (char *line = strtok_r(buf, "\n", &save); line; line = strtok_r(NULL, "\n", &save)) {
|
||||
char copy[sizeof(buf) + 1];
|
||||
if (!strncmp(line, "C4F-TEST-FEEDBACK", 17)) { setFeedback(line); continue; }
|
||||
if (!strncmp(line, "C4F-TEST-USERS", 14)) { setUsers(line); continue; }
|
||||
if (!strncmp(line, "C4F-TEST-USER-EVENT", 19)) { addUserEvent(line); continue; }
|
||||
snprintf(copy, sizeof(copy), "%s\n", line);
|
||||
logLine(copy);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (strstr(buf, "C4F-TEST-CLOSE-KLOG")) {
|
||||
pthread_mutex_lock(&logLock);
|
||||
if (logWrite >= 0) { close(logWrite); logWrite = -1; }
|
||||
@@ -147,6 +242,21 @@ void c4fVirtualPadAdopt(C4fVirtualPad *p, int32_t user, int32_t vdaUser, uint64_
|
||||
printf("ADOPT %x %d\n", (unsigned)p->handle, vdaUser);
|
||||
}
|
||||
|
||||
/* Alex, and a name built to break JSON and UTF-8 that only resolves on a retry,
|
||||
* as a user still signing in does. */
|
||||
int32_t c4fUserName(uint32_t userId, char *out, size_t size)
|
||||
{
|
||||
static int samTries;
|
||||
if (size) out[0] = 0;
|
||||
if (userId == 0x1a2b3c4d) { snprintf(out, size, "Alex"); return 0; }
|
||||
if (userId == 0x1a2b3c4e) {
|
||||
if (++samTries < 2) return (int32_t)0x80960009;
|
||||
snprintf(out, size, "Sam \"S\" \\ \x01 \xff \xc3\xa9");
|
||||
return 0;
|
||||
}
|
||||
return (int32_t)0x80960009;
|
||||
}
|
||||
|
||||
void c4fVirtualPadRemove(C4fVirtualPad *p) { printf("REMOVE %x\n", p->handle); p->owned = 0; }
|
||||
void c4fPadDataNeutral(ScePadData *p)
|
||||
{
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
<# Build the payload and installable PS4 launcher locally. Requires Docker. #>
|
||||
<# Build the payload and installable PS4 launcher locally. Requires Docker.
|
||||
-Diag builds the diagnostic package instead (Control4Free-<version>-diag.pkg). #>
|
||||
param([switch]$Diag)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$projectRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
|
||||
$flag = if ($Diag) { 'C4F_DIAG=1' } else { '' }
|
||||
$suffix = if ($Diag) { '-diag' } else { '' }
|
||||
Push-Location $projectRoot
|
||||
try {
|
||||
docker build -t control4free-build -f docker/Dockerfile docker
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Payload toolchain build failed.' }
|
||||
docker build -t control4free-launcher-build -f docker/Dockerfile.launcher docker
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Launcher toolchain build failed.' }
|
||||
docker run --rm --network none -v "${projectRoot}:/src" -w /src control4free-launcher-build bash -lc 'make && make -C launcher'
|
||||
docker run --rm --network none -v "${projectRoot}:/src" -w /src control4free-launcher-build bash -lc "make $flag && make -C launcher $flag"
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Launcher package build failed.' }
|
||||
$version = (Get-Content -LiteralPath (Join-Path $projectRoot 'VERSION') -Raw).Trim()
|
||||
Get-Item -LiteralPath (Join-Path $projectRoot "build/Control4Free-$version.pkg") | Select-Object FullName,Length
|
||||
Get-Item -LiteralPath (Join-Path $projectRoot "build/Control4Free-$version$suffix.pkg") | Select-Object FullName,Length
|
||||
} finally {
|
||||
Pop-Location
|
||||
}
|
||||
@@ -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.";
|
||||
@@ -66,7 +67,7 @@ int main(int argc, char **argv)
|
||||
s.running = -1; s.busy = 1; s.autorun = C4F_AUTORUN_UNKNOWN; message = "Setting up Control4Free. Please wait...";
|
||||
} else if (!strcmp(state, "locked")) {
|
||||
s.running = -1; s.locked = 1;
|
||||
message = "Sent, but Control4Free never answered (127.0.0.1 no reply). Restart the PS4 before you try again.";
|
||||
message = "Could not initialize launcher. Close with PS and retry.";
|
||||
} else if (!strcmp(state, "unknown")) {
|
||||
s.running = -1; s.autorun = C4F_AUTORUN_UNKNOWN;
|
||||
snprintf(s.autorunNote, sizeof(s.autorunNote), "GoldHEN did not let the app check (errno 78)");
|
||||
|
||||
@@ -95,9 +95,17 @@ def render(args, path, width, height):
|
||||
|
||||
|
||||
def main():
|
||||
global OUT, STAGE, ART, FONTS
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('--sdk', required=True, type=Path)
|
||||
sdk = parser.parse_args().sdk
|
||||
# The diagnostic build (make -C launcher C4F_DIAG=1): its own folder, payload,
|
||||
# title and file name, so it can never be mistaken for a release.
|
||||
parser.add_argument('--diag', action='store_true')
|
||||
args = parser.parse_args()
|
||||
sdk = args.sdk
|
||||
suffix = '-diag' if args.diag else ''
|
||||
OUT = ROOT / f'build/launcher{suffix}'
|
||||
STAGE, ART, FONTS = OUT / 'pkg', OUT / 'launcher-art', OUT / 'fonts.c'
|
||||
binary = sdk / 'bin/linux'
|
||||
# Start clean, so nothing left over from an older build gets packaged.
|
||||
shutil.rmtree(STAGE, ignore_errors=True)
|
||||
@@ -106,7 +114,7 @@ def main():
|
||||
run = lambda *args: subprocess.run([str(arg) for arg in args], cwd=STAGE, check=True)
|
||||
run(binary / 'create-fself', '-in=' + str(OUT / 'launcher.elf'), '-out=' + str(OUT / 'launcher.oelf'),
|
||||
'--eboot', 'eboot.bin', '--paid', '0x3800000000000011')
|
||||
shutil.copyfile(ROOT / 'build/control4free.elf', STAGE / 'assets/control4free.elf')
|
||||
shutil.copyfile(ROOT / f'build/control4free{suffix}.elf', STAGE / 'assets/control4free.elf')
|
||||
shutil.copyfile(sdk / 'samples/graphics/sce_sys/about/right.sprx', STAGE / 'sce_sys/about/right.sprx')
|
||||
# OpenOrbis's stub modules. Its CHANGELOG (v0.5) says homebrew packages
|
||||
# should carry them in sce_module/, and every sample does.
|
||||
@@ -136,7 +144,8 @@ def main():
|
||||
'APP_TYPE': ('Integer', 4, '1'), 'APP_VER': ('Utf8', 8, SFO_VERSION),
|
||||
'ATTRIBUTE': ('Integer', 4, '32'), 'CATEGORY': ('Utf8', 4, 'gde'),
|
||||
'CONTENT_ID': ('Utf8', 48, CONTENT_ID), 'DOWNLOAD_DATA_SIZE': ('Integer', 4, '0'),
|
||||
'SYSTEM_VER': ('Integer', 4, '0'), 'TITLE': ('Utf8', 128, 'Control4Free'),
|
||||
'SYSTEM_VER': ('Integer', 4, '0'),
|
||||
'TITLE': ('Utf8', 128, 'Control4Free (diagnostic)' if args.diag else 'Control4Free'),
|
||||
'TITLE_ID': ('Utf8', 12, TITLE_ID), 'VERSION': ('Utf8', 8, SFO_VERSION),
|
||||
}
|
||||
for key, (kind, size, value) in entries.items():
|
||||
@@ -163,7 +172,7 @@ def main():
|
||||
shutil.copyfile(project, OUT / 'launcher.gp4')
|
||||
run(pkgtool, 'pkg_build', project, OUT)
|
||||
generated = OUT / (CONTENT_ID + '.pkg')
|
||||
target = ROOT / f'build/Control4Free-{VERSION}.pkg'
|
||||
target = ROOT / f'build/Control4Free-{VERSION}{suffix}.pkg'
|
||||
shutil.copyfile(generated, target)
|
||||
print(f'Built {target} ({target.stat().st_size:,} bytes)', flush=True)
|
||||
|
||||
|
||||
@@ -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='')
|
||||