14 Commits
Author SHA1 Message Date
MoHadiShibli dba861bb7f Publish Control4Free release 2026-10-22 19:35:11 +03:00
MoHadiShibli 6a0e6925e5 Prepare Control4Free 1.3.0 with WebRTC and controller testing 2026-10-22 19:30:05 +03:00
MoHadiShibli aa45b9b123 Trigger rumble follows the trigger being pulled
A linked DualSense and Xbox controllers vibrate the trigger being pulled,
the last one pulled while both are, and neither while neither is. The
large motor used to drive L2 and the small one R2, so a shot fired with
R2 was felt in L2.
2026-10-20 13:56:18 +03:00
MoHadiShibli 9890560867 Read a linked controller's buttons from its own reports
Over Bluetooth a linked DualShock 4 or DualSense switches to its full
reports, which the browser's gamepad doesn't read, so the PS4 got no
input from it. While linked, its buttons, sticks and triggers now come
from those reports, over USB and Bluetooth alike.

A fault in the experimental code no longer stops the page's input loop,
and "How to turn it on" only shows when the page is insecure.
2026-10-16 23:42:52 +03:00
MoHadiShibli 7e503478c4 Add the PS4 controller test, motion input and experimental extras
PS4 app: Test controllers, opened with Options or from Actions, shows what
each signed-in user's controller sends, read the way a game reads it, on
the controller page's picture. Holding L3 + R3 opens its menu: change user,
rumble each motor, set or reset the light bar, reset orientation, go back.

Service: the control protocol takes motion ('m'), and info says so. Rumble
reached the page with the large and small motors swapped: fixed.

Page: Settings > Experimental, every switch off by default. Lights, rumble
and touchpad fingers for a DualShock 4 or DualSense over WebHID; trigger
rumble on Xbox controllers and a linked DualSense; motion from a phone or a
linked controller.
2026-10-16 16:08:13 +03:00
MoHadiShibli 5a8f9e3d0e Prepare Control4Free 1.2.1 release 2026-10-11 12:21:56 +03:00
MoHadiShibli 75743ef24b Render diagnostic previews with isolated page fixtures 2026-10-10 14:04:11 +03:00
MoHadiShibli 0b3b448ca5 Decode Sony remote Enter keys in the launcher 2026-10-10 13:58:09 +03:00
MoHadiShibli ed5e828701 Build with official mirrors and the verified OpenOrbis archive
Avoid Docker Hub pull limits using pinned Docker Official Images on ECR. Fetch the same OpenOrbis release directly and verify its SHA256; retain the existing toolchain and runtime dependencies.
2026-10-10 00:39:29 +03:00
MoHadiShibli 1242d7346e Add HDMI-CEC navigation to the PS4 launcher
Read system TV-remote input independently, expose launcher actions through arrows and OK, and preserve DS4 shortcuts. Add reconnect handling, explicit Stop confirmation, diagnostics, and reader/navigation regressions.

Prepare the next patch version as a test candidate; PS4 HDMI-CEC reception still needs hardware confirmation before release.
2026-10-10 00:19:43 +03:00
MoHadiShibli 252e26d2b7 Finalize Control4Free 1.2.0 fixes and release documentation 2026-10-09 21:13:37 +03:00
MoHadiShibli 41c476b2e2 Implement Control4Free 1.2.0 mapping and reliability 2026-10-09 17:38:23 +03:00
MoHadiShibli 017b8e2473 Docs: GoldHEN v2.4b18.10 is the minimum, and a compatibility table
GoldHEN versions before v2.4b18.10 don't run ELF payloads reliably (v2.4b18.8
improved ELF loading, v2.4b18.10 fixed a sceUserServiceInitialize failure in
them) and have no auto-start, and every firmware GoldHEN supports has a
v2.4b18.10 or later. The installation guide now lists each firmware with the
GoldHEN version it needs, marking the ones confirmed on a console (10.01,
13.02, 13.04, 13.52) apart from those that only have GoldHEN and start-up
support so far. The README points to it.
2026-10-07 02:21:39 +03:00
MoHadiShibli 96326c71b0 Docs: tested firmwares, and what to do when updating, restarting or reporting
- Requirements list the firmwares Control4Free is tested on (10.01, 13.02,
  13.04 and 13.52, with GoldHEN v2.4b18.10 and v2.4b18.12); 13.52 needs 1.1.1.
- Updating says you don't need to stop Control4Free before deleting or
  replacing the app. A new "After a restart" section says auto-start brings it
  back by itself and the app doesn't get in the way.
- Uninstalling warns that deleting the app alone leaves Control4Free starting
  with GoldHEN, and how to clean up when the app is already gone.
- Reporting a problem stops the normal version before starting the diagnostic
  one, so the report comes from the diagnostic version, and says how to switch
  back.
- The kernel-log message also names old test builds of the diagnostic app.
2026-10-07 02:03:52 +03:00
237 changed files with 136950 additions and 665 deletions

No files matched your search

+1 -1
View File
@@ -1 +1 @@
ko_fi: mohadishibli
liberapay: MoHadiShibli
+31 -3
View File
@@ -34,11 +34,34 @@ jobs:
docker run --rm --network none -v "$PWD:/src" -w /src \
control4free-launcher-build python3 -B tests/run.py
- name: Browser editor checks
run: |
docker run --rm -v "$PWD:/src" -w /src mcr.microsoft.com/playwright:v1.63.0-noble \
bash -lc 'mkdir -p /tmp/c4f-browser; cd /tmp/c4f-browser; npm init -y >/dev/null; npm install playwright@1.63.0; cd /src; NODE_PATH=/tmp/c4f-browser/node_modules node tests/browser_test.cjs'
- name: Emulated gamepad checks
run: |
docker run --rm -v "$PWD:/src" -w /src mcr.microsoft.com/playwright:v1.63.0-noble \
bash -lc 'mkdir -p /tmp/c4f-browser; cd /tmp/c4f-browser; npm init -y >/dev/null; npm install playwright@1.63.0; cd /src; NODE_PATH=/tmp/c4f-browser/node_modules node tests/fake_gamepads_test.cjs'
- name: Experimental feature 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/experimental_test.cjs'
- name: WebRTC browser and input recovery 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/rtc_e2e.cjs && NODE_PATH=/tmp/c4f-browser/node_modules node tests/rtc_input_e2e.cjs'
- name: Collect what was built
run: |
version=$(cat VERSION)
mkdir -p artifacts
cp build/control4free.elf "build/Control4Free-$version.pkg" "build/Control4Free-$version-diag.pkg" artifacts/
cp build/control4free.elf "build/Control4Free-$version.pkg" artifacts/
# GitHub sorts assets by name. Keep the diagnostic download below the
# normal installer, payload and checksums, with its purpose in the name.
cp "build/Control4Free-$version-diag.pkg" "artifacts/troubleshooting-Control4Free-$version-diag.pkg"
cd artifacts && sha256sum * > SHA256SUMS && cat SHA256SUMS
- uses: actions/upload-artifact@v4
@@ -60,6 +83,11 @@ 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
gh release create "${GITHUB_REF#refs/tags/}" \
"artifacts/Control4Free-$version.pkg#Normal package (recommended)" \
'artifacts/control4free.elf#Payload for manual loading' \
'artifacts/SHA256SUMS#Download checksums' \
"artifacts/troubleshooting-Control4Free-$version-diag.pkg#Troubleshooting only - diagnostic package" \
--verify-tag --title "Control4Free $version" --notes-file notes.md
+77 -2
View File
@@ -1,7 +1,82 @@
# Changelog
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.3.0 — 2026-10-22
WebRTC connections, controller testing on the PS4, and new experimental controller features.
- **Rebuilt controller connections around WebRTC.** The old WebSocket connection delivered everything
in order, so recovering a lost packet could hold up newer input. Input now uses an unordered channel
without retransmissions, while controller selection and status use a separate reliable channel.
This reduces delayed and bunched-up inputs during packet loss. After a brief disconnection, the page
reconnects and restores its controller selection.
- **Controller tester in the PS4 app:** see buttons, sticks, analog triggers, touchpad, motion and
connection latency; test rumble, choose light-bar colours with RGB sliders and reset orientation.
Supports controllers and HDMI-CEC remotes. See [Testing controllers](docs/playing.md#testing-controllers-on-the-ps4).
- **DualShock 4 and DualSense touchpads now work when linked:** both finger positions and the click.
Experimental Sony controller support also adds direct rumble and PS4 light-bar colours.
- **Experimental motion controls** use a phone's sensors or a linked DualShock 4 / DualSense.
- **Experimental trigger rumble** adds vibration to the trigger you're pulling on supported Xbox
controllers and a linked DualSense. It follows the game's rumble; when both triggers are held, the
last one pulled vibrates. See [Experimental features](docs/playing.md#experimental-features) for setup
and how it works.
- Corrected the large and small rumble motors being swapped on connected gamepads.
Experimental features are off by default. Sony linking and motion need the
[Chrome / Edge setup](docs/troubleshooting.md#experimental-features).
**Updating:** update the PS4 service, then reload the controller page. Replace any saved copy with a
fresh one from the updated service.
## 1.2.1 — 2026-10-11
HDMI-CEC navigation for the PS4 app.
- Use your TV remote to control the PS4 app through HDMI-CEC. Press **OK** to open **Actions**,
use the arrows to choose, and press **OK** again. **Back** returns; Stop has a separate confirmation.
The diagnostic app also supports remote navigation and retains the last received remote key.
See [HDMI-CEC navigation](docs/hdmi-cec.md).
- If the app cannot reach PayLoader, it tells you to enable **GoldHEN → Server Settings → Enable
PayLoader Server** and retry Start. The setup message now fits on screen, and the installation
guide explains when the server is needed.
Thanks to [NotDev-Kike](https://github.com/MoHadiShibli/Control4Free/issues/3#issuecomment-6103983733)
for testing and confirming OK, arrows, Back and service startup with a Samsung TV remote on firmware 11.02.
## 1.2.0 — 2026-10-09
Gamepad mapping.
![DS4 Mapping with an example gamepad](https://raw.githubusercontent.com/MoHadiShibli/Control4Free/v1.2.0/docs/images/mapping.png)
- 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
+39 -11
View File
@@ -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.
@@ -45,14 +45,36 @@ the page and the app show the version the service reports.
docker run --rm --network none -v "$PWD:/src" -w /src control4free-launcher-build python3 -B tests/run.py
```
The suites build the real sources for the host with only the PS4 calls stubbed, so they need no console:
The six suites build the real sources for the host with only the PS4 calls stubbed, so they need no console:
- `test_web` and `test_recovery` run the actual service against a fake kernel log
(`tests/web_stub.c`) that writes the same lines firmware 10.01 does, over real HTTP and WebSocket
connections;
(`tests/web_stub.c`) that writes the same lines firmware 10.01 does, over real HTTP and a test-only
WebSocket adapter; production controller connections use WebRTC;
- `test_frontend` runs the page's own functions under Node;
- `test_launcher` drives the app's logic, renders every state of its screen to `build/launcher-preview-*.png`,
and checks the package's `param.sfo`;
- `test_logging` covers the log, the kernel-log opener and DeviceId matching.
- `test_launcher` drives the app's logic and input worker, compares warm tester renders with fresh
renders, produces `build/launcher-preview-*.png`, and checks the package's `param.sfo`;
- `test_logging` covers the log, the kernel-log opener and DeviceId matching;
- `test_rtc` checks STUN/SCTP, congestion and receive windows, UDP backpressure, and the production HTTP
signalling endpoint with the WebSocket adapter disabled.
The [build workflow](.github/workflows/build.yml) gives the Docker commands for the browser checks, using
the pinned Playwright image and package. Run these after the host suites, which build their test server:
- `tests/browser_test.cjs`: controller-page editor, mapping and connection lifecycle;
- `tests/fake_gamepads_test.cjs`: researched gamepad emulations, without hardware compatibility claims;
- `tests/experimental_test.cjs`: optional HID, motion, output and reconnect behavior;
- `tests/rtc_e2e.cjs`: real Chromium and Firefox WebRTC connections and reliable fragmented replies;
- `tests/rtc_input_e2e.cjs`: input freshness and recovery through dropped, delayed and reordered UDP packets.
Run packaging and launcher preview tests sequentially because they share renderer scratch files.
For controller-test drawing changes, `tests/test_tester_render.py` runs the cache-transition checks on
their own. Its optional `--benchmark` reports host CPU drawing time, excluding input, framebuffer copy
and display presentation; do not describe those results as PS4 frame timings.
Changes to mapping belong in the page's mapping tests as well as its browser checks. Exercise independent
analog trigger pressures and digital flags, multiple sources driving a slot, profile changes and
reconnect gating. For connection RTT changes, check probe matching, expiry, ownership changes and the
launcher's unavailable/measuring states. RTT is client-reported diagnostic data, separate from pad input.
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.
@@ -76,10 +98,16 @@ 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 six Docker host suites and all five browser checks above, including WebRTC impairment 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
+11 -3
View File
@@ -20,7 +20,10 @@ 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/feedback.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 src/rtc.c src/stun.c src/sctp.c
# The pages' WebRTC connection is encrypted with Mbed TLS (vendor/mbedtls, Apache-2.0),
# built with only what include/c4f_mbedtls_config.h asks for.
MBEDTLS := $(patsubst vendor/mbedtls/library/%.c,%,$(wildcard vendor/mbedtls/library/*.c))
# Research build: logs what scePadVirtualDeviceGetRemoteSetting returns. Its ELF
# gets its own name, so it can never be mistaken for a release.
@@ -39,11 +42,13 @@ ifdef C4F_DIAG
OBJDIR := build/diag
endif
OBJECTS := $(SOURCES:src/%.c=$(OBJDIR)/%.o) $(OBJDIR)/client.o $(OBJDIR)/assets.o $(OBJDIR)/qrcodegen.o
OBJECTS := $(SOURCES:src/%.c=$(OBJDIR)/%.o) $(OBJDIR)/client.o $(OBJDIR)/assets.o $(OBJDIR)/qrcodegen.o \
$(MBEDTLS:%=$(OBJDIR)/mbedtls/%.o)
CFLAGS += -std=gnu11 -Wall -Wextra -Wpointer-arith -g -O2
CFLAGS += -MMD -MP
CFLAGS += -Iinclude -Ivendor/jsmn -Ivendor/qrcodegen -DC4F_VERSION='"$(VERSION)"' $(EXTRA_CFLAGS)
CFLAGS += -Ivendor/mbedtls/include -DMBEDTLS_CONFIG_FILE='<c4f_mbedtls_config.h>'
LDFLAGS += -lScePad -lSceUserService -ldl -lpthread
.PHONY: all clean
@@ -53,7 +58,7 @@ LDFLAGS += -lScePad -lSceUserService -ldl -lpthread
all: $(ELF)
$(OBJDIR) build:
$(OBJDIR) $(OBJDIR)/mbedtls build:
mkdir -p $@
# VERSION is compiled into the objects, and make doesn't notice a changed flag,
@@ -61,6 +66,9 @@ $(OBJDIR) build:
$(OBJDIR)/%.o: src/%.c VERSION | $(OBJDIR)
$(CC) $(CFLAGS) -c $< -o $@
$(OBJDIR)/mbedtls/%.o: vendor/mbedtls/library/%.c include/c4f_mbedtls_config.h | $(OBJDIR)/mbedtls
$(CC) $(CFLAGS) -c $< -o $@
build/client.c: client/index.html tools/embed_client.py | build
python3 tools/embed_client.py $< $@
+68 -17
View File
@@ -6,10 +6,10 @@
<a href="https://github.com/MoHadiShibli/Control4Free/releases/latest"><img src="https://img.shields.io/github/v/release/MoHadiShibli/Control4Free?label=release" alt="Latest release"></a>
<a href="https://github.com/MoHadiShibli/Control4Free/actions/workflows/build.yml"><img src="https://github.com/MoHadiShibli/Control4Free/actions/workflows/build.yml/badge.svg" alt="Build"></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/license-GPL--3.0-blue" alt="License: GPL v3"></a>
<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>
<a href="https://liberapay.com/MoHadiShibli/"><img src="https://img.shields.io/badge/Liberapay-support%20this%20project-F6C915?logo=liberapay&logoColor=black" alt="Support on Liberapay"></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,11 +22,11 @@ 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
@@ -38,26 +38,56 @@ sees a real controller, so it works wherever a DualShock does:
Move, resize or hide any button with the layout editor; each device keeps its own layout.
- **The gamepads you already have.** Connect one to the phone or PC and give it a controller. One computer
can run all four.
- **A keyboard**, with keys you can change.
- **A keyboard**, with keys you choose on the same picture.
- **Your own gamepad mapping.** Map any gamepad on a picture of a DualShock 4: click a control, then press the
button you want. 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.
- **DualShock 4 and DualSense touchpads**, including two finger positions, when linked in Chrome or Edge
on a computer. Experimental Sony controller support also adds direct rumble and PS4 light-bar colours.
- **Experimental motion controls** from a phone or a linked DualShock 4 / DualSense, and **trigger rumble**
on supported Xbox controllers and a linked DualSense. Turn them on in **Settings → Experimental**.
See [Experimental features](docs/playing.md#experimental-features) for what each switch does and setup.
- **WebRTC controller connections** keep newer input moving while reliable messages handle controller
selection and status. See [why the connection changed](#controller-connection).
- **Reconnects after a brief interruption**, reclaiming controllers the service still retains.
- **An app on the PS4** that shows the address and a QR code, how many controllers are connected, and starts
or stops Control4Free.
or stops Control4Free. Use a controller or your TV remote through [HDMI-CEC](docs/hdmi-cec.md).
- **Test controllers on the PS4.** Press **Options** in the app to see what each signed-in user's controller
sends: buttons, sticks, analog triggers, touchpad and motion. Check connection latency and test rumble,
light-bar colours and orientation. See [Testing controllers](docs/playing.md#testing-controllers-on-the-ps4).
- **Starts by itself.** One button in the app sets up GoldHEN's AutoRun, so Control4Free is ready after
every restart.
- **On your phone's home screen**, like an app.
- **Stays awake**: your phone's screen doesn't turn off while you play.
- **Gets through rest mode.** After the PS4 wakes up, open the page and pick your controller again.
## Controller connection
The controller page now connects directly to the PS4 through WebRTC data channels over UDP. Previously,
all messages travelled through one ordered
WebSocket connection over TCP: if a packet was lost, newer input could wait behind its retransmission.
For a controller, that can turn a brief loss into late presses or several updates arriving together.
WebRTC lets the page use separate channels for different jobs. Controller input is unordered and is not
retransmitted; the PS4 accepts newer state and ignores older updates arriving late. Controller selection
and status use a reliable channel. A short history of button changes helps recover isolated missed
presses, and automatic reconnection restores controllers after a brief interruption.
The improvement is how input behaves when the connection loses packets. An already healthy connection
may have a similar round-trip time. For the implementation and message formats, see
[How it works](docs/how-it-works.md#browser-connection) and [Protocol](docs/protocol.md).
## Quick start
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.
3. Enable **GoldHEN → Server Settings → Enable PayLoader Server**, then 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 PS4.
@@ -69,30 +99,51 @@ New to GoldHEN packages? The **[installation guide](docs/installation.md)** walk
|---|---|---|
| ![Home screen on a phone](docs/images/home.png) | ![The page in a PC's browser](docs/images/desktop.png) | ![The Control4Free app on the PS4](docs/images/launcher.png) |
| Controller test | Controller tools |
|---|---|
| ![Controller test with example readings](docs/images/tester.png) | ![Controller tools](docs/images/tester-tools.png) |
## Documentation
- **[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.
- **[HDMI-CEC navigation](docs/hdmi-cec.md)**: using your TV remote in the PS4 app.
- **[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.
- **[Virtual DS4 controls and values](docs/how-it-works.md#virtual-ds4-controls-and-values)**: individual
buttons, sticks, triggers, touch, motion, and motor/light-bar feedback exposed by Control4Free.
- **[Protocol](docs/protocol.md)**: the WebRTC channels and messages between the page and the console.
- **[Security](SECURITY.md)**: what being open on your home network does and doesn't mean.
- **[Contributing](CONTRIBUTING.md)**: building, testing and code style.
- **[Changelog](CHANGELOG.md)**.
## 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 phone, tablet or computer with a browser, on the same network as the PS4.
- A PS4 with GoldHEN v2.4b18.10 or later. Tested on firmware 10.01, 11.02, 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 supporting WebRTC data channels, on the same network as the PS4.
TCP and UDP port 4264 must be reachable; guest Wi-Fi or client isolation can block the connection.
- To start the service directly from the app, enable **GoldHEN → Server Settings → Enable PayLoader
Server**. AutoRun can start it without this server.
- 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.
- A Remote Play session (chiaki-ng, for example) works alongside Control4Free.
## Limitations
- **No motion controls.** Browsers only give phone motion sensors to secure (`https`) pages, and the
console's page is plain `http`.
- **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).
- **Eight connections at a time.** Up to eight phones or computers can connect to the service at once.
- **Motion and direct controller lights are experimental.** Browsers only give motion sensors and direct
controller access to secure pages, and the console's page is plain `http`. Chrome and Edge can treat it
as secure with one setting; Safari on an iPhone can't. Direct controller access works in Chrome and Edge
on a computer only. See [Experimental features](docs/troubleshooting.md#experimental-features).
- **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
@@ -100,7 +151,7 @@ New to GoldHEN packages? The **[installation guide](docs/installation.md)** walk
- **Rest mode signs everyone out**, so controllers have to be picked again after the PS4 wakes up, just like
real ones.
- **No pairing or password.** Anyone on your network can take a free controller. Read [SECURITY.md](SECURITY.md)
and keep port 4264 off the internet.
and keep TCP and UDP port 4264 off the internet.
## Support
@@ -108,7 +159,7 @@ 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).
[Liberapay](https://liberapay.com/MoHadiShibli/).
## Credits
+32 -17
View File
@@ -8,8 +8,10 @@ repository (**Security → Report a vulnerability**), not in a public issue.
## What Control4Free assumes
Control4Free turns a phone or PC into a controller for your PS4 over your own
network. There is no pairing, no password and no encryption, by design: a party
guest should be able to open a link and play.
network. There is no pairing or password: a party guest can open a link and play.
The WebRTC channels are encrypted with DTLS. The controller page and connection
handshake are served over plain HTTP, so this does not authenticate the console
against an attacker who can modify traffic on your network.
**So treat the console's port 4264 the way you treat your TV remote.** Anyone who
can reach your PS4 over the network can:
@@ -17,12 +19,13 @@ 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;
- see the names of the users signed in on the controllers.
- see the names and IDs of the users signed in on the controllers, and the reported connection RTT.
They cannot sign in as one of your users without someone at the PS4 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
Run Control4Free on a network you trust. Do not forward TCP or UDP port 4264 through your
router, and do not run it on open or guest Wi-Fi. When you are finished, stop it
from the launcher app or the controller page, or turn the console off.
@@ -35,26 +38,27 @@ are the limits the code does enforce.
visit can make your browser send requests to your PS4, so the service refuses the
ones that matter:
- A WebSocket handshake carrying another site's `Origin` is refused with 403, so an
ordinary page's script cannot open the control socket.
- A WebRTC offer posted to `/rtc` carrying another site's `Origin` is refused with
403, so an ordinary page's script cannot establish the controller connection.
- The page is only served for a literal IP address or `localhost` in the `Host`
header, so a domain name that resolves to your console (DNS rebinding) gets 403.
- The launcher's own `GET /api/status` and `POST /api/stop` require the header
`X-Control4Free-Launcher: 1` and refuse any request that carries an `Origin` at
all. A browser only sends a custom header cross-origin after a CORS preflight,
which this service never answers.
which the launcher API never answers.
- The page is served with a Content-Security-Policy that keeps it to its own
resources.
The gap: a handshake with `Origin: null` is accepted. That is what a browser sends
for a controller page opened from a saved file, and the saved file is how gamepads
The gap: an offer with `Origin: null` is accepted, with CORS responses and a
preflight for `/rtc`. That is what a browser sends for a controller page opened
from a saved file, and the saved file is how gamepads
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.
**One controller has one owner.** A slot is claimed by one WebRTC connection.
Another device asking for the same slot is refused (409), and input never claims a
slot implicitly. A client that leaves has its buttons released at once.
@@ -62,9 +66,20 @@ slot implicitly. A client that leaves has its buttons released at once.
are range-checked before they reach the virtual device, and a request that is not
well-formed JSON of the expected shape is rejected rather than guessed at.
**The service cannot be made to grow.** At most 4 controllers and 8 connections
exist at a time, each connection has fixed-size buffers, and a request body larger
than those buffers is refused.
**Connections and buffers are bounded.** At most 4 controllers and 8 WebRTC peers
exist at a time. HTTP connections and transport queues also have limits, and an
HTTP request larger than the receive buffer is refused.
**WebRTC validates its peer.** ICE checks use the handshake credentials, and the
DTLS certificate must match the SHA-256 fingerprint in the offer. The browser
checks the answer's fingerprint too. This ties the encrypted connection to the
HTTP exchange; it does not add a player password or protect a modified HTTP page.
**Connection RTT is diagnostic data.** Each page reports the duration of its own
echoed probe. The service matches a recent probe from that connection, associates
the result only with controllers it owns, and expires old reports. A modified
client can still misreport its own RTT. The value does not grant ownership,
change input handling or act as an authentication signal.
**Stopping from the page is deliberate.** The page's Stop is refused while another
device owns a controller, so one player cannot cut another off by accident. The
+21 -2
View File
@@ -6,6 +6,18 @@
Zaitsev, under the MIT license included in `vendor/jsmn/LICENSE`.
Retrieved 2026-10-04; SHA-256:
`c04533e9181e1e33baceb0f55ac449b05145bb936e8c68cc77dfe0d8277514fb`.
- `vendor/mbedtls/` contains the Mbed TLS headers and library sources used for
WebRTC's DTLS encryption. The included version header identifies Mbed TLS 3.6.7;
upstream reference: [tag `mbedtls-3.6.7`](https://github.com/Mbed-TLS/mbedtls/tree/mbedtls-3.6.7).
Its dual Apache-2.0 OR GPL-2.0-or-later license is retained in
`vendor/mbedtls/LICENSE`; Control4Free uses the Apache-2.0 option.
The SHA-256 of the vendored 126-file source manifest is
`cdc9b4a0025cb796015477377dbd3b2c2c4b13118670cd930dfff60ec9ce4daa`.
To reproduce this content fingerprint, sort all file paths relative to
`vendor/mbedtls/` by ordinal order, with `/` separators. For each file, append
its lowercase SHA-256, two spaces, its relative path and a line feed. Hash that
UTF-8 manifest without a byte-order mark. This identifies the included subset,
rather than an upstream download archive.
- The toolchain uses https://github.com/ps4-payload-dev/sdk; its version is pinned
in `docker/Dockerfile` and it retains its own licensing.
- The native launcher uses the OpenOrbis PS4 Toolchain and its software-display
@@ -34,8 +46,15 @@
- The on-screen DualShock 4 buttons in `client/index.html` are drawn after the
[DualShock 4 layout diagram](https://commons.wikimedia.org/wiki/File:Dualshock_4_Layout.svg)
by Tokyoship, licensed under CC BY 3.0
(https://creativecommons.org/licenses/by/3.0/).
(https://creativecommons.org/licenses/by/3.0/). The PS button's mark uses the
diagram's paths, resized and recolored; the surrounding buttons are adapted
to the interactive page. The PS4 app's controller test (`launcher/tester_draw.c`)
draws the same picture, PS mark included.
- The DualSense trigger vibration in `client/index.html` follows the effect format
of John "Nielk1" Klein's DualSense trigger effect generator
(https://gist.github.com/Nielk1/6d54cc2c00d2201ccb8c2720ad7538db), MIT.
These license notices are also included inside the package.
The HTTP/WebSocket transport in `src/net.c` is a new implementation.
The HTTP, ICE/STUN and SCTP transport code is implemented in this project;
the WebRTC DTLS layer uses Mbed TLS as credited above.
+1 -1
View File
@@ -1 +1 @@
1.1.1
1.3.0
+2674 -229
View File
File diff suppressed because it is too large. Load diff
+2 -1
View File
@@ -2,7 +2,8 @@
#
# Holds clang/lld plus a pinned ps4-payload-sdk release, so a build needs nothing
# installed on the host but Docker. Matches the SDK's own CI (clang-18 / lld-18).
FROM ubuntu:24.04
# Docker's official Ubuntu mirror avoids Docker Hub's shared pull quota.
FROM public.ecr.aws/docker/library/ubuntu:24.04@sha256:534baea6a22c03a63003dbc8dbe78fe34bc0d7e595d9a9dc9834884ff530eb55
ARG SDK_VERSION=v0.9
+16 -4
View File
@@ -1,7 +1,19 @@
# Reuse the pinned OpenOrbis release from the existing plugin toolchain image.
ARG OPENORBIS_IMAGE=xfangfang/goldhen_plugin@sha256:e3d2acad69a1905472bd9d73e373d1d0fb6d3ca293d9f9a6ba957b9b2f49fffc
FROM ${OPENORBIS_IMAGE} AS openorbis
FROM ubuntu:20.04 AS pkgcompat
# The official release contains the same OpenOrbis files as the former pinned
# toolchain image. Fetch it directly so this build needs no Docker Hub pulls.
FROM control4free-build:latest AS openorbis
ARG OPENORBIS_VERSION=v0.5.3
ARG OPENORBIS_ARCHIVE=toolchain-llvm-18.2.zip
ARG OPENORBIS_SHA256=5006bb8629a40093b712fac971456fa4d5ad3fafaadb16d11c10b8a93b8b496f
RUN curl -fsSL --retry 3 -o /tmp/openorbis.zip \
"https://github.com/OpenOrbis/OpenOrbis-PS4-Toolchain/releases/download/${OPENORBIS_VERSION}/${OPENORBIS_ARCHIVE}" \
&& echo "${OPENORBIS_SHA256} /tmp/openorbis.zip" | sha256sum -c - \
&& mkdir /tmp/openorbis \
&& unzip -q /tmp/openorbis.zip -d /tmp/openorbis \
&& tar -xzf /tmp/openorbis/toolchain-llvm-18.tar.gz -C /opt \
&& test -f /opt/OpenOrbis/PS4Toolchain/lib/crt1.o \
&& rm -rf /tmp/openorbis /tmp/openorbis.zip
# Docker's official Ubuntu mirror, pinned to the same image as Docker Hub.
FROM public.ecr.aws/docker/library/ubuntu:20.04@sha256:8feb4d8ca5354def3d8fce243717141ce31e2c428701f6682bd2fafe15388214 AS pkgcompat
RUN apt-get update && apt-get install -y --no-install-recommends libssl1.1 \
&& rm -rf /var/lib/apt/lists/*
FROM control4free-build:latest
+156
View File
@@ -0,0 +1,156 @@
# 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.
An experimentally linked DualShock 4 or DualSense sends its own buttons, sticks and analog trigger
pressure directly, so its mapping is bypassed while linked. Its touchpad also sends up to two fingers.
Use **Unlink**, reconnecting the controller if needed, to return to browser input and the saved mapping. See
[Experimental features](playing.md#experimental-features).
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.
![DS4 Mapping with an emulated example gamepad](images/mapping.png)
## 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.
![Gamepad Controls showing the emulated gamepad's raw inputs](images/mapping-inputs.png)
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.
+72
View File
@@ -0,0 +1,72 @@
# HDMI-CEC navigation
Use your TV remote to start and stop Control4Free, manage auto-start, and navigate the PS4 app.
The normal and diagnostic packages include an **Actions** menu for remotes.
## Set up
Enable **Settings → System → Enable HDMI Device Link** on the PS4 and HDMI-CEC on the TV.
The setting's name depends on the TV model. First check that the
remote's arrows and OK can navigate the PS4 home screen. TV and receiver/switch compatibility
varies; see [Sony's HDMI Device Link guide](https://manuals.playstation.net/document/en/ps4/settings/devicelink.html).
To start the service directly from the app, enable **GoldHEN → Server Settings → Enable
PayLoader Server**. If the app says PayLoader did not answer, enable it and retry Start.
The app cannot enable this GoldHEN setting automatically. AutoRun can start the service
after a restart without the server; see [Installation](installation.md).
## Using the remote
1. Press **OK** to open **Actions**. A Menu key can also open it if exposed by the PS4.
2. Use the arrows to choose an action, then press **OK** again. The menu includes setup/start,
auto-start, **Test controllers**, Stop and Close when those actions are available.
3. **Stop** opens a separate confirmation with **Cancel** selected. Choose **Stop**, release
OK, then press it again to confirm. Stopping disconnects every virtual controller.
4. **Back**, when the PS4 exposes it, returns from the menu or cancels confirmation. **Resume**
also returns without needing a Back button. **Close app** leaves the service running.
Existing DualShock 4 shortcuts work outside the menu. Its D-pad can also open and navigate
Actions in the normal app. Service operations finish before another operation or Close is
available. Release held buttons after opening the app, reconnecting or returning to it.
## Controller test
Choose **Actions → Test controllers**. The live picture and values show input received for the selected
PS4 user. Press **OK** for the controller-tools grid. Arrows move between its icons and wrap at the
edges; **OK** runs the selected tool. **Switch user** cycles through signed-in users. Tools include
rumble, light-bar colors and orientation reset; unavailable ones are dimmed and explain why.
**Resume** or **Back** returns to live input. **Leave test** returns to the launcher; Back from the live
test does the same. **Controller page RTT** shows the connection reading for that user's virtual
controllers when available. See [Testing controllers](playing.md#testing-controllers-on-the-ps4).
## Diagnostic app
**Left/Right** change pages and **Up/Down** scroll while the menu is
closed. **OK** opens Actions, which also includes **Capture kernel log**. Menu arrows move
the selection without changing the page underneath it. The Summary page reports DS4 and
TV-remote input status; a ready source shows its navigation button mask, and a failed source
shows the native error code. It also retains the last active remote sample's key code and raw
button mask, so a short press remains visible after you release it. These values describe
the PS4's input data; they are not HDMI-CEC wire command numbers.
If a remote fails inside the app, press and release the affected button, then photograph
Summary or save its report. See [Reporting a problem](troubleshooting.md#reporting-a-problem).
## Confirmed setup and limits
| TV | Remote | PS4 firmware | GoldHEN | Confirmed behavior |
|---|---|---|---|---|
| Samsung UN55NU7090P | AA59-00818A (TM1240) | 11.02 | v2.4b18.12 | OK, arrows, RETURN/Back and service startup with PayLoader Server enabled |
This setup was [confirmed by NotDev-Kike](https://github.com/MoHadiShibli/Control4Free/issues/3#issuecomment-6103983733)
with no physical DS4 connected. Other TV/remote models, simultaneous DS4 use, HDMI input
switching and rest/wake behavior still need hardware reports. Held-button handling, recovery
and Stop confirmation have automated tests; their hardware behavior was not separately reported.
The app reads the PS4's remote-control stream independently of DS4 input. TV keys control
the app only. Media/color keys and TV power/volume control are outside this implementation.
For another setup, report the TV and remote models, PS4 firmware, GoldHEN version, any
receiver/switch between TV and console, and which buttons work. Diagnostic screenshots help
identify inputs the PS4 exposes.
+186 -16
View File
@@ -2,9 +2,9 @@
```
phone / PC browser PS4
┌──────────────────┐ WebSocket ┌───────────────────────────────────────────────┐
┌──────────────────┐ WebRTC ┌───────────────────────────────────────────────┐
│ controller page │ ────────────► │ Control4Free service (GoldHEN payload, :4264) │
│ touch · keys · │ JSON, LAN │ ├─ scePadVirtualDevice* ─► virtual DS4 ×4 │
│ touch · keys · │ JSON, UDP │ ├─ scePadVirtualDevice* ─► virtual DS4 ×4 │
│ gamepads │ ◄──────────── │ └─ /dev/klog (only while one signs in) │
└──────────────────┘ status │ │
│ Control4Free app (home screen) │
@@ -16,8 +16,123 @@
GoldHEN loads plugins into game processes only. The home screen and the sign-in screen belong to the system's
own processes, which never load plugins, so a plugin can't reach them. Control4Free is a payload instead, and
uses Sony's virtual-device API: the one Remote Play goes through. A device made with it is a real MBus device,
and the whole system sees it like a DualShock 4 plugged in over USB.
uses Sony's virtual-device API: the one Remote Play goes through. A device made with it is an MBus device
that the system reads as a virtual DualShock 4, including on the home and sign-in screens. The individual
controller values and Control4Free's access to them are listed below.
## Virtual DS4 controls and values
These tables describe the virtual-controller values Control4Free exposes. Input goes into the
[`ScePadData` sample](../include/c4f_sce.h) passed to `scePadVirtualDeviceInsertData`; motor and light-bar
feedback comes back through `scePadVirtualDeviceGetRemoteSetting`.
The ranges below are accepted by Control4Free's [input parser](../src/web.c). Names such as `i.lx`,
`m.ax` and `v.large` identify positions in message arrays, not JSON object properties. See
[Protocol](protocol.md) for the exact array order.
### Buttons and analog controls
`0 / 1` means released / pressed. A pressed button sets its bit in `i.buttons`; several bits can be set
together. Sticks use `128` as neutral in Control4Free; low X is left and low Y is up.
| Individual value | Values | `ScePadData.buttons` bit, sent in `i.buttons` |
|---|---|---|
| D-pad Up | 0 / 1 | `0x0010` |
| D-pad Right | 0 / 1 | `0x0020` |
| D-pad Down | 0 / 1 | `0x0040` |
| D-pad Left | 0 / 1 | `0x0080` |
| Square | 0 / 1 | `0x8000` |
| Cross | 0 / 1 | `0x4000` |
| Circle | 0 / 1 | `0x2000` |
| Triangle | 0 / 1 | `0x1000` |
| L1 | 0 / 1 | `0x0400` |
| R1 | 0 / 1 | `0x0800` |
| L2 digital press | 0 / 1 | `0x0100` |
| R2 digital press | 0 / 1 | `0x0200` |
| L3, left-stick click | 0 / 1 | `0x0002` |
| R3, right-stick click | 0 / 1 | `0x0004` |
| Share | 0 / 1 | `0x0001` |
| Options | 0 / 1 | `0x0008` |
| PS | 0 / 1 | `0x10000` |
| Touchpad click | 0 / 1 | `0x100000` |
| Individual value | Accepted values | Native sample field / page field |
|---|---|---|
| Left stick X | 0–255 | `lx` / `i.lx` |
| Left stick Y | 0–255 | `ly` / `i.ly` |
| Right stick X | 0–255 | `rx` / `i.rx` |
| Right stick Y | 0–255 | `ry` / `i.ry` |
| L2 analog pressure | 0–255; 0 released, 255 fully pulled | `l2` / `i.l2` |
| R2 analog pressure | 0–255; 0 released, 255 fully pulled | `r2` / `i.r2` |
L2 and R2 each have a digital button bit and a separate analog-pressure field. A diagonal D-pad direction
sets two direction bits together.
### Touch and motion values
`touchData.fingers` selects how many contacts are active, up to two. Each contact has its own ID and
coordinates; touchpad click remains the separate button above.
| Individual value | Native sample field | Page field and accepted values |
|---|---|---|
| Active finger count | `touchData.fingers` | `i.fingers`: 0–2 |
| Finger 1 ID | `touchData.touch[0].finger` | First touch `id`: 0–127 |
| Finger 1 X | `touchData.touch[0].x` | First touch `x`: 0–1919 |
| Finger 1 Y | `touchData.touch[0].y` | First touch `y`: 0–941 |
| Finger 2 ID | `touchData.touch[1].finger` | Second touch `id`: 0–127 |
| Finger 2 X | `touchData.touch[1].x` | Second touch `x`: 0–1919 |
| Finger 2 Y | `touchData.touch[1].y` | Second touch `y`: 0–941 |
Motion fields in `ScePadData` are floating-point values. The service divides the message's quaternion
components by 10000 and its acceleration and angular-velocity components by 1000 before inserting them.
| Individual value | Native sample field and units | Page field and accepted values |
|---|---|---|
| Acceleration X | `accel[0]`, G | `m.ax`: −16000…16000 |
| Acceleration Y | `accel[1]`, G | `m.ay`: −16000…16000 |
| Acceleration Z | `accel[2]`, G | `m.az`: −16000…16000 |
| Angular velocity X | `gyro[0]`, rad/s | `m.gx`: −35000…35000 |
| Angular velocity Y | `gyro[1]`, rad/s | `m.gy`: −35000…35000 |
| Angular velocity Z | `gyro[2]`, rad/s | `m.gz`: −35000…35000 |
| Orientation quaternion X | `quat[0]` | `m.qx`: −10000…10000 |
| Orientation quaternion Y | `quat[1]` | `m.qy`: −10000…10000 |
| Orientation quaternion Z | `quat[2]` | `m.qz`: −10000…10000 |
| Orientation quaternion W | `quat[3]` | `m.qw`: −10000…10000 |
The neutral sample has orientation `[0, 0, 0, 1]` and zero acceleration and angular velocity.
### Motor and light-bar feedback
The service [decodes five feedback values](../src/feedback.c) requested by the PS4 game or system.
These travel from the PS4 to the page; the page protocol has no request for setting arbitrary feedback.
The native app's test tools can exercise the selected user's motors and light bar separately.
| Individual value | Decoded field and range | Page output |
|---|---|---|
| Large motor | `C4fPadFeedback.large`: 0–255; 0 stops it | `v.large`: 0–255 |
| Small motor | `C4fPadFeedback.small`: 0–255; 0 stops it | `v.small`: 0–255 |
| Light-bar red | `C4fPadFeedback.r`: 0–255 | Status `color[0]` |
| Light-bar green | `C4fPadFeedback.g`: 0–255 | Status `color[1]` |
| Light-bar blue | `C4fPadFeedback.b`: 0–255 | Status `color[2]` |
The `v` message preserves both 0–255 strengths. For a browser gamepad, the page divides each by 255
to use the [browser API's 0–1 scale](https://w3c.github.io/gamepad/); for example, 128 becomes about 0.502.
Linked Sony controllers receive the original strength bytes directly.
Red, green and blue are independent colour channels; for example, `[255, 255, 0]` combines red and green
to make yellow. The native tester's RGB selector adjusts each channel independently from 0 to 255.
The status colour preserves the RGB ratio but brightens its strongest channel to 255 for display.
When the feedback colour is all zero, the page keeps that controller's default colour.
### Other sample fields and current limits
The [native insertion code](../src/vda.c) sets `connected` and generates `timestamp` and `count`.
These are sample bookkeeping, not page controls. Extension, padding and unknown fields are zero-filled;
Control4Free assigns no controls to them.
Battery/charging status and headset detection are not fields in the virtual pad sample used here.
Control4Free also creates no audio device for speaker/headset playback or microphone capture, so
connecting a headset to a linked physical controller does not provide PS4 audio through Control4Free.
## Where it runs
@@ -32,13 +147,27 @@ GoldHEN's PayLoader doesn't start a process of its own: it runs the ELF inside a
payload, and calling into `libScePad` first can kill the process;
4. calls `scePadInit()`, then `scePadSetProcessPrivilege(1)`, in that order: the other way round the privilege
call fails with *not initialised*;
5. serves the page and the WebSocket on port 4264 until it's stopped.
5. serves the page and the WebRTC handshake on TCP port 4264, and handles the resulting controller
connections on UDP port 4264 until it's stopped.
## Browser connection
The page creates two negotiated WebRTC data channels and posts its SDP offer to `POST /rtc`; the service
returns an SDP answer over HTTP. ICE checks establish the local UDP path, and DTLS encrypts the SCTP
connection. Channel 0 carries reliable, ordered controller selection, status and errors. Channel 1 carries
unordered input without retransmissions, rumble and link probes. Both channels share the connection's
congestion limits. The page reconnects with a new HTTP offer when the connection fails.
The service advertises its local address only, with no relay or internet service. Both TCP and UDP port
4264 must be reachable. The PS4 app still uses HTTP for its local status and stop requests. See
[Protocol](protocol.md) for the handshake and messages, and [Security](../SECURITY.md) for the limits of an
HTTP page with encrypted WebRTC channels.
## Creating a controller
`scePadVirtualDeviceAddDevice` returns a status, not a handle; on this console it returns `0x803b0006` even
when the device is created. The handle is the device's MBus *DeviceId*, and the only place a payload can learn
it is the kernel log, where the login manager announces every new device:
when the device is created. The service obtains the handle, the device's MBus *DeviceId*, from the kernel
log, where the login manager announces every new device:
```
#LOGIN MGR# Receive Event : SCE_MBUS_EVENT_DEVICE_ADDED [DeviceId:0x7030d][type:1][subType:2]
@@ -54,7 +183,13 @@ controller?"*. The service never presses anything itself: the choice is made on
the login manager logs `DEVICE_OWNER_CHANGED [DeviceId:…][UserId:…]`, which is how the page learns the
controller is signed in.
None of this blocks. Creating a controller is a small state machine the main loop advances: drain the log's
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.
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.
@@ -71,24 +206,46 @@ The page sends the whole controller state as a compact array (see [protocol.md](
turns it into a `ScePadData` sample and hands it to `scePadVirtualDeviceInsertData`:
- as soon as it arrives, unless a report went out in the last 4 ms;
- every 4 ms while input changed in the last half second, as fast as a real DualShock 4 reports;
- every 4 ms while input changed in the last half second;
- every 16 ms otherwise, to keep the controller alive;
- each sample carries a rising timestamp and counter, as a real pad's do;
- 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.
- a bounded queue holds received button transitions between reports, while unchanged-button samples
coalesce to the latest stick 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.
Input includes a per-controller sequence number and up to four recent button changes. The service ignores
state older than the newest it applied and can recover missed button changes still present in that history.
Motion has its own sequence. Finite history cannot recover every press during a long loss burst, and
sequence numbers do not establish the age of packets held up by the network. A radio outage still delays
input, and recovered events cannot restore their original timing in a running game.
When a connection closes, its buttons are released at once. With no new controller-state input for 3 seconds
the controller goes neutral, and after 15 it's removed. A page reconnects automatically and tries to reclaim
controllers the service still retains, still signed in. Controllers removed after a timeout or PS4 logout
require a new selection.
Gamepad mapping happens on the page, before anything is sent. Each gamepad's inputs go through its profile's
bindings and calibration. Standard gamepads have a default layout; other devices can be mapped in the
editor, with profiles stored in the browser and imported or exported as JSON. 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. Linked Sony HID input keeps its analog trigger pressures
separate from its digital trigger flags. See
[Gamepad mapping](gamepad-mapping.md).
WebRTC connectivity checks and the page's probes keep the connection active. A peer from which nothing
arrives for 10 seconds is closed, so a device that left the network cannot hold a connection indefinitely.
At most eight WebRTC peers are connected at once; HTTP requests use separate bounded connection slots.
## 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,
`scePadVirtualDeviceGetRemoteSetting` reads back the two motor strengths and light-bar red, green and blue
values decoded by Control4Free. 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;
playing as that controller. Active rumble is repeated periodically to repair a missed message. Someone
who takes a controller over is told its current state at once. The page stops rumble after 650 ms without
a renewal, so a lost stop message cannot leave it running indefinitely;
- 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.
@@ -112,6 +269,19 @@ call (syscall 500, command 2), because the sandbox refuses connections to the co
`[AutoRun]` section of `/data/GoldHEN/payloads.ini`, written to a temporary file and renamed over the
original so a power cut can't leave it half written.
The native controller test reads signed-in users' controllers through `scePadReadState`. It samples input
on a separate worker with a 4 ms target interval, while the display requests 60 Hz. Static controller
artwork and the tools grid are cached; drawing does not block the input worker. The grid offers user
switching, orientation reset, motor tests and light-bar tests. Those output actions use the native pad
APIs for the selected user's controller.
The test's **Controller page RTT** line comes from the browser connection, independently of these local
pad reads. Every two seconds the page times an echoed input-channel probe and sends the result back to
the service. The app reads it through `/api/status`, matches virtual-controller slots to the selected
PS4 user and labels them C1 to C4. Reports expire after six seconds, and unavailable data is shown without
a number. RTT measures the browser-to-service round trip, including scheduling on that path; it does not
measure physical-controller input delay or the TV's display delay.
## Security
Being open on the home network is deliberate. What that does and doesn't protect is in
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 537 KiB

After

Width:  |  Height:  |  Size: 642 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 640 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 840 KiB

After

Width:  |  Height:  |  Size: 859 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 672 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 587 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 967 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 895 KiB

+64 -13
View File
@@ -6,14 +6,40 @@ Control4Free comes in two pieces:
controllers, and serves the controller page on port 4264.
- **The app** (`Control4Free-<version>.pkg`), which goes on the PS4's home screen. It starts and stops the
service, shows the address and a QR code, and sets up auto-start. The package carries its own copy of the
service, so the package is all you need.
service, so the package is all you need. **Test controllers** shows received input, motion and the
controller-page connection RTT, with tools for rumble, light bar and orientation.
## 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.
- **PayLoader Server** enabled for starting the service directly from the app:
**GoldHEN → Server Settings → Enable PayLoader Server**. AutoRun and Payloader LaunchPad can start the
service without this server.
- A phone, tablet or computer with a modern browser supporting WebRTC data channels, on the same network
as the PS4. Both TCP and UDP port 4264 must be reachable between them.
## Compatibility
**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) |
| 11.02 | v2.4b18.10 or later | **Tested** (v2.4b18.12) |
| 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, other 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
@@ -29,37 +55,56 @@ The app appears in **Library → Applications**.
## First start
Open **Control4Free** and press **Cross**. The app:
In GoldHEN, open **Server Settings** and check **Enable PayLoader Server**. Then 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, turn PayLoader on in GoldHEN's
settings and press **Cross** again. AutoRun also starts Control4Free each time GoldHEN loads.
3. starts it straight away through GoldHEN's PayLoader Server. If the server is unavailable, auto-start
stays enabled; enable PayLoader Server and retry, or let AutoRun start Control4Free the next 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. |
| **Options** | Opens **Test controllers** in the normal app. Hold **L3 + R3** for two seconds as another way in. |
With a TV remote over HDMI-CEC, press **OK** to open **Actions**, use the arrows to choose,
and press **OK** again. **Back** returns. See [HDMI-CEC navigation](hdmi-cec.md) for setup
and compatibility.
## Updating
1. Install the new package over the old one. Delete the old app first if the installer refuses.
2. Open Control4Free. It says the auto-start copy is older than the one in the app: press **Triangle** to
update it.
You don't need to stop Control4Free first: deleting or replacing the app leaves the running service and
its auto-start copy alone.
2. Fully close the old app if it was left open, then open Control4Free. If it says the auto-start copy
differs from 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.
Reload the controller page after the new service starts. If you kept a saved copy, load the page from
the updated service and save a fresh copy so it includes the current connection and controller features.
## Without the app
@@ -86,11 +131,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. |
+114 -10
View File
@@ -11,6 +11,14 @@
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.
After a brief network interruption, leave the page open: it reconnects and tries to reclaim its
controllers while the service still retains them. If they have been removed or another device owns them,
pick a free controller again. Rest mode signs users out, so it also requires a new selection.
The home screen shows all four controllers live:
| State | Meaning |
@@ -19,7 +27,7 @@ The home screen shows all four controllers live:
| **Connecting** | The PS4 is setting it up. Takes a moment. |
| **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. |
| **Paused** | Its phone or PC stopped sending input, for example because the screen locked. |
| **This device** / **+1 device** | Who's driving it: this browser, or other ones. |
## Inviting someone
@@ -46,8 +54,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 |
|---|---|---|---|
@@ -65,20 +74,36 @@ 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.
Without a direct link, a DualShock 4 or DualSense provides its touchpad click through the browser's
gamepad input. Link it under [Experimental features](#experimental-features) to send the positions of
up to two fingers as well.
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
way.
After updating Control4Free, load the page from the updated service before saving it again. A saved page
keeps its old connection code until you replace it. If the browser asks for
local-network access, allow it for the controller page so it can reach your PS4.
## 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. **Experimental** has
extras that stay off until you turn them on: see [Experimental features](#experimental-features).
| Setting | What it does |
|---|---|
@@ -89,8 +114,8 @@ way.
| **Double-tap a stick for L3 / R3** | Off: L3 and R3 get their own buttons. |
| **Show keyboard keys** | Labels the buttons with their keyboard keys. |
The bottom of the home screen shows the version, and the round trip to the console in milliseconds: a quick
way to see whether your Wi-Fi is the slow part.
The bottom of the home screen shows the version and the round trip to the console in milliseconds. It
helps spot network delays, but it does not measure the full delay from a button press to a game's response.
## On your phone's home screen
@@ -115,17 +140,96 @@ When a game rumbles the controller, the device driving it feels it:
- **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.
A gamepad's own light bar stays as it is, unless you link a DualShock 4 or DualSense under
[Experimental features](#experimental-features).
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).
## Experimental features
**☰ → All settings → Experimental** has three switches. Each stays off until you turn it on, and is saved in
this browser only. With all three off, the page works exactly as it does without them.
![Experimental settings in Chrome](images/experimental.png)
| Switch | What it does | Where it works |
|---|---|---|
| **Lights and rumble on Sony controllers** | Link a DualShock 4 or DualSense for its light bar, direct rumble and up to two touchpad fingers. While linked, its buttons, sticks and analog triggers come directly from the controller; its **Mapping** is bypassed. | Chrome or Edge on a computer |
| **Trigger rumble** | Adds an effect to the trigger you're pulling, derived from the game's normal rumble. See [Trigger rumble](#trigger-rumble) below for setup and behavior. | Xbox controllers with browser trigger-rumble support; a linked DualSense |
| **Motion controls** | Tilting the phone, or a linked DualShock 4 or DualSense, moves the PS4 controller's motion sensors. Hold the phone sideways with the screen towards you, like a controller. **Recenter** makes the way you hold it now the resting position. | Chrome on Android; a linked controller on a computer |
For **Lights and rumble** or **Motion controls**, first follow the short
[Chrome/Edge flag setup](troubleshooting.md#experimental-features). Then turn the switch on. For a Sony
controller, press **Link**, select it in the browser's device list, and allow access. For phone motion,
allow the sensor prompt if shown and press **Recenter** while holding the phone still. A linked Sony
controller can provide motion too.
### Trigger rumble
Turn on **Trigger rumble** and assign the gamepad to the controller you are playing as. An Xbox
controller uses the browser's trigger-rumble support; it does not need the Sony **Link** button.
For a DualSense, also turn on **Lights and rumble on Sony controllers** and **Link** it.
The effect follows normal rumble from the PS4 game and the physical trigger you are pulling:
- Pull only L2 or R2 and that trigger receives the effect. With both held, the one pulled last receives
it. Release that trigger while keeping the other pulled and the effect moves to the other one.
- With neither trigger pulled, neither receives an effect. The game must be rumbling for there to be
anything to feel.
- Stronger game rumble produces stronger trigger vibration. A linked DualSense pulses more slowly for
heavy rumble and faster for light rumble.
- Selection follows the gamepad's physical triggers, before any mapping. Remapping an unrelated button
to L2 does not make that button select a trigger effect.
On a DualSense this uses a vibration effect in its adaptive triggers. Control4Free generates it from
ordinary PS4 rumble; it does not reproduce a game's authored adaptive resistance or PS5 haptics.
It changes the feedback you feel, not the analog L2/R2 pressure sent to the game. A DualShock 4 keeps
its normal rumble and has no separate trigger effect.
For a quick check, use a game that rumbles while you pull a trigger, or select the same user in the PS4
app's **Test controllers** and run **Large motor** or **Small motor** while holding a trigger.
## Testing controllers on the PS4
The app on the PS4 shows what each signed-in user's controller sends, read the way a game reads it. Press
**Options** in the app, or choose **Actions → Test controllers** with the TV remote.
- The picture lights up each button as it's pressed, moves the sticks, fills L2 and R2 as far as they're
pulled, and shows each finger on the touchpad. The panel beside it has the numbers, motion included.
- **PS** and **Share** go to the PS4 itself, so the test doesn't show them.
- Hold **L3 + R3** for two seconds for its menu: choose another signed-in user, rumble the large or the small
motor, choose a light-bar colour, reset it, reset the orientation, or go back. The light bar
goes back to normal when you leave.
- The eight tools are arranged in a four-column, two-row grid. Move with the D-pad or TV remote arrows and choose with **Cross / OK**;
**Circle / Back** returns to the live readings. Arrows wrap at the grid edges, and holding a
controller's D-pad repeats navigation.
Choose **Switch user** to cycle through signed-in users. Unavailable tools are dimmed and explain why
they cannot run. Release the stick buttons that opened the menu before activating a tool.
- **Light bar** opens RGB sliders with a colour preview. **Up / Down** chooses red, green or blue;
**Left / Right** adjusts that channel from 0 to 255 and previews it on the selected controller.
**Cross / OK** switches between fast adjustments of 16 and precise adjustments of 1. **Circle / Back**
returns to the tools, keeping the test colour active. **Reset light** restores the PS4's normal colour;
leaving the tester or suspending the app also restores it.
![RGB light-bar selector on the PS4](images/tester-color.png)
- The tools view replaces the live picture while open. **Resume** returns to live buttons, sticks, touch
and motion. Controller sampling continues independently of the screen drawing.
- A controller from the page shows up under the user signed in on it, so the test also checks the page, a
gamepad or a keyboard from the PS4's side.
- **Controller page RTT** shows the connection round trip measured by that page, in milliseconds, in
both the live view and tools. Controller numbers identify each virtual controller for the selected
user. It needs the updated service and a freshly loaded or saved controller page. Missing readings
show as measuring or unavailable; old values expire. A local physical controller has no controller-page
connection to measure. RTT does not include the full controller, game and TV delay.
## 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.
- **Closing the page or locking the phone** normally releases its buttons. After 3 seconds without input the
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.
+151 -27
View File
@@ -1,7 +1,11 @@
# Protocol
The service listens on TCP port 4264 and speaks plain HTTP/1.1 and WebSocket (RFC 6455). This page is for
anyone writing their own client or checking what the page does.
The service uses TCP port 4264 for plain HTTP/1.1 and UDP port 4264 for WebRTC data channels. This page is
for anyone writing their own client or checking what the controller page does. Replies include
`protocol: 3`, an internal message-compatibility integer checked by the controller page, separate from
the application version and the WebRTC transport. The launcher HTTP API uses `api: 1`.
For the native input fields and feedback values Control4Free exposes, see
[Virtual DS4 controls and values](how-it-works.md#virtual-ds4-controls-and-values).
## HTTP
@@ -10,19 +14,50 @@ anyone writing their own client or checking what the page does.
| `GET /`, `GET /index.html`, `GET /?…` | The controller page, gzip-compressed, with a Content-Security-Policy. |
| `GET /manifest.webmanifest` | The web app manifest, for a home-screen shortcut. |
| `GET /icon-192.png` | The app icon. |
| `GET /ws` with `Upgrade: websocket` | The control socket, below. |
| `POST /rtc` | A WebRTC SDP answer to the offer in the request body, as `text/plain`. |
| `OPTIONS /rtc` | A saved-page preflight, below. |
| `GET /api/status`, `POST /api/stop` | The launcher API, below. |
| Anything else | `404`. |
Every request needs a `Host` header that is a literal IPv4 address or `localhost`, with or without a port;
anything else gets `403`. This keeps out domain names that resolve to the console (DNS rebinding).
## The control socket
## Establishing a WebRTC connection
`GET /ws` upgrades to a WebSocket. The handshake is refused with `403` when it carries an `Origin` other than
`http://<the Host header>` or `null`; no `Origin` at all is accepted. Messages are UTF-8 JSON text frames of at
most 4096 bytes; a longer frame closes the connection. Fragmented messages and ping/pong control frames are
handled.
1. Create an `RTCPeerConnection` with no STUN or TURN servers. Create the two channels below with
`negotiated: true` before making the offer.
2. Create an SDP offer and set it as the local description. The ICE-lite service supplies its host
candidate in the answer, so the page does not need to wait for local candidate gathering to finish.
3. POST the local description's SDP to `http://<PS4-address>:4264/rtc` with `Content-Type: text/plain`.
This is raw SDP, not a JSON object. A `Content-Length` is required; `Transfer-Encoding` is refused.
Headers and body together must fit the 8 KiB HTTP receive buffer.
4. Set the returned text as the remote description with type `answer`, then wait for both channels to open.
| Channel | ID | Options | Messages |
|---|---|---|---|
| `control` | 0 | `negotiated: true`, `ordered: true`; no partial-reliability limit | Requests and replies for selection, status, errors and other control methods. |
| `input` | 1 | `negotiated: true`, `ordered: false`, `maxRetransmits: 0` | Controller state `i`, motion `m`, link probes `e`, optional `latency` telemetry, and rumble `v`. |
No DCEP channel-opening exchange is used. The service is ICE-lite and returns one IPv4 host candidate on
UDP port 4264. The browser performs ICE checks, DTLS and SCTP establishment. DTLS certificate fingerprints
use SHA-256; the service checks the browser's certificate against its offer. No media, STUN discovery
service or TURN relay is involved. See [SECURITY.md](../SECURITY.md) for what the unencrypted HTTP exchange
means for authentication.
`POST /rtc` is refused with `403` when its `Origin` is other than `http://<the Host header>` or `null`;
an absent `Origin` is accepted. For `Origin: null`, the answer includes `Access-Control-Allow-Origin: null`.
`OPTIONS /rtc` accepts only `Origin: null` and returns `204`, allowing `POST`, `Content-Type` and private-network
access. Other endpoints do not inherit this CORS permission. Browser local-network permission rules can
still apply to a saved page.
Malformed offers get `400`; an oversized request gets `413`; a full or unavailable service gets `503`.
At most eight WebRTC peers can exist at once. An unfinished setup expires after 20 seconds, and an established peer is
closed after 10 seconds without incoming traffic. The page creates a new HTTP offer when reconnecting.
Channel messages are UTF-8 JSON strings, with an application limit of 4096 bytes. Input is unordered and
not retransmitted by the data-channel sender; it is not guaranteed to arrive. Control is reliable and
ordered while the connection remains usable. They share one SCTP association and its congestion limits;
separate channel ordering does not remove network delay or packet loss.
### Requests
@@ -31,19 +66,24 @@ handled.
```
- `method`: a string.
- `params`: an array of up to 16 integers; required, even if empty.
- `params`: an array of up to 32 integers; required, even if empty.
- `id`: optional, an integer from 0 to 2^53−1. Requests with an `id` get a reply carrying the same `id`.
- `jsonrpc: "2.0"` is allowed and ignored. Any other key, or anything that isn't this shape, is answered with
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`.
Send methods on `control` unless their channel is identified below.
| Method | Params | Reply |
|---|---|---|
| `info` | `[]` | `{"version": "1.1.0", "protocol": 2, "pads": 4}` |
| `info` | `[]` | `{"version": "<version>", "protocol": 3, "pads": 4, "motion": true, "input": true, "latency": true}` |
| `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. |
| `i` | sequenced input, below, on `input` | None. |
| `m` | sequenced motion, below, on `input` | None. |
| `e` | `[sequence, clientTime]`, on `input` | The same parameters as an `e` message on `input`. |
| `latency` | `[probeSequence, rttMs]`, on `input`, when `info.latency` is true | None. Informational RTT for the PS4 tester. |
| `leave` | `[pad]` | A status object. |
| `stop` | `[]` | None; the service stops and the connection closes. |
| `ping` | `[]` | `{}` |
@@ -55,21 +95,35 @@ usually within a second; the service keeps serving every other connection in the
changing nothing, if any controller in it belongs to another connection (`409`), if another controller is
being created at that moment (`409`), or if controllers can't be created (`503`).
**`u`** sends the complete state of one controller this connection owns. Input for any other controller is
**`i`** sends the complete state of one controller this connection owns. Input for any other controller is
ignored: input never claims a controller.
```
[pad, buttons, lx, ly, rx, ry, l2, r2, fingers, (id, x, y) × fingers]
[pad, seq, buttons, lx, ly, rx, ry, l2, r2, fingers, (id, x, y) × fingers,
historyCount, (eventSeq, eventButtons) × historyCount]
```
| Field | Range |
|---|---|
| `seq` | a nonnegative sequence number that increases for each input message for this controller |
| `buttons` | a bit mask, below |
| `lx`, `ly`, `rx`, `ry` | 0 to 255, 128 is centred; up and left are 0 |
| `l2`, `r2` | 0 to 255 |
| `fingers` | 0 to 2 touches on the touchpad |
| touch `id` | 0 to 127, stable while the finger stays down |
| touch `x`, `y` | 0 to 1919, 0 to 941 |
| `historyCount` | 0 to 4 recent button changes |
| `eventSeq`, `eventButtons` | the input sequence and complete button mask at that change |
Use integers exactly representable in JavaScript, at most 2^53−1. Keep button history in increasing sequence
order; each entry must precede the current `seq`. Once an input sequence has been applied, messages at or
below it are ignored. On a later accepted message, history entries newer than the previous applied
sequence are queued before its current state. History is not replayed before the first accepted state.
History carries button masks, not the original timestamps or analog positions. Recovery uses the current
message's sticks, triggers and touches. It can repair some isolated losses but cannot guarantee every press,
preserve its original timing, or reconstruct an analog sequence through a long loss burst. Retain a
periodic complete-state message so a final release can be repaired even when nothing changes afterwards.
| Button | Bit | Button | Bit |
|---|---|---|---|
@@ -83,10 +137,51 @@ ignored: input never claims a controller.
| D-pad left | `0x0080` | Square | `0x8000` |
| PS | `0x10000` | Touchpad click | `0x100000` |
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
**`m`** sends the motion sensors of one controller this connection owns. Like `i`, it is ignored for any
other controller.
```
[pad, seq, qx, qy, qz, qw, ax, ay, az, gx, gy, gz]
```
| Field | Range |
|---|---|
| `seq` | an increasing nonnegative motion sequence, separate from `i` |
| `qx`, `qy`, `qz`, `qw` | orientation as a quaternion, times 10000: −10000 to 10000 |
| `ax`, `ay`, `az` | acceleration in thousandths of a G: −16000 to 16000 |
| `gx`, `gy`, `gz` | angular velocity in thousandths of a radian per second: −35000 to 35000 |
The axes are the ones the PS4 reads a DualShock 4 in: x to the right, y up out of its face, z towards the
player. A controller lying flat and still reads `+1` G on y. The values go into every sample from then on,
together with the buttons, sticks and touches of the last `i`. Before the first `m`, and whenever the
controller goes neutral, it reports no motion: orientation
`0, 0, 0, 1` and everything else 0. Late or duplicate motion sequences are ignored. Motion messages do not
renew the controller-state input lease: continue sending `i` while the controller is in use. A service
that understands `m` says
`"motion": true` in its `info` reply.
Send the state whenever it changes, and at least once a second while the controller is in use; the page sends
complete-state keepalives after one second without a change. After 3 seconds without controller-state 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 reconnects and tries to reclaim its retained controllers. PS4
logout or controller removal clears the selection instead. Input and motion sequence state is maintained
for the current owner; a different owner starts a new sequence context.
`e` probes are echoed over the input channel for round-trip measurements. The client time
is returned unchanged; it is not compared with a PS4 clock and does not impose an input freshness deadline.
The page sends a probe every two seconds while connected and calculates RTT using its own monotonic clock.
When `info.latency` is true, a client may send `latency: [probeSequence, rttMs]` after receiving an echo.
Use a nonnegative, increasing probe sequence no greater than 2^53−1 and an RTT from 0 to 60000 milliseconds.
The service accepts one report matching this connection's most recently echoed probe, within six seconds
of that echo; unmatched, repeated, expired or invalid reports are ignored. This optional diagnostic method
does not acknowledge controller input or renew its lease. The number is supplied by the client, not
independently measured or authenticated by the PS4.
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.
@@ -98,11 +193,11 @@ modules around it when you draw it.
```json
{
"version": "1.1.0",
"protocol": 2,
"version": "<version>",
"protocol": 3,
"pads": [
{"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}
"mine": true, "state": "ready", "uid": "1A2B3C4D", "color": [32, 96, 255], "reports": 5120, "error": 0}
]
}
```
@@ -122,11 +217,18 @@ modules around it when you draw it.
### Messages from the service
- `{"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.
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>}` on `control`: the status, whenever something changes and at least once a second.
- `{"method": "v", "params": [pad, large, small]}` on `input`: rumble, 0 to 255 for each motor, sent to the controller's
owner when it changes and when a connection takes the controller over. Nonzero rumble is repeated every
200 ms. `[pad, 0, 0]` stops it; messages can be lost on this channel. The page stops rumble if no renewal
arrives for 650 ms, including when a final stop message is lost.
- `{"method": "e", "params": [sequence, clientTime]}` on `input`: the reply to a link probe.
- `{"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:
@@ -146,11 +248,33 @@ Errors in reply to a request:
For the app on the PS4. Both requests need the header `X-Control4Free-Launcher: 1`, and are refused with `403`
if they carry an `Origin`, an `Upgrade`, a body or `Transfer-Encoding`. A browser only sends a custom header
cross-origin after a CORS preflight, which the service never answers, so websites can't use these.
cross-origin after a CORS preflight, which these API endpoints never answer, so websites can't use them.
`GET /api/status` returns:
```json
{
"application": "Control4Free", "api": 1, "version": "<version>",
"controllers": 1, "stopping": false, "latency": true,
"pad0User": 439041101, "pad0Rtt": 8, "pad0Age": 120,
"pad1User": -1, "pad1Rtt": -1, "pad1Age": -1,
"pad2User": -1, "pad2Rtt": -1, "pad2Age": -1,
"pad3User": -1, "pad3Rtt": -1, "pad3Age": -1
}
```
GET /api/status → {"application": "Control4Free", "api": 1, "version": "1.1.0", "controllers": 2, "stopping": false}
POST /api/stop → {"application": "Control4Free", "stopping": true}
```
The flat `padN` fields use zero-based slots. `padNUser` is the numeric PS4 user ID for an active,
signed-in controller with an owner and fresh input; otherwise it is `-1`. `padNRtt` is that owner's
latest accepted RTT, or `-1` when none is fresh. `padNAge` is the number of milliseconds since the service
echoed the measured probe, or `-1` with an unknown RTT. A page owning multiple controllers supplies one
connection RTT shared by those slots.
The service expires RTT measurements six seconds after their probe and requires a new probe after controller
neutralization or ownership changes. The launcher also accounts for the report's age and time since
its HTTP check. It shows every matching slot for the selected PS4 user: a fresh number, `measuring...`
without a fresh report, or no controller page when no slot matches. An unavailable service or one
without these optional fields produces `--`; it does not assign a network RTT to a local controller.
`POST /api/stop` returns `{"application": "Control4Free", "stopping": true}`.
`/api/stop` removes every controller, even ones in use, and the service exits a quarter of a second later.
+139 -21
View File
@@ -7,8 +7,10 @@ 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
@@ -46,6 +48,19 @@ see [Updating](installation.md#updating).
- If the address worked before and doesn't now, the router may have given the PS4 a new one. Reserving an
address for the PS4 in the router's settings keeps it the same.
## The page opens but cannot connect
The page loads over HTTP, then opens its controller connection over WebRTC. Loading successfully does not
prove that the controller connection can get through.
- Reload the page from the PS4. After an update, replace saved copies so they use the current connection code.
- Use a browser with WebRTC data channels enabled. If the browser asks for local-network access, allow
it for the controller page.
- Both TCP and UDP port 4264 must be reachable between the phone or PC and the PS4. Guest networks,
client isolation, VPNs or firewall rules can allow page loading while blocking the controller connection.
Keep these ports on your own network; internet port forwarding is not needed.
- Close unused controller pages if the service has reached its connection limit.
## Picking a user
**The *"Who's using this controller?"* screen doesn't appear.** Wait a few seconds after picking the
@@ -63,13 +78,25 @@ way.
**A game ignores the virtual controller.** Turn off other GoldHEN controller plugins for that game. A plugin
that takes over a signed-in user's controller can stop games from reading Control4Free's.
**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.
**Buttons feel slow, arrive late or stay held briefly.** The number next to the version at the bottom of
the home screen is the round trip to the console, not the full button-to-game delay. Keep the controller
page visible and the device awake. Note whether the problem happens continuously or in short bursts, the
devices and browser used, and whether motion or linked controllers were enabled when reporting it.
**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.
For bursts of lag, ghost inputs or brief held buttons, connect the **PS4 to the router by wired LAN**
first. The phone or PC can stay on Wi-Fi. This removes the PS4's wireless link from the path and is a
useful first check even when the usual reading is only a few milliseconds. Compare the same game and
devices before changing other settings.
If bursts remain with the PS4 wired, check the phone or PC's Wi-Fi signal and compare another device or
browser. Report whether the RTT number spikes at the same time and whether the issue also occurs with
Control4Free stopped; that helps separate connection trouble from the controller or game.
**No vibration from the game.** For an Android phone, turn on **Vibration** in the settings; Safari on
iPhone cannot vibrate the phone. For a gamepad, check its assigned controller and keep the page in front.
A gamepad marked *no rumble in this browser* needs another supported browser or, for a Sony controller,
the experimental direct link. The phone's **Vibration** switch does not control gamepad rumble.
Not every game rumbles every player.
## Gamepads
@@ -77,16 +104,59 @@ front. And not every game rumbles every player.
enter the console's address in its connection box. See [Gamepads](playing.md#gamepads).
**The buttons are mixed up.** The page says *"buttons may be mixed up"* when the browser doesn't recognise the
gamepad's layout. Try another browser, or connect the gamepad another way (cable instead of Bluetooth, or the
other way round).
gamepad's layout. Press **Mapping** on its row and change each control that's wrong: see
[Gamepad mapping](gamepad-mapping.md). Another browser, or connecting the gamepad another way (cable instead
of Bluetooth, or the other way round), can also help.
**A gamepad button does nothing after changing its mapping.** Let go of it and press it again: a button held
down while a gamepad starts playing waits until it's released.
**The 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**.
## In the app on the PS4
**"PayLoader did not answer."** Turn on GoldHEN's PayLoader and press **Cross** again, or restart the PS4 and
run the jailbreak: with auto-start on, Control4Free starts by itself.
**"PayLoader did not answer."** Open **GoldHEN → Server Settings → Enable PayLoader Server**, check the
option and retry **Start** in the app, or restart the PS4 and 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.
@@ -104,20 +174,62 @@ the app and open it again; if that doesn't help, restart the PS4 and run the jai
**"The bundled payload is damaged."** Reinstall the package.
**Controller page RTT is measuring or unavailable.** Update and start the service bundled with the app,
then reload the controller page from the PS4; replace any saved copy too. Select the user signed in on
that page's controller. A local physical controller has no page connection to measure. The reading
expires when reports stop; it measures the page's connection round trip, not the full game or TV delay.
**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.
## Experimental features
**A switch is greyed out: "This browser treats the page as insecure".** Browsers give motion sensors and
direct controller access only to secure pages, and the PS4 serves the page as plain `http`. Chrome and Edge
can treat your PS4's address as secure:
1. Open `chrome://flags/#unsafely-treat-insecure-origin-as-secure`, or in Edge
`edge://flags/#unsafely-treat-insecure-origin-as-secure`. On Android, open the same address in Chrome.
2. Type the page's address exactly as the app on the PS4 shows it, for example `http://192.168.1.20:4264`,
and set the option to **Enabled**.
3. Press **Relaunch**.
The setting belongs to that browser on that device. If the PS4's address changes, update it here too.
Add only your PS4's address: the flag allows those browser features but does not encrypt the HTTP page.
Safari on an iPhone has no equivalent flag.
**"This browser can't talk to controllers directly."** Linking a DualShock 4 or DualSense needs Chrome or
Edge on a computer. Android, Firefox and Safari don't offer it.
**The controller isn't in the browser's list, or linking fails.** Close other programs that use it, such as
Steam or DS4Windows, reconnect it, press a button on it, then press **Link** again.
**Motion feels off.** Hold the phone sideways with the screen towards you, keep it still, and press
**Recenter** in **Settings → Experimental**.
**A linked DS4 or DualSense only clicks its touchpad, without showing fingers.** Turn on **Lights and
rumble on Sony controllers**, press **Link** and select the matching controller. Once linked, up to two
fingers are sent. Use **Test controllers** on the PS4 to check the received touch positions.
**Trigger rumble does nothing.** Turn on **Trigger rumble**, assign the gamepad to the controller being
used, and pull a trigger while the game is rumbling. Xbox controllers need a browser that exposes
trigger-rumble support. A DualSense also needs **Lights and rumble on Sony controllers** enabled and a
direct **Link**. A DS4 has no separate trigger effect. With both triggers held, only the one pulled last
receives the effect; release it to move the effect to the other. See
[Trigger rumble](playing.md#trigger-rumble) for what the effect does and a quick test.
## Reporting a problem
Every release comes with a diagnostic package, `Control4Free-<version>-diag.pkg`. It's the same Control4Free,
Every release comes with a diagnostic package, `troubleshooting-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:
1. Download `Control4Free-<version>-diag.pkg` from the
1. Download `troubleshooting-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 doesn't say RUNNING, press **Cross** and wait until the top stops saying "WORKING" (up to
30 seconds).
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
@@ -126,11 +238,17 @@ log files. Please use it before you open an issue:
5. [Open an issue](https://github.com/MoHadiShibli/Control4Free/issues/new/choose) and attach the
screenshots.
The app also saves the same report as text: `control4free-diag.txt` on a USB stick that was plugged in, and
`/data/control4free/diag-report.txt` on the PS4. You can attach that instead of the screenshots. The report
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.
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, with the previous run in
`control4free.log.previous`, readable over GoldHEN's FTP server (port 2121). The diagnostic app shows both.
The service's own log is `/data/control4free/control4free.log` on the PS4, readable over GoldHEN's FTP
server (port 2121). 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.
+27
View File
@@ -0,0 +1,27 @@
#ifndef C4F_CONN_H
#define C4F_CONN_H
/* A page's connection, whichever way it arrived: a WebRTC peer (the product)
* or a WebSocket (host tests only). The service treats both the same way. */
#include <stddef.h>
#include <stdint.h>
typedef struct C4fConn C4fConn;
struct C4fConn {
/* Reliable and ordered while connected: replies, status, errors. 0, or -1 when it
* could not be queued (the connection is then closed). */
int (*text)(C4fConn *, const char *);
/* The newest state wins and a lost one is not resent: rumble. NULL means
* the connection has no such path and text is used. */
int (*input)(C4fConn *, const char *);
};
static inline int c4fConnText(C4fConn *c, const char *text) { return c->text(c, text); }
static inline int c4fConnInput(C4fConn *c, const char *text) { return (c->input ? c->input : c->text)(c, text); }
enum { C4F_NET_OPEN, C4F_NET_MESSAGE_EVENT, C4F_NET_INPUT_EVENT, C4F_NET_CLOSE,
C4F_NET_HTTP_STATUS, C4F_NET_HTTP_STOP };
typedef void (*C4fNetHandler)(C4fConn *, int event, const char *text, size_t len, void *context);
/* Milliseconds on a clock that only moves forward (net.c). */
uint64_t c4fTimeMs(void);
#endif
+61
View File
@@ -0,0 +1,61 @@
/* Mbed TLS 3.6 configuration for Control4Free (MBEDTLS_CONFIG_FILE).
*
* Just what a WebRTC data channel needs on the PS4 side: a DTLS 1.2 server
* with ECDHE-ECDSA and AES-128-GCM, the self-signed P-256 certificate it shows
* the browser, parsing the browser's certificate to check its fingerprint,
* and HMAC-SHA1 for STUN. No files, sockets, threads or PSA: the service
* feeds datagrams in and sends them out itself. */
#ifndef C4F_MBEDTLS_CONFIG_H
#define C4F_MBEDTLS_CONFIG_H
/* System */
#define MBEDTLS_HAVE_ASM
/* Features */
#define MBEDTLS_ECP_DP_SECP256R1_ENABLED
#define MBEDTLS_ECP_NIST_OPTIM
#define MBEDTLS_ECDSA_DETERMINISTIC
#define MBEDTLS_KEY_EXCHANGE_ECDHE_ECDSA_ENABLED
#define MBEDTLS_NO_PLATFORM_ENTROPY
#define MBEDTLS_SSL_EXTENDED_MASTER_SECRET
#define MBEDTLS_SSL_KEEP_PEER_CERTIFICATE
#define MBEDTLS_SSL_PROTO_TLS1_2
#define MBEDTLS_SSL_PROTO_DTLS
#define MBEDTLS_SSL_DTLS_ANTI_REPLAY
/* Modules */
#define MBEDTLS_AES_C
#define MBEDTLS_ASN1_PARSE_C
#define MBEDTLS_ASN1_WRITE_C
#define MBEDTLS_BIGNUM_C
#define MBEDTLS_CIPHER_C
#define MBEDTLS_CTR_DRBG_C
#define MBEDTLS_ECDH_C
#define MBEDTLS_ECDSA_C
#define MBEDTLS_ECP_C
#define MBEDTLS_ENTROPY_C
#define MBEDTLS_GCM_C
#define MBEDTLS_HMAC_DRBG_C
#define MBEDTLS_MD_C
#define MBEDTLS_OID_C
#define MBEDTLS_PK_C
#define MBEDTLS_PK_PARSE_C
#define MBEDTLS_PK_WRITE_C
#define MBEDTLS_SHA1_C
#define MBEDTLS_SHA224_C
#define MBEDTLS_SHA256_C
#define MBEDTLS_SSL_SRV_C
#define MBEDTLS_SSL_TLS_C
#define MBEDTLS_X509_USE_C
#define MBEDTLS_X509_CRT_PARSE_C
#define MBEDTLS_X509_CREATE_C
#define MBEDTLS_X509_CRT_WRITE_C
/* Sizes: SCTP over DTLS stays under 1200 bytes a datagram, and the largest
* handshake message is a certificate of a few hundred bytes. */
#define MBEDTLS_SSL_IN_CONTENT_LEN 4096
#define MBEDTLS_SSL_OUT_CONTENT_LEN 4096
#define MBEDTLS_SSL_CIPHERSUITES MBEDTLS_TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256
#define MBEDTLS_ENTROPY_MAX_SOURCES 2
#endif
+19 -7
View File
@@ -3,12 +3,22 @@
#include <stddef.h>
#include <stdint.h>
#include "c4f_conn.h"
#include "c4f_rtc.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;
C4fConn conn; /* first: a WebSocket is one of the service's connections */
int fd, websocket, closing, fragmented, savedOrigin;
uint64_t openedMs, pingAt, pongDeadline, pingToken;
unsigned char rx[8192], tx[8192];
size_t rxUsed, txUsed, txSent, messageUsed;
char message[C4F_NET_MESSAGE + 1];
@@ -20,25 +30,27 @@ typedef struct C4fNetClient {
#endif
} C4fNetClient;
enum { C4F_NET_OPEN, C4F_NET_MESSAGE_EVENT, C4F_NET_CLOSE,
C4F_NET_HTTP_STATUS, C4F_NET_HTTP_STOP };
typedef void (*C4fNetHandler)(C4fNetClient *, int, const char *, size_t, void *);
typedef struct {
int fd;
uint64_t checkedMs;
C4fNetClient clients[C4F_NET_CLIENTS];
C4fNetHandler handler;
void *context;
C4fRtc rtc; /* the pages' WebRTC peers, on the same port number over UDP */
} C4fNet;
uint64_t c4fTimeMs(void);
/* Opens the HTTP listener and the WebRTC port. A certificate made by an
* earlier open is kept. */
int c4fNetOpen(C4fNet *, int port, C4fNetHandler, void *);
/* -1: listener/select failure; -2: long pause inside select. Reopen transport. */
int c4fNetPoll(C4fNet *, int timeoutMs);
void c4fNetClose(C4fNet *);
#ifdef C4F_TEST_WEBSOCKET
int c4fNetText(C4fNetClient *, const char *);
void c4fNetHttpJson(C4fNetClient *, const char *);
void c4fWebSocketAccept(const char *key, char out[29]);
#endif
/* The JSON answer to a launcher request (a C4F_NET_HTTP_* event). */
void c4fNetHttpJson(C4fConn *, const char *);
extern const unsigned char c4fPage[];
extern const size_t c4fPageSize;
+54
View File
@@ -0,0 +1,54 @@
#ifndef C4F_RTC_H
#define C4F_RTC_H
/* WebRTC for the controller page: the PS4 end of a data-channel connection.
*
* The page posts its offer to POST /rtc and gets the answer. From then on
* everything goes over UDP: ICE-lite connectivity checks (STUN), DTLS 1.2, and
* SCTP with two negotiated channels, 0 for replies and status (reliable, in
* order) and 1 for input and rumble (newest wins, never resent). A lost packet
* on channel 1 is simply skipped, so it can't hold up the ones after it. */
#include <stddef.h>
#include <stdint.h>
#include "c4f_conn.h"
#define C4F_RTC_PEERS 8
#define C4F_RTC_SILENT_MS 10000 /* nothing from a peer for this long: gone */
#define C4F_RTC_SETUP_MS 20000 /* an offer that never got as far as SCTP */
typedef struct C4fRtcPeer C4fRtcPeer;
typedef struct C4fRtcCrypto C4fRtcCrypto;
typedef struct {
int fd; /* UDP, or -1 */
int port;
C4fRtcCrypto *crypto; /* certificate, DTLS settings, random numbers: made once */
char fingerprint[96]; /* our certificate's SHA-256, "AB:CD:...", for answers */
C4fRtcPeer *peers[C4F_RTC_PEERS];
C4fNetHandler handler;
void *context;
unsigned nextId;
unsigned writeCursor; /* rotate the first peer drained on UDP writability */
} C4fRtc;
/* Makes the certificate the first time, then opens the UDP port. 0 or -1. */
int c4fRtcOpen(C4fRtc *, int port, C4fNetHandler, void *context);
/* Closes every peer (those that were open get a close event) and the socket.
* The certificate is kept for the next open. */
void c4fRtcClose(C4fRtc *);
/* Permanent service shutdown: close peers/socket and release certificate state. */
void c4fRtcDestroy(C4fRtc *);
/* An offer from POST /rtc. `local` is the console's address the page reached,
* in network byte order: the only candidate the answer offers. Writes the
* answer and returns 0, or an HTTP status: 400 for an offer that isn't one,
* 503 when no peer slot is free. */
int c4fRtcOffer(C4fRtc *, const char *offer, size_t len, uint32_t local, char *answer, size_t size);
/* Reads every datagram waiting on the socket. */
void c4fRtcReadable(C4fRtc *);
/* Register UDP writability only while a nonblocking DTLS write is pending. */
int c4fRtcWantsWrite(const C4fRtc *);
void c4fRtcWritable(C4fRtc *);
/* DTLS and SCTP resends, silent and failed peers. */
void c4fRtcTick(C4fRtc *, uint64_t now);
/* Milliseconds until c4fRtcTick has work, at most `limit`. */
int c4fRtcWait(const C4fRtc *, uint64_t now, int limit);
#endif
+8 -3
View File
@@ -16,6 +16,11 @@
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)
@@ -77,9 +82,9 @@ typedef struct {
uint8_t rx, ry;
uint8_t l2, r2;
uint16_t padding;
float quat[4];
float vel[3];
float accel[3];
float quat[4]; /* orientation, x y z w */
float accel[3]; /* acceleration in G */
float gyro[3]; /* angular velocity in rad/s */
ScePadTouchData touchData;
uint8_t connected;
uint8_t align[3];
+91
View File
@@ -0,0 +1,91 @@
#ifndef C4F_SCTP_H
#define C4F_SCTP_H
/* A minimal SCTP association for WebRTC data channels, carried inside DTLS
* (RFC 9260, RFC 8261, RFC 8831). The browser opens it with INIT; the PS4 only
* answers. Two negotiated channels, so no DCEP: stream 0 is reliable and in
* order, stream 1 unordered and never resent (partial reliability, RFC 3758).
*
* No sockets and no clock of its own: packets go in through c4fSctpReceive,
* out through ops.send, and the caller passes the time. */
#include <stddef.h>
#include <stdint.h>
#define C4F_SCTP_MTU 1150 /* the biggest packet sent: DTLS adds ~40 bytes under WebRTC's 1200 */
#define C4F_SCTP_MAX_MESSAGE 16384 /* the biggest message either way (a=max-message-size) */
#define C4F_SCTP_PPID_STRING 51
#define C4F_SCTP_PPID_BINARY 53
#define C4F_SCTP_PPID_STRING_EMPTY 56
#define C4F_SCTP_PPID_BINARY_EMPTY 57
typedef struct {
void *user;
/* One SCTP packet to send (inside a DTLS record). The receiver copies or
* sends it before returning: 0 accepted, -1 fatal. */
int (*send)(void *user, const uint8_t *packet, size_t len);
/* One whole message. The association may be used (sent on) from here. */
void (*deliver)(void *user, uint16_t stream, uint32_t ppid, const uint8_t *data, size_t len);
/* Unpredictable bytes, for tags and the first TSN. */
void (*random)(void *user, void *out, size_t len);
} C4fSctpOps;
typedef struct C4fSctpOut C4fSctpOut; /* a sent chunk waiting for its acknowledgement */
typedef struct C4fSctpIn C4fSctpIn; /* a received chunk waiting for the rest of its message or its turn */
enum { C4F_SCTP_CLOSED, C4F_SCTP_COOKIE_WAIT, C4F_SCTP_ESTABLISHED, C4F_SCTP_ENDED };
typedef struct {
C4fSctpOps ops;
int state;
uint16_t localPort, remotePort;
uint32_t localTag, remoteTag;
uint32_t localInitialTsn, remoteInitialTsn;
uint8_t cookie[16];
/* Receiving */
uint32_t cumTsn; /* every TSN up to here has arrived */
uint32_t highTsn; /* the highest arrived */
uint8_t seen[128]; /* TSNs cumTsn+1 .. cumTsn+1024, one bit each, by TSN % 1024 */
uint16_t nextSsn[2]; /* the next message due on an ordered stream */
C4fSctpIn *in; /* waiting chunks, by TSN */
size_t inBytes;
int sackDue;
/* Sending */
uint32_t nextTsn;
uint16_t ssn[2];
C4fSctpOut *out, *outTail; /* in TSN order */
size_t outBytes;
unsigned outChunks;
uint32_t peerCumTsn; /* the browser has everything up to here */
uint32_t highestSent; /* unsent queued chunks cannot be acknowledged */
uint32_t forwardTsn; /* given up on (or acknowledged) up to here */
uint32_t peerRwnd, cwnd, ssthresh, partialBytesAcked;
uint32_t flightBytes, flightData, recoveryTsn;
int fastRecovery;
uint64_t rto, srtt, rttvar;
int rttKnown;
uint64_t t3; /* when the oldest unacknowledged chunk is due, 0 when nothing is */
uint64_t probeAt; /* a closed receive window is probed after one RTO */
uint64_t sendAt; /* continue a bounded burst on the next service turn */
unsigned timeouts;
uint32_t reconfigSeq;
/* For the log */
uint64_t packetsIn, packetsOut, resent, abandoned, duplicates, badChecksums;
} C4fSctp;
void c4fSctpInit(C4fSctp *, const C4fSctpOps *ops);
void c4fSctpFree(C4fSctp *);
/* One SCTP packet, as it came out of DTLS. */
void c4fSctpReceive(C4fSctp *, const uint8_t *packet, size_t len, uint64_t now);
/* Queues stream 0 reliably and in order, subject to congestion/receive windows.
* Stream 1 is a single datagram, sent once; -1 when blocked so the application
* can replace it with fresh state instead of buffering old rumble/input.
* 0 accepted, -1 when not established, too big, blocked or queue full. */
int c4fSctpSend(C4fSctp *, uint16_t stream, uint32_t ppid, const uint8_t *data, size_t len, uint64_t now);
/* Resends what is overdue and gives up on what may be lost. */
void c4fSctpTick(C4fSctp *, uint64_t now);
/* When c4fSctpTick next has work, or 0 for nothing pending. */
uint64_t c4fSctpDeadline(const C4fSctp *);
uint32_t c4fCrc32c(const uint8_t *data, size_t len);
#endif
+32
View File
@@ -0,0 +1,32 @@
#ifndef C4F_STUN_H
#define C4F_STUN_H
/* STUN for an ICE-lite WebRTC endpoint (RFC 8489, RFC 8445): the PS4 only
* answers the browser's connectivity checks. */
#include <stddef.h>
#include <stdint.h>
#define C4F_STUN_MAGIC 0x2112A442u
#define C4F_STUN_ANSWER_SIZE 64
/* RFC 7983: on the shared port, a first byte of 0-3 is STUN and 20-63 DTLS. */
static inline int c4fIsStun(const uint8_t *data, size_t len) { return len >= 20 && data[0] <= 3; }
static inline int c4fIsDtls(const uint8_t *data, size_t len) { return len >= 13 && data[0] >= 20 && data[0] <= 63; }
/* CRC-32 (IEEE 802.3), as STUN's FINGERPRINT uses it. */
uint32_t c4fCrc32(const uint8_t *data, size_t len);
/* The USERNAME of a well-formed Binding request ("ours:theirs"), or NULL. */
const uint8_t *c4fStunUsername(const uint8_t *msg, size_t len, size_t *size);
/* Whether the message's MESSAGE-INTEGRITY matches the key (ICE uses the
* password as is), and its FINGERPRINT, when it has one, is right. */
int c4fStunVerify(const uint8_t *msg, size_t len, const char *key);
/* Answers a Binding request made with our password: writes the success
* response, carrying the address and port it came from (both in network byte
* order), into out (C4F_STUN_ANSWER_SIZE bytes). Returns its length, or 0 when
* the request is not one to answer. *nominate is set when the browser picked
* this path (USE-CANDIDATE). */
size_t c4fStunAnswer(const uint8_t *msg, size_t len, const char *password,
uint32_t address, uint16_t port, uint8_t out[C4F_STUN_ANSWER_SIZE], int *nominate);
#endif
+4
View File
@@ -77,6 +77,10 @@ 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);
+1 -1
View File
@@ -20,7 +20,7 @@ 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)"' $(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
OBJECTS += $(OUT)/main.o $(OUT)/input.o $(OUT)/navigation.o $(OUT)/screen.o $(OUT)/tester.o $(OUT)/tester_draw.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
+10
View File
@@ -174,11 +174,21 @@ int c4fAutorunCheck(char *problem, size_t size)
if (c4fSandboxLeave()) { snprintf(problem, size, "GoldHEN did not let the app check (errno %d)", errno); return C4F_AUTORUN_UNKNOWN; }
int state = C4F_AUTORUN_OFF;
char *ini = c4fPath(path, sizeof(path), "GoldHEN/payloads.ini") ? NULL : (char *)c4fReadFile(path, &length, 1u << 20);
if (!ini && errno != ENOENT) {
snprintf(problem, size, "Could not inspect AutoRun (errno %d)", errno); return C4F_AUTORUN_UNKNOWN;
}
if (ini && c4fIniEnabled(ini) && !c4fPath(path, sizeof(path), "payloads/control4free.elf")) {
size_t installedSize = 0;
unsigned char *installed = c4fReadFile(path, &installedSize, 16u << 20);
if (installed)
state = installedSize == c4fBundledSize && !memcmp(installed, c4fBundled, installedSize) ? C4F_AUTORUN_ON : C4F_AUTORUN_OUTDATED;
else if (errno == ENOENT) {
state = C4F_AUTORUN_OUTDATED;
snprintf(problem, size, "Enabled, but the payload is missing; choose Update auto-start in Actions or press Triangle");
} else {
state = C4F_AUTORUN_UNKNOWN;
snprintf(problem, size, "Enabled, but the payload cannot be inspected (errno %d)", errno);
}
free(installed);
}
free(ini);
+51 -20
View File
@@ -15,6 +15,7 @@
#include <stdlib.h>
#include <string.h>
#include <sys/select.h>
#include <sys/stat.h>
#include <unistd.h>
#include <orbis/libkernel.h>
#include "autorun.h"
@@ -22,7 +23,9 @@
#include "sandbox.h"
#include "service.h"
#ifndef C4F_DIAG_DIR
#define C4F_DIAG_DIR "/user/data/control4free"
#endif
#define C4F_DIAG_LOG C4F_DIAG_DIR "/control4free.log"
#define C4F_DIAG_REPORT C4F_DIAG_DIR "/diag-report.txt"
#define C4F_DIAG_KLOG C4F_DIAG_DIR "/diag-klog.txt"
@@ -181,18 +184,19 @@ static int c4fContains(const char *haystack, const char *needle)
return 0;
}
static void c4fWriteAll(const char *path, const char *text, int append)
static int c4fWriteAll(const char *path, const char *text, int append)
{
int fd = open(path, O_WRONLY | O_CREAT | (append ? O_APPEND : O_TRUNC), 0666);
if (fd < 0) return;
if (fd < 0) return -1;
size_t size = strlen(text);
while (size) {
ssize_t n = write(fd, text, size);
if (n < 0 && errno == EINTR) continue;
if (n <= 0) break;
if (n <= 0) { int saved = n < 0 ? errno : EIO; close(fd); errno = saved; return -1; }
text += n; size -= (size_t)n;
}
close(fd);
if (fsync(fd)) { int saved = errno; close(fd); errno = saved; return -1; }
return close(fd);
}
/* ---- the kernel log ---- */
@@ -284,6 +288,8 @@ void c4fDiagCapture(unsigned milliseconds)
void c4fDiagStart(int sandboxResult, int sandboxError)
{
/* A pre-main payload failure has never created this directory. */
(void)mkdir(C4F_DIAG_DIR, 0777);
c4fBootMs = c4fLauncherTimeMs();
OrbisKernelSwVersion version;
memset(&version, 0, sizeof(version));
@@ -365,8 +371,8 @@ static char *c4fSummary(const C4fDiagState *s)
">> 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");
c4fAdd(&t, ">> GoldHEN's PayLoader Server (port 9090) did not take the payload. Open GoldHEN > Server Settings,\n"
">> check Enable PayLoader Server, then retry Start.\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"
@@ -390,6 +396,7 @@ static char *c4fSummary(const C4fDiagState *s)
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, " input: %s\n", s->inputNote && s->inputNote[0] ? s->inputNote : "waiting");
c4fAdd(&t, "\nService (port 4264)\n");
if (s->running == 1) c4fAdd(&t, " running, version %s, %d controller(s)\n", s->version, s->controllers);
@@ -447,7 +454,20 @@ static char *c4fLogPage(const char *path)
c4fAdd(&t, "%s is not there (errno %d).\n", path, errno);
return c4fTake(&t);
}
return text;
C4fText t = {0};
c4fAdd(&t, "===== %s (%lld bytes%s) =====\n%s", path, size,
size > C4F_LOG_TAIL ? "; truncated to last 64 KiB" : "", text);
free(text);
return c4fTake(&t);
}
static char *c4fCurrentLogPage(void)
{
char *rollover = c4fLogPage(C4F_DIAG_LOG ".1"), *active = c4fLogPage(C4F_DIAG_LOG);
C4fText t = {0};
c4fAdd(&t, "%s\n%s", rollover, active);
free(rollover); free(active);
return c4fTake(&t);
}
/* Lines that tend to say why a payload did not run. */
@@ -484,18 +504,19 @@ static void c4fSaveReport(void)
c4fAdd(&t, "===== %s =====\n%s\n", c4fPageNames[i], c4fPages[i] ? c4fPages[i] : "");
pthread_mutex_unlock(&c4fLock);
char *text = c4fTake(&t);
c4fWriteAll(C4F_DIAG_REPORT, text, 0);
snprintf(c4fReportNote, sizeof(c4fReportNote), "/data/control4free/diag-report.txt");
if (!c4fWriteAll(C4F_DIAG_REPORT, text, 0))
snprintf(c4fReportNote, sizeof(c4fReportNote), "Saved /data/control4free/diag-report.txt");
else snprintf(c4fReportNote, sizeof(c4fReportNote), "Internal save failed (errno %d)", errno);
static const char *const usb[] = { "/mnt/usb0", "/mnt/usb1" };
for (int i = 0; i < 2; i++) {
char path[64];
snprintf(path, sizeof(path), "%s/control4free-diag.txt", usb[i]);
int fd = open(path, O_WRONLY | O_CREAT | O_TRUNC, 0666);
if (fd < 0) continue;
close(fd);
c4fWriteAll(path, text, 0);
struct stat info;
if (stat(usb[i], &info) || !S_ISDIR(info.st_mode)) continue;
int result = c4fWriteAll(path, text, 0), saved = errno;
size_t used = strlen(c4fReportNote);
snprintf(c4fReportNote + used, sizeof(c4fReportNote) - used, " and %s", path);
if (!result) snprintf(c4fReportNote + used, sizeof(c4fReportNote) - used, "; saved %s", path);
else snprintf(c4fReportNote + used, sizeof(c4fReportNote) - used, "; USB%d save failed (%d)", i, saved);
}
free(text);
}
@@ -508,7 +529,7 @@ unsigned c4fDiagRefresh(const C4fDiagState *state)
pthread_mutex_unlock(&c4fLock);
if (!c4fRefreshed++ && state->running != 1) c4fDiagCapture(4000);
char *pages[C4F_DIAG_PAGES] = {
c4fSummary(state), c4fLogPage(C4F_DIAG_LOG), c4fLogPage(C4F_DIAG_LOG ".previous"),
c4fSummary(state), c4fCurrentLogPage(), c4fLogPage(C4F_DIAG_LOG ".previous"),
c4fKlogPage(1), c4fKlogPage(0),
};
int changed = 0;
@@ -559,11 +580,12 @@ void c4fDrawDiag(uint32_t *pixels, const C4fLauncherScreen *s)
__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");
"Left / Right: page Up / Down: scroll TV remote OK: actions "
"Cross: start Triangle: auto-start 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.");
if (s->confirmStop) snprintf(line, sizeof(line), "%s", s->confirmMenu ?
"Select Cancel or Stop with the arrows, then press OK." : "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);
@@ -571,7 +593,7 @@ void c4fDrawDiag(uint32_t *pixels, const C4fLauncherScreen *s)
pthread_mutex_lock(&c4fLock);
char *text = strdup(c4fPages[page] ? c4fPages[page] : "(not read yet)");
pthread_mutex_unlock(&c4fLock);
if (!text) return;
if (!text) { c4fDrawActionOverlay(pixels, s); return; }
/* Wrap every line, then show the window the scroll position picks. */
int count = 0, capacity = 256;
@@ -595,7 +617,9 @@ void c4fDrawDiag(uint32_t *pixels, const C4fLauncherScreen *s)
}
int visible = (C4F_D_BOTTOM - C4F_D_TOP) / C4F_D_ROW;
int maxScroll = count > visible ? count - visible : 0;
pthread_mutex_lock(&c4fLock);
c4fMaxScroll[page] = maxScroll;
pthread_mutex_unlock(&c4fLock);
int first = s->diagScroll[page] >= C4F_DIAG_END ? maxScroll : s->diagScroll[page];
if (first > maxScroll) first = maxScroll;
if (first < 0) first = 0;
@@ -606,11 +630,18 @@ void c4fDrawDiag(uint32_t *pixels, const C4fLauncherScreen *s)
}
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);
const char *remoteHint = "TV remote: OK actions / Back returns L1 / R1: page Options: kernel log";
c4fText(&c, C4F_FONT_REGULAR, C4F_D_SIZE, C4F_SCREEN_WIDTH - C4F_D_LEFT -
c4fTextWidth(C4F_FONT_REGULAR, C4F_D_SIZE, remoteHint), 1072, C4F_D_FAINT, remoteHint);
free(rows);
free(text);
c4fDrawActionOverlay(pixels, s);
}
int c4fDiagMaxScroll(int page)
{
return page >= 0 && page < C4F_DIAG_PAGES ? c4fMaxScroll[page] : 0;
pthread_mutex_lock(&c4fLock);
int maximum = page >= 0 && page < C4F_DIAG_PAGES ? c4fMaxScroll[page] : 0;
pthread_mutex_unlock(&c4fLock);
return maximum;
}
+1 -1
View File
@@ -15,7 +15,7 @@ extern "C" {
typedef struct {
int running, controllers, autorun, locked, busy, confirmStop;
const char *version, *problem, *message, *address, *autorunNote;
const char *version, *problem, *message, *address, *autorunNote, *inputNote;
} C4fDiagState;
/* Records what is known at start-up and reads the kernel log's backlog. */
+41
View File
@@ -548,3 +548,44 @@ int c4fTextWrapped(C4fCanvas *c, int font, float size, float x, float y, float m
}
return lines;
}
void c4fDrawSolidarityBadge(C4fCanvas *c, int right, int top)
{
enum { FLAG_W = 45, FLAG_H = 30, FLAG_GAP = 10, LABEL_GAP = 14 };
const int palestine = right - 2 * FLAG_W - FLAG_GAP, syria = right - FLAG_W;
const C4fColor black = C4F_RGBA(0, 0, 0, 1), white = C4F_RGBA(255, 255, 255, 1);
const float textWidth = c4fTextWidth(C4F_FONT_REGULAR, 24, "Free Palestine");
c4fText(c, C4F_FONT_REGULAR, 24, palestine - LABEL_GAP - textWidth, top + 23,
C4F_RGBA(236, 240, 248, .95f), "Free Palestine");
c4fFillRect(c, palestine, top, FLAG_W, FLAG_H / 3, black);
c4fFillRect(c, palestine, top + FLAG_H / 3, FLAG_W, FLAG_H / 3, white);
c4fFillRect(c, palestine, top + 2 * FLAG_H / 3, FLAG_W, FLAG_H / 3, C4F_RGBA(0, 151, 54, 1));
C4fPath triangle = { 0 };
c4fPathMove(&triangle, palestine, top);
c4fPathLine(&triangle, palestine + FLAG_W / 2, top + FLAG_H / 2);
c4fPathLine(&triangle, palestine, top + FLAG_H);
c4fPathLine(&triangle, palestine, top);
c4fFillPath(c, &triangle, (C4fPlacement){ 0, 0, 1, 0, 0, 0 }, C4F_RGBA(237, 46, 56, 1));
c4fPathFree(&triangle);
c4fFillRect(c, syria, top, FLAG_W, FLAG_H / 3, C4F_RGBA(0, 122, 61, 1));
c4fFillRect(c, syria, top + FLAG_H / 3, FLAG_W, FLAG_H / 3, white);
c4fFillRect(c, syria, top + 2 * FLAG_H / 3, FLAG_W, FLAG_H / 3, black);
for (int star = 1; star <= 3; star++) {
C4fPath path = { 0 };
const float cx = syria + star * FLAG_W / 4.0f, cy = top + FLAG_H / 2.0f;
for (int point = 0; point <= 10; point++) {
const float angle = (point % 10) * .628318531f - 1.570796327f;
const float radius = point % 2 ? 1.25f : 3.2f;
const float x = cx + cosf(angle) * radius, y = cy + sinf(angle) * radius;
if (!point) c4fPathMove(&path, x, y);
else c4fPathLine(&path, x, y);
}
c4fFillPath(c, &path, (C4fPlacement){ 0, 0, 1, 0, 0, 0 }, C4F_RGBA(206, 17, 38, 1));
c4fPathFree(&path);
}
const C4fColor edge = C4F_RGBA(255, 255, 255, .24f);
c4fInsetRoundRect(c, palestine, top, FLAG_W, FLAG_H, 0, 1, edge);
c4fInsetRoundRect(c, syria, top, FLAG_W, FLAG_H, 0, 1, edge);
}
+4
View File
@@ -70,4 +70,8 @@ float c4fTextWidth(int font, float size, const char *text);
int c4fTextWrapped(C4fCanvas *c, int font, float size, float x, float y, float maxWidth,
float lineHeight, int maxLines, C4fColor color, const char *text);
/* Corner label and two equally sized vector flags; paint into static layers.
* `right` is the right edge, `top` the top of the 60 x 30 pixel flags. */
void c4fDrawSolidarityBadge(C4fCanvas *c, int right, int top);
#endif
+98
View File
@@ -0,0 +1,98 @@
#include "input.h"
#include <string.h>
uint32_t c4fInputRemoteButtons(uint32_t rawButtons, uint8_t keyCode)
{
/* PS5 SDL reads this Sony key table alongside ordinary pad buttons:
* https://github.com/ps5-payload-dev/SDL/blob/ee4c47dc0d617b3bc8f35108f9956baf228a1322/src/video/ps5/SDL_ps5remote.c
* Applying these navigation codes to PS4 is a hardware-test candidate;
* they are not the HDMI-CEC wire command numbers. */
if (rawButtons & C4F_INPUT_BUTTON_INTERCEPTED) return rawButtons;
switch (keyCode) {
case 13: return rawButtons | C4F_INPUT_BUTTON_CROSS;
case 15: return rawButtons | C4F_INPUT_BUTTON_CIRCLE;
case 18: return rawButtons | C4F_INPUT_BUTTON_OPTIONS;
default: return rawButtons;
}
}
static void c4fInputRelease(C4fInput *input, C4fInputSource *source, uint64_t retryAt)
{
if (source->handle >= 0 && source->owned && input->ops.close)
input->ops.close(input->ops.context, source->handle);
source->handle = -1;
source->owned = 0;
source->ready = 0;
source->user = -1;
source->previous = 0;
source->retryAt = retryAt;
}
void c4fInputInit(C4fInput *input, const C4fInputOps *ops)
{
memset(input, 0, sizeof(*input));
if (ops) input->ops = *ops;
for (int i = 0; i < C4F_INPUT_SOURCE_COUNT; i++) {
input->sources[i].handle = -1;
input->sources[i].user = -1;
}
}
static void c4fInputRead(C4fInput *input, int index, uint64_t now, C4fInputSample *sample)
{
C4fInputSource *source = &input->sources[index];
if (!input->ops.open || !input->ops.read) return;
if (source->handle < 0) {
if (now < source->retryAt) return;
int32_t user = C4F_INPUT_SYSTEM_USER;
if (index == C4F_INPUT_STANDARD &&
(!input->ops.initialUser || input->ops.initialUser(input->ops.context, &user) != 0 || user < 0)) {
source->retryAt = now + C4F_INPUT_RETRY_MS;
return;
}
int owned = 0;
int port = index == C4F_INPUT_STANDARD ? C4F_INPUT_PORT_STANDARD : C4F_INPUT_PORT_REMOTE;
int handle = input->ops.open(input->ops.context, user, port, &owned);
if (handle < 0) {
source->retryAt = now + C4F_INPUT_RETRY_MS;
return;
}
source->handle = handle;
source->owned = !!owned;
source->user = user;
source->ready = 0;
source->previous = 0;
}
uint32_t buttons = 0;
int connected = 0;
if (input->ops.read(input->ops.context, source->handle, &buttons, &connected) != 0 || !connected) {
c4fInputRelease(input, source, now + C4F_INPUT_RETRY_MS);
return;
}
sample->available = 1;
if (!source->ready) {
if (!buttons) source->ready = 1;
return;
}
sample->buttons = buttons;
sample->pressed = buttons & ~source->previous;
source->previous = buttons;
}
void c4fInputPoll(C4fInput *input, uint64_t now, C4fInputFrame *frame)
{
memset(frame, 0, sizeof(*frame));
c4fInputRead(input, C4F_INPUT_STANDARD, now, &frame->standard);
c4fInputRead(input, C4F_INPUT_REMOTE, now, &frame->remote);
}
void c4fInputReset(C4fInput *input, uint64_t now)
{
for (int i = 0; i < C4F_INPUT_SOURCE_COUNT; i++)
c4fInputRelease(input, &input->sources[i], now);
}
void c4fInputClose(C4fInput *input)
{
c4fInputReset(input, 0);
}
+80
View File
@@ -0,0 +1,80 @@
#ifndef C4F_LAUNCHER_INPUT_H
#define C4F_LAUNCHER_INPUT_H
#include <stdint.h>
/* These sources use the native pad API independently. Remote-control input
* belongs to the system user, rather than the launcher's signed-in user. */
enum { C4F_INPUT_STANDARD, C4F_INPUT_REMOTE, C4F_INPUT_SOURCE_COUNT };
enum { C4F_INPUT_PORT_STANDARD = 0, C4F_INPUT_PORT_REMOTE = 16 };
#define C4F_INPUT_SYSTEM_USER 0xff
#define C4F_INPUT_RETRY_MS 1000
/* Native read-side buttons used by launcher navigation and the controller test. */
#define C4F_INPUT_BUTTON_L3 UINT32_C(0x000002)
#define C4F_INPUT_BUTTON_R3 UINT32_C(0x000004)
#define C4F_INPUT_BUTTON_OPTIONS UINT32_C(0x000008)
#define C4F_INPUT_BUTTON_UP UINT32_C(0x000010)
#define C4F_INPUT_BUTTON_RIGHT UINT32_C(0x000020)
#define C4F_INPUT_BUTTON_DOWN UINT32_C(0x000040)
#define C4F_INPUT_BUTTON_LEFT UINT32_C(0x000080)
#define C4F_INPUT_BUTTON_L2 UINT32_C(0x000100)
#define C4F_INPUT_BUTTON_R2 UINT32_C(0x000200)
#define C4F_INPUT_BUTTON_L1 UINT32_C(0x000400)
#define C4F_INPUT_BUTTON_R1 UINT32_C(0x000800)
#define C4F_INPUT_BUTTON_TRIANGLE UINT32_C(0x001000)
#define C4F_INPUT_BUTTON_CIRCLE UINT32_C(0x002000)
#define C4F_INPUT_BUTTON_CROSS UINT32_C(0x004000)
#define C4F_INPUT_BUTTON_SQUARE UINT32_C(0x008000)
#define C4F_INPUT_BUTTON_TOUCHPAD UINT32_C(0x100000)
/* PS4 reverse definitions identify bit31 as system-intercepted input. This is
* a read-side flag, not an additional virtual-controller button. */
#define C4F_INPUT_BUTTON_INTERCEPTED UINT32_C(0x80000000)
typedef struct {
void *context;
int (*initialUser)(void *context, int32_t *user);
/* Returns a handle, or a negative error. owned is true only for handles
* opened here; an already-open handle may be borrowed and must not close. */
int (*open)(void *context, int32_t user, int port, int *owned);
int (*read)(void *context, int handle, uint32_t *buttons, int *connected);
void (*close)(void *context, int handle);
} C4fInputOps;
typedef struct {
int handle, owned, ready;
int32_t user;
uint32_t previous;
uint64_t retryAt;
} C4fInputSource;
typedef struct {
C4fInputOps ops;
C4fInputSource sources[C4F_INPUT_SOURCE_COUNT];
} C4fInput;
typedef struct {
uint32_t buttons, pressed;
int available;
} C4fInputSample;
typedef struct {
C4fInputSample standard, remote;
} C4fInputFrame;
#ifdef __cplusplus
extern "C" {
#endif
void c4fInputInit(C4fInput *input, const C4fInputOps *ops);
/* Adds navigation keys reported separately by the system remote-control
* stream, preserving the ordinary button mask and unknown key codes. */
uint32_t c4fInputRemoteButtons(uint32_t rawButtons, uint8_t keyCode);
/* Each recovered source requires a neutral sample before generating edges.
* Failure or disconnect produces neutral input and a bounded retry. */
void c4fInputPoll(C4fInput *input, uint64_t now, C4fInputFrame *frame);
/* Focus changes may reset sources; durable launcher state is unaffected. */
void c4fInputReset(C4fInput *input, uint64_t now);
void c4fInputClose(C4fInput *input);
#ifdef __cplusplus
}
#endif
#endif
+514 -57
View File
@@ -1,6 +1,7 @@
/* Native PS4 launcher. The controller service runs outside this application. */
#include <errno.h>
#include <signal.h>
#include <stddef.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
@@ -13,6 +14,8 @@
#include <orbis/UserService.h>
#include <orbis/VideoOut.h>
#include "autorun.h"
#include "input.h"
#include "navigation.h"
#include "sandbox.h"
#include "screen.h"
#include "service.h"
@@ -28,6 +31,11 @@ enum { C4F_DO_NOTHING, C4F_DO_START, C4F_DO_STOP, C4F_DO_SET_UP, C4F_DO_AUTORUN_
static pthread_mutex_t c4fMutex = PTHREAD_MUTEX_INITIALIZER;
static C4fLauncherScreen c4fScreen;
static int c4fCommand, c4fQuit;
static int c4fCloseRequested;
static uint64_t c4fRenderAt, c4fInputAt;
static C4fServiceStatus c4fConnectionStatus;
static int c4fConnectionService = C4F_SERVICE_UNKNOWN;
static uint64_t c4fConnectionCheckedAt;
/* Set up: auto-start on, then start it now if PayLoader takes it. */
static int c4fSetUp(char *message, size_t size)
@@ -40,7 +48,7 @@ static int c4fSetUp(char *message, size_t size)
result = c4fLauncherStart(payload, payloadSize, started, sizeof(started));
if (result == 0) snprintf(message, size, "Set up and running. GoldHEN will also start it after each restart.");
else if (result == -2) snprintf(message, size, "%s", started);
else snprintf(message, size, "Auto-start is on: Control4Free starts the next time GoldHEN loads. %s", started);
else snprintf(message, size, "Auto-start is on. %s", started);
return result == -2 ? -2 : 0;
}
@@ -86,6 +94,7 @@ static void *c4fWorker(void *)
if (command == C4F_DO_AUTORUN_OFF) result = c4fAutorunDisable(message, sizeof(message));
if (first || command >= C4F_DO_SET_UP) autorun = c4fAutorunCheck(autorunNote, sizeof(autorunNote));
C4fServiceStatus status;
uint64_t probeAt = c4fLauncherTimeMs();
int running = c4fLauncherProbe(&status);
const char *problem = c4fLauncherProblem();
pthread_mutex_lock(&c4fMutex);
@@ -100,7 +109,11 @@ static void *c4fWorker(void *)
snprintf(shownProblem, sizeof(shownProblem), "%s", problem);
}
c4fScreen.running = running;
c4fConnectionStatus = status;
c4fConnectionService = running;
c4fConnectionCheckedAt = probeAt;
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);
@@ -110,7 +123,7 @@ static void *c4fWorker(void *)
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 };
status.version, problem, copy.message, copy.address, copy.autorunNote, copy.inputNote };
unsigned generation = c4fDiagRefresh(&diag);
pthread_mutex_lock(&c4fMutex);
c4fScreen.diagGeneration = generation;
@@ -165,6 +178,424 @@ static void c4fCloseVideo(void)
if (c4fVideoSize) sceKernelReleaseDirectMemory(c4fVideoOffset, c4fVideoSize);
}
/* The pinned SDK omits this error from Pad.h. A handle already opened by the
* application can be borrowed, but must not be closed by our reader. */
#define C4F_PAD_ERROR_ALREADY_OPENED 0x80920004u
static_assert(C4F_INPUT_SYSTEM_USER == ORBIS_USER_SERVICE_USER_ID_SYSTEM, "system input user");
static_assert(C4F_INPUT_PORT_STANDARD == ORBIS_PAD_PORT_TYPE_STANDARD, "standard input port");
static_assert(C4F_INPUT_BUTTON_UP == ORBIS_PAD_BUTTON_UP, "Up button");
static_assert(C4F_INPUT_BUTTON_RIGHT == ORBIS_PAD_BUTTON_RIGHT, "Right button");
static_assert(C4F_INPUT_BUTTON_DOWN == ORBIS_PAD_BUTTON_DOWN, "Down button");
static_assert(C4F_INPUT_BUTTON_LEFT == ORBIS_PAD_BUTTON_LEFT, "Left button");
static_assert(C4F_INPUT_BUTTON_CROSS == ORBIS_PAD_BUTTON_CROSS, "OK button");
static_assert(C4F_INPUT_BUTTON_CIRCLE == ORBIS_PAD_BUTTON_CIRCLE, "Back button");
static_assert(C4F_INPUT_BUTTON_OPTIONS == ORBIS_PAD_BUTTON_OPTIONS, "Menu button");
static_assert(offsetof(OrbisPadData, unknown) + 3 == 108, "remote-control key byte");
enum { C4F_INPUT_WAITING, C4F_INPUT_READY, C4F_INPUT_DISCONNECTED,
C4F_INPUT_OPEN_FAILED, C4F_INPUT_READ_FAILED, C4F_INPUT_USER_FAILED };
typedef struct {
int initialized, handles[2], state[2];
uint32_t error[2], buttons[2];
uint32_t remoteLastRawButtons;
uint8_t remoteLastKey;
} C4fNativeInput;
static const char *const c4fInputNames[] = { "DS4", "TV remote" };
static const char *const c4fInputStates[] = {
"waiting", "ready", "disconnected", "open failed", "read failed", "user unavailable"
};
static void c4fNativeInputState(C4fNativeInput *input, int source, int state, int result)
{
if (input->state[source] != state || input->error[source] != (uint32_t)result)
printf("[c4f-launcher] %s input: %s (0x%08x)\n",
c4fInputNames[source], c4fInputStates[state], (unsigned)result);
input->state[source] = state;
input->error[source] = (uint32_t)result;
if (state != C4F_INPUT_READY) input->buttons[source] = 0;
}
static int c4fNativeInitialUser(void *context, int32_t *user)
{
int result = sceUserServiceGetInitialUser(user);
if (result) c4fNativeInputState((C4fNativeInput *)context, 0, C4F_INPUT_USER_FAILED, result);
return result;
}
static int c4fNativeOpen(void *context, int32_t user, int port, int *owned)
{
C4fNativeInput *input = (C4fNativeInput *)context;
int source = port == ORBIS_PAD_PORT_TYPE_STANDARD ? 0 : 1;
*owned = 0;
if (!input->initialized) {
int result = scePadInit();
if (result) {
c4fNativeInputState(input, source, C4F_INPUT_OPEN_FAILED, result);
return -1;
}
input->initialized = 1;
}
int handle = scePadOpen(user, port, 0, NULL);
if ((uint32_t)handle == C4F_PAD_ERROR_ALREADY_OPENED)
handle = scePadGetHandle(user, port, 0);
else if (handle >= 0)
*owned = 1;
input->handles[source] = handle;
if (handle < 0) c4fNativeInputState(input, source, C4F_INPUT_OPEN_FAILED, handle);
return handle;
}
static int c4fNativeRead(void *context, int handle, uint32_t *buttons, int *connected)
{
C4fNativeInput *input = (C4fNativeInput *)context;
int source = handle == input->handles[0] ? 0 : 1;
OrbisPadData sample;
memset(&sample, 0, sizeof(sample));
int result = scePadReadState(handle, &sample);
uint32_t mappedButtons = sample.buttons;
if (source == C4F_INPUT_REMOTE && result == 0) {
/* The SDK's byte108 is deviceUniqueData[0] in the reverse PS4 layout.
* PS5 implementations use it for remote Enter/Back/Menu even when the
* device-data length is zero. PS4 key decoding still needs hardware
* confirmation. */
uint8_t keyCode = sample.unknown[3];
if (keyCode || sample.buttons) {
input->remoteLastKey = keyCode;
input->remoteLastRawButtons = sample.buttons;
}
mappedButtons = c4fInputRemoteButtons(sample.buttons, keyCode);
}
*buttons = result == 0 && sample.connected ? mappedButtons : 0;
*connected = result == 0 && sample.connected;
c4fNativeInputState(input, source, result ? C4F_INPUT_READ_FAILED :
sample.connected ? C4F_INPUT_READY : C4F_INPUT_DISCONNECTED, result);
input->buttons[source] = *buttons;
return result;
}
static void c4fNativeClose(void *, int handle) { scePadClose(handle); }
/* ---- the controller test's native side ---- */
/* The pinned header names the two motion vectors vel and acell. Sony's order
* is orientation, acceleration (G), angular velocity (rad/s). */
static_assert(offsetof(OrbisPadData, quat) == 12, "orientation");
static_assert(offsetof(OrbisPadData, vel) == 28, "acceleration");
static_assert(offsetof(OrbisPadData, acell) == 40, "angular velocity");
static_assert(offsetof(OrbisPadData, touch) == 52, "touch data");
static_assert(C4F_INPUT_BUTTON_L3 == ORBIS_PAD_BUTTON_L3, "L3 button");
static_assert(C4F_INPUT_BUTTON_R3 == ORBIS_PAD_BUTTON_R3, "R3 button");
static_assert(C4F_INPUT_BUTTON_L2 == ORBIS_PAD_BUTTON_L2, "L2 button");
static_assert(C4F_INPUT_BUTTON_R2 == ORBIS_PAD_BUTTON_R2, "R2 button");
static_assert(C4F_INPUT_BUTTON_L1 == ORBIS_PAD_BUTTON_L1, "L1 button");
static_assert(C4F_INPUT_BUTTON_R1 == ORBIS_PAD_BUTTON_R1, "R1 button");
static_assert(C4F_INPUT_BUTTON_TRIANGLE == ORBIS_PAD_BUTTON_TRIANGLE, "Triangle button");
static_assert(C4F_INPUT_BUTTON_SQUARE == ORBIS_PAD_BUTTON_SQUARE, "Square button");
static_assert(C4F_INPUT_BUTTON_TOUCHPAD == ORBIS_PAD_BUTTON_TOUCH_PAD, "touchpad button");
static int c4fTesterLoginUsers(void *, int32_t *users)
{
OrbisUserServiceLoginUserIdList list;
memset(&list, 0, sizeof(list));
int result = sceUserServiceGetLoginUserIdList(&list);
if (result) return result < 0 ? result : -1;
int count = 0;
for (int i = 0; i < ORBIS_USER_SERVICE_MAX_LOGIN_USERS && count < C4F_TESTER_USERS; i++)
if (list.userId[i] != ORBIS_USER_SERVICE_USER_ID_INVALID) users[count++] = list.userId[i];
return count;
}
static void c4fTesterUserName(void *, int32_t user, char *name, int size)
{
if (sceUserServiceGetUserName(user, name, (size_t)size)) name[0] = 0;
}
static int c4fTesterOpenPad(void *context, int32_t user, int *owned)
{
C4fNativeInput *input = (C4fNativeInput *)context;
*owned = 0;
if (!input->initialized) {
int result = scePadInit();
if (result) return result < 0 ? result : -1;
input->initialized = 1;
}
/* The launcher's own reader may hold this user's handle: borrow it. */
int handle = scePadOpen(user, ORBIS_PAD_PORT_TYPE_STANDARD, 0, NULL);
if ((uint32_t)handle == C4F_PAD_ERROR_ALREADY_OPENED)
handle = scePadGetHandle(user, ORBIS_PAD_PORT_TYPE_STANDARD, 0);
else if (handle >= 0)
*owned = 1;
return handle;
}
static int c4fTesterRead(void *, int handle, C4fTesterSample *out)
{
OrbisPadData data;
memset(&data, 0, sizeof(data));
int result = scePadReadState(handle, &data);
if (result) return result;
out->buttons = data.buttons;
out->lx = data.leftStick.x; out->ly = data.leftStick.y;
out->rx = data.rightStick.x; out->ry = data.rightStick.y;
out->l2 = data.analogButtons.l2; out->r2 = data.analogButtons.r2;
out->touches = data.touch.fingers > 2 ? 2 : data.touch.fingers;
for (int i = 0; i < out->touches; i++) {
out->touch[i].x = data.touch.touch[i].x;
out->touch[i].y = data.touch.touch[i].y;
out->touch[i].id = data.touch.touch[i].finger;
}
out->quat[0] = data.quat.x; out->quat[1] = data.quat.y; out->quat[2] = data.quat.z; out->quat[3] = data.quat.w;
out->accel[0] = data.vel.x; out->accel[1] = data.vel.y; out->accel[2] = data.vel.z;
out->gyro[0] = data.acell.x; out->gyro[1] = data.acell.y; out->gyro[2] = data.acell.z;
out->connected = data.connected;
return 0;
}
static void c4fTesterClosePad(void *, int handle) { scePadClose(handle); }
static int c4fTesterVibrate(void *, int handle, uint8_t large, uint8_t small)
{
OrbisPadVibeParam param = { large, small };
int result = scePadSetVibration(handle, &param);
if (result) printf("[c4f-launcher] controller test: scePadSetVibration = 0x%08x\n", (unsigned)result);
return result;
}
static int c4fTesterLight(void *, int handle, uint8_t r, uint8_t g, uint8_t b)
{
OrbisPadColor color = { r, g, b, 255 };
int result = scePadSetLightBar(handle, &color);
if (result) printf("[c4f-launcher] controller test: scePadSetLightBar = 0x%08x\n", (unsigned)result);
return result;
}
static int c4fTesterResetLight(void *, int handle) { return scePadResetLightBar(handle); }
static int c4fTesterResetOrientation(void *, int handle) { return scePadResetOrientation(handle); }
static int c4fTesterMotion(void *, int handle, int on)
{
/* OpenOrbis declares this without parameters; Sony's call (as shadPS4
* documents it) takes the handle and an on/off flag. */
int result = reinterpret_cast<int32_t (*)(int32_t, bool)>(&scePadSetMotionSensorState)(handle, on != 0);
printf("[c4f-launcher] controller test: scePadSetMotionSensorState = 0x%08x\n", (unsigned)result);
return result;
}
static void c4fTesterLogState(void *, int slot, int state, uint32_t error)
{
static const char *const names[] = { "opening", "ready", "no controller", "failed", "signed out" };
printf("[c4f-launcher] controller test reader %d: %s (0x%08x)\n", slot + 1,
state >= 0 && state <= C4F_TESTER_SIGNED_OUT ? names[state] : "?", (unsigned)error);
}
static void c4fNativeInputNote(const C4fNativeInput *input, char *note, size_t size)
{
snprintf(note, size, "%s: %s (0x%08x); %s: %s (0x%08x), last key 0x%02x / raw 0x%08x",
c4fInputNames[0], c4fInputStates[input->state[0]],
input->error[0] ? input->error[0] : input->buttons[0],
c4fInputNames[1], c4fInputStates[input->state[1]],
input->error[1] ? input->error[1] : input->buttons[1],
(unsigned)input->remoteLastKey, input->remoteLastRawButtons);
}
/* Own every native input/tester handle on this thread. Rendering and flip waits
* must not postpone button edges or the one-second rumble safety timer. */
static void *c4fInputWorker(void *)
{
C4fNativeInput nativeInput = {};
nativeInput.handles[0] = nativeInput.handles[1] = -1;
C4fInputOps inputOps = { &nativeInput, c4fNativeInitialUser, c4fNativeOpen, c4fNativeRead, c4fNativeClose };
C4fInput inputs;
c4fInputInit(&inputs, &inputOps);
C4fTesterOps testerOps = { &nativeInput, c4fTesterLoginUsers, c4fTesterUserName, c4fTesterOpenPad, c4fTesterRead,
c4fTesterClosePad, c4fTesterVibrate, c4fTesterLight, c4fTesterResetLight,
c4fTesterResetOrientation, c4fTesterMotion, c4fTesterLogState };
C4fTester tester;
c4fTesterInit(&tester, &testerOps);
C4fTesterHold launcherHold = {};
int waitRelease = 0, paused = 0;
uint64_t lastInputAt = c4fLauncherTimeMs();
#ifdef C4F_DIAG
uint64_t polls = 0, gapMax = 0, pollMax = 0, reportAt = lastInputAt + 5000;
#endif
for (;;) {
uint64_t now = c4fLauncherTimeMs();
pthread_mutex_lock(&c4fMutex);
int quit = c4fQuit || c4fCloseRequested;
uint64_t renderedAt = c4fRenderAt;
pthread_mutex_unlock(&c4fMutex);
if (quit) break;
/* A suspended or stalled display must not leave test output running.
* The first fresh frame reopens readers with their normal neutral gate. */
if (now > renderedAt && now - renderedAt > 1000) {
if (!paused) {
c4fTesterSuspend(&tester, now);
c4fInputReset(&inputs, now);
C4fTesterView view;
c4fTesterView(&tester, &view);
pthread_mutex_lock(&c4fMutex);
c4fScreen.tester = view;
pthread_mutex_unlock(&c4fMutex);
}
paused = 1;
usleep(10000);
continue;
}
int resumed = paused || (now > lastInputAt && now - lastInputAt > 1000);
if (resumed) {
c4fTesterSuspend(&tester, now);
c4fInputReset(&inputs, now);
launcherHold = {};
}
paused = 0;
#ifdef C4F_DIAG
uint64_t gap = now - lastInputAt;
if (!resumed && gap > gapMax) gapMax = gap;
#endif
lastInputAt = now;
C4fInputFrame input;
c4fInputPoll(&inputs, now, &input);
int wasTesting = tester.active;
if (wasTesting && !c4fTesterFrame(&tester, now, input.remote.pressed)) waitRelease = 1;
int held = c4fTesterHoldUpdate(&launcherHold, input.standard.buttons, now);
C4fTesterView view;
c4fTesterView(&tester, &view);
char inputNote[sizeof(c4fScreen.inputNote)];
c4fNativeInputNote(&nativeInput, inputNote, sizeof(inputNote));
int openTester = 0;
#ifdef C4F_DIAG
const int diagnostic = 1;
int capture = 0;
#else
const int diagnostic = 0;
#endif
/* No native pad/user calls or drawing while this publication lock is
* held. The service worker owns its own status fields in the same view. */
pthread_mutex_lock(&c4fMutex);
snprintf(c4fScreen.inputNote, sizeof(c4fScreen.inputNote), "%s", inputNote);
c4fScreen.tester = view;
c4fInputAt = c4fLauncherTimeMs();
if (resumed) {
c4fScreen.actionMenu = c4fScreen.confirmStop = c4fScreen.confirmMenu = 0;
c4fScreen.actionFocus = C4F_ACTION_NONE;
}
uint32_t standardUnhandled = 0, remoteUnhandled = 0;
C4fLauncherAction action = C4F_ACTION_NONE;
if (!wasTesting) {
uint32_t standardPressed = input.standard.pressed, remotePressed = input.remote.pressed;
if (waitRelease) {
if (!input.standard.buttons && !input.remote.buttons) waitRelease = 0;
standardPressed = remotePressed = 0;
}
action = c4fNavigate(&c4fScreen, standardPressed, remotePressed,
diagnostic, &standardUnhandled, &remoteUnhandled);
if (action == C4F_ACTION_TESTER || (action == C4F_ACTION_NONE && held && !c4fScreen.actionMenu &&
!c4fScreen.confirmStop && !c4fScreen.locked)) {
openTester = 1;
action = C4F_ACTION_NONE;
}
}
if (action == C4F_ACTION_CLOSE) c4fCloseRequested = 1;
else if (action == C4F_ACTION_START) {
int setUp = c4fScreen.autorun != C4F_AUTORUN_ON;
c4fCommand = setUp ? C4F_DO_SET_UP : C4F_DO_START; c4fScreen.busy = 1;
snprintf(c4fScreen.message, sizeof(c4fScreen.message), "%s",
setUp ? "Setting up Control4Free. Please wait..." : "Starting Control4Free. Please wait...");
} else if (action == C4F_ACTION_STOP) {
c4fCommand = C4F_DO_STOP; c4fScreen.busy = 1;
snprintf(c4fScreen.message, sizeof(c4fScreen.message), "Stopping and releasing controllers...");
} else if (action == C4F_ACTION_AUTORUN) {
int off = c4fScreen.autorun == C4F_AUTORUN_ON;
c4fCommand = off ? C4F_DO_AUTORUN_OFF : C4F_DO_AUTORUN_ON; c4fScreen.busy = 1;
snprintf(c4fScreen.message, sizeof(c4fScreen.message), "%s",
off ? "Turning auto-start off..." : "Turning auto-start on...");
}
#ifdef C4F_DIAG
uint32_t pressed = wasTesting ? 0 : standardUnhandled | remoteUnhandled;
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;
}
capture = action == C4F_ACTION_CAPTURE || (pressed & ORBIS_PAD_BUTTON_OPTIONS);
#endif
pthread_mutex_unlock(&c4fMutex);
if (openTester) {
c4fTesterOpen(&tester, now);
c4fTesterView(&tester, &view);
pthread_mutex_lock(&c4fMutex);
c4fScreen.tester = view;
pthread_mutex_unlock(&c4fMutex);
}
#ifdef C4F_DIAG
if (capture) c4fDiagCapture(4000);
uint64_t took = c4fLauncherTimeMs() - now;
if (took > pollMax) pollMax = took;
polls++;
if (now >= reportAt) {
printf("[c4f-launcher] input sampler: %llu polls, largest gap %llu ms, slowest poll %llu ms\n",
(unsigned long long)polls, (unsigned long long)gapMax, (unsigned long long)pollMax);
polls = gapMax = pollMax = 0; reportAt = now + 5000;
}
#endif
uint64_t finished = c4fLauncherTimeMs();
unsigned delay = finished < now + 4 ? (unsigned)(now + 4 - finished) : 1;
usleep(delay * 1000);
}
c4fTesterClose(&tester);
c4fInputClose(&inputs);
return NULL;
}
/* RTT is reported by the controller page for its own input-channel probe.
* Match all virtual slots for the selected user; never call a local pad 0 ms. */
static C4fTesterConnection c4fTesterConnectionView(const C4fServiceStatus *status, int running,
uint64_t checkedAt, uint64_t now, const C4fTesterView *tester)
{
C4fTesterConnection result = {};
for (int p = 0; p < C4F_SERVICE_PADS; p++) result.rttMs[p] = -1;
if (running != C4F_SERVICE_RUNNING || !status->latencyAvailable || now < checkedAt ||
now - checkedAt > C4F_SERVICE_LATENCY_STALE_MS) return result;
result.available = 1;
if (tester->selected < 0 || tester->selected >= C4F_TESTER_USERS ||
!tester->user[tester->selected].used || tester->user[tester->selected].state != C4F_TESTER_READY) return result;
uint32_t user = (uint32_t)tester->user[tester->selected].id;
for (int p = 0; p < C4F_SERVICE_PADS; p++) {
const C4fServiceLatency *link = &status->latency[p];
if (!link->userKnown || link->userId != user) continue;
result.pads |= 1U << p;
if (link->rttMs >= 0 && link->ageMs >= 0 &&
(uint64_t)link->ageMs + now - checkedAt <= C4F_SERVICE_LATENCY_STALE_MS) result.rttMs[p] = link->rttMs;
}
return result;
}
/* Background service fields and hidden live readings cannot change the tester
* tools view. Compare only what is visible before spending a frame on it. */
static C4fLauncherScreen c4fVisibleScreen(const C4fLauncherScreen *screen)
{
C4fLauncherScreen visible = *screen;
if (!screen->tester.active) return visible;
memset(&visible, 0, sizeof(visible));
visible.tester = screen->tester;
visible.connection = screen->connection;
if (visible.tester.menu) {
C4fTesterView *v = &visible.tester;
v->buttons = 0;
v->lx = v->ly = v->rx = v->ry = v->l2 = v->r2 = v->touches = 0;
memset(v->touchX, 0, sizeof(v->touchX)); memset(v->touchY, 0, sizeof(v->touchY));
memset(v->quat, 0, sizeof(v->quat)); memset(v->accel, 0, sizeof(v->accel));
memset(v->gyro, 0, sizeof(v->gyro));
}
return visible;
}
int main(void)
{
const size_t frameBytes = (size_t)C4F_SCREEN_WIDTH * C4F_SCREEN_HEIGHT * sizeof(uint32_t);
@@ -183,9 +614,6 @@ int main(void)
OrbisUserServiceInitializeParams params = {};
params.priority = ORBIS_KERNEL_PRIO_FIFO_LOWEST;
sceUserServiceInitialize(&params);
int32_t user = -1, pad = -1;
if (scePadInit() == 0 && sceUserServiceGetInitialUser(&user) == 0)
pad = scePadOpen(user, ORBIS_PAD_PORT_TYPE_STANDARD, 0, NULL);
c4fScreen.running = -1; c4fScreen.busy = 1; c4fScreen.autorun = C4F_AUTORUN_UNKNOWN;
snprintf(c4fScreen.message, sizeof(c4fScreen.message), "Checking Control4Free...");
/* Read before leaving the sandbox: /app0 is only visible from inside it. */
@@ -200,73 +628,81 @@ int main(void)
#endif
pthread_t worker;
int workerStarted = pthread_create(&worker, NULL, c4fWorker, NULL) == 0;
if (!workerStarted || pad < 0) {
if (!workerStarted) {
pthread_mutex_lock(&c4fMutex);
c4fScreen.locked = 1;
snprintf(c4fScreen.message, sizeof(c4fScreen.message), "Could not initialize launcher. Close with PS and retry.");
pthread_mutex_unlock(&c4fMutex);
}
c4fRenderAt = c4fLauncherTimeMs();
pthread_t inputWorker;
int inputStarted = pthread_create(&inputWorker, NULL, c4fInputWorker, NULL) == 0;
if (!inputStarted) {
pthread_mutex_lock(&c4fMutex);
c4fScreen.locked = 1;
snprintf(c4fScreen.message, sizeof(c4fScreen.message), "Could not initialize input. Close with PS and retry.");
pthread_mutex_unlock(&c4fMutex);
}
sceSystemServiceHideSplashScreen();
uint32_t previous = 0;
int buffer = 0, done = 0, drawn = 0, shown[2] = { 0, 0 };
int buffer = 0, done = 0, drawn = 0, shown[2] = { 0, 0 }, requestedRate = 1;
int64_t frame = 1;
C4fLauncherScreen drawnState;
memset(&drawnState, 0, sizeof(drawnState));
#ifdef C4F_DIAG
uint64_t testFrames = 0, testDraws = 0, testDrawMax = 0, testCopyMax = 0;
uint64_t testPresentMax = 0, testFrameMax = 0, testInputAgeMax = 0, testDrawReportAt = 0;
#endif
while (!done) {
OrbisPadData input;
memset(&input, 0, sizeof(input));
uint32_t buttons = pad >= 0 && scePadReadState(pad, &input) == 0 && input.connected ? input.buttons : 0;
uint32_t pressed = buttons & ~previous;
previous = buttons;
uint64_t now = c4fLauncherTimeMs();
pthread_mutex_lock(&c4fMutex);
if (!c4fScreen.busy) {
if (pressed & ORBIS_PAD_BUTTON_CIRCLE) {
if (c4fScreen.confirmStop) c4fScreen.confirmStop = 0;
else done = 1;
} else if (pressed & ORBIS_PAD_BUTTON_CROSS) {
if (c4fScreen.confirmStop) {
c4fCommand = C4F_DO_STOP; c4fScreen.confirmStop = 0; c4fScreen.busy = 1;
snprintf(c4fScreen.message, sizeof(c4fScreen.message), "Stopping and releasing controllers...");
} else if (!c4fScreen.locked && c4fScreen.running != 1) {
/* Until auto-start is on, Cross sets it up and starts it. */
int setUp = c4fScreen.autorun != C4F_AUTORUN_ON;
c4fCommand = setUp ? C4F_DO_SET_UP : C4F_DO_START; c4fScreen.busy = 1;
snprintf(c4fScreen.message, sizeof(c4fScreen.message), "%s",
setUp ? "Setting up Control4Free. Please wait..." : "Starting Control4Free. Please wait...");
}
} else if ((pressed & ORBIS_PAD_BUTTON_TRIANGLE) && !c4fScreen.confirmStop &&
c4fScreen.autorun != C4F_AUTORUN_UNKNOWN) {
int off = c4fScreen.autorun == C4F_AUTORUN_ON;
c4fCommand = off ? C4F_DO_AUTORUN_OFF : C4F_DO_AUTORUN_ON; c4fScreen.busy = 1;
snprintf(c4fScreen.message, sizeof(c4fScreen.message), "%s",
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
c4fRenderAt = now;
done = c4fCloseRequested;
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))) {
C4fServiceStatus connectionStatus = c4fConnectionStatus;
int connectionService = c4fConnectionService;
uint64_t connectionCheckedAt = c4fConnectionCheckedAt;
#ifdef C4F_DIAG
c4fDrawDiag(canvas, &snapshot);
#else
c4fDrawLauncher(canvas, &snapshot);
uint64_t sampledAt = c4fInputAt;
#endif
drawnState = snapshot; drawn = 1; shown[0] = shown[1] = 0;
pthread_mutex_unlock(&c4fMutex);
if (done) break;
snapshot.connection = c4fTesterConnectionView(&connectionStatus, connectionService, connectionCheckedAt, now, &snapshot.tester);
int wantedRate = snapshot.tester.active ? 0 : 1;
if (wantedRate != requestedRate) {
int result = sceVideoOutSetFlipRate(c4fVideo, wantedRate);
if (result < 0) printf("[c4f-launcher] display rate change failed: 0x%08x\n", (unsigned)result);
requestedRate = wantedRate;
}
C4fLauncherScreen visible = c4fVisibleScreen(&snapshot);
/* Redraw only when something visible changed, then copy each buffer once. */
if (!drawn || memcmp(&visible, &drawnState, sizeof(visible))) {
if (snapshot.tester.active) {
#ifdef C4F_DIAG
uint64_t drawStart = c4fLauncherTimeMs();
c4fDrawTester(canvas, &snapshot);
uint64_t took = c4fLauncherTimeMs() - drawStart;
if (took > testDrawMax) testDrawMax = took;
testDraws++;
#else
c4fDrawTester(canvas, &snapshot);
#endif
} else {
#ifdef C4F_DIAG
c4fDrawDiag(canvas, &snapshot);
#else
c4fDrawLauncher(canvas, &snapshot);
#endif
}
drawnState = visible; drawn = 1; shown[0] = shown[1] = 0;
}
#ifdef C4F_DIAG
uint64_t copiedAt = c4fLauncherTimeMs();
#endif
if (!shown[buffer]) { memcpy(c4fBuffers[buffer], canvas, frameBytes); shown[buffer] = 1; }
#ifdef C4F_DIAG
uint64_t presentAt = c4fLauncherTimeMs();
uint64_t copyMs = presentAt - copiedAt;
#endif
if (sceVideoOutSubmitFlip(c4fVideo, buffer, ORBIS_VIDEO_OUT_FLIP_VSYNC, frame) < 0) break;
for (;;) {
OrbisVideoOutFlipStatus status;
@@ -278,14 +714,35 @@ int main(void)
OrbisKernelUseconds timeout = 100000;
sceKernelWaitEqueue(c4fFlipQueue, &event, 1, &count, &timeout);
}
#ifdef C4F_DIAG
if (snapshot.tester.active) {
uint64_t completedAt = c4fLauncherTimeMs();
uint64_t presentMs = completedAt - presentAt, frameMs = completedAt - now;
uint64_t inputAge = sampledAt && now >= sampledAt ? now - sampledAt : 0;
if (copyMs > testCopyMax) testCopyMax = copyMs;
if (presentMs > testPresentMax) testPresentMax = presentMs;
if (frameMs > testFrameMax) testFrameMax = frameMs;
if (inputAge > testInputAgeMax) testInputAgeMax = inputAge;
testFrames++;
}
if (testFrames && now >= testDrawReportAt) {
printf("[c4f-launcher] controller test: %llu frames, %llu redraws; max draw %llu, copy %llu, present %llu, frame %llu, sample age %llu ms\n",
(unsigned long long)testFrames, (unsigned long long)testDraws,
(unsigned long long)testDrawMax, (unsigned long long)testCopyMax,
(unsigned long long)testPresentMax, (unsigned long long)testFrameMax,
(unsigned long long)testInputAgeMax);
testFrames = testDraws = testDrawMax = testCopyMax = testPresentMax = testFrameMax = testInputAgeMax = 0;
testDrawReportAt = now + 5000;
}
#endif
buffer ^= 1; frame++;
}
pthread_mutex_lock(&c4fMutex); c4fQuit = 1; pthread_mutex_unlock(&c4fMutex);
if (inputStarted) pthread_join(inputWorker, NULL);
if (workerStarted) pthread_join(worker, NULL);
#ifdef C4F_DIAG
c4fDiagStop();
#endif
if (pad >= 0) scePadClose(pad);
sceNetCtlTerm();
c4fCloseVideo();
free(canvas);
+187
View File
@@ -0,0 +1,187 @@
#include "navigation.h"
#include "autorun.h"
#include "input.h"
#define C4F_NAV_BACK C4F_INPUT_BUTTON_CIRCLE
#define C4F_NAV_OK C4F_INPUT_BUTTON_CROSS
#define C4F_NAV_PREVIOUS (C4F_INPUT_BUTTON_UP | C4F_INPUT_BUTTON_LEFT)
#define C4F_NAV_NEXT (C4F_INPUT_BUTTON_DOWN | C4F_INPUT_BUTTON_RIGHT)
#define C4F_NAV_DIRECTIONS (C4F_NAV_PREVIOUS | C4F_NAV_NEXT)
int c4fActionAvailable(const C4fLauncherScreen *screen, C4fLauncherAction action, int diagnostic)
{
switch (action) {
case C4F_ACTION_RESUME: return 1;
case C4F_ACTION_START: return !screen->busy && !screen->locked && screen->running != 1;
case C4F_ACTION_AUTORUN:
return !screen->busy && !screen->locked && screen->autorun != C4F_AUTORUN_UNKNOWN;
case C4F_ACTION_STOP: return !screen->busy && !screen->locked && screen->running == 1;
case C4F_ACTION_CAPTURE: return !!diagnostic;
case C4F_ACTION_TESTER: return !screen->locked;
case C4F_ACTION_CLOSE: return !screen->busy;
default: return 0;
}
}
int c4fActionList(const C4fLauncherScreen *screen, int diagnostic,
C4fLauncherAction *actions, int capacity)
{
const C4fLauncherAction order[] = { C4F_ACTION_RESUME, C4F_ACTION_START,
C4F_ACTION_AUTORUN, C4F_ACTION_STOP, C4F_ACTION_TESTER, C4F_ACTION_CAPTURE, C4F_ACTION_CLOSE };
int count = 0;
for (unsigned i = 0; i < sizeof(order) / sizeof(order[0]); i++) {
if (!c4fActionAvailable(screen, order[i], diagnostic)) continue;
if (actions && count < capacity) actions[count] = order[i];
count++;
}
return count;
}
const char *c4fActionLabel(const C4fLauncherScreen *screen, C4fLauncherAction action)
{
switch (action) {
case C4F_ACTION_RESUME: return "Resume";
case C4F_ACTION_START: return screen->autorun == C4F_AUTORUN_ON ? "Start Control4Free" : "Set up and start";
case C4F_ACTION_AUTORUN:
if (screen->autorun == C4F_AUTORUN_ON) return "Turn auto-start off";
if (screen->autorun == C4F_AUTORUN_OUTDATED) return "Update auto-start";
return "Turn auto-start on";
case C4F_ACTION_STOP: return "Stop Control4Free";
case C4F_ACTION_CLOSE: return "Close app";
case C4F_ACTION_CAPTURE: return "Capture kernel log";
case C4F_ACTION_TESTER: return "Test controllers";
default: return "";
}
}
static C4fLauncherAction c4fDefaultAction(const C4fLauncherScreen *screen, int diagnostic)
{
return c4fActionAvailable(screen, C4F_ACTION_START, diagnostic) ? C4F_ACTION_START : C4F_ACTION_RESUME;
}
static int c4fDirection(uint32_t buttons)
{
int previous = !!(buttons & C4F_NAV_PREVIOUS), next = !!(buttons & C4F_NAV_NEXT);
return previous == next ? 0 : next ? 1 : -1;
}
static void c4fConsume(uint32_t *standardUnhandled, uint32_t *remoteUnhandled)
{
if (standardUnhandled) *standardUnhandled = 0;
if (remoteUnhandled) *remoteUnhandled = 0;
}
static void c4fCancelConfirmation(C4fLauncherScreen *screen)
{
screen->confirmStop = 0;
screen->confirmChoice = 0;
screen->confirmMenu = 0;
}
C4fLauncherAction c4fNavigate(C4fLauncherScreen *screen, uint32_t standardPressed,
uint32_t remotePressed, int diagnostic,
uint32_t *standardUnhandled, uint32_t *remoteUnhandled)
{
if (standardUnhandled) *standardUnhandled = standardPressed;
if (remoteUnhandled) *remoteUnhandled = remotePressed;
uint32_t pressed = standardPressed | remotePressed;
if (screen->confirmStop) {
c4fConsume(standardUnhandled, remoteUnhandled);
if (!c4fActionAvailable(screen, C4F_ACTION_STOP, diagnostic)) {
c4fCancelConfirmation(screen);
return C4F_ACTION_NONE;
}
if (pressed & C4F_NAV_BACK) {
c4fCancelConfirmation(screen);
return C4F_ACTION_RESUME;
}
/* The DS4 shortcut displays Cross-to-stop. A remote's first event
* instead reveals an explicit, Cancel-focused selection and cannot
* accept the confirmation that another source opened. */
if (!screen->confirmMenu && remotePressed) {
screen->confirmMenu = 1;
screen->confirmChoice = 0;
return C4F_ACTION_NONE;
}
if (pressed & C4F_NAV_DIRECTIONS) {
if (!screen->confirmMenu) {
screen->confirmMenu = 1;
screen->confirmChoice = 0;
}
int direction = c4fDirection(pressed);
if (direction) screen->confirmChoice = direction > 0;
return C4F_ACTION_NONE;
}
if (pressed & C4F_NAV_OK) {
int stop = screen->confirmChoice;
c4fCancelConfirmation(screen);
return stop ? C4F_ACTION_STOP : C4F_ACTION_RESUME;
}
return C4F_ACTION_NONE;
}
if (screen->actionMenu) {
c4fConsume(standardUnhandled, remoteUnhandled);
if (!c4fActionAvailable(screen, (C4fLauncherAction)screen->actionFocus, diagnostic)) {
screen->actionFocus = c4fDefaultAction(screen, diagnostic);
return C4F_ACTION_NONE;
}
if (pressed & C4F_NAV_BACK) {
screen->actionMenu = 0;
return C4F_ACTION_RESUME;
}
if (pressed & C4F_NAV_DIRECTIONS) {
int direction = c4fDirection(pressed);
if (direction) {
C4fLauncherAction actions[8];
int count = c4fActionList(screen, diagnostic, actions, 8), at = 0;
while (at < count && (int)actions[at] != screen->actionFocus) at++;
screen->actionFocus = actions[(at + count + direction) % count];
}
return C4F_ACTION_NONE;
}
if (pressed & C4F_NAV_OK) {
C4fLauncherAction action = (C4fLauncherAction)screen->actionFocus;
screen->actionMenu = 0;
if (action == C4F_ACTION_STOP) {
screen->confirmStop = 1;
screen->confirmChoice = 0;
screen->confirmMenu = 1;
return C4F_ACTION_NONE;
}
return action;
}
return C4F_ACTION_NONE;
}
/* A TV remote needs an action menu because it has no Triangle or Square.
* In diagnostic mode its arrows keep the existing paging and scrolling. */
if ((remotePressed & (C4F_NAV_OK | C4F_INPUT_BUTTON_OPTIONS)) ||
(!diagnostic && (pressed & C4F_NAV_DIRECTIONS))) {
c4fConsume(standardUnhandled, remoteUnhandled);
screen->actionMenu = 1;
screen->actionFocus = c4fDefaultAction(screen, diagnostic);
return C4F_ACTION_NONE;
}
/* A DS4's Options opens the controller test. The diagnostic build keeps
* Options for capturing the kernel log. */
if (!diagnostic && (standardPressed & C4F_INPUT_BUTTON_OPTIONS) &&
c4fActionAvailable(screen, C4F_ACTION_TESTER, diagnostic)) {
c4fConsume(standardUnhandled, remoteUnhandled);
return C4F_ACTION_TESTER;
}
if (screen->busy) return C4F_ACTION_NONE;
if (pressed & C4F_NAV_BACK) {
c4fConsume(standardUnhandled, remoteUnhandled);
return C4F_ACTION_CLOSE;
}
if (standardPressed & C4F_NAV_OK)
return c4fActionAvailable(screen, C4F_ACTION_START, diagnostic) ? C4F_ACTION_START : C4F_ACTION_NONE;
if ((standardPressed & C4F_INPUT_BUTTON_TRIANGLE) &&
c4fActionAvailable(screen, C4F_ACTION_AUTORUN, diagnostic)) return C4F_ACTION_AUTORUN;
if ((standardPressed & C4F_INPUT_BUTTON_SQUARE) && c4fActionAvailable(screen, C4F_ACTION_STOP, diagnostic)) {
screen->confirmStop = 1;
screen->confirmChoice = 1;
screen->confirmMenu = 0;
c4fConsume(standardUnhandled, remoteUnhandled);
}
return C4F_ACTION_NONE;
}
+21
View File
@@ -0,0 +1,21 @@
#ifndef C4F_LAUNCHER_NAVIGATION_H
#define C4F_LAUNCHER_NAVIGATION_H
#include "screen.h"
#ifdef __cplusplus
extern "C" {
#endif
/* Returns the number of available actions, writing up to capacity entries. */
int c4fActionList(const C4fLauncherScreen *screen, int diagnostic,
C4fLauncherAction *actions, int capacity);
int c4fActionAvailable(const C4fLauncherScreen *screen, C4fLauncherAction action, int diagnostic);
const char *c4fActionLabel(const C4fLauncherScreen *screen, C4fLauncherAction action);
/* Only rising edges are accepted. The remaining bits preserve diagnostic
* paging/scrolling outside the menu, without moving both a page and focus. */
C4fLauncherAction c4fNavigate(C4fLauncherScreen *screen, uint32_t standardPressed,
uint32_t remotePressed, int diagnostic,
uint32_t *standardUnhandled, uint32_t *remoteUnhandled);
#ifdef __cplusplus
}
#endif
#endif
+128 -57
View File
@@ -9,10 +9,9 @@
#include "autorun.h"
#include "draw.h"
#include "logo.h"
#include "navigation.h"
#include "qrcodegen.h"
/* Page CSS pixels to TV pixels. */
#define C4F_S 1.5f
#include "style.h"
/* Layout, in TV pixels. */
#define C4F_LEFT 150.0f
@@ -26,25 +25,6 @@
#define C4F_PANEL_TOP (C4F_ROW_TOP + C4F_ROW_HEIGHT + 28)
#define C4F_PANEL_HEIGHT 128.0f
/* The page's palette (:root). */
#define C4F_TEXT C4F_RGBA(244, 247, 255, 1)
#define C4F_MUTED C4F_RGBA(226, 236, 255, .72f)
#define C4F_FAINT C4F_RGBA(226, 236, 255, .46f)
#define C4F_LINE C4F_RGBA(255, 255, 255, .16f)
#define C4F_LINE_SOFT C4F_RGBA(255, 255, 255, .09f)
#define C4F_PANEL C4F_RGBA(3, 16, 44, .55f)
#define C4F_PANEL_STRONG C4F_RGBA(2, 11, 31, .86f)
#define C4F_TILE C4F_RGBA(12, 40, 98, .5f)
#define C4F_ACCENT C4F_RGBA(58, 140, 255, 1)
#define C4F_OK C4F_RGBA(69, 224, 138, 1)
#define C4F_WARN C4F_RGBA(255, 208, 77, 1)
#define C4F_BAD C4F_RGBA(255, 107, 125, 1)
#define C4F_CIRCLE_KEY C4F_RGBA(255, 102, 128, 1)
#define C4F_CROSS_KEY C4F_RGBA(134, 182, 255, 1)
#define C4F_SQUARE_KEY C4F_RGBA(255, 146, 220, 1)
#define C4F_TRIANGLE_KEY C4F_RGBA(63, 224, 184, 1)
#define C4F_WHITE C4F_RGBA(255, 255, 255, 1)
static uint32_t *c4fBackdrop;
static float c4fClamp(float v) { return v < 0 ? 0 : v > 1 ? 1 : v; }
@@ -171,7 +151,7 @@ static void c4fPaintWave(C4fCanvas *c, const float path[25], float offset, float
}
}
static void c4fPaintBackdrop(C4fCanvas *c, float scale)
void c4fPaintBackdrop(C4fCanvas *c, float scale)
{
const float w = (float)c->w, h = (float)c->h, sx = 2 * w / 3200;
c4fPaintGradient(c);
@@ -225,7 +205,7 @@ static void c4fBoxPass(const uint8_t *src, uint8_t *dst, int w, int h, int radiu
/* backdrop-filter: blur(sigma) under a tile. The backdrop never changes, so
* this is done once, clipped to the tile's rounded shape. */
static void c4fBlurUnder(C4fCanvas *c, float x, float y, float w, float h, float r, float sigma)
void c4fBlurUnder(C4fCanvas *c, float x, float y, float w, float h, float r, float sigma)
{
/* Three box passes of this radius approximate a Gaussian of that sigma. */
const int radius = (int)((sqrtf(4 * sigma * sigma + 1) - 1) / 2 + 0.5f), margin = 3 * radius;
@@ -269,7 +249,7 @@ static void c4fBlurUnder(C4fCanvas *c, float x, float y, float w, float h, float
/* ---- pieces of the page ---- */
/* The DualShock 4 mark, its cut-outs showing what is behind. */
static void c4fMark(C4fCanvas *c, float x, float y, float width, C4fColor color)
void c4fMark(C4fCanvas *c, float x, float y, float width, C4fColor color)
{
C4fPath path = { 0 };
c4fLogoPath(&path, 1);
@@ -279,8 +259,8 @@ static void c4fMark(C4fCanvas *c, float x, float y, float width, C4fColor color)
/* A DualShock 4 with its light bar lit: the bar glows over the top edge, and
* the cut-outs read as dark recesses. */
static void c4fLitController(C4fCanvas *c, float x, float y, float width, float angle,
C4fColor body, C4fColor light, int shadow)
void c4fLitController(C4fCanvas *c, float x, float y, float width, float angle,
C4fColor body, C4fColor light, int shadow)
{
C4fPath outline = { 0 }, detail = { 0 }, bar = { 0 };
C4fPlacement at = c4fLogoAt(x, y, width, angle);
@@ -309,13 +289,13 @@ static void c4fLitController(C4fCanvas *c, float x, float y, float width, float
c4fPathFree(&bar);
}
static void c4fTextCentered(C4fCanvas *c, int font, float size, float cx, float y, C4fColor color, const char *text)
void c4fTextCentered(C4fCanvas *c, int font, float size, float cx, float y, C4fColor color, const char *text)
{
c4fText(c, font, size, cx - c4fTextWidth(font, size, text) / 2, y, color, text);
}
/* .tile: --tile with a light sheen, a 1px line, a drop shadow and the colour bar. */
static void c4fTile(C4fCanvas *c, float x, float y, float w, float h, C4fColor bar)
void c4fTile(C4fCanvas *c, float x, float y, float w, float h, C4fColor bar)
{
const float r = 6 * C4F_S;
c4fShadowRoundRect(c, x, y, w, h, r, 0, 16 * C4F_S, 34 * C4F_S, -18 * C4F_S, C4F_RGBA(0, 0, 0, .9f));
@@ -327,7 +307,7 @@ static void c4fTile(C4fCanvas *c, float x, float y, float w, float h, C4fColor b
}
/* .panel */
static void c4fPanel(C4fCanvas *c, float x, float y, float w, float h, C4fColor fill, C4fColor line)
void c4fPanel(C4fCanvas *c, float x, float y, float w, float h, C4fColor fill, C4fColor line)
{
c4fFillRoundRect(c, x, y, w, h, 6 * C4F_S, fill);
c4fInsetRoundRect(c, x, y, w, h, 6 * C4F_S, 1 * C4F_S, line);
@@ -351,21 +331,18 @@ static void c4fRingBadge(C4fCanvas *c, float cx, float cy, C4fColor color, const
}
/* .dot, with the glow the page gives a live connection. */
static void c4fDot(C4fCanvas *c, float cx, float cy, C4fColor color, int glow)
void c4fDot(C4fCanvas *c, float cx, float cy, C4fColor color, int glow)
{
if (glow) c4fGlowCircle(c, cx, cy, 4 * C4F_S, 10 * C4F_S, 0, color);
c4fFillCircle(c, cx, cy, 4 * C4F_S, color);
}
/* Button guide in the PS4's manner, with the page's face-button colours. */
enum { C4F_KEY_CROSS, C4F_KEY_CIRCLE, C4F_KEY_SQUARE, C4F_KEY_TRIANGLE };
typedef struct { int key; const char *label; } C4fHint;
#define C4F_HINT_RADIUS 22.0f
#define C4F_HINT_GAP 14.0f
#define C4F_HINT_SPACING 40.0f
#define C4F_HINT_SIZE 26.0f
static void c4fHints(C4fCanvas *c, float right, float cy, const C4fHint *hints, int count)
void c4fHints(C4fCanvas *c, float right, float cy, const C4fHint *hints, int count)
{
float x = right;
for (int i = 0; i < count; i++)
@@ -382,6 +359,10 @@ static void c4fHints(C4fCanvas *c, float right, float cy, const C4fHint *hints,
c4fRing(c, cx, cy, 10.5f, 3.6f, C4F_CIRCLE_KEY);
} else if (hints[i].key == C4F_KEY_TRIANGLE) {
c4fTriangleOutline(c, cx, cy + 1.5f, 11.5f, 3.4f, C4F_TRIANGLE_KEY);
} else if (hints[i].key == C4F_KEY_OPTIONS) {
/* The page's menu mark, three lines, for the Options button. */
for (int line = -1; line <= 1; line++)
c4fSegment(c, cx - 9, cy + line * 6.5f, cx + 9, cy + line * 6.5f, 3, C4F_TEXT);
} else {
c4fSquareOutline(c, cx, cy, 8.5f, 3.4f, C4F_SQUARE_KEY);
}
@@ -443,18 +424,90 @@ static C4fLook c4fLookFor(const C4fLauncherScreen *s, char *meta, size_t metaSiz
meta, C4F_OK, C4F_ACCENT, 1 };
} else if (s->running == 0 && s->autorun == C4F_AUTORUN_ON) {
look = (C4fLook){ "Stopped", "Start Control4Free",
"It starts with GoldHEN after each restart. To start it now, turn on GoldHEN's PayLoader "
"and press Cross.",
"It starts with GoldHEN after each restart. To start now, enable GoldHEN's PayLoader "
"Server and press Cross.",
"Not running", C4F_FAINT, C4F_FAINT, 0 };
} else if (s->running == 0) {
look = (C4fLook){ "Stopped", "Set up Control4Free",
"Press Cross once. GoldHEN will start Control4Free each time it loads, and it starts "
"right away if GoldHEN's PayLoader is on.",
"Press Cross to set up auto-start. To start Control4Free now, enable GoldHEN's "
"PayLoader Server.",
"Not running", C4F_FAINT, C4F_FAINT, 0 };
}
return look;
}
/* The TV remote and controller share a visible action menu. Keep its rendering
* here so the normal and diagnostic screens use the same focus and confirmation. */
void c4fDrawActionOverlay(uint32_t *pixels, const C4fLauncherScreen *s)
{
if (!s->confirmStop && !s->actionMenu) return;
C4fCanvas c = { pixels, C4F_SCREEN_WIDTH, C4F_SCREEN_HEIGHT };
const float dw = 980;
const float dx = (c.w - dw) / 2;
int diagnostic = 0;
#ifdef C4F_DIAG
diagnostic = 1;
#endif
C4fLauncherAction actions[8];
int count = s->confirmStop ? 0 : c4fActionList(s, diagnostic, actions, 8);
const float dh = s->confirmStop ? (s->confirmMenu ? 410 : 330) : 220 + count * 72;
const float dy = (c.h - dh) / 2;
c4fFillRect(&c, 0, 0, c.w, c.h, C4F_RGBA(2, 11, 31, .65f));
c4fShadowRoundRect(&c, dx, dy, dw, dh, 6 * C4F_S, 0, 16 * C4F_S,
34 * C4F_S, -18 * C4F_S, C4F_RGBA(0, 0, 0, .9f));
c4fPanel(&c, dx, dy, dw, dh, C4F_RGBA(2, 11, 31, .97f), C4F_LINE);
c4fBandRoundRect(&c, dx, dy, dw, dh, 6 * C4F_S, 3 * C4F_S, C4F_ACCENT);
if (s->confirmStop) {
c4fText(&c, C4F_FONT_LIGHT, 48, dx + 48, dy + 92, C4F_TEXT, "Stop Control4Free?");
c4fTextWrapped(&c, C4F_FONT_LIGHT, 26, dx + 48, dy + 148, dw - 96, 39, 2, C4F_MUTED,
"Every controller disconnects. You can start Control4Free again from this screen.");
if (!s->confirmMenu) {
static const C4fHint hints[2] = { { C4F_KEY_CROSS, "Stop" }, { C4F_KEY_CIRCLE, "Cancel" } };
c4fHints(&c, dx + dw - 48, dy + dh - 58, hints, 2);
return;
}
const float bw = 240, gap = 24, by = dy + 235;
for (int i = 0; i < 2; i++) {
float bx = dx + dw - 48 - 2 * bw - gap + i * (bw + gap);
int focused = s->confirmChoice == i;
c4fFillRoundRect(&c, bx, by, bw, 64, 6 * C4F_S,
focused ? C4F_RGBA(40, 103, 205, .85f) : C4F_TILE);
c4fInsetRoundRect(&c, bx, by, bw, 64, 6 * C4F_S,
focused ? 3 : 1 * C4F_S, focused ? C4F_ACCENT : C4F_LINE);
c4fTextCentered(&c, C4F_FONT_REGULAR, 30, bx + bw / 2, by + 42,
focused ? C4F_TEXT : C4F_MUTED, i ? "Stop" : "Cancel");
}
c4fText(&c, C4F_FONT_LIGHT, 23, dx + 48, dy + dh - 48, C4F_MUTED,
"Arrows / D-pad: select OK / Cross: confirm Back / Circle: cancel");
return;
}
c4fText(&c, C4F_FONT_LIGHT, 48, dx + 48, dy + 84, C4F_TEXT, "Actions");
c4fText(&c, C4F_FONT_LIGHT, 24, dx + 48, dy + 125, C4F_MUTED,
s->busy ? "Working... You can return to the screen." : "Use arrows or the D-pad to choose an action.");
int focus = 0;
for (int i = 0; i < count; i++) if ((int)actions[i] == s->actionFocus) focus = i;
for (int i = 0; i < count; i++) {
const float rx = dx + 32, ry = dy + 153 + i * 72, rw = dw - 64;
int focused = i == focus;
c4fFillRoundRect(&c, rx, ry, rw, 64, 6 * C4F_S,
focused ? C4F_RGBA(40, 103, 205, .85f) : C4F_RGBA(255, 255, 255, .04f));
c4fInsetRoundRect(&c, rx, ry, rw, 64, 6 * C4F_S,
focused ? 3 : 1 * C4F_S, focused ? C4F_ACCENT : C4F_LINE_SOFT);
c4fText(&c, C4F_FONT_REGULAR, 30, rx + 24, ry + 42,
focused ? C4F_TEXT : C4F_MUTED, c4fActionLabel(s, actions[i]));
if (focused) {
float cx = rx + rw - 30, cy = ry + 32;
c4fSegment(&c, cx - 5, cy - 9, cx + 4, cy, 3, C4F_TEXT);
c4fSegment(&c, cx + 4, cy, cx - 5, cy + 9, 3, C4F_TEXT);
}
}
static const C4fHint hints[2] = { { C4F_KEY_CROSS, "Select" }, { C4F_KEY_CIRCLE, "Back" } };
c4fText(&c, C4F_FONT_LIGHT, 23, dx + 48, dy + dh - 32, C4F_MUTED, "TV remote: OK select / Back return");
c4fHints(&c, dx + dw - 48, dy + dh - 40, hints, 2);
}
void c4fDrawLauncher(uint32_t *pixels, const C4fLauncherScreen *s)
{
C4fCanvas c = { pixels, C4F_SCREEN_WIDTH, C4F_SCREEN_HEIGHT };
@@ -524,19 +577,25 @@ 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 = "Press Square, then Cross to stop, and Cross again to start this app's version. All players disconnect.";
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.";
panelDot = C4F_OK;
} else if (s->autorun == C4F_AUTORUN_OUTDATED) {
panelTitle = "Auto-start uses an older Control4Free";
panelText = "Press Triangle to switch GoldHEN's auto-start to this version. It takes effect after the "
"next restart.";
panelText = "Press Triangle to update auto-start. GoldHEN uses this version after the next restart.";
panelDot = C4F_WARN;
} else if (s->autorun == C4F_AUTORUN_OFF) {
panelTitle = "Auto-start is off";
panelText = "Press Triangle and GoldHEN starts Control4Free each time it loads, with no PC needed.";
panelText = "Press Triangle to turn auto-start on. GoldHEN then starts Control4Free each time it loads.";
} else {
panelTitle = "Auto-start status unknown";
panelText = s->autorunNote[0] ? s->autorunNote : "Checking with GoldHEN...";
@@ -550,9 +609,11 @@ void c4fDrawLauncher(uint32_t *pixels, const C4fLauncherScreen *s)
panelText);
/* .home-foot and the button guide. */
if (!s->confirmStop && !s->actionMenu)
c4fText(&c, C4F_FONT_LIGHT, 23, left, 973, C4F_MUTED, "TV remote: OK opens actions / arrows select / Back returns");
c4fText(&c, C4F_FONT_LIGHT, 20, left, 1030, C4F_FAINT, "Control4Free " C4F_LAUNCHER_VERSION);
if (!s->confirmStop && !s->busy) {
C4fHint hints[4];
C4fHint hints[5];
int n = 0;
if (!s->locked && !live) {
hints[n].key = C4F_KEY_CROSS;
@@ -567,23 +628,16 @@ void c4fDrawLauncher(uint32_t *pixels, const C4fLauncherScreen *s)
hints[n].key = C4F_KEY_SQUARE;
hints[n++].label = "Stop";
}
if (!s->locked) {
hints[n].key = C4F_KEY_OPTIONS;
hints[n++].label = "Test controllers";
}
hints[n].key = C4F_KEY_CIRCLE;
hints[n++].label = "Close";
c4fHints(&c, right, 1022, hints, n);
}
if (s->confirmStop) {
const float dw = 980, dh = 330, dx = (c.w - dw) / 2, dy = (c.h - dh) / 2;
static const C4fHint hints[2] = { { C4F_KEY_CROSS, "Stop" }, { C4F_KEY_CIRCLE, "Cancel" } };
c4fFillRect(&c, 0, 0, c.w, c.h, C4F_RGBA(2, 11, 31, .6f));
c4fShadowRoundRect(&c, dx, dy, dw, dh, 6 * C4F_S, 0, 16 * C4F_S, 34 * C4F_S, -18 * C4F_S, C4F_RGBA(0, 0, 0, .9f));
/* A little more opaque than --panel-strong, so the tiles do not ghost through. */
c4fPanel(&c, dx, dy, dw, dh, C4F_RGBA(2, 11, 31, .94f), C4F_LINE);
c4fText(&c, C4F_FONT_LIGHT, 48, dx + 48, dy + 96, C4F_TEXT, "Stop Control4Free?");
c4fTextWrapped(&c, C4F_FONT_LIGHT, 26, dx + 48, dy + 152, dw - 96, 39, 2, C4F_MUTED,
"Every controller disconnects. You can start Control4Free again from this screen.");
c4fHints(&c, dx + dw - 48, dy + dh - 58, hints, 2);
}
c4fDrawActionOverlay(pixels, s);
}
void c4fDrawIcon(uint32_t *pixels, int size)
@@ -598,18 +652,35 @@ void c4fDrawIcon(uint32_t *pixels, int size)
c4fTextCentered(&c, C4F_FONT_LIGHT, s * 0.1f, s / 2.0f, s * 0.89f, C4F_TEXT, "Control4Free");
}
/* The backdrop before this screen's blurs, for the controller test to build its
* own layer on. Taken once; NULL if memory was short. */
static uint32_t *c4fCleanBackdrop;
uint32_t *c4fTakeCleanBackdrop(void)
{
uint32_t *taken = c4fCleanBackdrop;
c4fCleanBackdrop = NULL;
return taken;
}
int c4fScreenInit(void)
{
if (c4fDrawInit()) return -1;
if (!c4fBackdrop) {
c4fBackdrop = malloc((size_t)C4F_SCREEN_WIDTH * C4F_SCREEN_HEIGHT * sizeof(uint32_t));
const size_t bytes = (size_t)C4F_SCREEN_WIDTH * C4F_SCREEN_HEIGHT * sizeof(uint32_t);
c4fBackdrop = malloc(bytes);
if (!c4fBackdrop) return -1;
C4fCanvas c = { c4fBackdrop, C4F_SCREEN_WIDTH, C4F_SCREEN_HEIGHT };
c4fPaintBackdrop(&c, C4F_S);
c4fCleanBackdrop = malloc(bytes);
if (c4fCleanBackdrop) memcpy(c4fCleanBackdrop, c4fBackdrop, bytes);
/* .tile has backdrop-filter: blur(6px), .panel blur(10px). */
c4fBlurUnder(&c, C4F_LEFT, C4F_ROW_TOP, C4F_MAIN_WIDTH, C4F_ROW_HEIGHT, 6 * C4F_S, 6 * C4F_S);
c4fBlurUnder(&c, C4F_QR_LEFT, C4F_ROW_TOP, C4F_QR_WIDTH, C4F_ROW_HEIGHT, 6 * C4F_S, 6 * C4F_S);
c4fBlurUnder(&c, C4F_LEFT, C4F_PANEL_TOP, C4F_RIGHT - C4F_LEFT, C4F_PANEL_HEIGHT, 6 * C4F_S, 10 * C4F_S);
#ifndef C4F_DIAG
c4fDrawSolidarityBadge(&c, C4F_SCREEN_WIDTH - 64, 40);
#endif
}
return 0;
}
+27 -1
View File
@@ -1,16 +1,38 @@
#ifndef C4F_LAUNCHER_SCREEN_H
#define C4F_LAUNCHER_SCREEN_H
#include <stdint.h>
#include "tester.h"
/* Set by the Makefile from the VERSION file. */
#ifndef C4F_LAUNCHER_VERSION
#define C4F_LAUNCHER_VERSION "dev"
#endif
#define C4F_SCREEN_WIDTH 1920
#define C4F_SCREEN_HEIGHT 1080
typedef enum {
C4F_ACTION_NONE,
C4F_ACTION_RESUME,
C4F_ACTION_START,
C4F_ACTION_AUTORUN,
C4F_ACTION_STOP,
C4F_ACTION_CLOSE,
C4F_ACTION_CAPTURE,
C4F_ACTION_TESTER
} C4fLauncherAction;
/* Connection round trips for the selected user's virtual-controller slots.
* A negative RTT means that no fresh measurement is available yet. */
typedef struct {
int available;
unsigned pads;
int rttMs[4];
} C4fTesterConnection;
typedef struct {
int running, busy, controllers, confirmStop, locked;
int actionMenu, actionFocus;
int confirmChoice, confirmMenu; /* 0 Cancel / 1 Stop; menu-origin confirmation. */
int autorun; /* C4F_AUTORUN_* from autorun.h */
char address[64], message[160], autorunNote[96];
char address[64], message[160], autorunNote[96], runningVersion[32], inputNote[160];
C4fTesterView tester; /* the controller test, when active */
C4fTesterConnection connection;
#ifdef C4F_DIAG
unsigned diagGeneration; /* bumped when a diagnostic page changes */
int diagPage, diagScroll[8];
@@ -24,6 +46,10 @@ int c4fScreenInit(void);
/* Draws a whole frame. Blending reads pixels back, so pass cached memory,
* never the write-combined framebuffer. */
void c4fDrawLauncher(uint32_t *pixels, const C4fLauncherScreen *state);
/* Shared actions and stop-confirmation overlay for both package variants. */
void c4fDrawActionOverlay(uint32_t *pixels, const C4fLauncherScreen *state);
/* The controller test, in both package variants (tester_draw.c). */
void c4fDrawTester(uint32_t *pixels, const C4fLauncherScreen *state);
/* The package icon (size x size), in the same style. */
void c4fDrawIcon(uint32_t *pixels, int size);
#ifdef __cplusplus
+44 -7
View File
@@ -192,17 +192,35 @@ static int c4fEquals(const char *json, const jsmntok_t *token, const char *value
return token->end - token->start == (int)strlen(value) && !memcmp(json + token->start, value, strlen(value));
}
static int c4fStatusInteger(const char *json, const jsmntok_t *token, int64_t maximum, int64_t *out)
{
if (token->type != JSMN_PRIMITIVE) return -1;
if (c4fEquals(json, token, "-1")) { *out = -1; return 0; }
int length = token->end - token->start;
if (length < 1 || length > 10 || (length > 1 && json[token->start] == '0')) return -1;
int64_t value = 0;
for (int i = token->start; i < token->end; i++) {
if (json[i] < '0' || json[i] > '9') return -1;
value = value * 10 + json[i] - '0';
}
if (value > maximum) return -1;
*out = value;
return 0;
}
int c4fLauncherProbe(C4fServiceStatus *status)
{
char body[512]; jsmntok_t tokens[24]; jsmn_parser parser;
char body[1024]; jsmntok_t tokens[64]; jsmn_parser parser;
memset(status, 0, sizeof(*status));
for (int i = 0; i < C4F_SERVICE_PADS; i++) status->latency[i].rttMs = status->latency[i].ageMs = -1;
int result = c4fHttp("GET", "/api/status", body, sizeof(body));
if (result <= 0) return result;
jsmn_init(&parser);
int count = jsmn_parse(&parser, body, strlen(body), tokens, 24);
int count = jsmn_parse(&parser, body, strlen(body), tokens, 64);
snprintf(c4fProblem, sizeof(c4fProblem), "the status reply is not from Control4Free");
if (count < 1 || tokens[0].type != JSMN_OBJECT || tokens[0].end != (int)strlen(body)) return -1;
int app = 0, api = 0, controllers = 0, stopping = 0;
unsigned latencyFields[C4F_SERVICE_PADS] = { 0 };
for (int i = 1; i + 1 < count; i += 2) {
jsmntok_t *key = &tokens[i], *value = &tokens[i+1];
if (key->type != JSMN_STRING || (value->type != JSMN_STRING && value->type != JSMN_PRIMITIVE)) return -1;
@@ -218,9 +236,28 @@ int c4fLauncherProbe(C4fServiceStatus *status)
int size = value->end - value->start;
if (value->type != JSMN_STRING || size >= (int)sizeof(status->version)) return -1;
memcpy(status->version, body + value->start, (size_t)size);
} else if (c4fEquals(body, key, "latency")) {
if (value->type != JSMN_PRIMITIVE || (!c4fEquals(body, value, "true") && !c4fEquals(body, value, "false"))) return -1;
status->latencyAvailable = c4fEquals(body, value, "true");
} else {
static const char *const fields[] = { "User", "Rtt", "Age" };
for (int p = 0; p < C4F_SERVICE_PADS; p++) for (int f = 0; f < 3; f++) {
char name[16];
snprintf(name, sizeof(name), "pad%d%s", p, fields[f]);
if (!c4fEquals(body, key, name)) continue;
int64_t number;
if ((latencyFields[p] & (1U << f)) ||
c4fStatusInteger(body, value, f == 0 ? UINT32_MAX : f == 1 ? 60000 : INT32_MAX, &number)) return -1;
latencyFields[p] |= 1U << f;
C4fServiceLatency *link = &status->latency[p];
if (f == 0) { link->userKnown = number >= 0; link->userId = number >= 0 ? (uint32_t)number : 0; }
else if (f == 1) link->rttMs = (int)number;
else link->ageMs = (int)number;
}
}
}
if (!(app && api && controllers && stopping)) return -1;
for (int p = 0; p < C4F_SERVICE_PADS; p++) if (latencyFields[p] != 7) status->latencyAvailable = 0;
c4fProblem[0] = 0;
return 1;
}
@@ -239,10 +276,10 @@ int c4fLauncherStart(const unsigned char *payload, size_t payloadSize, char *mes
const char *targets[2];
int count = c4fTargets(targets), fd = -1;
for (int i = 0; i < count && fd < 0; i++) fd = c4fConnect(targets[i], C4F_PAYLOADER_PORT);
if (fd < 0) { snprintf(message, size, "PayLoader did not answer (errno %d). Turn it on in GoldHEN, then press Cross again.", errno); return -1; }
if (fd < 0) { snprintf(message, size, "PayLoader did not answer (errno %d). Enable PayLoader Server in GoldHEN > Server Settings, then retry Start.", 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. Press Cross to try again."); return -2; }
if (failed) { snprintf(message, size, "The transfer to PayLoader broke off. Retry Start."); return -2; }
uint64_t deadline;
deadline = c4fLauncherTimeMs() + 20000;
while (c4fLauncherTimeMs() < deadline) {
@@ -251,7 +288,7 @@ int c4fLauncherStart(const unsigned char *payload, size_t payloadSize, char *mes
}
usleep(250000);
}
snprintf(message, size, "Sent, but Control4Free did not start (%s). Press Cross to try again.", c4fProblem);
snprintf(message, size, "Sent, but Control4Free did not start (%s). Retry Start.", 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;
@@ -261,7 +298,7 @@ int c4fLauncherStop(char *message, size_t size)
{
C4fServiceStatus status;
int probe = c4fLauncherProbe(&status);
if (probe == 0) { snprintf(message, size, "Stopped. Press Cross to start it again."); return 0; }
if (probe == 0) { snprintf(message, size, "Stopped. Ready to start again."); return 0; }
if (probe != 1 && !c4fReached) { snprintf(message, size, "The app cannot reach Control4Free (%s). Stop it on its controller page.", c4fProblem); return -1; }
if (probe != 1) { snprintf(message, size, "This copy does not answer the app (%s). Stop it on its controller page.", c4fProblem); return -1; }
char reply[256];
@@ -269,7 +306,7 @@ int c4fLauncherStop(char *message, size_t size)
if (ret != 1) { snprintf(message, size, "Stop was not confirmed (%s). Check the controller page.", c4fProblem); return -1; }
uint64_t deadline = c4fLauncherTimeMs() + 5000;
while (c4fLauncherTimeMs() < deadline) {
if (c4fLauncherProbe(&status) == 0) { snprintf(message, size, "Stopped. Press Cross to start it again."); return 0; }
if (c4fLauncherProbe(&status) == 0) { snprintf(message, size, "Stopped. Ready to start again."); return 0; }
usleep(100000);
}
snprintf(message, size, "Still stopping. Wait a moment, then try again."); return -1;
+13 -1
View File
@@ -7,7 +7,19 @@ extern "C" {
#endif
enum { C4F_SERVICE_UNKNOWN = -1, C4F_SERVICE_DOWN = 0, C4F_SERVICE_RUNNING = 1 };
typedef struct { int controllers, stopping; char version[24]; } C4fServiceStatus;
#define C4F_SERVICE_PADS 4
#define C4F_SERVICE_LATENCY_STALE_MS 6000
typedef struct {
int userKnown;
uint32_t userId;
int rttMs, ageMs;
} C4fServiceLatency;
typedef struct {
int controllers, stopping;
char version[24];
int latencyAvailable;
C4fServiceLatency latency[C4F_SERVICE_PADS];
} C4fServiceStatus;
uint64_t c4fLauncherTimeMs(void);
/* The console's own IPv4 address, when known. The app's sandbox may not reach
* 127.0.0.1, so it is the second way to reach Control4Free and PayLoader. */
+52
View File
@@ -0,0 +1,52 @@
#ifndef C4F_LAUNCHER_STYLE_H
#define C4F_LAUNCHER_STYLE_H
/* The controller page's look, shared by the launcher's screens (screen.c,
* tester_draw.c): palette, sizes and the drawn pieces. */
#include "draw.h"
/* Page CSS pixels to TV pixels. */
#define C4F_S 1.5f
/* The page's palette (:root). */
#define C4F_TEXT C4F_RGBA(244, 247, 255, 1)
#define C4F_MUTED C4F_RGBA(226, 236, 255, .72f)
#define C4F_FAINT C4F_RGBA(226, 236, 255, .46f)
#define C4F_LINE C4F_RGBA(255, 255, 255, .16f)
#define C4F_LINE_SOFT C4F_RGBA(255, 255, 255, .09f)
#define C4F_PANEL C4F_RGBA(3, 16, 44, .55f)
#define C4F_PANEL_STRONG C4F_RGBA(2, 11, 31, .86f)
#define C4F_TILE C4F_RGBA(12, 40, 98, .5f)
#define C4F_ACCENT C4F_RGBA(58, 140, 255, 1)
#define C4F_OK C4F_RGBA(69, 224, 138, 1)
#define C4F_WARN C4F_RGBA(255, 208, 77, 1)
#define C4F_BAD C4F_RGBA(255, 107, 125, 1)
#define C4F_CIRCLE_KEY C4F_RGBA(255, 102, 128, 1)
#define C4F_CROSS_KEY C4F_RGBA(134, 182, 255, 1)
#define C4F_SQUARE_KEY C4F_RGBA(255, 146, 220, 1)
#define C4F_TRIANGLE_KEY C4F_RGBA(63, 224, 184, 1)
#define C4F_WHITE C4F_RGBA(255, 255, 255, 1)
/* Button guide in the PS4's manner, with the page's face-button colours. */
enum { C4F_KEY_CROSS, C4F_KEY_CIRCLE, C4F_KEY_SQUARE, C4F_KEY_TRIANGLE, C4F_KEY_OPTIONS };
typedef struct { int key; const char *label; } C4fHint;
void c4fPaintBackdrop(C4fCanvas *c, float scale);
/* The full-screen backdrop without the launcher's blurs, handed over once
* (the caller frees it); NULL when it is gone or memory was short. */
uint32_t *c4fTakeCleanBackdrop(void);
/* backdrop-filter: blur(sigma) under a rounded box, done once on a still backdrop. */
void c4fBlurUnder(C4fCanvas *c, float x, float y, float w, float h, float r, float sigma);
/* The DualShock 4 mark, its cut-outs showing what is behind. */
void c4fMark(C4fCanvas *c, float x, float y, float width, C4fColor color);
/* A DualShock 4 with its light bar lit. */
void c4fLitController(C4fCanvas *c, float x, float y, float width, float angle,
C4fColor body, C4fColor light, int shadow);
void c4fTextCentered(C4fCanvas *c, int font, float size, float cx, float y, C4fColor color, const char *text);
/* .tile, .panel, .dot */
void c4fTile(C4fCanvas *c, float x, float y, float w, float h, C4fColor bar);
void c4fPanel(C4fCanvas *c, float x, float y, float w, float h, C4fColor fill, C4fColor line);
void c4fDot(C4fCanvas *c, float cx, float cy, C4fColor color, int glow);
/* Right-aligned at `right`, centred on cy. */
void c4fHints(C4fCanvas *c, float right, float cy, const C4fHint *hints, int count);
#endif
+561
View File
@@ -0,0 +1,561 @@
#include "tester.h"
#include <math.h>
#include <stdio.h>
#include <string.h>
#include "input.h"
#define C4F_TESTER_STICKS (C4F_INPUT_BUTTON_L3 | C4F_INPUT_BUTTON_R3)
#define C4F_TESTER_DIRECTIONS (C4F_INPUT_BUTTON_UP | C4F_INPUT_BUTTON_DOWN | C4F_INPUT_BUTTON_LEFT | C4F_INPUT_BUTTON_RIGHT)
#define C4F_TESTER_ACTIVATE (C4F_INPUT_BUTTON_CROSS | C4F_INPUT_BUTTON_CIRCLE)
int c4fTesterHoldUpdate(C4fTesterHold *hold, uint32_t buttons, uint64_t now)
{
if ((buttons & C4F_TESTER_STICKS) != C4F_TESTER_STICKS) {
hold->holding = hold->fired = 0;
return 0;
}
if (!hold->holding) {
hold->holding = 1;
hold->since = now;
}
if (hold->fired || now - hold->since < C4F_TESTER_HOLD_MS) return 0;
hold->fired = 1;
return 1;
}
static void c4fReaderState(C4fTester *t, int slot, int state, uint32_t error)
{
C4fTesterReader *r = &t->readers[slot];
if ((r->state != state || r->error != error) && t->ops.state) t->ops.state(t->ops.context, slot, state, error);
r->state = state;
r->error = error;
}
/* Undo this test's output on a reader whose handle still works. */
static void c4fReaderQuiet(C4fTester *t, int slot)
{
C4fTesterReader *r = &t->readers[slot];
if (r->handle >= 0 && t->rumble && t->rumbleReader == slot && t->ops.vibrate)
t->ops.vibrate(t->ops.context, r->handle, 0, 0);
if (r->handle >= 0 && r->lightSet && t->ops.resetLightBar)
t->ops.resetLightBar(t->ops.context, r->handle);
if (t->rumble && t->rumbleReader == slot) t->rumble = 0;
r->lightSet = 0;
}
static void c4fReaderRelease(C4fTester *t, int slot)
{
C4fTesterReader *r = &t->readers[slot];
if (r->handle >= 0 && r->owned && t->ops.close) t->ops.close(t->ops.context, r->handle);
if (t->rumble && t->rumbleReader == slot) t->rumble = 0;
r->handle = -1;
r->owned = r->lightSet = r->seen = 0;
r->previous = r->pressed = 0;
r->menuBlocked = r->repeatDirection = 0;
r->repeatAt = 0;
memset(&r->hold, 0, sizeof(r->hold));
memset(&r->sample, 0, sizeof(r->sample));
}
static void c4fReaderEmpty(C4fTester *t, int slot)
{
c4fReaderRelease(t, slot);
C4fTesterReader *r = &t->readers[slot];
r->user = -1;
r->state = C4F_TESTER_OPENING;
r->error = 0;
r->name[0] = 0;
}
/* Signed-out readers stay only while they are the one on screen. */
static void c4fTesterForgetSignedOut(C4fTester *t)
{
for (int s = 0; s < C4F_TESTER_USERS; s++)
if (s != t->selected && t->readers[s].user >= 0 && t->readers[s].state == C4F_TESTER_SIGNED_OUT)
c4fReaderEmpty(t, s);
}
static void c4fTesterSelectFirst(C4fTester *t)
{
if (t->selected >= 0 && t->readers[t->selected].user >= 0) return;
t->selected = -1;
for (int s = 0; s < C4F_TESTER_USERS && t->selected < 0; s++)
if (t->readers[s].user >= 0) t->selected = s;
}
static void c4fTesterUsers(C4fTester *t, uint64_t now)
{
int32_t users[C4F_TESTER_USERS];
t->usersAt = now + C4F_TESTER_RETRY_MS;
if (!t->ops.loginUsers) return;
for (int i = 0; i < C4F_TESTER_USERS; i++) users[i] = -1;
int count = t->ops.loginUsers(t->ops.context, users);
if (count < 0) return; /* keep what is known until the next try */
if (count > C4F_TESTER_USERS) count = C4F_TESTER_USERS;
for (int s = 0; s < C4F_TESTER_USERS; s++) {
C4fTesterReader *r = &t->readers[s];
if (r->user < 0) continue;
int present = 0;
for (int i = 0; i < count; i++) present |= users[i] == r->user;
if (present) {
if (r->state == C4F_TESTER_SIGNED_OUT) {
c4fReaderState(t, s, C4F_TESTER_OPENING, 0);
r->retryAt = now;
}
} else if (r->state != C4F_TESTER_SIGNED_OUT) {
c4fReaderQuiet(t, s);
if (s == t->selected) {
c4fReaderRelease(t, s);
c4fReaderState(t, s, C4F_TESTER_SIGNED_OUT, 0);
} else {
c4fReaderEmpty(t, s);
}
}
}
for (int i = 0; i < count; i++) {
int known = users[i] < 0;
for (int s = 0; s < C4F_TESTER_USERS && !known; s++) known = t->readers[s].user == users[i];
for (int s = 0; s < C4F_TESTER_USERS && !known; s++) {
C4fTesterReader *r = &t->readers[s];
if (r->user >= 0) continue;
r->user = users[i];
r->name[0] = 0;
if (t->ops.userName) t->ops.userName(t->ops.context, r->user, r->name, (int)sizeof(r->name));
r->name[sizeof(r->name) - 1] = 0;
/* Names are drawn in plain ASCII. */
for (char *p = r->name; *p; p++) if ((unsigned char)*p < 32 || (unsigned char)*p > 126) *p = '?';
if (!r->name[0]) snprintf(r->name, sizeof(r->name), "User %d", s + 1);
r->retryAt = now;
c4fReaderState(t, s, C4F_TESTER_OPENING, 0);
known = 1;
}
}
c4fTesterSelectFirst(t);
}
static void c4fReaderPoll(C4fTester *t, int slot, uint64_t now)
{
C4fTesterReader *r = &t->readers[slot];
r->pressed = 0;
if (r->user < 0 || r->state == C4F_TESTER_SIGNED_OUT) return;
if (r->handle < 0) {
if (now < r->retryAt || !t->ops.open) return;
int owned = 0, handle = t->ops.open(t->ops.context, r->user, &owned);
if (handle < 0) {
c4fReaderState(t, slot, C4F_TESTER_FAILED, (uint32_t)handle);
r->retryAt = now + C4F_TESTER_RETRY_MS;
return;
}
r->handle = handle;
r->owned = !!owned;
r->seen = 0;
/* Games turn the motion sensor on for their own handles; so does this. */
if (t->ops.motionSensor) t->ops.motionSensor(t->ops.context, handle, 1);
}
C4fTesterSample sample;
memset(&sample, 0, sizeof(sample));
int result = t->ops.read ? t->ops.read(t->ops.context, r->handle, &sample) : -1;
if (result != 0) {
c4fReaderQuiet(t, slot);
c4fReaderRelease(t, slot);
c4fReaderState(t, slot, C4F_TESTER_FAILED, (uint32_t)result);
r->retryAt = now + C4F_TESTER_RETRY_MS;
return;
}
if (!sample.connected) {
c4fReaderQuiet(t, slot);
memset(&r->sample, 0, sizeof(r->sample));
r->previous = 0;
r->seen = 0;
c4fReaderState(t, slot, C4F_TESTER_NO_CONTROLLER, 0);
return;
}
if (sample.touches > 2) sample.touches = 2;
if (!r->seen) {
/* A hold already in progress when a reader starts cannot open a menu. */
r->hold.holding = r->hold.fired = 1;
r->hold.since = now;
}
if (!r->seen && t->menu) r->menuBlocked |= sample.buttons & (C4F_TESTER_DIRECTIONS | C4F_TESTER_ACTIVATE);
/* Buttons already held when reading starts are shown, but are not presses. */
r->pressed = r->seen ? sample.buttons & ~r->previous : 0;
r->previous = sample.buttons;
r->seen = 1;
r->sample = sample;
c4fReaderState(t, slot, C4F_TESTER_READY, 0);
}
static void c4fTesterStopRumble(C4fTester *t)
{
if (!t->rumble) return;
C4fTesterReader *r = &t->readers[t->rumbleReader];
if (r->handle >= 0 && t->ops.vibrate) t->ops.vibrate(t->ops.context, r->handle, 0, 0);
t->rumble = 0;
}
static void c4fTesterNextUser(C4fTester *t, int direction)
{
int start = t->selected >= 0 ? t->selected : 0;
for (int step = 1; step <= C4F_TESTER_USERS; step++) {
int s = (start + direction * step + 2 * C4F_TESTER_USERS) % C4F_TESTER_USERS;
if (t->readers[s].user >= 0 && t->readers[s].state != C4F_TESTER_SIGNED_OUT) {
t->selected = s;
break;
}
}
c4fTesterForgetSignedOut(t);
t->note[0] = 0;
}
static void c4fTesterResult(C4fTester *t, int result, const char *done)
{
if (result) snprintf(t->note, sizeof(t->note), "The PS4 refused that (0x%08x).", (unsigned)result);
else snprintf(t->note, sizeof(t->note), "%s", done);
}
static void c4fTesterAct(C4fTester *t, int item, uint64_t now)
{
C4fTesterView view;
c4fTesterView(t, &view);
if (!c4fTesterItemAvailable(&view, item)) {
snprintf(t->note, sizeof(t->note), "%s", item == C4F_TESTER_USER ?
"No other user is signed in." : "This user has no controller to test.");
return;
}
C4fTesterReader *r = t->selected >= 0 ? &t->readers[t->selected] : NULL;
if (item == C4F_TESTER_RESUME) {
t->menu = 0;
return;
}
if (item == C4F_TESTER_USER) {
c4fTesterNextUser(t, 1);
return;
}
if (!r || r->user < 0 || r->handle < 0 || r->state != C4F_TESTER_READY) {
snprintf(t->note, sizeof(t->note), "This user has no controller to test.");
return;
}
switch (item) {
case C4F_TESTER_RUMBLE_LARGE:
case C4F_TESTER_RUMBLE_SMALL: {
int large = item == C4F_TESTER_RUMBLE_LARGE;
c4fTesterStopRumble(t);
int result = t->ops.vibrate ? t->ops.vibrate(t->ops.context, r->handle, large ? 255 : 0, large ? 0 : 255) : -1;
if (!result) {
t->rumble = large ? 1 : 2;
t->rumbleReader = t->selected;
t->rumbleUntil = now + C4F_TESTER_RUMBLE_MS;
}
c4fTesterResult(t, result, large ? "Large motor: rumbling for a second." : "Small motor: rumbling for a second.");
break;
}
case C4F_TESTER_LIGHT_COLOR: {
uint8_t rgb[3] = { 255, 255, 255 };
if (r->lightSet) memcpy(rgb, r->light, sizeof(rgb));
int result = t->ops.lightBar ? t->ops.lightBar(t->ops.context, r->handle, rgb[0], rgb[1], rgb[2]) : -1;
if (!result) {
r->lightSet = 1;
memcpy(r->light, rgb, 3);
t->colorMenu = 1;
t->note[0] = 0;
} else {
c4fTesterResult(t, result, "");
}
break;
}
case C4F_TESTER_LIGHT_RESET: {
int result = t->ops.resetLightBar ? t->ops.resetLightBar(t->ops.context, r->handle) : -1;
if (!result) r->lightSet = 0;
c4fTesterResult(t, result, "Light bar reset.");
break;
}
case C4F_TESTER_ORIENTATION: {
int result = t->ops.resetOrientation ? t->ops.resetOrientation(t->ops.context, r->handle) : -1;
c4fTesterResult(t, result, "Orientation reset.");
break;
}
default:
break;
}
}
void c4fTesterInit(C4fTester *t, const C4fTesterOps *ops)
{
memset(t, 0, sizeof(*t));
if (ops) t->ops = *ops;
t->selected = -1;
for (int s = 0; s < C4F_TESTER_USERS; s++) {
t->readers[s].user = -1;
t->readers[s].handle = -1;
}
}
void c4fTesterOpen(C4fTester *t, uint64_t now)
{
if (t->active) return;
C4fTesterOps ops = t->ops;
c4fTesterInit(t, &ops);
t->active = 1;
c4fTesterUsers(t, now);
}
static void c4fTesterQuietAll(C4fTester *t)
{
c4fTesterStopRumble(t);
for (int s = 0; s < C4F_TESTER_USERS; s++) c4fReaderQuiet(t, s);
}
void c4fTesterSuspend(C4fTester *t, uint64_t now)
{
if (!t->active) return;
c4fTesterQuietAll(t);
for (int s = 0; s < C4F_TESTER_USERS; s++) {
C4fTesterReader *r = &t->readers[s];
c4fReaderRelease(t, s);
if (r->user >= 0 && r->state != C4F_TESTER_SIGNED_OUT) {
c4fReaderState(t, s, C4F_TESTER_OPENING, 0);
r->retryAt = now;
}
}
t->menu = 0;
t->colorMenu = 0;
t->menuOpeners = 0;
t->armed = 0;
}
void c4fTesterClose(C4fTester *t)
{
if (!t->active) return;
c4fTesterQuietAll(t);
for (int s = 0; s < C4F_TESTER_USERS; s++) c4fReaderRelease(t, s);
C4fTesterOps ops = t->ops;
c4fTesterInit(t, &ops);
}
static void c4fTesterMenuOpen(C4fTester *t, int opener)
{
t->menu = 1;
t->colorMenu = 0;
t->focus = C4F_TESTER_RESUME;
t->menuOpeners = opener >= 0 ? 1u << opener : 0;
for (int s = 0; s < C4F_TESTER_USERS; s++) {
C4fTesterReader *r = &t->readers[s];
uint32_t buttons = r->state == C4F_TESTER_READY ? r->sample.buttons : 0;
r->menuBlocked = buttons & (C4F_TESTER_DIRECTIONS | C4F_TESTER_ACTIVATE);
r->repeatDirection = 0;
r->repeatAt = 0;
}
t->armed = !t->menuOpeners;
}
/* A held direction repeats, but activation always needs its own fresh edge.
* Track each controller separately so a key held on another pad cannot mask a
* new press, or create a repeat from input held before the menu opened. */
static uint32_t c4fTesterMenuDirection(C4fTesterReader *r, uint64_t now)
{
uint32_t held = r->sample.buttons & ~r->menuBlocked & C4F_TESTER_DIRECTIONS;
if ((held & (C4F_INPUT_BUTTON_UP | C4F_INPUT_BUTTON_DOWN)) == (C4F_INPUT_BUTTON_UP | C4F_INPUT_BUTTON_DOWN))
held &= ~(C4F_INPUT_BUTTON_UP | C4F_INPUT_BUTTON_DOWN);
if ((held & (C4F_INPUT_BUTTON_LEFT | C4F_INPUT_BUTTON_RIGHT)) == (C4F_INPUT_BUTTON_LEFT | C4F_INPUT_BUTTON_RIGHT))
held &= ~(C4F_INPUT_BUTTON_LEFT | C4F_INPUT_BUTTON_RIGHT);
uint32_t fresh = r->pressed & held;
uint32_t candidates = fresh ? fresh : held, direction = 0;
static const uint32_t directions[] = { C4F_INPUT_BUTTON_UP, C4F_INPUT_BUTTON_DOWN, C4F_INPUT_BUTTON_LEFT, C4F_INPUT_BUTTON_RIGHT };
for (unsigned i = 0; i < sizeof(directions) / sizeof(directions[0]); i++)
if (candidates & directions[i]) { direction = directions[i]; break; }
if (!direction || (!fresh && direction != r->repeatDirection)) {
r->repeatDirection = 0; r->repeatAt = 0;
return 0;
}
if (fresh) {
r->repeatDirection = direction;
r->repeatAt = now + C4F_TESTER_REPEAT_DELAY_MS;
return direction;
}
if (now < r->repeatAt) return 0;
r->repeatAt = now + C4F_TESTER_REPEAT_MS;
return direction;
}
static void c4fTesterMenuMove(C4fTester *t, uint32_t direction)
{
int row = t->focus / C4F_TESTER_MENU_COLUMNS;
int column = t->focus % C4F_TESTER_MENU_COLUMNS;
if (direction & C4F_INPUT_BUTTON_UP) {
row = (row + C4F_TESTER_MENU_ROWS - 1) % C4F_TESTER_MENU_ROWS;
} else if (direction & C4F_INPUT_BUTTON_DOWN) {
row = (row + 1) % C4F_TESTER_MENU_ROWS;
} else if (direction & C4F_INPUT_BUTTON_LEFT) {
column = (column + C4F_TESTER_MENU_COLUMNS - 1) % C4F_TESTER_MENU_COLUMNS;
} else if (direction & C4F_INPUT_BUTTON_RIGHT) {
column = (column + 1) % C4F_TESTER_MENU_COLUMNS;
}
t->focus = row * C4F_TESTER_MENU_COLUMNS + column;
}
static void c4fTesterColorInput(C4fTester *t, uint32_t navigation, uint32_t pressed)
{
if (pressed & C4F_INPUT_BUTTON_CIRCLE) {
t->colorMenu = 0;
snprintf(t->note, sizeof(t->note), "Light bar set. It goes back to normal when you leave.");
return;
}
if (navigation & C4F_INPUT_BUTTON_UP) t->colorChannel = (t->colorChannel + 2) % 3;
else if (navigation & C4F_INPUT_BUTTON_DOWN) t->colorChannel = (t->colorChannel + 1) % 3;
else if (navigation & (C4F_INPUT_BUTTON_LEFT | C4F_INPUT_BUTTON_RIGHT)) {
C4fTesterReader *r = &t->readers[t->selected];
uint8_t rgb[3];
memcpy(rgb, r->light, sizeof(rgb));
const int direction = navigation & C4F_INPUT_BUTTON_LEFT ? -1 : 1;
int value = rgb[t->colorChannel] + direction * (t->colorFine ? 1 : 16);
if (value < 0) value = 0;
if (value > 255) value = 255;
if (value == rgb[t->colorChannel]) return;
rgb[t->colorChannel] = (uint8_t)value;
int result = t->ops.lightBar ? t->ops.lightBar(t->ops.context, r->handle, rgb[0], rgb[1], rgb[2]) : -1;
if (!result) {
r->lightSet = 1;
memcpy(r->light, rgb, sizeof(rgb));
t->note[0] = 0;
} else c4fTesterResult(t, result, "");
} else if (pressed & C4F_INPUT_BUTTON_CROSS) t->colorFine = !t->colorFine;
}
int c4fTesterFrame(C4fTester *t, uint64_t now, uint32_t remotePressed)
{
if (!t->active) return 0;
if (now >= t->usersAt) c4fTesterUsers(t, now);
int menuOpener = -1;
for (int s = 0; s < C4F_TESTER_USERS; s++) {
c4fReaderPoll(t, s, now);
C4fTesterReader *r = &t->readers[s];
if (c4fTesterHoldUpdate(&r->hold, r->state == C4F_TESTER_READY ? r->sample.buttons : 0, now) && menuOpener < 0)
menuOpener = s;
}
if (t->rumble && now >= t->rumbleUntil) c4fTesterStopRumble(t);
if (t->colorMenu && (t->selected < 0 || t->readers[t->selected].state != C4F_TESTER_READY)) {
t->colorMenu = 0;
snprintf(t->note, sizeof(t->note), "This user's controller is unavailable.");
}
if (menuOpener >= 0 && !t->menu) {
c4fTesterMenuOpen(t, menuOpener);
return 1;
}
if (!t->menu) {
/* The remote is not under test, so it may leave and open the menu. */
if (remotePressed & C4F_INPUT_BUTTON_CIRCLE) {
c4fTesterClose(t);
return 0;
}
if (remotePressed & (C4F_INPUT_BUTTON_CROSS | C4F_INPUT_BUTTON_OPTIONS)) {
c4fTesterMenuOpen(t, -1);
}
return 1;
}
for (int s = 0; s < C4F_TESTER_USERS; s++) {
const C4fTesterReader *r = &t->readers[s];
if (r->state != C4F_TESTER_READY || !(r->sample.buttons & C4F_TESTER_STICKS)) t->menuOpeners &= ~(1u << s);
}
t->armed = !t->menuOpeners;
uint32_t navigation = remotePressed & C4F_TESTER_DIRECTIONS;
uint32_t pressed = t->armed ? remotePressed & C4F_TESTER_ACTIVATE : 0;
for (int s = 0; s < C4F_TESTER_USERS; s++) {
C4fTesterReader *r = &t->readers[s];
if (r->state != C4F_TESTER_READY) continue;
r->menuBlocked &= r->sample.buttons;
if (!t->armed) r->menuBlocked |= r->sample.buttons & C4F_TESTER_ACTIVATE;
navigation |= c4fTesterMenuDirection(r, now);
if (t->armed) pressed |= r->pressed & ~r->menuBlocked & C4F_TESTER_ACTIVATE;
}
if (t->colorMenu) {
c4fTesterColorInput(t, navigation, pressed);
} else if (pressed & C4F_INPUT_BUTTON_CIRCLE) {
t->menu = 0;
} else if (navigation) {
c4fTesterMenuMove(t, navigation);
} else if (pressed & C4F_INPUT_BUTTON_CROSS) {
if (t->focus == C4F_TESTER_EXIT) {
c4fTesterClose(t);
return 0;
}
c4fTesterAct(t, t->focus, now);
}
return 1;
}
int c4fTesterItemAvailable(const C4fTesterView *v, int item)
{
if (item < 0 || item >= C4F_TESTER_ITEMS) return 0;
if (item == C4F_TESTER_RESUME || item == C4F_TESTER_EXIT) return 1;
if (item == C4F_TESTER_USER) {
for (int s = 0; s < C4F_TESTER_USERS; s++)
if (s != v->selected && v->user[s].used && v->user[s].state != C4F_TESTER_SIGNED_OUT) return 1;
return 0;
}
return v->selected >= 0 && v->selected < C4F_TESTER_USERS && v->user[v->selected].state == C4F_TESTER_READY;
}
static int16_t c4fScaled(float value, float scale)
{
if (isnan(value)) return 0;
float v = roundf(value * scale);
return (int16_t)(v > 32767 ? 32767 : v < -32767 ? -32767 : v);
}
void c4fTesterView(const C4fTester *t, C4fTesterView *v)
{
memset(v, 0, sizeof(*v));
for (int s = 0; s < C4F_TESTER_USERS; s++) v->user[s].id = -1;
if (!t->active) return;
v->active = 1;
v->menu = t->menu;
v->focus = t->focus;
v->colorMenu = t->colorMenu;
v->colorChannel = t->colorChannel;
v->colorFine = t->colorFine;
v->selected = t->selected;
for (int s = 0; s < C4F_TESTER_USERS; s++) {
const C4fTesterReader *r = &t->readers[s];
v->user[s].used = r->user >= 0;
if (!v->user[s].used) continue;
v->user[s].id = r->user;
snprintf(v->user[s].name, sizeof(v->user[s].name), "%s", r->name);
v->user[s].state = r->state;
v->user[s].error = r->error;
}
snprintf(v->note, sizeof(v->note), "%s", t->note);
if (t->selected < 0) return;
const C4fTesterReader *r = &t->readers[t->selected];
v->rumble = t->rumble && t->rumbleReader == t->selected ? t->rumble : 0;
v->lightSet = r->lightSet;
memcpy(v->light, r->light, 3);
if (r->state != C4F_TESTER_READY) return;
const C4fTesterSample *s = &r->sample;
v->buttons = s->buttons;
v->lx = s->lx; v->ly = s->ly; v->rx = s->rx; v->ry = s->ry; v->l2 = s->l2; v->r2 = s->r2;
v->touches = s->touches;
for (int i = 0; i < v->touches; i++) {
v->touchX[i] = s->touch[i].x;
v->touchY[i] = s->touch[i].y;
}
for (int i = 0; i < 4; i++) v->quat[i] = c4fScaled(s->quat[i], 1000);
for (int i = 0; i < 3; i++) {
v->accel[i] = c4fScaled(s->accel[i], 100);
v->gyro[i] = c4fScaled(s->gyro[i], 100);
}
}
const char *c4fTesterItemLabel(int item)
{
switch (item) {
case C4F_TESTER_RESUME: return "Resume";
case C4F_TESTER_USER: return "Switch user";
case C4F_TESTER_RUMBLE_LARGE: return "Rumble the large motor";
case C4F_TESTER_RUMBLE_SMALL: return "Rumble the small motor";
case C4F_TESTER_LIGHT_COLOR: return "Choose the light-bar color";
case C4F_TESTER_LIGHT_RESET: return "Reset the light bar";
case C4F_TESTER_ORIENTATION: return "Reset orientation";
case C4F_TESTER_EXIT: return "Back to the launcher";
default: return "";
}
}
+124
View File
@@ -0,0 +1,124 @@
#ifndef C4F_LAUNCHER_TESTER_H
#define C4F_LAUNCHER_TESTER_H
/* Controller test: what each signed-in user's controller sends, read the way a
* game reads it, with rumble and light-bar test actions. The native pad and
* user calls are injected, so this logic also runs in host tests. */
#include <stdint.h>
#define C4F_TESTER_USERS 4
#define C4F_TESTER_HOLD_MS 2000
#define C4F_TESTER_RUMBLE_MS 1000
#define C4F_TESTER_RETRY_MS 1000
#define C4F_TESTER_NAME 24
#define C4F_TESTER_MENU_COLUMNS 4
#define C4F_TESTER_REPEAT_DELAY_MS 350
#define C4F_TESTER_REPEAT_MS 100
/* One sample as the read side reports it. */
typedef struct {
uint32_t buttons;
uint8_t lx, ly, rx, ry, l2, r2, touches;
struct { uint16_t x, y; uint8_t id; } touch[2];
float quat[4]; /* orientation x y z w */
float accel[3]; /* G */
float gyro[3]; /* rad/s */
int connected;
} C4fTesterSample;
typedef struct {
void *context;
/* Fills up to C4F_TESTER_USERS signed-in user IDs; returns how many, or a
* negative error. */
int (*loginUsers)(void *context, int32_t *users);
/* The name the PS4 shows. Only ever drawn, never logged. */
void (*userName)(void *context, int32_t user, char *name, int size);
/* A handle, or a negative error. owned as in C4fInputOps: a handle borrowed
* from the launcher's own reader must not be closed here. */
int (*open)(void *context, int32_t user, int *owned);
int (*read)(void *context, int handle, C4fTesterSample *sample);
void (*close)(void *context, int handle);
int (*vibrate)(void *context, int handle, uint8_t large, uint8_t small);
int (*lightBar)(void *context, int handle, uint8_t r, uint8_t g, uint8_t b);
int (*resetLightBar)(void *context, int handle);
int (*resetOrientation)(void *context, int handle);
int (*motionSensor)(void *context, int handle, int on);
/* Optional: a reader changed state (for the log; slot, never the user). */
void (*state)(void *context, int slot, int state, uint32_t error);
} C4fTesterOps;
/* Per user. */
enum { C4F_TESTER_OPENING, C4F_TESTER_READY, C4F_TESTER_NO_CONTROLLER, C4F_TESTER_FAILED, C4F_TESTER_SIGNED_OUT };
/* The menu, in order. */
typedef enum {
C4F_TESTER_RESUME, C4F_TESTER_USER, C4F_TESTER_ORIENTATION, C4F_TESTER_LIGHT_COLOR,
C4F_TESTER_RUMBLE_LARGE, C4F_TESTER_RUMBLE_SMALL, C4F_TESTER_LIGHT_RESET,
C4F_TESTER_EXIT, C4F_TESTER_ITEMS
} C4fTesterItem;
#define C4F_TESTER_MENU_ROWS ((C4F_TESTER_ITEMS + C4F_TESTER_MENU_COLUMNS - 1) / C4F_TESTER_MENU_COLUMNS)
/* L3 and R3 held together for C4F_TESTER_HOLD_MS. */
typedef struct {
int holding, fired;
uint64_t since;
} C4fTesterHold;
typedef struct {
int32_t user; /* -1: empty slot */
int handle, owned, state, lightSet, seen;
uint32_t error, previous, pressed, menuBlocked, repeatDirection;
uint64_t retryAt, repeatAt;
C4fTesterHold hold;
uint8_t light[3];
char name[C4F_TESTER_NAME];
C4fTesterSample sample;
} C4fTesterReader;
typedef struct {
C4fTesterOps ops;
int active, menu, focus, armed, selected, rumble, rumbleReader;
int colorMenu, colorChannel, colorFine;
unsigned menuOpeners;
uint64_t usersAt, rumbleUntil;
C4fTesterReader readers[C4F_TESTER_USERS];
char note[96];
} C4fTester;
/* What the screen shows, in whole display units so that an unchanged reading
* compares equal and causes no redraw. */
typedef struct {
int active, menu, focus, selected, rumble, lightSet;
int colorMenu, colorChannel, colorFine;
uint8_t light[3];
struct { int32_t id; char name[C4F_TESTER_NAME]; int state, used; uint32_t error; } user[C4F_TESTER_USERS];
uint32_t buttons;
uint8_t lx, ly, rx, ry, l2, r2, touches;
uint16_t touchX[2], touchY[2];
int16_t quat[4]; /* x 1000 */
int16_t accel[3]; /* x 100, G */
int16_t gyro[3]; /* x 100, rad/s */
char note[96];
} C4fTesterView;
#ifdef __cplusplus
extern "C" {
#endif
/* True once per hold; the buttons must be released before it fires again. */
int c4fTesterHoldUpdate(C4fTesterHold *hold, uint32_t buttons, uint64_t now);
void c4fTesterInit(C4fTester *tester, const C4fTesterOps *ops);
void c4fTesterOpen(C4fTester *tester, uint64_t now);
/* One frame. remotePressed is the TV remote's new presses: OK and Options open
* the menu, Back closes it or leaves. Returns 0 once the tester has closed. */
int c4fTesterFrame(C4fTester *tester, uint64_t now, uint32_t remotePressed);
/* The app lost focus: stop test output and reopen every reader afterwards. */
void c4fTesterSuspend(C4fTester *tester, uint64_t now);
/* Stops rumble, resets light bars this test set, closes owned handles. */
void c4fTesterClose(C4fTester *tester);
void c4fTesterView(const C4fTester *tester, C4fTesterView *view);
const char *c4fTesterItemLabel(int item);
/* Shared by menu drawing and activation. Unavailable tiles may take focus. */
int c4fTesterItemAvailable(const C4fTesterView *view, int item);
#ifdef __cplusplus
}
#endif
#endif
File diff suppressed because it is too large. Load diff
+9 -7
View File
@@ -10,14 +10,16 @@
#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:
/* scePadVirtualDeviceGetRemoteSetting fills a buffer much like the DualShock 4's
* HID output report without its report ID. Read on firmware 10.01 while games
* rumbled and users signed in (2026-10-05), and with the PS4 app's controller
* test driving each motor on its own (2026-10-16):
*
* [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.
* [3] the large, slow motor: 0xff for ~300 ms on a game's hit. Not the
* DS4 report's order: scePadSetVibration's large motor lands here.
* [4] the small, fast motor.
* [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.
@@ -26,8 +28,8 @@ 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->large = buf[3];
out->small = buf[4];
out->r = buf[5];
out->g = buf[6];
out->b = buf[7];
+16 -1
View File
@@ -16,6 +16,10 @@
static int g_logFd = -1;
static int g_klogEnabled = 1;
static uint64_t g_startedMs;
static size_t g_logSize;
#ifndef C4F_LOG_SEGMENT
#define C4F_LOG_SEGMENT (1024u * 1024u)
#endif
static uint64_t c4fLogNowMs(void)
{
@@ -31,12 +35,15 @@ void c4fLogOpen(void)
{
(void)mkdir("/data", 0777);
(void)mkdir(C4F_LOG_DIR, 0777);
(void)unlink(C4F_LOG_PATH ".1");
/* Keep the previous run for post-restart diagnosis. */
if (rename(C4F_LOG_PATH, C4F_LOG_PATH ".previous") != 0 && errno != ENOENT)
g_logFd = open(C4F_LOG_PATH, O_WRONLY | O_CREAT | O_APPEND, 0666);
else
g_logFd = open(C4F_LOG_PATH, O_WRONLY | O_CREAT | O_TRUNC, 0666);
g_startedMs = c4fLogNowMs();
struct stat info;
g_logSize = g_logFd >= 0 && !fstat(g_logFd, &info) ? (size_t)info.st_size : 0;
c4fLog("---- Control4Free %s; timestamps are elapsed time ----\n", C4F_VERSION);
}
@@ -75,12 +82,20 @@ void c4fLog(const char *fmt, ...)
(unsigned long long)(elapsed % 1000), (int)length, p);
if (g_klogEnabled) klog_printf("%s", line);
if (g_logFd >= 0) {
if (g_logSize + (size_t)used > C4F_LOG_SEGMENT) {
(void)close(g_logFd); g_logFd = -1;
if (!rename(C4F_LOG_PATH, C4F_LOG_PATH ".1")) {
g_logFd = open(C4F_LOG_PATH, O_WRONLY | O_CREAT | O_TRUNC, 0666);
g_logSize = 0;
}
}
size_t offset = 0;
while (offset < (size_t)used) {
while (g_logFd >= 0 && offset < (size_t)used) {
ssize_t written = write(g_logFd, line + offset, (size_t)used - offset);
if (written < 0 && errno == EINTR) continue;
if (written <= 0) break;
offset += (size_t)written;
g_logSize += (size_t)written;
}
}
p += length + (end != NULL);
+138 -15
View File
@@ -1,4 +1,7 @@
/* Bounded HTTP/WebSocket transport for the embedded controller page. */
/* Bounded HTTP transport for the embedded controller page: the page itself,
* the launcher's requests, and the WebRTC offer (POST /rtc). The page's
* connection then runs over UDP (rtc.c). The WebSocket path stays for the
* host tests. */
#include <arpa/inet.h>
#include <ctype.h>
#include <errno.h>
@@ -6,16 +9,17 @@
#include <netinet/in.h>
#include <netinet/tcp.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <strings.h>
#include <sys/select.h>
#include <sys/socket.h>
#include <time.h>
#include <unistd.h>
#include "c4f_log.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")) \
@@ -51,6 +55,7 @@ uint64_t c4fTimeMs(void)
return (uint64_t)t.tv_sec * 1000 + (uint64_t)t.tv_nsec / 1000000;
}
#ifdef C4F_TEST_WEBSOCKET
static uint32_t c4fRol(uint32_t v, unsigned n) { return (v << n) | (v >> (32 - n)); }
/* RFC 6455's SHA-1 + base64 handshake. Keys are fixed at 24 ASCII bytes; this
@@ -94,6 +99,7 @@ void c4fWebSocketAccept(const char *key, char out[29])
}
out[j] = 0;
}
#endif
static int c4fQueue(C4fNetClient *c, const void *data, size_t n)
{
@@ -108,6 +114,7 @@ static int c4fQueue(C4fNetClient *c, const void *data, size_t n)
return 0;
}
#ifdef C4F_TEST_WEBSOCKET
static int c4fFrame(C4fNetClient *c, unsigned op, const void *data, size_t n)
{
unsigned char head[4] = { (unsigned char)(0x80 | op), (unsigned char)n, 0, 0 };
@@ -118,9 +125,12 @@ static int c4fFrame(C4fNetClient *c, unsigned op, const void *data, size_t n)
}
int c4fNetText(C4fNetClient *c, const char *text) { return c4fFrame(c, 1, text, strlen(text)); }
static int c4fNetConnText(C4fConn *conn, const char *text) { return c4fNetText((C4fNetClient *)conn, text); }
#endif
void c4fNetHttpJson(C4fNetClient *c, const char *json)
void c4fNetHttpJson(C4fConn *conn, const char *json)
{
C4fNetClient *c = (C4fNetClient *)conn;
char header[256];
int n = snprintf(header, sizeof(header), "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: %zu\r\nCache-Control: no-store\r\nConnection: close\r\n\r\n", strlen(json));
if (c4fQueue(c, header, (size_t)n) || c4fQueue(c, json, strlen(json))) return;
@@ -129,7 +139,7 @@ void c4fNetHttpJson(C4fNetClient *c, const char *json)
static void c4fDrop(C4fNet *net, C4fNetClient *c)
{
if (c->websocket) net->handler(c, C4F_NET_CLOSE, NULL, 0, net->context);
if (c->websocket) net->handler(&c->conn, C4F_NET_CLOSE, NULL, 0, net->context);
close(c->fd);
memset(c, 0, sizeof(*c)); c->fd = -1;
}
@@ -161,14 +171,62 @@ static int c4fHeader(const char *request, const char *key, char *value, size_t s
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);
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%s\r\n%s", code, strlen(message),
c->savedOrigin ? "Access-Control-Allow-Origin: null\r\n" : "", message);
C4F_NET_LOG(c, "answered %d %s", code, message);
c4fQueue(c, buf, (size_t)n); c->closing = 2;
}
/* POST /rtc: the page's WebRTC offer in, our answer out, as plain text (a
* "simple" request, so a saved copy of the page needs no preflight). The same
* Origin rule as the page's own connection: none, this page, or a saved file
* ("null"). A saved file is also answered on a preflight, for browsers that ask
* before reaching into a local network. */
static void c4fHttpOffer(C4fNet *net, C4fNetClient *c, const char *request, size_t used,
const char *host, const char *origin, int preflight)
{
char expected[160], length[16], transfer[32], reply[512];
snprintf(expected, sizeof(expected), "http://%s", host);
int saved = !strcmp(origin, "null");
if (origin[0] && !saved && strcmp(origin, expected)) { c4fHttpError(c, 403, "Origin not allowed"); return; }
c->savedOrigin = saved;
const char *allow = saved ? "Access-Control-Allow-Origin: null\r\n" : "";
if (preflight) {
if (!saved) { c4fHttpError(c, 403, "Origin not allowed"); return; }
int n = snprintf(reply, sizeof(reply), "HTTP/1.1 204 No Content\r\n%sAccess-Control-Allow-Methods: POST\r\n"
"Access-Control-Allow-Headers: Content-Type\r\nAccess-Control-Allow-Private-Network: true\r\n"
"Access-Control-Max-Age: 600\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", allow);
c4fQueue(c, reply, (size_t)n); c->closing = 2;
return;
}
if (c4fHeader(request, "Content-Length", length, sizeof(length)) != 1 ||
c4fHeader(request, "Transfer-Encoding", transfer, sizeof(transfer)) != 0 ||
!length[0] || strspn(length, "0123456789") != strlen(length) || strlen(length) > 5) {
c4fHttpError(c, 400, "An offer needs a Content-Length"); return;
}
size_t body = (size_t)atoi(length);
if (!body || used + body > sizeof(c->rx)) { c4fHttpError(c, 413, "Offer too large"); return; }
if (c->rxUsed < used + body) return; /* the rest of it is still coming */
struct sockaddr_in local;
socklen_t localSize = sizeof(local);
if (getsockname(c->fd, (struct sockaddr *)&local, &localSize)) { c4fHttpError(c, 500, "No local address"); return; }
static char answer[2048];
int status = c4fRtcOffer(&net->rtc, (const char *)c->rx + used, body, local.sin_addr.s_addr, answer, sizeof(answer));
c->rxUsed = 0;
if (status == 503) { c4fHttpError(c, 503, "Controller connections full; close an unused page"); return; }
if (status) { c4fHttpError(c, 400, "That is not a WebRTC data-channel offer"); return; }
int n = snprintf(reply, sizeof(reply), "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: %zu\r\n"
"Cache-Control: no-store\r\n%sConnection: close\r\n\r\n", strlen(answer), allow);
C4F_NET_LOG(c, "answered a WebRTC offer%s", "");
if (c4fQueue(c, reply, (size_t)n) == 0 && c4fQueue(c, answer, strlen(answer)) == 0) c->closing = 2;
}
static void c4fHttp(C4fNet *net, C4fNetClient *c)
{
char request[8193], host[128], origin[160], key[40], upgrade[32], version[12], connection[128];
char request[8193], host[128], origin[160], upgrade[32];
#ifdef C4F_TEST_WEBSOCKET
char key[40], version[12], connection[128];
#endif
char *end;
size_t used;
memcpy(request, c->rx, c->rxUsed); request[c->rxUsed] = 0;
@@ -179,6 +237,12 @@ static void c4fHttp(C4fNet *net, C4fNetClient *c)
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);
int offerRequest = !strncmp(request, "POST /rtc HTTP/1.1\r\n", 20);
int preflightRequest = !strncmp(request, "OPTIONS /rtc HTTP/1.1\r\n", 23);
int websocketRequest = 0;
#ifdef C4F_TEST_WEBSOCKET
websocketRequest = !strncmp(request, "GET /ws HTTP/1.1\r\n", 18);
#endif
/* The two files a phone needs to keep this page on its home screen. */
const unsigned char *asset = NULL;
size_t assetSize = 0;
@@ -188,9 +252,10 @@ static void c4fHttp(C4fNet *net, C4fNetClient *c)
} else if (!strncmp(request, "GET /icon-192.png HTTP/1.1\r\n", 28)) {
asset = c4fIcon; assetSize = c4fIconSize; assetType = "image/png";
}
if (!statusRequest && !stopRequest && !asset && strncmp(request, "GET / HTTP/1.1\r\n", 16) &&
if (!statusRequest && !stopRequest && !offerRequest && !preflightRequest && !asset &&
strncmp(request, "GET / HTTP/1.1\r\n", 16) &&
strncmp(request, "GET /index.html HTTP/1.1\r\n", 26) &&
strncmp(request, "GET /ws HTTP/1.1\r\n", 18) &&
!websocketRequest &&
strncmp(request, "GET /?", 6)) {
c4fHttpError(c, 404, "Not found"); return;
}
@@ -208,7 +273,9 @@ static void c4fHttp(C4fNet *net, C4fNetClient *c)
c4fHttpError(c, 403, "Open the PS4 IP address"); return;
}
}
if (statusRequest || stopRequest) {
if (offerRequest || preflightRequest) {
c4fHttpOffer(net, c, request, used, host, origin, preflightRequest);
} else if (statusRequest || stopRequest) {
char launcher[8], length[16], transfer[32];
/* For the launcher app. A website cannot reach this: it would need the
* custom header, which a browser only sends cross-origin after a CORS
@@ -223,8 +290,10 @@ static void c4fHttp(C4fNet *net, C4fNetClient *c)
c4fHttpError(c, 403, "Launcher only"); return;
}
c->rxUsed = 0;
net->handler(c, stopRequest ? C4F_NET_HTTP_STOP : C4F_NET_HTTP_STATUS, NULL, 0, net->context);
net->handler(&c->conn, stopRequest ? C4F_NET_HTTP_STOP : C4F_NET_HTTP_STATUS, NULL, 0, net->context);
} else if (upgrade[0]) {
#ifdef C4F_TEST_WEBSOCKET
if (!websocketRequest) { c4fHttpError(c, 404, "Not found"); return; }
char accept[29], reply[256], expected[160];
int n;
snprintf(expected, sizeof(expected), "http://%s", host);
@@ -241,12 +310,19 @@ 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);
net->handler(&c->conn, C4F_NET_OPEN, NULL, 0, net->context);
#else
c4fHttpError(c, 400, "Use the controller page to connect over WebRTC");
#endif
} 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);
@@ -255,13 +331,14 @@ static void c4fHttp(C4fNet *net, C4fNetClient *c)
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);
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 'self'; 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;
}
}
#ifdef C4F_TEST_WEBSOCKET
static void c4fWs(C4fNet *net, C4fNetClient *c)
{
while (c->rxUsed >= 2 && !c->closing) {
@@ -281,14 +358,21 @@ 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;
c->fragmented = !fin;
if (fin) {
c->message[c->messageUsed] = 0;
net->handler(c, C4F_NET_MESSAGE_EVENT, c->message, c->messageUsed, net->context);
net->handler(&c->conn, C4F_NET_MESSAGE_EVENT, c->message, c->messageUsed, net->context);
c->messageUsed = 0;
}
} else { c->closing = 1; break; }
@@ -296,12 +380,20 @@ static void c4fWs(C4fNet *net, C4fNetClient *c)
memmove(c->rx, c->rx+taken, c->rxUsed-taken); c->rxUsed -= taken;
}
}
#endif
int c4fNetOpen(C4fNet *net, int port, C4fNetHandler handler, void *context)
{
struct sockaddr_in addr;
int one = 1;
/* The certificate outlives a reopen: making one takes a moment. */
C4fRtcCrypto *crypto = net->rtc.crypto;
char fingerprint[sizeof(net->rtc.fingerprint)];
unsigned nextId = net->rtc.nextId;
memcpy(fingerprint, net->rtc.fingerprint, sizeof(fingerprint));
memset(net, 0, sizeof(*net)); net->fd = -1;
net->rtc.crypto = crypto; net->rtc.nextId = nextId; net->rtc.fd = -1;
memcpy(net->rtc.fingerprint, fingerprint, sizeof(fingerprint));
for (int i = 0; i < C4F_NET_CLIENTS; i++) net->clients[i].fd = -1;
net->handler = handler; net->context = context;
net->fd = socket(AF_INET, SOCK_STREAM, 0);
@@ -316,15 +408,22 @@ int c4fNetOpen(C4fNet *net, int port, C4fNetHandler handler, void *context)
bind(net->fd, (struct sockaddr *)&addr, sizeof(addr)) || listen(net->fd, 8)) {
close(net->fd); net->fd = -1; return -1;
}
/* A listener without the input transport is not a running service. */
if (c4fRtcOpen(&net->rtc, port, handler, context)) {
c4fLog("rtc: not available on port %d\n", port);
close(net->fd); net->fd = -1;
return -1;
}
return 0;
}
int c4fNetPoll(C4fNet *net, int timeoutMs)
{
fd_set rd, wr;
struct timeval timeout = { .tv_sec = 0, .tv_usec = timeoutMs * 1000 };
int maxFd = net->fd, ready;
uint64_t now = c4fTimeMs();
timeoutMs = c4fRtcWait(&net->rtc, now, timeoutMs);
struct timeval timeout = { .tv_sec = timeoutMs / 1000, .tv_usec = (timeoutMs % 1000) * 1000 };
if (now >= net->checkedMs) {
int error = 0, listening = 0; socklen_t size = sizeof(error);
if (getsockopt(net->fd, SOL_SOCKET, SO_ERROR, &error, &size)) return -1;
@@ -335,9 +434,24 @@ int c4fNetPoll(C4fNet *net, int timeoutMs)
net->checkedMs = now + 1000;
}
FD_ZERO(&rd); FD_ZERO(&wr); FD_SET(net->fd, &rd);
if (net->rtc.fd >= 0 && net->rtc.fd < (int)FD_SETSIZE) {
FD_SET(net->rtc.fd, &rd);
if (c4fRtcWantsWrite(&net->rtc)) FD_SET(net->rtc.fd, &wr);
if (net->rtc.fd > maxFd) maxFd = net->rtc.fd;
}
for (int i = 0; i < C4F_NET_CLIENTS; i++) {
C4fNetClient *c = &net->clients[i];
if (c->fd < 0) continue;
#ifdef C4F_TEST_WEBSOCKET
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;
}
}
#endif
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);
@@ -350,6 +464,9 @@ int c4fNetPoll(C4fNet *net, int timeoutMs)
if (waitedTo < waitedFrom || waitedTo - waitedFrom > 5000 || time(NULL) - wallFrom > 5)
return -2;
if (ready < 0) return errno == EINTR ? 0 : -1;
if (net->rtc.fd >= 0 && FD_ISSET(net->rtc.fd, &rd)) c4fRtcReadable(&net->rtc);
if (net->rtc.fd >= 0 && FD_ISSET(net->rtc.fd, &wr)) c4fRtcWritable(&net->rtc);
c4fRtcTick(&net->rtc, c4fTimeMs());
if (FD_ISSET(net->fd, &rd)) {
struct sockaddr_in peer;
socklen_t peerSize = sizeof(peer);
@@ -372,6 +489,9 @@ 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_TEST_WEBSOCKET
net->clients[index].conn.text = c4fNetConnText;
#endif
#ifdef C4F_DIAG
memcpy(net->clients[index].peer, peerName, sizeof(peerName));
C4F_NET_LOG(&net->clients[index], "connected: slot %d, fd %d", index, fd);
@@ -390,7 +510,9 @@ int c4fNetPoll(C4fNet *net, int timeoutMs)
} else if (n > 0) {
c->rxUsed += (size_t)n;
if (!c->websocket) c4fHttp(net, c);
#ifdef C4F_TEST_WEBSOCKET
if (c->websocket) c4fWs(net, c);
#endif
}
}
if (c->closing != 1 && FD_ISSET(c->fd, &wr)) {
@@ -427,6 +549,7 @@ int c4fNetPoll(C4fNet *net, int timeoutMs)
void c4fNetClose(C4fNet *net)
{
c4fRtcClose(&net->rtc);
for (int i = 0; i < C4F_NET_CLIENTS; i++) if (net->clients[i].fd >= 0) c4fDrop(net, &net->clients[i]);
if (net->fd >= 0) close(net->fd);
net->fd = -1;
+631
View File
@@ -0,0 +1,631 @@
/* WebRTC for the controller page: ICE-lite, DTLS through Mbed TLS, and the
* SCTP in sctp.c. See c4f_rtc.h. */
#include <arpa/inet.h>
#include <errno.h>
#include <fcntl.h>
#include <netinet/in.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <strings.h>
#include <sys/select.h>
#include <sys/socket.h>
#include <unistd.h>
#include <mbedtls/ctr_drbg.h>
#include <mbedtls/ecp.h>
#include <mbedtls/entropy.h>
#include <mbedtls/pk.h>
#include <mbedtls/sha256.h>
#include <mbedtls/ssl.h>
#include <mbedtls/x509_crt.h>
#include "c4f_log.h"
#include "c4f_rtc.h"
#include "c4f_sctp.h"
#include "c4f_stun.h"
#define C4F_RTC_MTU 1200
#define C4F_RTC_TEXT 4096 /* the biggest message the service takes (C4F_NET_MESSAGE) */
#define C4F_RTC_OUT_BYTES (64u * 1024u)
#define C4F_RTC_OUT_PACKETS 64u
#define C4F_RTC_WRITE_BURST 8u
typedef struct C4fRtcDatagram C4fRtcDatagram;
struct C4fRtcDatagram {
C4fRtcDatagram *next;
struct sockaddr_in to;
size_t len;
uint8_t data[];
};
struct C4fRtcCrypto {
mbedtls_entropy_context entropy;
mbedtls_ctr_drbg_context drbg;
mbedtls_pk_context key;
mbedtls_x509_crt cert;
mbedtls_ssl_config conf;
};
struct C4fRtcPeer {
C4fConn conn; /* first: the service holds a peer by it */
C4fRtc *rtc;
unsigned id;
char localUfrag[9], localPwd[25], remoteUfrag[257];
uint8_t remoteFingerprint[32];
struct sockaddr_in addr;
int addrKnown;
mbedtls_ssl_context ssl;
int dtlsUp;
const uint8_t *in; /* the datagram Mbed TLS is reading, until it has */
size_t inLen;
uint64_t timerAt;
uint32_t timerInt, timerFin;
C4fSctp sctp;
int opened, ending, failed;
const char *failure;
uint64_t createdAt, heardAt;
uint64_t datagramsIn, datagramsOut;
C4fRtcDatagram *out, *outTail;
size_t outBytes;
unsigned outPackets;
uint64_t retryWriteAt;
};
/* ------------------------------------------------------------ certificate */
static int c4fRtcEntropy(void *data, unsigned char *out, size_t len, size_t *olen)
{
(void)data;
arc4random_buf(out, len);
*olen = len;
return 0;
}
static void c4fRtcFreeCrypto(C4fRtcCrypto *k)
{
mbedtls_ssl_config_free(&k->conf);
mbedtls_x509_crt_free(&k->cert);
mbedtls_pk_free(&k->key);
mbedtls_ctr_drbg_free(&k->drbg);
mbedtls_entropy_free(&k->entropy);
free(k);
}
/* A fresh P-256 key and a self-signed certificate for it, once per run: the
* browser checks it by the fingerprint in the answer, not by who signed it. */
static int c4fRtcMakeCrypto(C4fRtc *rtc)
{
C4fRtcCrypto *k = calloc(1, sizeof(*k));
if (!k) return -1;
uint64_t started = c4fTimeMs();
mbedtls_entropy_init(&k->entropy);
mbedtls_ctr_drbg_init(&k->drbg);
mbedtls_pk_init(&k->key);
mbedtls_x509_crt_init(&k->cert);
mbedtls_ssl_config_init(&k->conf);
unsigned char der[1024];
int derLen = 0;
int ret = mbedtls_entropy_add_source(&k->entropy, c4fRtcEntropy, NULL, 32, MBEDTLS_ENTROPY_SOURCE_STRONG);
if (!ret) ret = mbedtls_ctr_drbg_seed(&k->drbg, mbedtls_entropy_func, &k->entropy, (const unsigned char *)"Control4Free", 12);
if (!ret) ret = mbedtls_pk_setup(&k->key, mbedtls_pk_info_from_type(MBEDTLS_PK_ECKEY));
if (!ret) ret = mbedtls_ecp_gen_key(MBEDTLS_ECP_DP_SECP256R1, mbedtls_pk_ec(k->key), mbedtls_ctr_drbg_random, &k->drbg);
if (!ret) {
mbedtls_x509write_cert w;
unsigned char serial[8];
mbedtls_x509write_crt_init(&w);
mbedtls_x509write_crt_set_version(&w, MBEDTLS_X509_CRT_VERSION_3);
mbedtls_x509write_crt_set_md_alg(&w, MBEDTLS_MD_SHA256);
mbedtls_x509write_crt_set_subject_key(&w, &k->key);
mbedtls_x509write_crt_set_issuer_key(&w, &k->key);
ret = mbedtls_x509write_crt_set_subject_name(&w, "CN=Control4Free");
if (!ret) ret = mbedtls_x509write_crt_set_issuer_name(&w, "CN=Control4Free");
if (!ret) ret = mbedtls_ctr_drbg_random(&k->drbg, serial, sizeof(serial));
serial[0] = (serial[0] & 0x7F) | 0x01; /* positive, no leading zero */
if (!ret) ret = mbedtls_x509write_crt_set_serial_raw(&w, serial, sizeof(serial));
if (!ret) ret = mbedtls_x509write_crt_set_validity(&w, "20250101000000", "20450101000000");
if (!ret) {
derLen = mbedtls_x509write_crt_der(&w, der, sizeof(der), mbedtls_ctr_drbg_random, &k->drbg);
if (derLen < 0) ret = derLen;
}
mbedtls_x509write_crt_free(&w);
}
const unsigned char *cert = der + sizeof(der) - derLen; /* written at the end of the buffer */
if (!ret) ret = mbedtls_x509_crt_parse_der(&k->cert, cert, (size_t)derLen);
if (!ret) {
unsigned char hash[32];
ret = mbedtls_sha256(cert, (size_t)derLen, hash, 0);
for (int i = 0; !ret && i < 32; i++) snprintf(rtc->fingerprint + 3 * i, 4, i < 31 ? "%02X:" : "%02X", hash[i]);
}
if (!ret) ret = mbedtls_ssl_config_defaults(&k->conf, MBEDTLS_SSL_IS_SERVER, MBEDTLS_SSL_TRANSPORT_DATAGRAM,
MBEDTLS_SSL_PRESET_DEFAULT);
if (!ret) {
mbedtls_ssl_conf_rng(&k->conf, mbedtls_ctr_drbg_random, &k->drbg);
/* The browser's certificate is self-signed too: asked for, then checked
* against the fingerprint in its offer. */
mbedtls_ssl_conf_authmode(&k->conf, MBEDTLS_SSL_VERIFY_OPTIONAL);
mbedtls_ssl_conf_handshake_timeout(&k->conf, 400, 6000);
ret = mbedtls_ssl_conf_own_cert(&k->conf, &k->cert, &k->key);
}
if (ret) {
c4fLog("rtc: making the certificate failed: -0x%04x\n", (unsigned)-ret);
c4fRtcFreeCrypto(k);
return -1;
}
rtc->crypto = k;
c4fLog("rtc: certificate ready in %llu ms\n", (unsigned long long)(c4fTimeMs() - started));
return 0;
}
/* ------------------------------------------------------------ peers */
static int c4fRtcPeerTimer(void *ctx)
{
C4fRtcPeer *p = ctx;
if (!p->timerFin) return -1;
uint64_t elapsed = c4fTimeMs() - p->timerAt;
return elapsed >= p->timerFin ? 2 : elapsed >= p->timerInt ? 1 : 0;
}
static void c4fRtcPeerSetTimer(void *ctx, uint32_t intMs, uint32_t finMs)
{
C4fRtcPeer *p = ctx;
p->timerAt = c4fTimeMs();
p->timerInt = intMs;
p->timerFin = finMs;
}
static int c4fRtcSendDatagram(C4fRtcPeer *p, const unsigned char *buf, size_t len, const struct sockaddr_in *to)
{
if (p->failed || len > C4F_RTC_MTU) return -1;
if (!p->out) {
ssize_t n = sendto(p->rtc->fd, buf, len, 0, (const struct sockaddr *)to, sizeof(*to));
if (n == (ssize_t)len) { p->datagramsOut++; return 0; }
if (n >= 0 || (errno != EAGAIN && errno != EWOULDBLOCK && errno != EINTR && errno != ENOBUFS)) {
p->failed = 1; p->failure = "UDP send failed"; return -1;
}
if (errno == ENOBUFS) p->retryWriteAt = c4fTimeMs() + 2;
}
if (p->outPackets >= C4F_RTC_OUT_PACKETS || p->outBytes + len > C4F_RTC_OUT_BYTES) {
p->failed = 1; p->failure = "UDP output queue full"; return -1;
}
C4fRtcDatagram *d = malloc(sizeof(*d) + len);
if (!d) { p->failed = 1; p->failure = "UDP output allocation failed"; return -1; }
d->next = NULL; d->to = *to; d->len = len; memcpy(d->data, buf, len);
if (p->outTail) p->outTail->next = d; else p->out = d;
p->outTail = d; p->outBytes += len; p->outPackets++;
return 0;
}
static void c4fRtcDrainPeer(C4fRtcPeer *p, uint64_t now)
{
if (p->ending || p->failed || now < p->retryWriteAt) return;
for (unsigned i = 0; p->out && i < C4F_RTC_WRITE_BURST; i++) {
C4fRtcDatagram *d = p->out;
ssize_t n = sendto(p->rtc->fd, d->data, d->len, 0, (const struct sockaddr *)&d->to, sizeof(d->to));
if (n != (ssize_t)d->len) {
if (n < 0 && (errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR || errno == ENOBUFS)) {
if (errno == ENOBUFS) p->retryWriteAt = now + 2;
} else { p->failed = 1; p->failure = "UDP queued send failed"; }
return;
}
p->datagramsOut++; p->out = d->next;
p->outBytes -= d->len; p->outPackets--; free(d);
if (!p->out) p->outTail = NULL;
}
p->retryWriteAt = 0;
}
int c4fRtcWantsWrite(const C4fRtc *rtc)
{
uint64_t now = c4fTimeMs();
for (int i = 0; i < C4F_RTC_PEERS; i++) {
const C4fRtcPeer *p = rtc->peers[i];
if (p && !p->ending && !p->failed && p->out && now >= p->retryWriteAt) return 1;
}
return 0;
}
void c4fRtcWritable(C4fRtc *rtc)
{
uint64_t now = c4fTimeMs();
unsigned first = rtc->writeCursor++ % C4F_RTC_PEERS;
for (unsigned i = 0; i < C4F_RTC_PEERS; i++) {
C4fRtcPeer *p = rtc->peers[(first + i) % C4F_RTC_PEERS];
if (p) c4fRtcDrainPeer(p, now);
}
}
static int c4fRtcBioSend(void *ctx, const unsigned char *buf, size_t len)
{
C4fRtcPeer *p = ctx;
/* Accept a complete owned ciphertext datagram before telling DTLS that
* its write succeeded. A later TLS write can never overwrite a blocked one. */
if (!p->addrKnown || c4fRtcSendDatagram(p, buf, len, &p->addr)) return MBEDTLS_ERR_SSL_INTERNAL_ERROR;
return (int)len;
}
static int c4fRtcBioRecv(void *ctx, unsigned char *buf, size_t len)
{
C4fRtcPeer *p = ctx;
if (!p->inLen) return MBEDTLS_ERR_SSL_WANT_READ;
size_t n = p->inLen < len ? p->inLen : len;
memcpy(buf, p->in, n);
p->inLen = 0;
return (int)n;
}
static int c4fRtcSctpSend(void *user, const uint8_t *packet, size_t len)
{
C4fRtcPeer *p = user;
if (p->ending || p->failed || !p->dtlsUp) return -1;
int ret = mbedtls_ssl_write(&p->ssl, packet, len);
if (ret != (int)len) {
if (!p->failed) { p->failed = 1; p->failure = "DTLS send failed"; }
return -1;
}
return 0;
}
static void c4fRtcSctpRandom(void *user, void *out, size_t len)
{
C4fRtcPeer *p = user;
if (mbedtls_ctr_drbg_random(&p->rtc->crypto->drbg, out, len)) arc4random_buf(out, len);
}
static void c4fRtcOpened(C4fRtcPeer *p)
{
p->opened = 1;
c4fLog("rtc: peer %u open in %llu ms\n", p->id, (unsigned long long)(c4fTimeMs() - p->createdAt));
p->rtc->handler(&p->conn, C4F_NET_OPEN, NULL, 0, p->rtc->context);
}
static void c4fRtcSctpDeliver(void *user, uint16_t stream, uint32_t ppid, const uint8_t *data, size_t len)
{
static char text[C4F_RTC_TEXT + 1];
C4fRtcPeer *p = user;
if (p->ending) return;
if (!p->opened) c4fRtcOpened(p);
if (ppid != C4F_SCTP_PPID_STRING || len > C4F_RTC_TEXT) return;
memcpy(text, data, len);
text[len] = 0;
p->rtc->handler(&p->conn, stream == 1 ? C4F_NET_INPUT_EVENT : C4F_NET_MESSAGE_EVENT, text, len, p->rtc->context);
}
static int c4fRtcSend(C4fConn *c, const char *text, uint16_t stream)
{
C4fRtcPeer *p = (C4fRtcPeer *)c;
if (p->ending || p->failed || !p->opened) return -1;
if (!c4fSctpSend(&p->sctp, stream, C4F_SCTP_PPID_STRING, (const uint8_t *)text, strlen(text), c4fTimeMs())) return 0;
/* A reply that can't be queued means the page lost too much: let it reconnect. */
if (stream == 0) { p->failed = 1; p->failure = "too much waiting to be sent"; }
return -1;
}
static int c4fRtcText(C4fConn *c, const char *text) { return c4fRtcSend(c, text, 0); }
static int c4fRtcInput(C4fConn *c, const char *text) { return c4fRtcSend(c, text, 1); }
/* Ends a peer: the service hears of it now, the memory goes on the next tick. */
static void c4fRtcEnd(C4fRtcPeer *p, const char *why)
{
if (p->ending) return;
p->ending = 1;
c4fLog("rtc: peer %u closed (%s) after %llu s; datagrams %llu in, %llu out; sctp resent %llu, gave up %llu, "
"duplicates %llu, bad checksums %llu\n", p->id, why, (unsigned long long)((c4fTimeMs() - p->createdAt) / 1000),
(unsigned long long)p->datagramsIn, (unsigned long long)p->datagramsOut,
(unsigned long long)p->sctp.resent, (unsigned long long)p->sctp.abandoned,
(unsigned long long)p->sctp.duplicates, (unsigned long long)p->sctp.badChecksums);
if (p->opened) p->rtc->handler(&p->conn, C4F_NET_CLOSE, NULL, 0, p->rtc->context);
}
static void c4fRtcFreePeer(C4fRtc *rtc, int slot)
{
C4fRtcPeer *p = rtc->peers[slot];
rtc->peers[slot] = NULL;
if (p->dtlsUp && rtc->fd >= 0) (void)mbedtls_ssl_close_notify(&p->ssl);
mbedtls_ssl_free(&p->ssl);
c4fSctpFree(&p->sctp);
while (p->out) { C4fRtcDatagram *d = p->out; p->out = d->next; free(d); }
free(p);
}
/* After SCTP had a packet or a tick: open, or ended by the browser. */
static void c4fRtcAfterSctp(C4fRtcPeer *p)
{
if (p->ending) return;
if (!p->opened && p->sctp.state == C4F_SCTP_ESTABLISHED) c4fRtcOpened(p);
if (p->sctp.state == C4F_SCTP_ENDED) c4fRtcEnd(p, "the page closed it");
}
/* ------------------------------------------------------------ datagrams */
static void c4fRtcStun(C4fRtc *rtc, const uint8_t *msg, size_t len, const struct sockaddr_in *from, uint64_t now)
{
size_t size;
const uint8_t *user = c4fStunUsername(msg, len, &size);
if (!user) return;
const uint8_t *colon = memchr(user, ':', size);
if (!colon) return;
size_t ours = (size_t)(colon - user), theirs = size - ours - 1;
for (int i = 0; i < C4F_RTC_PEERS; i++) {
C4fRtcPeer *p = rtc->peers[i];
if (!p || p->ending || strlen(p->localUfrag) != ours || memcmp(p->localUfrag, user, ours) ||
strlen(p->remoteUfrag) != theirs || memcmp(p->remoteUfrag, colon + 1, theirs)) continue;
uint8_t answer[C4F_STUN_ANSWER_SIZE];
int nominate;
size_t n = c4fStunAnswer(msg, len, p->localPwd, from->sin_addr.s_addr, from->sin_port, answer, &nominate);
if (!n) return;
p->datagramsIn++;
(void)c4fRtcSendDatagram(p, answer, n, from);
p->heardAt = now;
/* Only a check signed with our password may move where the peer is. */
if (!p->addrKnown || nominate) {
p->addr = *from;
p->addrKnown = 1;
}
return;
}
}
static void c4fRtcDtls(C4fRtcPeer *p, const uint8_t *data, size_t len, uint64_t now)
{
static unsigned char plain[2048];
p->heardAt = now;
p->datagramsIn++;
p->in = data;
p->inLen = len;
if (!p->dtlsUp) {
int ret = mbedtls_ssl_handshake(&p->ssl);
if (ret == 0) {
/* Is this the browser that sent the offer? */
const mbedtls_x509_crt *peer = mbedtls_ssl_get_peer_cert(&p->ssl);
unsigned char hash[32];
if (!peer || mbedtls_sha256(peer->raw.p, peer->raw.len, hash, 0) ||
memcmp(hash, p->remoteFingerprint, sizeof(hash))) {
c4fRtcEnd(p, "its certificate doesn't match its offer");
p->inLen = 0;
return;
}
p->dtlsUp = 1;
c4fLog("rtc: peer %u secured in %llu ms\n", p->id, (unsigned long long)(now - p->createdAt));
} else if (ret != MBEDTLS_ERR_SSL_WANT_READ && ret != MBEDTLS_ERR_SSL_WANT_WRITE) {
char why[48];
snprintf(why, sizeof(why), "DTLS handshake failed: -0x%04x", (unsigned)-ret);
c4fRtcEnd(p, why);
p->inLen = 0;
return;
}
}
while (p->dtlsUp && !p->ending) {
int ret = mbedtls_ssl_read(&p->ssl, plain, sizeof(plain));
if (ret > 0) {
c4fSctpReceive(&p->sctp, plain, (size_t)ret, now);
c4fRtcAfterSctp(p);
continue;
}
if (ret == MBEDTLS_ERR_SSL_WANT_READ || ret == MBEDTLS_ERR_SSL_WANT_WRITE) break;
if (ret == 0 || ret == MBEDTLS_ERR_SSL_PEER_CLOSE_NOTIFY) { c4fRtcEnd(p, "the page closed it"); break; }
char why[40];
snprintf(why, sizeof(why), "DTLS read failed: -0x%04x", (unsigned)-ret);
c4fRtcEnd(p, why);
break;
}
p->inLen = 0;
}
void c4fRtcReadable(C4fRtc *rtc)
{
static uint8_t buf[2048];
for (int i = 0; i < 64 && rtc->fd >= 0; i++) {
struct sockaddr_in from;
socklen_t fromLen = sizeof(from);
ssize_t n = recvfrom(rtc->fd, buf, sizeof(buf), 0, (struct sockaddr *)&from, &fromLen);
if (n <= 0) break;
uint64_t now = c4fTimeMs();
if (c4fIsStun(buf, (size_t)n)) { c4fRtcStun(rtc, buf, (size_t)n, &from, now); continue; }
if (!c4fIsDtls(buf, (size_t)n)) continue;
for (int k = 0; k < C4F_RTC_PEERS; k++) {
C4fRtcPeer *p = rtc->peers[k];
if (p && !p->ending && p->addrKnown && p->addr.sin_addr.s_addr == from.sin_addr.s_addr &&
p->addr.sin_port == from.sin_port) {
c4fRtcDtls(p, buf, (size_t)n, now);
break;
}
}
}
}
void c4fRtcTick(C4fRtc *rtc, uint64_t now)
{
for (int i = 0; i < C4F_RTC_PEERS; i++) {
C4fRtcPeer *p = rtc->peers[i];
if (!p) continue;
if (!p->ending && p->failed) c4fRtcEnd(p, p->failure ? p->failure : "it failed");
if (!p->ending && !p->dtlsUp && c4fRtcPeerTimer(p) == 2) {
/* A handshake flight went unanswered: Mbed TLS sends it again. */
int ret = mbedtls_ssl_handshake(&p->ssl);
if (ret && ret != MBEDTLS_ERR_SSL_WANT_READ && ret != MBEDTLS_ERR_SSL_WANT_WRITE) c4fRtcEnd(p, "DTLS handshake timed out");
}
if (!p->ending && p->dtlsUp) { c4fSctpTick(&p->sctp, now); c4fRtcAfterSctp(p); }
if (!p->ending && now - p->heardAt > C4F_RTC_SILENT_MS) c4fRtcEnd(p, "nothing heard for 10 s");
if (!p->ending && !p->opened && now - p->createdAt > C4F_RTC_SETUP_MS) c4fRtcEnd(p, "it never connected");
if (p->ending) c4fRtcFreePeer(rtc, i);
}
}
int c4fRtcWait(const C4fRtc *rtc, uint64_t now, int limit)
{
uint64_t soonest = now + (uint64_t)limit;
for (int i = 0; i < C4F_RTC_PEERS; i++) {
const C4fRtcPeer *p = rtc->peers[i];
if (!p) continue;
if (p->ending || p->failed) return 0;
uint64_t at = p->heardAt + C4F_RTC_SILENT_MS + 1;
if (at < soonest) soonest = at;
if (p->out && p->retryWriteAt > now && p->retryWriteAt < soonest) soonest = p->retryWriteAt;
if (!p->dtlsUp && p->timerFin && p->timerAt + p->timerFin < soonest) soonest = p->timerAt + p->timerFin;
uint64_t sctp = c4fSctpDeadline(&p->sctp);
if (sctp && sctp < soonest) soonest = sctp;
}
return soonest <= now ? 0 : (int)(soonest - now);
}
/* ------------------------------------------------------------ offer and answer */
/* Copies an "a=name:value" line's value. */
static int c4fSdpValue(const char *line, size_t len, const char *name, char *out, size_t size)
{
size_t n = strlen(name);
if (len < n || memcmp(line, name, n)) return 0;
size_t v = len - n;
if (v >= size) return -1;
memcpy(out, line + n, v);
out[v] = 0;
return 1;
}
static int c4fContains(const char *text, size_t len, const char *word)
{
size_t n = strlen(word);
for (size_t i = 0; i + n <= len; i++) if (!memcmp(text + i, word, n)) return 1;
return 0;
}
static int c4fHex(char c) { return c >= '0' && c <= '9' ? c - '0' : c >= 'a' && c <= 'f' ? c - 'a' + 10 : c >= 'A' && c <= 'F' ? c - 'A' + 10 : -1; }
static void c4fRtcToken(C4fRtc *rtc, char *out, size_t chars)
{
static const char alphabet[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
unsigned char raw[32];
if (mbedtls_ctr_drbg_random(&rtc->crypto->drbg, raw, chars)) arc4random_buf(raw, chars);
for (size_t i = 0; i < chars; i++) out[i] = alphabet[raw[i] % 62];
out[chars] = 0;
}
int c4fRtcOffer(C4fRtc *rtc, const char *offer, size_t len, uint32_t local, char *answer, size_t size)
{
char ufrag[257] = "", mid[33] = "0", setup[16] = "", fingerprint[128] = "", value[128];
int application = 0, other = 0, bundle = 0;
if (!rtc->crypto || rtc->fd < 0) return 503;
for (size_t at = 0; at < len; ) {
size_t end = at;
while (end < len && offer[end] != '\n') end++;
size_t n = end - at;
const char *line = offer + at;
if (n && line[n - 1] == '\r') n--;
if (n > 2 && line[0] == 'm' && line[1] == '=') {
if (n >= 14 && !memcmp(line, "m=application ", 14) && c4fContains(line, n, "webrtc-datachannel")) application++;
else other++;
}
else if (c4fSdpValue(line, n, "a=ice-ufrag:", ufrag, sizeof(ufrag)) < 0) return 400;
else if (c4fSdpValue(line, n, "a=mid:", mid, sizeof(mid)) < 0) return 400;
else if (c4fSdpValue(line, n, "a=setup:", setup, sizeof(setup)) < 0) return 400;
else if (c4fSdpValue(line, n, "a=fingerprint:", value, sizeof(value)) > 0) {
if (!strncasecmp(value, "sha-256 ", 8) && !fingerprint[0]) snprintf(fingerprint, sizeof(fingerprint), "%s", value + 8);
}
else if (n >= 15 && !memcmp(line, "a=group:BUNDLE ", 15)) bundle = 1;
at = end + 1;
}
/* One data-channel section, credentials, and a SHA-256 fingerprint. */
if (application != 1 || other || strlen(ufrag) < 4 || !strcmp(setup, "passive") || strlen(fingerprint) != 95) return 400;
for (size_t i = 0; mid[i]; i++) if (mid[i] <= ' ' || mid[i] > '~') return 400;
for (size_t i = 0; ufrag[i]; i++) if (ufrag[i] <= ' ' || ufrag[i] > '~' || ufrag[i] == ':') return 400;
uint8_t remote[32];
for (int i = 0; i < 32; i++) {
int hi = c4fHex(fingerprint[3 * i]), lo = c4fHex(fingerprint[3 * i + 1]);
if (hi < 0 || lo < 0 || (i < 31 && fingerprint[3 * i + 2] != ':')) return 400;
remote[i] = (uint8_t)(hi << 4 | lo);
}
int slot = -1;
for (int i = 0; i < C4F_RTC_PEERS && slot < 0; i++) if (!rtc->peers[i]) slot = i;
if (slot < 0) return 503;
C4fRtcPeer *p = calloc(1, sizeof(*p));
if (!p) return 503;
p->conn.text = c4fRtcText;
p->conn.input = c4fRtcInput;
p->rtc = rtc;
p->id = ++rtc->nextId;
c4fRtcToken(rtc, p->localUfrag, 8);
c4fRtcToken(rtc, p->localPwd, 24);
snprintf(p->remoteUfrag, sizeof(p->remoteUfrag), "%s", ufrag);
memcpy(p->remoteFingerprint, remote, sizeof(remote));
p->createdAt = p->heardAt = c4fTimeMs();
mbedtls_ssl_init(&p->ssl);
if (mbedtls_ssl_setup(&p->ssl, &rtc->crypto->conf)) { mbedtls_ssl_free(&p->ssl); free(p); return 503; }
mbedtls_ssl_set_bio(&p->ssl, p, c4fRtcBioSend, c4fRtcBioRecv, NULL);
mbedtls_ssl_set_timer_cb(&p->ssl, p, c4fRtcPeerSetTimer, c4fRtcPeerTimer);
mbedtls_ssl_set_mtu(&p->ssl, C4F_RTC_MTU);
C4fSctpOps ops = { p, c4fRtcSctpSend, c4fRtcSctpDeliver, c4fRtcSctpRandom };
c4fSctpInit(&p->sctp, &ops);
char ip[INET_ADDRSTRLEN];
struct in_addr address = { .s_addr = local };
inet_ntop(AF_INET, &address, ip, sizeof(ip));
unsigned long long session;
c4fRtcSctpRandom(p, &session, sizeof(session));
int n = snprintf(answer, size,
"v=0\r\n"
"o=- %llu 2 IN IP4 0.0.0.0\r\n"
"s=-\r\n"
"t=0 0\r\n"
"%s%s%s"
"a=ice-lite\r\n"
"m=application 9 UDP/DTLS/SCTP webrtc-datachannel\r\n"
"c=IN IP4 0.0.0.0\r\n"
"a=mid:%s\r\n"
"a=ice-ufrag:%s\r\n"
"a=ice-pwd:%s\r\n"
"a=fingerprint:sha-256 %s\r\n"
"a=setup:passive\r\n"
"a=sctp-port:5000\r\n"
"a=max-message-size:%d\r\n"
"a=candidate:1 1 udp 2130706431 %s %d typ host\r\n"
"a=end-of-candidates\r\n",
session & 0x7FFFFFFFFFFFFFFFull, bundle ? "a=group:BUNDLE " : "", bundle ? mid : "", bundle ? "\r\n" : "",
mid, p->localUfrag, p->localPwd, rtc->fingerprint, C4F_SCTP_MAX_MESSAGE, ip, rtc->port);
if (n < 0 || (size_t)n >= size) { mbedtls_ssl_free(&p->ssl); c4fSctpFree(&p->sctp); free(p); return 503; }
rtc->peers[slot] = p;
c4fLog("rtc: peer %u offered, answering with %s:%d\n", p->id, ip, rtc->port);
return 0;
}
/* ------------------------------------------------------------ socket */
int c4fRtcOpen(C4fRtc *rtc, int port, C4fNetHandler handler, void *context)
{
rtc->handler = handler;
rtc->context = context;
rtc->port = port;
rtc->fd = -1;
if (!rtc->crypto && c4fRtcMakeCrypto(rtc)) return -1;
int fd = socket(AF_INET, SOCK_DGRAM, 0), one = 1;
if (fd < 0) return -1;
struct sockaddr_in addr = { .sin_family = AF_INET, .sin_port = htons((uint16_t)port), .sin_addr.s_addr = htonl(INADDR_ANY) };
(void)setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one));
if (fd >= (int)FD_SETSIZE || bind(fd, (struct sockaddr *)&addr, sizeof(addr)) || fcntl(fd, F_SETFL, O_NONBLOCK)) {
c4fLog("rtc: UDP port %d unavailable, errno %d\n", port, errno);
close(fd);
return -1;
}
rtc->fd = fd;
return 0;
}
void c4fRtcClose(C4fRtc *rtc)
{
for (int i = 0; i < C4F_RTC_PEERS; i++) {
if (!rtc->peers[i]) continue;
c4fRtcEnd(rtc->peers[i], "the service reset its connections");
c4fRtcFreePeer(rtc, i);
}
if (rtc->fd >= 0) close(rtc->fd);
rtc->fd = -1;
}
void c4fRtcDestroy(C4fRtc *rtc)
{
c4fRtcClose(rtc);
if (rtc->crypto) { c4fRtcFreeCrypto(rtc->crypto); rtc->crypto = NULL; }
memset(rtc->fingerprint, 0, sizeof(rtc->fingerprint));
}
+742
View File
@@ -0,0 +1,742 @@
/* A minimal SCTP association for WebRTC data channels. See c4f_sctp.h.
*
* What it does, and no more:
* - answers the browser's INIT (INIT-ACK with a cookie, COOKIE-ECHO, COOKIE-ACK);
* - receives DATA on streams 0 (reliable, in order) and 1 (unordered), puts
* fragments back together, and acknowledges every packet straight away;
* - sends on stream 0 reliably (resent after a timeout, or sooner when three
* acknowledgements in a row show it missing) and on stream 1 once (given up
* on instead, and skipped over with FORWARD-TSN);
* - answers HEARTBEAT, stream resets (RE-CONFIG) and FORWARD-TSN, and ends on
* ABORT or SHUTDOWN.
* New DATA observes a shared congestion window and the peer's receive window.
* Reliable messages wait in a bounded queue; blocked unreliable state is
* rejected for the caller to replace. No message is delayed to batch it. */
#include <stdlib.h>
#include <string.h>
#include "c4f_sctp.h"
enum {
CHUNK_DATA = 0, CHUNK_INIT = 1, CHUNK_INIT_ACK = 2, CHUNK_SACK = 3, CHUNK_HEARTBEAT = 4,
CHUNK_HEARTBEAT_ACK = 5, CHUNK_ABORT = 6, CHUNK_SHUTDOWN = 7, CHUNK_SHUTDOWN_ACK = 8,
CHUNK_ERROR = 9, CHUNK_COOKIE_ECHO = 10, CHUNK_COOKIE_ACK = 11, CHUNK_SHUTDOWN_COMPLETE = 14,
CHUNK_RECONFIG = 130, CHUNK_FORWARD_TSN = 192,
};
enum { DATA_END = 1, DATA_BEGIN = 2, DATA_UNORDERED = 4 };
enum { PARAM_STATE_COOKIE = 7, PARAM_OUTGOING_RESET = 13, PARAM_INCOMING_RESET = 14,
PARAM_RECONFIG_RESPONSE = 16, PARAM_SUPPORTED_EXTENSIONS = 0x8008, PARAM_FORWARD_TSN = 0xC000 };
enum { RECONFIG_PERFORMED = 1, RECONFIG_DENIED = 2 };
#define C4F_SCTP_RWND (128u * 1024u) /* what we take in before delivering */
#define C4F_SCTP_OUT_LIMIT (256u * 1024u) /* what may wait for acknowledgement */
#define C4F_SCTP_RTO_START 200
#define C4F_SCTP_RTO_MIN 100
#define C4F_SCTP_RTO_MAX 1000
#define C4F_SCTP_WINDOW 1024 /* TSNs tracked beyond the cumulative one */
#define C4F_SCTP_MAX_GAPS 32
#define C4F_SCTP_OUT_CHUNKS 512
#define C4F_SCTP_MAX_BURST 4
#define C4F_SCTP_MAX_TIMEOUTS 8
#define C4F_SCTP_DATA_MAX ((C4F_SCTP_MTU - 28u) & ~3u)
#define C4F_SCTP_INITIAL_CWND 4380u
struct C4fSctpOut {
C4fSctpOut *next;
uint32_t tsn, ppid;
uint16_t stream, ssn, len;
uint8_t flags, reliable, acked, abandoned, fastDone, inFlight;
int missing;
unsigned sends;
uint64_t sentAt;
uint8_t data[]; /* retained until cumulative acknowledgement */
};
struct C4fSctpIn {
C4fSctpIn *next;
uint32_t tsn, ppid;
uint16_t stream, ssn, len;
uint8_t flags;
uint8_t data[];
};
static uint16_t get16(const uint8_t *p) { return (uint16_t)(p[0] << 8 | p[1]); }
static uint32_t get32(const uint8_t *p) { return (uint32_t)p[0] << 24 | (uint32_t)p[1] << 16 | (uint32_t)p[2] << 8 | p[3]; }
static void put16(uint8_t *p, uint16_t v) { p[0] = v >> 8; p[1] = (uint8_t)v; }
static void put32(uint8_t *p, uint32_t v) { p[0] = v >> 24; p[1] = (uint8_t)(v >> 16); p[2] = (uint8_t)(v >> 8); p[3] = (uint8_t)v; }
/* Serial number arithmetic (RFC 1982), as TSNs wrap. */
static int tsnLt(uint32_t a, uint32_t b) { return (int32_t)(a - b) < 0; }
static int tsnLe(uint32_t a, uint32_t b) { return (int32_t)(a - b) <= 0; }
static uint32_t c4fCrc32cUpdate(uint32_t c, const uint8_t *data, size_t len)
{
static uint32_t table[256];
static int ready;
if (!ready) {
for (uint32_t i = 0; i < 256; i++) {
uint32_t v = i;
for (int k = 0; k < 8; k++) v = v & 1 ? 0x82F63B78u ^ (v >> 1) : v >> 1;
table[i] = v;
}
ready = 1;
}
for (size_t i = 0; i < len; i++) c = table[(c ^ data[i]) & 0xFF] ^ (c >> 8);
return c;
}
uint32_t c4fCrc32c(const uint8_t *data, size_t len)
{
return c4fCrc32cUpdate(0xFFFFFFFFu, data, len) ^ 0xFFFFFFFFu;
}
/* ------------------------------------------------------------ packets */
typedef struct { uint8_t buf[C4F_SCTP_MTU]; size_t len; } C4fPacket;
static void pktStart(const C4fSctp *s, C4fPacket *p, uint32_t tag)
{
put16(p->buf, s->localPort); put16(p->buf + 2, s->remotePort);
put32(p->buf + 4, tag); put32(p->buf + 8, 0);
p->len = 12;
}
static size_t pktRoom(const C4fPacket *p) { return C4F_SCTP_MTU - p->len; }
/* Adds a chunk header; the value goes right after it. NULL when it doesn't fit. */
static uint8_t *pktChunk(C4fPacket *p, uint8_t type, uint8_t flags, size_t valueLen)
{
size_t total = (4 + valueLen + 3) & ~(size_t)3;
if (total > pktRoom(p)) return NULL;
uint8_t *c = p->buf + p->len;
memset(c, 0, total);
c[0] = type; c[1] = flags; put16(c + 2, (uint16_t)(4 + valueLen));
p->len += total;
return c + 4;
}
static int pktSend(C4fSctp *s, C4fPacket *p)
{
if (p->len <= 12) return 0;
uint32_t crc = c4fCrc32c(p->buf, p->len);
/* The checksum goes in least significant byte first (RFC 9260, appendix A). */
p->buf[8] = (uint8_t)crc; p->buf[9] = (uint8_t)(crc >> 8); p->buf[10] = (uint8_t)(crc >> 16); p->buf[11] = (uint8_t)(crc >> 24);
if (s->ops.send(s->ops.user, p->buf, p->len)) { s->state = C4F_SCTP_ENDED; return -1; }
s->packetsOut++;
pktStart(s, p, get32(p->buf + 4));
return 0;
}
/* ------------------------------------------------------------ receiving */
static int seenGet(const C4fSctp *s, uint32_t tsn) { uint32_t i = tsn % C4F_SCTP_WINDOW; return s->seen[i >> 3] >> (i & 7) & 1; }
static void seenSet(C4fSctp *s, uint32_t tsn, int on)
{
uint32_t i = tsn % C4F_SCTP_WINDOW;
if (on) s->seen[i >> 3] |= (uint8_t)(1u << (i & 7)); else s->seen[i >> 3] &= (uint8_t)~(1u << (i & 7));
}
static void advanceCum(C4fSctp *s)
{
while (seenGet(s, s->cumTsn + 1)) { seenSet(s, s->cumTsn + 1, 0); s->cumTsn++; }
if (tsnLt(s->highTsn, s->cumTsn)) s->highTsn = s->cumTsn;
}
/* Adds a SACK for what has arrived so far. */
static int addSack(C4fSctp *s, C4fPacket *p)
{
uint16_t gaps[C4F_SCTP_MAX_GAPS][2];
int count = 0;
uint32_t t = s->cumTsn + 1;
while (tsnLe(t, s->highTsn) && count < C4F_SCTP_MAX_GAPS) {
while (tsnLe(t, s->highTsn) && !seenGet(s, t)) t++;
if (tsnLt(s->highTsn, t)) break;
uint32_t start = t;
while (tsnLe(t, s->highTsn) && seenGet(s, t)) t++;
gaps[count][0] = (uint16_t)(start - s->cumTsn);
gaps[count][1] = (uint16_t)(t - 1 - s->cumTsn);
count++;
}
uint8_t *v = pktChunk(p, CHUNK_SACK, 0, 12 + 4 * (size_t)count);
if (!v) return -1;
put32(v, s->cumTsn);
put32(v + 4, s->inBytes < C4F_SCTP_RWND ? C4F_SCTP_RWND - (uint32_t)s->inBytes : 0);
put16(v + 8, (uint16_t)count); put16(v + 10, 0);
for (int i = 0; i < count; i++) { put16(v + 12 + 4 * i, gaps[i][0]); put16(v + 14 + 4 * i, gaps[i][1]); }
s->sackDue = 0;
return 0;
}
static void freeIn(C4fSctp *s, C4fSctpIn *e)
{
s->inBytes -= e->len;
free(e);
}
/* The message starting at `first` (a BEGIN chunk), if all of it is here: its
* last chunk, else NULL. Chunks are kept in TSN order. */
static C4fSctpIn *messageEnd(C4fSctpIn *first)
{
C4fSctpIn *e = first;
for (;;) {
if (e->flags & DATA_END) return e;
C4fSctpIn *n = e->next;
if (!n || n->tsn != e->tsn + 1 || n->stream != first->stream ||
(n->flags & (DATA_BEGIN | DATA_UNORDERED)) != (first->flags & DATA_UNORDERED) ||
(!(first->flags & DATA_UNORDERED) && n->ssn != first->ssn)) return NULL;
e = n;
}
}
/* Takes a whole message out of the list and hands it over. */
static void deliverRun(C4fSctp *s, C4fSctpIn **link, C4fSctpIn *last)
{
C4fSctpIn *first = *link;
size_t total = 0;
for (C4fSctpIn *e = first; ; e = e->next) { total += e->len; if (e == last) break; }
*link = last->next;
uint8_t *message = total <= C4F_SCTP_MAX_MESSAGE ? malloc(total ? total : 1) : NULL;
size_t at = 0;
uint16_t stream = first->stream;
uint32_t ppid = first->ppid;
for (C4fSctpIn *e = first, *next; ; e = next) {
next = e->next;
int end = e == last;
if (message) memcpy(message + at, e->data, e->len);
at += e->len;
freeIn(s, e);
if (end) break;
}
if (message) {
s->ops.deliver(s->ops.user, stream, ppid, message, total);
free(message);
} else s->state = C4F_SCTP_ENDED; /* acknowledged reliable data must not disappear silently */
}
/* Hands over every message that is complete and due. */
static void deliverReady(C4fSctp *s)
{
for (int progressed = 1; progressed; ) {
progressed = 0;
for (C4fSctpIn **link = &s->in; *link; link = &(*link)->next) {
C4fSctpIn *e = *link;
if (!(e->flags & DATA_BEGIN)) continue;
if (!(e->flags & DATA_UNORDERED) && (e->stream > 1 || e->ssn != s->nextSsn[e->stream])) continue;
C4fSctpIn *last = messageEnd(e);
if (!last) continue;
if (!(e->flags & DATA_UNORDERED)) s->nextSsn[e->stream]++;
deliverRun(s, link, last);
progressed = 1;
break;
}
}
}
static void onData(C4fSctp *s, uint8_t flags, const uint8_t *v, size_t vlen)
{
if (s->state != C4F_SCTP_ESTABLISHED || vlen < 12) return;
uint32_t tsn = get32(v), ppid = get32(v + 8);
uint16_t stream = get16(v + 4), ssn = get16(v + 6);
const uint8_t *payload = v + 12;
size_t plen = vlen - 12;
s->sackDue = 1;
if (tsnLe(tsn, s->cumTsn)) { s->duplicates++; return; }
if (tsn - s->cumTsn > C4F_SCTP_WINDOW) return; /* beyond what we track: it comes again */
if (seenGet(s, tsn)) { s->duplicates++; return; }
int unordered = flags & DATA_UNORDERED, whole = (flags & (DATA_BEGIN | DATA_END)) == (DATA_BEGIN | DATA_END);
int stale = !unordered && stream <= 1 && (int16_t)(ssn - s->nextSsn[stream]) < 0;
if (stream > 1 || plen == 0 || plen > C4F_SCTP_MAX_MESSAGE || stale) {
/* Not one of ours, or a message already delivered: acknowledged and dropped. */
seenSet(s, tsn, 1);
} else if (whole && (unordered || ssn == s->nextSsn[stream])) {
seenSet(s, tsn, 1);
if (tsnLt(s->highTsn, tsn)) s->highTsn = tsn;
advanceCum(s);
if (!unordered) s->nextSsn[stream]++;
s->ops.deliver(s->ops.user, stream, ppid, payload, plen);
deliverReady(s);
return;
} else {
if (s->inBytes + plen > C4F_SCTP_RWND) return; /* full: not acknowledged, so it comes again */
C4fSctpIn *e = malloc(sizeof(*e) + plen);
if (!e) return;
e->tsn = tsn; e->ppid = ppid; e->stream = stream; e->ssn = ssn; e->len = (uint16_t)plen; e->flags = flags;
memcpy(e->data, payload, plen);
C4fSctpIn **link = &s->in;
while (*link && tsnLt((*link)->tsn, tsn)) link = &(*link)->next;
e->next = *link; *link = e;
s->inBytes += plen;
seenSet(s, tsn, 1);
}
if (tsnLt(s->highTsn, tsn)) s->highTsn = tsn;
advanceCum(s);
deliverReady(s);
}
static void onForwardTsn(C4fSctp *s, const uint8_t *v, size_t vlen)
{
if (s->state != C4F_SCTP_ESTABLISHED || vlen < 4) return;
uint32_t target = get32(v);
s->sackDue = 1;
if (tsnLe(target, s->cumTsn)) return;
if (target - s->cumTsn >= C4F_SCTP_WINDOW) memset(s->seen, 0, sizeof(s->seen));
else for (uint32_t t = s->cumTsn + 1; tsnLe(t, target); t++) seenSet(s, t, 0);
s->cumTsn = target;
if (tsnLt(s->highTsn, target)) s->highTsn = target;
/* What it skipped can never be completed now. */
for (C4fSctpIn **link = &s->in; *link; ) {
if (tsnLe((*link)->tsn, target)) { C4fSctpIn *e = *link; *link = e->next; freeIn(s, e); }
else link = &(*link)->next;
}
for (size_t at = 4; at + 4 <= vlen; at += 4) {
uint16_t stream = get16(v + at), ssn = get16(v + at + 2);
if (stream <= 1 && (int16_t)(ssn + 1 - s->nextSsn[stream]) > 0) s->nextSsn[stream] = ssn + 1;
}
advanceCum(s);
deliverReady(s);
}
/* ------------------------------------------------------------ sending */
static void addData(C4fPacket *p, const C4fSctpOut *o, const uint8_t *data)
{
uint8_t *v = pktChunk(p, CHUNK_DATA, o->flags, 12 + (size_t)o->len);
put32(v, o->tsn); put16(v + 4, o->stream); put16(v + 6, o->ssn); put32(v + 8, o->ppid);
memcpy(v + 12, data, o->len);
}
static uint32_t dataSize(const C4fSctpOut *o) { return (16u + o->len + 3u) & ~3u; }
static void releaseFlight(C4fSctp *s, C4fSctpOut *o)
{
if (!o->inFlight) return;
s->flightBytes -= dataSize(o); s->flightData -= o->len; o->inFlight = 0;
}
static void addFlight(C4fSctp *s, C4fSctpOut *o)
{
if (o->inFlight) return;
s->flightBytes += dataSize(o); s->flightData += o->len; o->inFlight = 1;
}
static void retransmit(C4fSctp *s, C4fSctpOut *o, C4fPacket *p, uint64_t now)
{
if (pktRoom(p) < dataSize(o) && pktSend(s, p)) return;
addData(p, o, o->data);
o->sends++; o->sentAt = now; o->missing = 0;
addFlight(s, o);
s->resent++;
for (C4fSctpOut *first = s->out; first; first = first->next) {
if (!first->sends || first->acked || first->abandoned) continue;
if (first == o) s->t3 = now + s->rto;
break;
}
}
static void removeOut(C4fSctp *s, C4fSctpOut **link)
{
C4fSctpOut *o = *link;
*link = o->next;
if (s->outTail == o) {
s->outTail = NULL;
for (C4fSctpOut *e = s->out; e; e = e->next) s->outTail = e;
}
releaseFlight(s, o);
s->outBytes -= o->len; s->outChunks--;
free(o);
}
/* Moves the forward point over everything acknowledged or given up on. */
static int advanceForward(C4fSctp *s)
{
int skipped = 0;
if (tsnLt(s->forwardTsn, s->peerCumTsn)) s->forwardTsn = s->peerCumTsn;
for (C4fSctpOut *o = s->out; o; o = o->next) {
if (tsnLe(o->tsn, s->forwardTsn)) { skipped |= o->abandoned; continue; }
if (!o->sends || o->tsn != s->forwardTsn + 1 || !(o->acked || o->abandoned)) break;
s->forwardTsn = o->tsn;
skipped |= o->abandoned;
}
return skipped && tsnLt(s->peerCumTsn, s->forwardTsn);
}
static void addForwardTsn(C4fSctp *s, C4fPacket *p)
{
uint8_t *v = pktChunk(p, CHUNK_FORWARD_TSN, 0, 4);
if (!v) { if (pktSend(s, p)) return; v = pktChunk(p, CHUNK_FORWARD_TSN, 0, 4); }
put32(v, s->forwardTsn);
}
static int anyOutstanding(const C4fSctp *s)
{
for (const C4fSctpOut *o = s->out; o; o = o->next) if (o->sends && !o->acked && !o->abandoned) return 1;
return tsnLt(s->peerCumTsn, s->forwardTsn);
}
static int hasQueued(const C4fSctp *s)
{
for (const C4fSctpOut *o = s->out; o; o = o->next) if (!o->sends) return 1;
return 0;
}
/* Release up to Max.Burst packets without delaying the first for batching.
* A closed (or too-small) peer window gets one DATA probe after an RTO. */
static void pump(C4fSctp *s, uint64_t now)
{
unsigned burst = 0;
s->sendAt = 0;
for (C4fSctpOut *o = s->out; o && burst < C4F_SCTP_MAX_BURST && s->state == C4F_SCTP_ESTABLISHED; o = o->next) {
if (o->sends) continue;
uint32_t room = s->peerRwnd > s->flightData ? s->peerRwnd - s->flightData : 0;
if (s->flightBytes + dataSize(o) > s->cwnd) break;
int probe = o->len > room;
if (probe) {
if (s->flightBytes) break;
if (!s->probeAt) s->probeAt = now + s->rto;
if (now < s->probeAt) break;
} else s->probeAt = 0;
C4fPacket p;
pktStart(s, &p, s->remoteTag);
if (s->sackDue) addSack(s, &p);
if (pktRoom(&p) < dataSize(o) && pktSend(s, &p)) return;
addData(&p, o, o->data);
if (pktSend(s, &p)) return;
o->sends = 1; o->sentAt = now;
s->highestSent = o->tsn;
addFlight(s, o);
if (!s->t3) s->t3 = now + s->rto;
burst++;
if (probe) { s->probeAt = 0; break; }
}
if (burst == C4F_SCTP_MAX_BURST) {
for (C4fSctpOut *o = s->out; o; o = o->next) if (!o->sends) {
uint32_t room = s->peerRwnd > s->flightData ? s->peerRwnd - s->flightData : 0;
if (o->len <= room && s->flightBytes + dataSize(o) <= s->cwnd) s->sendAt = now + 1;
break;
}
}
}
static void reduceWindow(C4fSctp *s, int timeout)
{
s->ssthresh = s->cwnd / 2;
if (s->ssthresh < 4u * C4F_SCTP_MTU) s->ssthresh = 4u * C4F_SCTP_MTU;
s->cwnd = timeout ? C4F_SCTP_MTU : s->ssthresh;
s->partialBytesAcked = 0;
}
static int sackHas(const uint8_t *v, size_t gaps, uint32_t cum, uint32_t tsn)
{
if (tsnLe(tsn, cum)) return 1;
for (size_t i = 0; i < gaps; i++) {
uint32_t from = cum + get16(v + 12 + 4 * i), to = cum + get16(v + 14 + 4 * i);
if (tsnLe(from, tsn) && tsnLe(tsn, to)) return 1;
}
return 0;
}
static void onSack(C4fSctp *s, const uint8_t *v, size_t vlen, uint64_t now)
{
if (s->state != C4F_SCTP_ESTABLISHED || vlen < 12) return;
uint32_t cum = get32(v);
size_t gaps = get16(v + 8);
if (12 + 4 * gaps > vlen || tsnLt(cum, s->peerCumTsn) || tsnLt(s->highestSent, cum)) return;
uint16_t last = 0;
for (size_t i = 0; i < gaps; i++) {
uint16_t from = get16(v + 12 + 4 * i), to = get16(v + 14 + 4 * i);
if (!from || from <= last || to < from || tsnLt(s->highestSent, cum + to)) return;
last = to;
}
int advanced = tsnLt(s->peerCumTsn, cum);
uint32_t flightBefore = s->flightBytes, ackBytes = 0, highestNew = cum;
C4fSctpOut *sample = NULL;
for (C4fSctpOut *o = s->out; o && o->sends; o = o->next) {
int acked = sackHas(v, gaps, cum, o->tsn);
if (acked && !o->acked && !o->abandoned) {
ackBytes += dataSize(o);
if (tsnLt(highestNew, o->tsn)) highestNew = o->tsn;
if (o->sends == 1 && tsnLe(o->tsn, cum)) sample = o;
}
if (acked) releaseFlight(s, o);
else if (o->acked && !o->abandoned) addFlight(s, o); /* receiver reneged a gap ACK */
o->acked = (uint8_t)acked;
}
if (sample && advanced) {
uint64_t r = now >= sample->sentAt ? now - sample->sentAt : 0;
if (!s->rttKnown) { s->srtt = r; s->rttvar = r / 2; s->rttKnown = 1; }
else {
uint64_t d = s->srtt > r ? s->srtt - r : r - s->srtt;
s->rttvar = (3 * s->rttvar + d) / 4;
s->srtt = (7 * s->srtt + r) / 8;
}
uint64_t rto = s->srtt + 4 * s->rttvar;
s->rto = rto < C4F_SCTP_RTO_MIN ? C4F_SCTP_RTO_MIN : rto > C4F_SCTP_RTO_MAX ? C4F_SCTP_RTO_MAX : rto;
}
if (s->fastRecovery && tsnLe(s->recoveryTsn, cum)) s->fastRecovery = 0;
if (advanced && !s->fastRecovery && flightBefore + C4F_SCTP_MTU >= s->cwnd) {
if (s->cwnd <= s->ssthresh) s->cwnd += ackBytes < C4F_SCTP_MTU ? ackBytes : C4F_SCTP_MTU;
else {
s->partialBytesAcked += ackBytes;
if (s->partialBytesAcked >= s->cwnd) { s->partialBytesAcked -= s->cwnd; s->cwnd += C4F_SCTP_MTU; }
}
if (s->cwnd > C4F_SCTP_OUT_LIMIT) s->cwnd = C4F_SCTP_OUT_LIMIT;
}
while (s->out && tsnLe(s->out->tsn, cum)) removeOut(s, &s->out);
s->peerCumTsn = cum;
s->peerRwnd = get32(v + 4);
s->timeouts = 0;
if (s->peerRwnd) s->probeAt = 0;
C4fPacket p;
pktStart(s, &p, s->remoteTag);
int fastSent = 0;
for (C4fSctpOut *o = s->out; o && o->sends && tsnLt(o->tsn, highestNew); o = o->next) {
if (o->acked || o->abandoned || o->fastDone) continue;
/* Only newly acknowledged TSNs report a new hole (HTNA): a repeated
* copy of the same SACK must not trigger retransmission. */
if (++o->missing < 3) continue;
if (!s->fastRecovery) {
reduceWindow(s, 0); s->fastRecovery = 1; s->recoveryTsn = s->highestSent;
}
if (!o->reliable) { releaseFlight(s, o); o->abandoned = 1; o->fastDone = 1; s->abandoned++; }
else if (!fastSent) {
o->fastDone = 1; retransmit(s, o, &p, now); fastSent = 1;
}
}
if (advanceForward(s)) addForwardTsn(s, &p);
if (pktSend(s, &p)) return;
if (!anyOutstanding(s)) s->t3 = 0;
else if (advanced || !s->t3) s->t3 = now + s->rto;
pump(s, now);
}
int c4fSctpSend(C4fSctp *s, uint16_t stream, uint32_t ppid, const uint8_t *data, size_t len, uint64_t now)
{
if (s->state != C4F_SCTP_ESTABLISHED || stream > 1 || len == 0 || len > C4F_SCTP_MAX_MESSAGE) return -1;
int reliable = stream == 0;
unsigned chunks = (unsigned)((len + C4F_SCTP_DATA_MAX - 1) / C4F_SCTP_DATA_MAX);
if (s->outBytes + len > C4F_SCTP_OUT_LIMIT || s->outChunks + chunks > C4F_SCTP_OUT_CHUNKS) return -1;
/* Never queue stale unreliable state behind a window or another message. */
uint32_t room = s->peerRwnd > s->flightData ? s->peerRwnd - s->flightData : 0;
if (!reliable && (chunks != 1 || hasQueued(s) || len > room ||
s->flightBytes + ((16u + len + 3u) & ~3u) > s->cwnd)) return -1;
C4fSctpOut *head = NULL, *tail = NULL;
size_t at = 0;
while (at < len) {
size_t part = len - at < C4F_SCTP_DATA_MAX ? len - at : C4F_SCTP_DATA_MAX;
C4fSctpOut *o = calloc(1, sizeof(*o) + part);
if (!o) {
while (head) { C4fSctpOut *next = head->next; free(head); head = next; }
return -1; /* no partial message or consumed sequence numbers */
}
o->ppid = ppid; o->stream = stream; o->ssn = reliable ? s->ssn[stream] : 0;
o->len = (uint16_t)part; o->reliable = (uint8_t)reliable;
o->flags = (uint8_t)((reliable ? 0 : DATA_UNORDERED) | (at == 0 ? DATA_BEGIN : 0) |
(at + part == len ? DATA_END : 0));
memcpy(o->data, data + at, part);
if (tail) tail->next = o; else head = o;
tail = o; at += part;
}
for (C4fSctpOut *o = head; o; o = o->next) o->tsn = s->nextTsn++;
if (s->outTail) s->outTail->next = head; else s->out = head;
s->outTail = tail; s->outBytes += len; s->outChunks += chunks;
if (reliable) s->ssn[stream]++;
pump(s, now);
return s->state == C4F_SCTP_ESTABLISHED ? 0 : -1;
}
void c4fSctpTick(C4fSctp *s, uint64_t now)
{
if (s->state != C4F_SCTP_ESTABLISHED) return;
if (s->t3 && now >= s->t3) {
if (++s->timeouts > C4F_SCTP_MAX_TIMEOUTS) { s->state = C4F_SCTP_ENDED; return; }
C4fPacket p;
pktStart(s, &p, s->remoteTag);
int retransmitted = 0;
if (s->peerRwnd) reduceWindow(s, 1);
s->fastRecovery = 0;
for (C4fSctpOut *o = s->out; o && o->sends; o = o->next) {
if (o->acked || o->abandoned || now - o->sentAt < s->rto) continue;
if (!o->reliable) { releaseFlight(s, o); o->abandoned = 1; s->abandoned++; }
else if (!retransmitted) {
o->fastDone = 0; retransmit(s, o, &p, now); retransmitted = 1;
}
}
s->rto = s->rto * 2 > C4F_SCTP_RTO_MAX ? C4F_SCTP_RTO_MAX : s->rto * 2;
if (advanceForward(s) || tsnLt(s->peerCumTsn, s->forwardTsn)) addForwardTsn(s, &p);
if (pktSend(s, &p)) return;
s->t3 = anyOutstanding(s) ? now + s->rto : 0;
}
pump(s, now);
}
uint64_t c4fSctpDeadline(const C4fSctp *s)
{
if (s->state != C4F_SCTP_ESTABLISHED) return 0;
uint64_t due = s->t3;
if (s->probeAt && (!due || s->probeAt < due)) due = s->probeAt;
if (s->sendAt && (!due || s->sendAt < due)) due = s->sendAt;
return due;
}
static void freeQueues(C4fSctp *s)
{
while (s->out) { C4fSctpOut *o = s->out; s->out = o->next; free(o); }
while (s->in) { C4fSctpIn *e = s->in; s->in = e->next; free(e); }
s->outTail = NULL; s->outBytes = s->inBytes = 0; s->outChunks = 0;
s->flightBytes = s->flightData = 0;
}
void c4fSctpInit(C4fSctp *s, const C4fSctpOps *ops)
{
memset(s, 0, sizeof(*s));
s->ops = *ops;
s->state = C4F_SCTP_CLOSED;
s->rto = C4F_SCTP_RTO_START;
}
void c4fSctpFree(C4fSctp *s)
{
freeQueues(s);
s->state = C4F_SCTP_ENDED;
}
static void onInit(C4fSctp *s, const uint8_t *packet, const uint8_t *v, size_t vlen)
{
if (vlen < 16) return;
uint32_t tag = get32(v), tsn = get32(v + 12);
uint16_t os = get16(v + 8), mis = get16(v + 10);
if (!tag || os < 2 || mis < 2 || get32(v + 4) < 1500) return;
int duplicate = (s->state == C4F_SCTP_COOKIE_WAIT || s->state == C4F_SCTP_ESTABLISHED) &&
s->remoteTag == tag && s->remoteInitialTsn == tsn;
if (!duplicate) {
/* A restart needs a fresh DTLS/page session too, so ownership and
* application sequence floors cannot survive an association reset. */
if (s->state == C4F_SCTP_ESTABLISHED) { s->state = C4F_SCTP_ENDED; return; }
freeQueues(s);
memset(s->seen, 0, sizeof(s->seen));
s->remotePort = get16(packet); s->localPort = get16(packet + 2);
s->remoteTag = tag; s->remoteInitialTsn = tsn;
do s->ops.random(s->ops.user, &s->localTag, sizeof(s->localTag)); while (!s->localTag);
s->ops.random(s->ops.user, &s->nextTsn, sizeof(s->nextTsn));
s->localInitialTsn = s->nextTsn;
s->ops.random(s->ops.user, s->cookie, sizeof(s->cookie));
s->cumTsn = s->highTsn = tsn - 1;
s->nextSsn[0] = s->nextSsn[1] = s->ssn[0] = s->ssn[1] = 0;
s->peerCumTsn = s->forwardTsn = s->nextTsn - 1;
s->highestSent = s->nextTsn - 1;
s->peerRwnd = get32(v + 4); s->cwnd = C4F_SCTP_INITIAL_CWND; s->ssthresh = s->peerRwnd;
s->partialBytesAcked = 0; s->fastRecovery = 0; s->probeAt = s->sendAt = 0; s->timeouts = 0;
s->rto = C4F_SCTP_RTO_START; s->rttKnown = 0; s->t3 = 0; s->sackDue = 0;
s->state = C4F_SCTP_COOKIE_WAIT;
}
C4fPacket p;
pktStart(s, &p, tag);
uint8_t *a = pktChunk(&p, CHUNK_INIT_ACK, 0, 16 + 20 + 8 + 4);
put32(a, s->localTag);
put32(a + 4, C4F_SCTP_RWND);
put16(a + 8, mis); /* outbound streams: as many as it takes in */
put16(a + 10, 65535); /* inbound */
put32(a + 12, s->localInitialTsn);
put16(a + 16, PARAM_STATE_COOKIE); put16(a + 18, 4 + sizeof(s->cookie));
memcpy(a + 20, s->cookie, sizeof(s->cookie));
put16(a + 36, PARAM_SUPPORTED_EXTENSIONS); put16(a + 38, 6);
a[40] = CHUNK_FORWARD_TSN; a[41] = CHUNK_RECONFIG; /* a[42..43]: padding */
put16(a + 44, PARAM_FORWARD_TSN); put16(a + 46, 4);
pktSend(s, &p);
}
static void onCookieEcho(C4fSctp *s, const uint8_t *v, size_t vlen)
{
if (s->state != C4F_SCTP_COOKIE_WAIT && s->state != C4F_SCTP_ESTABLISHED) return;
if (vlen != sizeof(s->cookie) || memcmp(v, s->cookie, sizeof(s->cookie))) return;
s->state = C4F_SCTP_ESTABLISHED;
C4fPacket p;
pktStart(s, &p, s->remoteTag);
pktChunk(&p, CHUNK_COOKIE_ACK, 0, 0);
pktSend(s, &p);
}
/* Stream resets (RFC 6525): the browser closing a channel resets its side of
* the stream; the next message on it starts again at 0. */
static void onReconfig(C4fSctp *s, C4fPacket *reply, const uint8_t *v, size_t vlen)
{
for (size_t at = 0; at + 4 <= vlen; ) {
uint16_t type = get16(v + at), size = get16(v + at + 2);
if (size < 4 || at + size > vlen) break;
if ((type == PARAM_OUTGOING_RESET && size >= 16) || (type == PARAM_INCOMING_RESET && size >= 8)) {
uint32_t seq = get32(v + at + 4);
int result = RECONFIG_DENIED;
if (type == PARAM_OUTGOING_RESET) {
size_t first = at + 16, count = (size - 16) / 2;
if (!count) s->nextSsn[0] = s->nextSsn[1] = 0;
for (size_t i = 0; i < count; i++) { uint16_t st = get16(v + first + 2 * i); if (st <= 1) s->nextSsn[st] = 0; }
result = RECONFIG_PERFORMED;
}
uint8_t *r = pktChunk(reply, CHUNK_RECONFIG, 0, 12);
if (!r) { pktSend(s, reply); r = pktChunk(reply, CHUNK_RECONFIG, 0, 12); }
put16(r, PARAM_RECONFIG_RESPONSE); put16(r + 2, 12);
put32(r + 4, seq); put32(r + 8, (uint32_t)result);
}
at += (size + 3) & ~3u;
}
}
void c4fSctpReceive(C4fSctp *s, const uint8_t *packet, size_t len, uint64_t now)
{
if (len < 16 || s->state == C4F_SCTP_ENDED) return;
/* The checksum is taken over the packet with its own field as zero. */
static const uint8_t zero[4];
uint32_t c = c4fCrc32cUpdate(0xFFFFFFFFu, packet, 8);
c = c4fCrc32cUpdate(c, zero, 4);
c = c4fCrc32cUpdate(c, packet + 12, len - 12) ^ 0xFFFFFFFFu;
uint32_t sent = (uint32_t)packet[8] | (uint32_t)packet[9] << 8 | (uint32_t)packet[10] << 16 | (uint32_t)packet[11] << 24;
if (c != sent) { s->badChecksums++; return; }
s->packetsIn++;
uint32_t tag = get32(packet + 4);
if (packet[12] == CHUNK_INIT) {
size_t size = get16(packet + 14);
if (tag == 0 && size >= 20 && size <= len - 12) onInit(s, packet, packet + 16, size - 4);
return;
}
if (s->state == C4F_SCTP_CLOSED || tag != s->localTag) return;
C4fPacket reply;
pktStart(s, &reply, s->remoteTag);
for (size_t at = 12; at + 4 <= len; ) {
uint8_t type = packet[at], flags = packet[at + 1];
size_t size = get16(packet + at + 2);
if (size < 4 || at + size > len) break;
const uint8_t *v = packet + at + 4;
size_t vlen = size - 4;
switch (type) {
case CHUNK_DATA: onData(s, flags, v, vlen); break;
case CHUNK_SACK: onSack(s, v, vlen, now); break;
case CHUNK_HEARTBEAT: {
uint8_t *r = pktChunk(&reply, CHUNK_HEARTBEAT_ACK, 0, vlen);
if (!r) { pktSend(s, &reply); r = pktChunk(&reply, CHUNK_HEARTBEAT_ACK, 0, vlen); }
if (r) memcpy(r, v, vlen);
break;
}
case CHUNK_COOKIE_ECHO: onCookieEcho(s, v, vlen); break;
case CHUNK_FORWARD_TSN: onForwardTsn(s, v, vlen); break;
case CHUNK_RECONFIG: if (s->state == C4F_SCTP_ESTABLISHED) onReconfig(s, &reply, v, vlen); break;
case CHUNK_SHUTDOWN:
pktChunk(&reply, CHUNK_SHUTDOWN_ACK, 0, 0);
pktSend(s, &reply);
s->state = C4F_SCTP_ENDED;
return;
case CHUNK_ABORT: case CHUNK_SHUTDOWN_COMPLETE:
s->state = C4F_SCTP_ENDED;
return;
case CHUNK_INIT_ACK: case CHUNK_COOKIE_ACK: case CHUNK_ERROR: case CHUNK_HEARTBEAT_ACK: case CHUNK_SHUTDOWN_ACK:
break;
default:
/* Unknown: the top two bits say whether to skip it or stop here. */
if (!(type & 0x80)) { at = len; continue; }
break;
}
if (s->state == C4F_SCTP_ENDED) return;
at += (size + 3) & ~(size_t)3;
}
if (s->sackDue && s->state == C4F_SCTP_ESTABLISHED && addSack(s, &reply) != 0) { pktSend(s, &reply); addSack(s, &reply); }
pktSend(s, &reply);
}
+157
View File
@@ -0,0 +1,157 @@
/* STUN for an ICE-lite WebRTC endpoint: checks the browser's connectivity checks
* and answers them. See c4f_stun.h. */
#include <string.h>
#include <mbedtls/md.h>
#include "c4f_stun.h"
enum {
STUN_BINDING_REQUEST = 0x0001,
STUN_BINDING_SUCCESS = 0x0101,
ATTR_USERNAME = 0x0006,
ATTR_MESSAGE_INTEGRITY = 0x0008,
ATTR_XOR_MAPPED_ADDRESS = 0x0020,
ATTR_USE_CANDIDATE = 0x0025,
ATTR_FINGERPRINT = 0x8028,
};
static uint16_t c4fGet16(const uint8_t *p) { return (uint16_t)(p[0] << 8 | p[1]); }
static uint32_t c4fGet32(const uint8_t *p) { return (uint32_t)p[0] << 24 | (uint32_t)p[1] << 16 | (uint32_t)p[2] << 8 | p[3]; }
static void c4fPut16(uint8_t *p, uint16_t v) { p[0] = v >> 8; p[1] = (uint8_t)v; }
static void c4fPut32(uint8_t *p, uint32_t v) { p[0] = v >> 24; p[1] = (uint8_t)(v >> 16); p[2] = (uint8_t)(v >> 8); p[3] = (uint8_t)v; }
/* CRC-32 in pieces: start from 0xFFFFFFFF, invert at the end. */
static uint32_t c4fCrc32Update(uint32_t c, const uint8_t *data, size_t len)
{
static uint32_t table[256];
static int ready;
if (!ready) {
for (uint32_t i = 0; i < 256; i++) {
uint32_t v = i;
for (int k = 0; k < 8; k++) v = v & 1 ? 0xEDB88320u ^ (v >> 1) : v >> 1;
table[i] = v;
}
ready = 1;
}
for (size_t i = 0; i < len; i++) c = table[(c ^ data[i]) & 0xFF] ^ (c >> 8);
return c;
}
uint32_t c4fCrc32(const uint8_t *data, size_t len)
{
return c4fCrc32Update(0xFFFFFFFFu, data, len) ^ 0xFFFFFFFFu;
}
/* A STUN message filling the whole datagram, or 0. */
static int c4fStunWellFormed(const uint8_t *msg, size_t len)
{
if (len < 20 || len > 1500 || (msg[0] & 0xC0)) return 0;
if (c4fGet32(msg + 4) != C4F_STUN_MAGIC) return 0;
size_t body = c4fGet16(msg + 2);
return (body & 3) == 0 && body + 20 == len;
}
/* Finds an attribute: the offset of its header, or 0. */
static size_t c4fStunFind(const uint8_t *msg, size_t len, uint16_t type)
{
for (size_t at = 20; at + 4 <= len; ) {
size_t size = c4fGet16(msg + at + 2);
if (at + 4 + size > len) return 0;
if (c4fGet16(msg + at) == type) return at;
at += 4 + ((size + 3) & ~(size_t)3);
}
return 0;
}
/* HMAC-SHA1 over the message up to an attribute at `at`, with the header's
* length set as if a MESSAGE-INTEGRITY there were the last attribute. */
static void c4fStunHmac(const uint8_t *msg, size_t at, const char *key, uint8_t out[20])
{
uint8_t head[20];
memcpy(head, msg, 20);
c4fPut16(head + 2, (uint16_t)(at - 20 + 24));
const mbedtls_md_info_t *sha1 = mbedtls_md_info_from_type(MBEDTLS_MD_SHA1);
mbedtls_md_context_t ctx;
mbedtls_md_init(&ctx);
if (mbedtls_md_setup(&ctx, sha1, 1) == 0 &&
mbedtls_md_hmac_starts(&ctx, (const unsigned char *)key, strlen(key)) == 0 &&
mbedtls_md_hmac_update(&ctx, head, 20) == 0 &&
mbedtls_md_hmac_update(&ctx, msg + 20, at - 20) == 0 &&
mbedtls_md_hmac_finish(&ctx, out) == 0) {
mbedtls_md_free(&ctx);
return;
}
mbedtls_md_free(&ctx);
memset(out, 0, 20);
}
/* CRC-32 over the message up to a FINGERPRINT at `at`, the length counting it. */
static uint32_t c4fStunFingerprint(const uint8_t *msg, size_t at)
{
uint8_t head[20];
memcpy(head, msg, 20);
c4fPut16(head + 2, (uint16_t)(at - 20 + 8));
uint32_t c = c4fCrc32Update(0xFFFFFFFFu, head, 20);
c = c4fCrc32Update(c, msg + 20, at - 20);
return (c ^ 0xFFFFFFFFu) ^ 0x5354554Eu;
}
const uint8_t *c4fStunUsername(const uint8_t *msg, size_t len, size_t *size)
{
if (!c4fStunWellFormed(msg, len) || c4fGet16(msg) != STUN_BINDING_REQUEST) return NULL;
size_t at = c4fStunFind(msg, len, ATTR_USERNAME);
if (!at) return NULL;
*size = c4fGet16(msg + at + 2);
return msg + at + 4;
}
int c4fStunVerify(const uint8_t *msg, size_t len, const char *key)
{
if (!c4fStunWellFormed(msg, len)) return 0;
size_t mi = c4fStunFind(msg, len, ATTR_MESSAGE_INTEGRITY);
if (!mi || c4fGet16(msg + mi + 2) != 20) return 0;
uint8_t mac[20];
c4fStunHmac(msg, mi, key, mac);
/* Constant time is not needed against a LAN guesser, but costs nothing. */
uint8_t diff = 0;
for (int i = 0; i < 20; i++) diff |= mac[i] ^ msg[mi + 4 + i];
if (diff) return 0;
/* Only FINGERPRINT may follow MESSAGE-INTEGRITY. */
size_t after = mi + 24;
if (after == len) return 1;
if (after + 8 != len || c4fGet16(msg + after) != ATTR_FINGERPRINT || c4fGet16(msg + after + 2) != 4) return 0;
return c4fGet32(msg + after + 4) == c4fStunFingerprint(msg, after);
}
size_t c4fStunAnswer(const uint8_t *msg, size_t len, const char *password,
uint32_t address, uint16_t port, uint8_t out[C4F_STUN_ANSWER_SIZE], int *nominate)
{
*nominate = 0;
if (!c4fStunWellFormed(msg, len) || c4fGet16(msg) != STUN_BINDING_REQUEST) return 0;
if (!c4fStunVerify(msg, len, password)) return 0;
size_t mi = c4fStunFind(msg, len, ATTR_MESSAGE_INTEGRITY);
size_t use = c4fStunFind(msg, len, ATTR_USE_CANDIDATE);
*nominate = use && use < mi;
memset(out, 0, C4F_STUN_ANSWER_SIZE);
c4fPut16(out, STUN_BINDING_SUCCESS);
c4fPut16(out + 2, 44); /* 12 + 24 + 8 */
memcpy(out + 4, msg + 4, 16); /* magic cookie and transaction ID */
/* XOR-MAPPED-ADDRESS: the address and port as the PS4 saw them. */
uint8_t *a = out + 20;
c4fPut16(a, ATTR_XOR_MAPPED_ADDRESS); c4fPut16(a + 2, 8);
a[5] = 0x01; /* IPv4 */
uint16_t hostPort = (uint16_t)(((const uint8_t *)&port)[0] << 8 | ((const uint8_t *)&port)[1]);
c4fPut16(a + 6, hostPort ^ (uint16_t)(C4F_STUN_MAGIC >> 16));
uint32_t hostAddress = c4fGet32((const uint8_t *)&address);
c4fPut32(a + 8, hostAddress ^ C4F_STUN_MAGIC);
/* MESSAGE-INTEGRITY with our password, then FINGERPRINT. */
uint8_t *m = out + 32;
c4fPut16(m, ATTR_MESSAGE_INTEGRITY); c4fPut16(m + 2, 20);
c4fStunHmac(out, 32, password, m + 4);
uint8_t *f = out + 56;
c4fPut16(f, ATTR_FINGERPRINT); c4fPut16(f + 2, 4);
c4fPut32(f + 4, c4fStunFingerprint(out, 56));
return C4F_STUN_ANSWER_SIZE;
}
+17
View File
@@ -170,6 +170,23 @@ int32_t c4fUserName(uint32_t userId, char *out, size_t size)
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));
+391 -68
View File
@@ -1,5 +1,6 @@
/* Browser-controlled VDA lifecycle. No automatic button presses or user binds.
* The page talks JSON over a WebSocket: info, status, claim, u (input), leave, stop. */
* WebRTC control carries requests and status; its unordered input channel
* carries sequenced controller state and motion. */
#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
@@ -34,26 +35,54 @@
/* 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
/* Rumble goes where a lost message isn't resent, so while it's on it is sent
* again this often. */
#define C4F_RUMBLE_REPEAT_MS 200
#define C4F_USER_POLL_MS 250
#ifndef C4F_LATENCY_MS
#define C4F_LATENCY_MS 6000
#endif
typedef struct {
C4fVirtualPad device;
C4fNetClient *owner;
C4fConn *owner;
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;
uint64_t feedbackAt, colorLogAt, rumbleSentAt;
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;
/* Motion its owner sends ('m'), put into every sample. Until then, and after
* any neutral reset, samples carry the still values c4fPadDataNeutral sets. */
int motion;
float quat[4], accel[3], gyro[3];
/* The newest input ('i') and motion ('m') applied: over WebRTC they can
* arrive late or out of order, and an older one must not undo a newer. */
int64_t inputSeq, motionSeq;
int inputSeqKnown, motionSeqKnown;
uint64_t latencyAfter; /* no sample from before the last ownership/stale reset */
} C4fWebPad;
/* Informational, browser-measured input-channel RTT. The service accepts one
* bounded report for a probe it just echoed on this connection; it never uses
* the browser's clock or this report to renew a controller's input lease. */
typedef struct {
C4fConn *conn;
int64_t probeSeq;
uint64_t probeAt, measuredProbeAt;
int probeKnown, pending, known, rttMs;
} C4fWebConn;
/* Creating a controller means issuing AddDevice and then finding its DeviceId in
* the kernel log, which takes a moment. None of that may stop the service from
* answering the other players, so it runs as a state machine driven from the main
@@ -71,7 +100,7 @@ typedef struct {
unsigned wanted; /* the whole claim this request asked for */
unsigned pending; /* the slots still to create */
unsigned created; /* the slots created so far, to undo on failure */
C4fNetClient *client; /* who asked, or NULL once they left */
C4fConn *client; /* who asked, or NULL once they left */
int64_t id;
int hasId;
int slot; /* the slot AddDevice was issued for, or -1 */
@@ -85,7 +114,7 @@ typedef struct {
typedef struct {
C4fNet net;
C4fWebPad pads[C4F_MAX_PADS];
int klogFd, stop, changed, creationBlocked, feedbackMissing;
int klogFd, changed, creationBlocked, feedbackMissing;
unsigned rumbleChanges; /* since the last heartbeat, to tell a silent game from a silent phone */
/* What reading rumble and light bar costs, since the last heartbeat. Every
* microsecond here is one an arriving input could have to wait. */
@@ -93,12 +122,20 @@ typedef struct {
char klogLine[1024];
size_t klogUsed;
C4fAdd add;
uint64_t broadcastAt, stopAt, heartbeatAt;
uint64_t broadcastAt, stopAt, heartbeatAt, usersAt;
int usersFailed;
C4fWebConn conns[C4F_NET_CLIENTS + C4F_RTC_PEERS]; /* open peers and their optional RTT */
int connCount;
/* Loop iterations that took long, since the last heartbeat: while the loop
* is held up, every page's input waits with it. */
unsigned slowLoops;
uint64_t slowestLoop, slowLogAt;
} C4fWeb;
#define C4F_MAX_ARGS 32
typedef struct {
char method[24];
int64_t id, args[16];
int64_t id, args[C4F_MAX_ARGS];
unsigned argc;
int hasId;
} C4fRequest;
@@ -139,7 +176,7 @@ static int c4fParseRequest(const char *json, size_t len, C4fRequest *r)
if (method++ || value->type != JSMN_STRING || n < 1 || n >= (int)sizeof(r->method)) return -1;
memcpy(r->method, json+value->start, (size_t)n); r->method[n] = 0;
} else if (c4fTokenEquals(json, key, "params")) {
if (params++ || value->type != JSMN_ARRAY || value->size > 16) return -1;
if (params++ || value->type != JSMN_ARRAY || value->size > C4F_MAX_ARGS) return -1;
r->argc = (unsigned)value->size;
for (unsigned a = 0; a < r->argc; a++) {
if (i >= count || c4fInteger(json, &tok[i++], &r->args[a])) return -1;
@@ -151,13 +188,13 @@ static int c4fParseRequest(const char *json, size_t len, C4fRequest *r)
return method == 1 && params == 1 ? 0 : -1;
}
static void c4fError(C4fNetClient *c, const C4fRequest *r, int code, const char *message)
static void c4fError(C4fConn *c, const C4fRequest *r, int code, const char *message)
{
char reply[384];
/* Messages are constant ASCII strings under our control. */
if (r && r->hasId) snprintf(reply, sizeof(reply), "{\"id\":%lld,\"error\":{\"code\":%d,\"message\":\"%s\"}}", (long long)r->id, code, message);
else snprintf(reply, sizeof(reply), "{\"method\":\"error\",\"params\":{\"message\":\"%s\"}}", message);
c4fNetText(c, reply);
c4fConnText(c, reply);
}
/* `in` as the inside of a JSON string. Quotes and backslashes are escaped, and
@@ -185,13 +222,13 @@ static void c4fJsonText(char *out, size_t size, const char *in)
out[o] = 0;
}
static void c4fStatus(C4fWeb *app, C4fNetClient *c, const C4fRequest *request)
static void c4fStatus(C4fWeb *app, C4fConn *c, const C4fRequest *request)
{
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\":");
pos += (size_t)snprintf(json+pos, sizeof(json)-pos, "{\"version\":\"%s\",\"protocol\":2,\"pads\":[", C4F_VERSION);
pos += (size_t)snprintf(json+pos, sizeof(json)-pos, "{\"version\":\"%s\",\"protocol\":3,\"pads\":[", C4F_VERSION);
for (int i = 0; i < C4F_MAX_PADS; i++) {
C4fWebPad *p = &app->pads[i];
const char *state = p->error ? "error" : app->add.state && (app->add.pending & (1u << i)) ? "connecting"
@@ -215,7 +252,7 @@ static void c4fStatus(C4fWeb *app, C4fNetClient *c, const C4fRequest *request)
p->owner == c ? "true" : "false", state, p->assigned ? "" : "unassigned-", p->userId,
rgb[0], rgb[1], rgb[2], (unsigned long long)p->reports, p->error);
}
snprintf(json+pos, sizeof(json)-pos, "]}}"); c4fNetText(c, json);
snprintf(json+pos, sizeof(json)-pos, "]}}"); c4fConnText(c, json);
}
/* How long ago `then` was, and never more than that. These are unsigned
@@ -229,12 +266,31 @@ static uint64_t c4fSince(uint64_t now, uint64_t then)
return now > then ? now - then : 0;
}
static C4fWebConn *c4fWebConn(C4fWeb *app, const C4fConn *conn)
{
for (int i = 0; i < app->connCount; i++) if (app->conns[i].conn == conn) return &app->conns[i];
return NULL;
}
static void c4fNeutralize(C4fWebPad *p)
{
p->head = p->count = 0;
p->motion = 0;
p->latencyAfter = c4fTimeMs();
/* Preserve sequence floors across a stale/error reset: delayed packets
* must not restore a state that a newer packet already superseded. A new
* owner starts a new sequence space in c4fApplyClaim. */
c4fPadDataNeutral(&p->current);
}
static void c4fApplyMotion(const C4fWebPad *p, ScePadData *data)
{
if (!p->motion) return;
memcpy(data->quat, p->quat, sizeof(data->quat));
memcpy(data->accel, p->accel, sizeof(data->accel));
memcpy(data->gyro, p->gyro, sizeof(data->gyro));
}
static void c4fRemove(C4fWeb *app, C4fWebPad *p)
{
if (p->active) {
@@ -245,6 +301,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)
{
C4fConn *owner = p->owner;
if (owner) {
char message[64];
snprintf(message, sizeof(message), "{\"method\":\"v\",\"params\":[%d,0,0]}", index);
c4fConnInput(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)
{
@@ -260,6 +370,7 @@ static void c4fReportPad(C4fWeb *app, C4fWebPad *p, int index, uint64_t now)
c4fNeutralize(p);
c4fLog("web controller %d InsertData = 0x%08x\n", index+1, (uint32_t)ret);
}
if (ret >= 0 && p->error) { p->error = 0; app->changed = 1; }
}
static void c4fReportPads(C4fWeb *app)
@@ -333,19 +444,25 @@ static void c4fPollFeedback(C4fWeb *app, uint64_t now)
continue;
}
if (!p->feedbackKnown || f.r != p->feedback.r || f.g != p->feedback.g || f.b != p->feedback.b) {
/* Rare: a sign-in, or a game setting its colour. */
c4fLog("web controller %d light bar %02x %02x %02x\n", i + 1, f.r, f.g, f.b);
app->changed = 1;
}
if ((!p->colorLogKnown || f.r != p->colorLogged[0] || f.g != p->colorLogged[1] ||
f.b != p->colorLogged[2]) && now >= p->colorLogAt) {
c4fLog("web controller %d light bar %02x %02x %02x\n", i + 1, f.r, f.g, f.b);
p->colorLogged[0] = f.r; p->colorLogged[1] = f.g; p->colorLogged[2] = f.b;
p->colorLogKnown = 1; p->colorLogAt = now + 1000;
}
if (p->feedbackKnown && (f.large != p->feedback.large || f.small != p->feedback.small))
app->rumbleChanges++;
p->feedback = f;
p->feedbackKnown = 1;
if (p->owner && (!p->rumbleSentValid || f.large != p->rumbleSent[0] || f.small != p->rumbleSent[1])) {
int changed = !p->rumbleSentValid || f.large != p->rumbleSent[0] || f.small != p->rumbleSent[1];
int again = p->owner && p->owner->input && (f.large || f.small) && now - p->rumbleSentAt >= C4F_RUMBLE_REPEAT_MS;
if (p->owner && (changed || again)) {
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;
if (c4fConnInput(p->owner, message) == 0) {
p->rumbleSent[0] = f.large; p->rumbleSent[1] = f.small; p->rumbleSentValid = 1; p->rumbleSentAt = now;
}
}
}
@@ -444,6 +561,8 @@ 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);
@@ -496,12 +615,15 @@ static void c4fReleaseIdleKlog(C4fWeb *app)
}
/* Hands the claim its slots once every device it needed exists. */
static void c4fApplyClaim(C4fWeb *app, C4fNetClient *c, unsigned wanted, C4fRequest *r)
static void c4fApplyClaim(C4fWeb *app, C4fConn *c, unsigned wanted, C4fRequest *r)
{
for (int i = 0; i < C4F_MAX_PADS; i++) {
C4fWebPad *p = &app->pads[i];
if (wanted & (1u << i)) {
if (p->owner != c) { c4fNeutralize(p); p->rumbleSentValid = 0; }
if (p->owner != c) {
c4fNeutralize(p); p->rumbleSentValid = 0;
p->inputSeqKnown = p->motionSeqKnown = 0;
}
p->owner = c; p->detachedAt = 0; p->lastInput = c4fTimeMs(); p->stale = 0;
} else if (p->owner == c) c4fRemove(app, p);
}
@@ -518,7 +640,7 @@ static void c4fAddReset(C4fWeb *app)
* run, so a device we can no longer address may exist. */
static void c4fAddFail(C4fWeb *app, int code, const char *message, int blocked)
{
C4fNetClient *client = app->add.client;
C4fConn *client = app->add.client;
C4fRequest reply;
if (blocked) app->creationBlocked = 1;
@@ -532,6 +654,16 @@ static void c4fAddFail(C4fWeb *app, int code, const char *message, int blocked)
if (client) c4fError(client, reply.hasId ? &reply : NULL, code, message);
}
/* Never abandon an issued AddDevice: capture its handle, then roll it back.
* Unissued work can be canceled immediately without touching retained pads. */
static void c4fCancelAdd(C4fWeb *app)
{
if (!app->add.state) return;
app->add.client = NULL;
if (app->add.state != C4F_ADD_DEVICE)
c4fAddFail(app, 503, "Controller connection canceled", 0);
}
/* Issues AddDevice for the lowest slot still waiting. */
static void c4fAddNextDevice(C4fWeb *app, uint64_t now)
{
@@ -593,7 +725,6 @@ static void c4fAdvanceAdd(C4fWeb *app, uint64_t now, long klogBytes)
app->add.created |= 1u << app->add.slot;
app->add.pending &= ~(1u << app->add.slot);
app->changed = 1;
if (app->add.pending) { c4fAddNextDevice(app, now); return; }
/* Done. If the player left while we worked, the devices have nobody
* to drive them. */
if (!app->add.client) {
@@ -602,12 +733,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;
C4fConn *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);
}
@@ -626,7 +764,7 @@ static void c4fAdvanceAdd(C4fWeb *app, uint64_t now, long klogBytes)
}
}
static void c4fClaim(C4fWeb *app, C4fNetClient *c, C4fRequest *r)
static void c4fClaim(C4fWeb *app, C4fConn *c, C4fRequest *r)
{
unsigned wanted = 0, needed = 0;
int needsAssignment = 0;
@@ -634,6 +772,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];
@@ -670,7 +811,7 @@ static void c4fClaim(C4fWeb *app, C4fNetClient *c, C4fRequest *r)
* 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)
static void c4fInvite(C4fConn *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)];
@@ -707,55 +848,207 @@ static void c4fInvite(C4fNetClient *c, const C4fRequest *r)
reply[pos++] = '"';
}
snprintf(reply + pos, sizeof(reply) - pos, "]}}");
c4fNetText(c, reply);
c4fConnText(c, reply);
}
static void c4fUpdate(C4fWeb *app, C4fNetClient *c, C4fRequest *r)
/* Buttons, sticks, triggers and touches from args[at..]: [buttons, lx, ly, rx,
* ry, l2, r2, n, (id, x, y) x n]. The number of args it took, or 0 after
* answering an error. */
static unsigned c4fParseState(C4fConn *c, C4fRequest *r, unsigned at, ScePadData *data)
{
const int64_t *a = r->args + at;
if (r->argc < at + 8) { c4fError(c, r, 400, "That input was not in a form Control4Free understands"); return 0; }
if (a[0] < 0 || a[0] > C4F_INPUT_MASK || (a[0] & ~C4F_INPUT_MASK)) { c4fError(c, r, 400, "Those are not buttons a DualShock 4 has"); return 0; }
for (unsigned i = 1; i < 7; i++) if (a[i] < 0 || a[i] > 255) { c4fError(c, r, 400, "A stick or trigger was outside its range"); return 0; }
if (a[7] < 0 || a[7] > 2 || r->argc < at + 8 + 3 * (unsigned)a[7]) { c4fError(c, r, 400, "That touch was not in a form Control4Free understands"); return 0; }
c4fPadDataNeutral(data); data->buttons = (uint32_t)a[0];
data->lx = a[1]; data->ly = a[2]; data->rx = a[3]; data->ry = a[4]; data->l2 = a[5]; data->r2 = a[6];
data->touchData.fingers = a[7];
for (unsigned i = 0; i < data->touchData.fingers; i++) {
const int64_t *t = &a[8 + 3 * i];
if (t[0] < 0 || t[0] > 127 || t[1] < 0 || t[1] > 1919 || t[2] < 0 || t[2] > 941) { c4fError(c, r, 400, "A touch landed outside the touchpad"); return 0; }
data->touchData.touch[i].finger = t[0]; data->touchData.touch[i].x = t[1]; data->touchData.touch[i].y = t[2];
}
return 8 + 3 * data->touchData.fingers;
}
/* A new state for the pad: queued, and out at once unless a report has only
* just gone (holding it for the next tick would add lag for nothing). */
static void c4fTakeInput(C4fWeb *app, C4fWebPad *p, int index, ScePadData *data)
{
c4fApplyMotion(p, data);
if (p->stale) { p->stale = 0; app->changed = 1; }
uint64_t now = c4fTimeMs();
if (c4fSince(now, p->lastInput) >= C4F_STALE_MS) p->latencyAfter = now;
p->lastInput = now; c4fEnqueue(p, data);
if (c4fSince(now, p->lastReport) >= C4F_REPORT_FAST_MS) c4fReportPad(app, p, index, now);
}
/* u: [pad, buttons, lx, ly, rx, ry, l2, r2, n, (id, x, y) x n], in order on a
* reliable connection (the host tests' WebSocket). */
#ifdef C4F_TEST_WEBSOCKET
static void c4fUpdate(C4fWeb *app, C4fConn *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; }
C4fWebPad *p = &app->pads[r->args[0]];
if (p->owner != c || !p->active) return; /* input never implicitly claims a pad */
if (r->args[1] < 0 || r->args[1] > C4F_INPUT_MASK || (r->args[1] & ~C4F_INPUT_MASK)) { c4fError(c, r, 400, "Those are not buttons a DualShock 4 has"); return; }
for (unsigned i = 2; i < 8; i++) if (r->args[i] < 0 || r->args[i] > 255) { c4fError(c, r, 400, "A stick or trigger was outside its range"); return; }
if (r->args[8] < 0 || r->args[8] > 2 || r->argc != 9+3*r->args[8]) { c4fError(c, r, 400, "That touch was not in a form Control4Free understands"); return; }
ScePadData data;
c4fPadDataNeutral(&data); data.buttons = (uint32_t)r->args[1];
data.lx = r->args[2]; data.ly = r->args[3]; data.rx = r->args[4]; data.ry = r->args[5]; data.l2 = r->args[6]; data.r2 = r->args[7];
data.touchData.fingers = r->args[8];
for (unsigned i = 0; i < data.touchData.fingers; i++) {
int64_t *t = &r->args[9+3*i];
if (t[0] < 0 || t[0] > 127 || t[1] < 0 || t[1] > 1919 || t[2] < 0 || t[2] > 941) { c4fError(c, r, 400, "A touch landed outside the touchpad"); return; }
data.touchData.touch[i].finger = t[0]; data.touchData.touch[i].x = t[1]; data.touchData.touch[i].y = t[2];
unsigned used = c4fParseState(c, r, 1, &data);
if (!used) return;
if (r->argc != 1 + used) { c4fError(c, r, 400, "That touch was not in a form Control4Free understands"); return; }
c4fTakeInput(app, p, (int)r->args[0], &data);
}
#endif
/* i: [pad, seq, buttons, lx, ly, rx, ry, l2, r2, n, (id, x, y) x n, k,
* (seq, buttons) x k], the page's input over WebRTC, where a message can be
* lost or overtaken. Anything older than the newest applied is dropped. The k
* pairs are the page's latest button changes: one newer than what arrived last
* means messages were lost in between, so that button state is queued first,
* and a quick tap still happens. */
static void c4fInput(C4fWeb *app, C4fConn *c, C4fRequest *r)
{
if (r->argc < 11 || r->args[0] < 0 || r->args[0] >= C4F_MAX_PADS ||
r->args[1] < 0 || r->args[1] > 9007199254740991LL) { c4fError(c, r, 400, "That input was not in a form Control4Free understands"); return; }
C4fWebPad *p = &app->pads[r->args[0]];
if (p->owner != c || !p->active) return;
int64_t seq = r->args[1];
ScePadData data;
unsigned used = c4fParseState(c, r, 2, &data);
if (!used) return;
unsigned k = 2 + used;
if (k >= r->argc || r->args[k] < 0 || r->args[k] > 4 || r->argc != k + 1 + 2 * (unsigned)r->args[k]) { c4fError(c, r, 400, "That input was not in a form Control4Free understands"); return; }
unsigned edges = (unsigned)r->args[k];
for (unsigned e = 0; e < edges; e++) {
int64_t at = r->args[k + 1 + 2 * e];
int64_t buttons = r->args[k + 2 + 2 * e];
if (at < 0 || at > seq || (e && at <= r->args[k - 1 + 2 * e])) {
c4fError(c, r, 400, "Input history is not in sequence"); return;
}
if (buttons < 0 || buttons > C4F_INPUT_MASK || (buttons & ~C4F_INPUT_MASK)) { c4fError(c, r, 400, "Those are not buttons a DualShock 4 has"); return; }
}
if (p->stale) { p->stale = 0; app->changed = 1; }
if (p->inputSeqKnown && seq <= p->inputSeq) return; /* late: a newer one is in already */
if (p->inputSeqKnown) {
for (unsigned e = 0; e < edges; e++) {
int64_t at = r->args[k + 1 + 2 * e];
if (at <= p->inputSeq || at >= seq) continue;
ScePadData missed = data;
missed.buttons = (uint32_t)r->args[k + 2 + 2 * e];
c4fApplyMotion(p, &missed);
c4fEnqueue(p, &missed);
}
}
p->inputSeq = seq; p->inputSeqKnown = 1;
c4fTakeInput(app, p, (int)r->args[0], &data);
}
/* e: [seq, time]: answered at once on the same path, for the page's link
* quality line. */
static void c4fEcho(C4fWeb *app, C4fConn *c, C4fRequest *r)
{
if (r->argc != 2) { c4fError(c, r, 400, "Invalid request"); return; }
char reply[96];
snprintf(reply, sizeof(reply), "{\"method\":\"e\",\"params\":[%lld,%lld]}", (long long)r->args[0], (long long)r->args[1]);
C4fWebConn *peer = c4fWebConn(app, c);
if (c4fConnInput(c, reply) == 0 && peer && r->args[0] >= 0 && r->args[0] <= 9007199254740991LL &&
(!peer->probeKnown || r->args[0] > peer->probeSeq)) {
peer->probeSeq = r->args[0]; peer->probeAt = c4fTimeMs();
peer->probeKnown = peer->pending = 1;
}
}
static void c4fLatency(C4fWeb *app, C4fConn *c, const C4fRequest *r)
{
if (r->argc != 2 || r->args[0] < 0 || r->args[0] > 9007199254740991LL || r->args[1] < 0 || r->args[1] > 60000)
return;
C4fWebConn *peer = c4fWebConn(app, c);
uint64_t now = c4fTimeMs();
p->lastInput = now; c4fEnqueue(p, &data);
/* Out at once, unless a report has only just gone. Holding a new sample back
* for the next tick would add lag for nothing. */
if (!peer || !peer->pending || r->args[0] != peer->probeSeq || c4fSince(now, peer->probeAt) > C4F_LATENCY_MS) return;
peer->pending = 0; peer->known = 1;
peer->rttMs = (int)r->args[1]; peer->measuredProbeAt = peer->probeAt;
}
/* m: [pad, seq, qx, qy, qz, qw, ax, ay, az, gx, gy, gz] in whole numbers: the
* orientation x 10000, acceleration in thousandths of a G, angular velocity in
* thousandths of a radian per second. Sequence numbers reject older motion.
* The buttons, sticks and touches stay as
* the last input left them. */
static void c4fMotion(C4fWeb *app, C4fConn *c, C4fRequest *r)
{
static const int64_t limits[3] = { 10000, 16000, 35000 };
unsigned offset = 2;
#ifdef C4F_TEST_WEBSOCKET
if (r->argc == 11) offset = 1; /* legacy application test adapter only */
#endif
if (r->argc != offset + 10 || r->args[0] < 0 || r->args[0] >= C4F_MAX_PADS ||
(offset == 2 && (r->args[1] < 0 || r->args[1] > 9007199254740991LL))) { c4fError(c, r, 400, "That motion was not in a form Control4Free understands"); return; }
C4fWebPad *p = &app->pads[r->args[0]];
if (p->owner != c || !p->active) return;
for (unsigned i = 0; i < 10; i++) {
int64_t limit = limits[i < 4 ? 0 : i < 7 ? 1 : 2];
if (r->args[offset+i] < -limit || r->args[offset+i] > limit) { c4fError(c, r, 400, "Motion was outside its range"); return; }
}
if (offset == 2) {
if (p->motionSeqKnown && r->args[1] <= p->motionSeq) return;
p->motionSeq = r->args[1]; p->motionSeqKnown = 1;
}
for (int i = 0; i < 4; i++) p->quat[i] = (float)r->args[offset+i] / 10000.0f;
for (int i = 0; i < 3; i++) {
p->accel[i] = (float)r->args[offset+4+i] / 1000.0f;
p->gyro[i] = (float)r->args[offset+7+i] / 1000.0f;
}
p->motion = 1;
uint64_t now = c4fTimeMs();
/* Motion alone cannot renew an old held button's input lease. */
/* The newest state, moved: coalesces with a queued sample of the same buttons. */
ScePadData data = p->count ? p->queue[(p->head+p->count-1)%C4F_QUEUE_SIZE] : p->current;
c4fApplyMotion(p, &data);
c4fEnqueue(p, &data);
if (c4fSince(now, p->lastReport) >= C4F_REPORT_FAST_MS) c4fReportPad(app, p, (int)r->args[0], now);
}
static void c4fWebEvent(C4fNetClient *c, int event, const char *text, size_t len, void *context)
static void c4fWebEvent(C4fConn *c, int event, const char *text, size_t len, void *context)
{
C4fWeb *app = context;
if (event == C4F_NET_HTTP_STATUS) {
int active = 0;
char reply[192];
char reply[1024];
for (int i = 0; i < C4F_MAX_PADS; i++) active += app->pads[i].active != 0;
snprintf(reply, sizeof(reply), "{\"application\":\"Control4Free\",\"api\":1,\"version\":\"%s\",\"controllers\":%d,\"stopping\":%s}", C4F_VERSION, active, app->stopAt ? "true" : "false");
size_t pos = (size_t)snprintf(reply, sizeof(reply), "{\"application\":\"Control4Free\",\"api\":1,\"version\":\"%s\",\"controllers\":%d,\"stopping\":%s,\"latency\":true", C4F_VERSION, active, app->stopAt ? "true" : "false");
uint64_t now = c4fTimeMs();
for (int i = 0; i < C4F_MAX_PADS; i++) {
const C4fWebPad *p = &app->pads[i];
int eligible = p->active && p->assigned && p->owner && !p->stale && c4fSince(now, p->lastInput) < C4F_STALE_MS;
C4fWebConn *peer = eligible ? c4fWebConn(app, p->owner) : NULL;
int known = peer && peer->known && peer->measuredProbeAt > p->latencyAfter &&
c4fSince(now, peer->measuredProbeAt) <= C4F_LATENCY_MS;
pos += (size_t)snprintf(reply + pos, sizeof(reply) - pos,
",\"pad%dUser\":%lld,\"pad%dRtt\":%d,\"pad%dAge\":%lld", i,
eligible ? (long long)p->userId : -1LL, i, known ? peer->rttMs : -1, i,
known ? (long long)c4fSince(now, peer->measuredProbeAt) : -1LL);
}
snprintf(reply + pos, sizeof(reply) - pos, "}");
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;
return;
}
if (event == C4F_NET_OPEN) { c4fStatus(app, c, NULL); return; }
if (event == C4F_NET_OPEN) {
if (app->connCount < (int)(sizeof(app->conns) / sizeof(app->conns[0]))) {
C4fWebConn *peer = &app->conns[app->connCount++];
memset(peer, 0, sizeof(*peer)); peer->conn = c;
}
c4fStatus(app, c, NULL);
return;
}
if (event == C4F_NET_CLOSE) {
for (int i = 0; i < app->connCount; i++) if (app->conns[i].conn == c) { app->conns[i] = app->conns[--app->connCount]; break; }
/* 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;
@@ -764,26 +1057,38 @@ static void c4fWebEvent(C4fNetClient *c, int event, const char *text, size_t len
}
C4fRequest r;
if (c4fParseRequest(text, len, &r)) { c4fError(c, NULL, 400, "Invalid request"); return; }
int input = !strcmp(r.method, "i") || !strcmp(r.method, "m") || !strcmp(r.method, "e") || !strcmp(r.method, "latency");
if (c->input && input != (event == C4F_NET_INPUT_EVENT)) {
c4fError(c, &r, 400, "Message sent on the wrong data channel"); return;
}
if (app->stopAt) { c4fError(c, &r, 503, "Control4Free is shutting down"); return; }
if (!strcmp(r.method, "info")) {
char reply[160];
if (r.hasId) { snprintf(reply, sizeof(reply), "{\"id\":%lld,\"result\":{\"version\":\"%s\",\"protocol\":2,\"pads\":%d}}", (long long)r.id, C4F_VERSION, C4F_MAX_PADS); c4fNetText(c, reply); }
char reply[192];
if (r.hasId) { snprintf(reply, sizeof(reply), "{\"id\":%lld,\"result\":{\"version\":\"%s\",\"protocol\":3,\"pads\":%d,\"motion\":true,\"input\":true,\"latency\":true}}", (long long)r.id, C4F_VERSION, C4F_MAX_PADS); c4fConnText(c, reply); }
} 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); }
if (r.hasId) { snprintf(reply, sizeof(reply), "{\"id\":%lld,\"result\":{}}", (long long)r.id); c4fConnText(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);
#ifdef C4F_TEST_WEBSOCKET
else if (!strcmp(r.method, "u")) c4fUpdate(app, c, &r);
#endif
else if (!strcmp(r.method, "i")) c4fInput(app, c, &r);
else if (!strcmp(r.method, "e")) c4fEcho(app, c, &r);
else if (!strcmp(r.method, "latency")) c4fLatency(app, c, &r);
else if (!strcmp(r.method, "m")) c4fMotion(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.");
}
@@ -796,6 +1101,7 @@ int c4fWebRun(int klogFd)
if (c4fNetOpen(&app->net, C4F_WEB_PORT, c4fWebEvent, app)) {
c4fLog("web listen failed errno=%d\n", errno);
if (klogFd >= 0) close(klogFd);
c4fRtcDestroy(&app->net.rtc);
free(app); return -1;
}
c4fLog("Control4Free %s: browser controller on port %d\n", C4F_VERSION, C4F_WEB_PORT);
@@ -812,19 +1118,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;
@@ -836,10 +1141,17 @@ int c4fWebRun(int klogFd)
retryAt = now + 1000;
} else c4fLog("listener recovered on port %d\n", C4F_WEB_PORT);
}
uint64_t loopStart = now, mark = c4fTimeMs();
long klogBytes = c4fReadKlog(app);
uint64_t klogMs = c4fTimeMs() - mark; mark += klogMs;
c4fPollUsers(app, now);
uint64_t usersMs = c4fTimeMs() - mark; mark += usersMs;
c4fReportPads(app);
uint64_t reportMs = c4fTimeMs() - mark; mark += reportMs;
c4fPollFeedback(app, now);
uint64_t feedbackMs = c4fTimeMs() - mark; mark += feedbackMs;
c4fLookUpNames(app, now);
uint64_t namesMs = c4fTimeMs() - mark;
#ifdef C4F_PROBE_SETTING
c4fProbeSetting(app, now);
#endif
@@ -847,10 +1159,10 @@ 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;
C4fConn *owner = p->owner;
c4fLog("web controller %d removed after %llu ms %s\n", i + 1,
(unsigned long long)c4fSince(now, owner ? p->lastInput : p->detachedAt),
owner ? "without input" : "with nobody connected");
@@ -863,20 +1175,18 @@ int c4fWebRun(int klogFd)
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 rumble-changes=%u "
"feedback-reads=%llu avg=%lluus max=%lluus\n",
"feedback-reads=%llu avg=%lluus max=%lluus slow-loops=%u slowest=%llums\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);
(unsigned long long)app->feedbackMaxUs, app->slowLoops, (unsigned long long)app->slowestLoop);
app->slowLoops = 0; app->slowestLoop = 0;
app->rumbleChanges = 0;
app->feedbackCalls = app->feedbackUs = app->feedbackMaxUs = 0;
app->heartbeatAt = now + 60000;
}
if (app->changed || now >= app->broadcastAt) {
for (int i = 0; i < C4F_NET_CLIENTS; i++) {
C4fNetClient *c = &app->net.clients[i];
if (c->fd >= 0 && c->websocket && !c->closing) c4fStatus(app, c, NULL);
}
for (int i = 0; i < app->connCount; i++) c4fStatus(app, app->conns[i].conn, NULL);
app->changed = 0; app->broadcastAt = now+1000;
}
/* Wake often enough to keep a moving controller at its report rate, and
@@ -889,16 +1199,28 @@ int c4fWebRun(int klogFd)
if (c4fSince(now, p->lastInput) < C4F_ACTIVE_MS) moving = 1;
}
int wait = moving ? 2 : anyActive || app->add.state ? 8 : 50;
uint64_t netStart = c4fTimeMs();
if (app->net.fd < 0) usleep(8000);
else {
int result = c4fNetPoll(&app->net, wait);
uint64_t netMs = c4fTimeMs() - netStart, total = c4fTimeMs() - loopStart;
if (total > 150) {
app->slowLoops++;
if (total > app->slowestLoop) app->slowestLoop = total;
if (netStart >= app->slowLogAt) {
c4fLog("slow loop: %llu ms (klog %llu, users %llu, reports %llu, feedback %llu, names %llu, "
"network %llu for a %d ms wait)\n", (unsigned long long)total, (unsigned long long)klogMs,
(unsigned long long)usersMs, (unsigned long long)reportMs, (unsigned long long)feedbackMs,
(unsigned long long)namesMs, (unsigned long long)netMs, wait);
app->slowLogAt = netStart + 1000;
}
}
if (result) {
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);
@@ -910,6 +1232,7 @@ int c4fWebRun(int klogFd)
}
for (int i = 0; i < C4F_MAX_PADS; i++) c4fRemove(app, &app->pads[i]);
c4fNetClose(&app->net);
c4fRtcDestroy(&app->net.rtc);
if (app->klogFd >= 0) close(app->klogFd);
free(app);
c4fLog("Control4Free web controller stopped\n");
+545
View File
@@ -0,0 +1,545 @@
// 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 { relayOffer, recordRtcMessages, copyService } = require('./rtc_browser_helpers.cjs');
const hook = '\nwindow.c4fTest = { ControllerMapping, mapper, profileStore, gamepadSources, pollGamepads, setGamepadPad, mergedState, renderGamepads, renderPads, openSettings, closeSettings, openPad, profileFor, loadProfiles, persistProfiles, clearDevice, applyStatus, status, conn, sendInput, sendMotion, getKeys: () => keymap };\n';
const html = fs.readFileSync('/src/client/index.html', 'utf8').replace('\n })();', hook + ' })();');
copyService('/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);
// Focused connection failures need deterministic peers, independently of the
// real RTC/service integration below. No console or other network destination.
async function checkConnectionLifecycle(browser) {
const context = await browser.newContext();
await context.addInitScript(() => {
window.mockPeers = []; window.mockSends = [];
window.mockPad = { index: 0, id: 'Synthetic standard controller', mapping: 'standard', connected: true,
buttons: Array.from({ length: 17 }, () => ({ value: 0, pressed: false })), axes: [0, 0, 0, 0] };
Object.defineProperty(navigator, 'getGamepads', { value: () => [mockPad] });
window.WebSocket = class { constructor() { throw Error('The controller must use RTC only'); } };
class Channel {
constructor(peer, label, options) { Object.assign(this, options, { peer, label, readyState: 'connecting' }); }
send(data) {
if (this.readyState !== 'open') throw Error('closed');
const message = JSON.parse(data);
mockSends.push({ peer: this.peer.index, channel: this.id, message });
if (!message.id) return;
let result = {};
if (message.method === 'info') result = { protocol: 3, input: true, motion: true };
if (message.method === 'claim' || message.method === 'status') result = { pads: Array.from({ length: 4 }, (_, pad) => ({
pad, mine: message.params.includes(pad), open: message.params.includes(pad), connected: message.params.includes(pad),
clients: message.params.includes(pad) ? 1 : 0, state: message.params.includes(pad) ? 'ready' : 'free',
color: [32, 96, 255], name: 'Controller ' + (pad + 1),
})) };
queueMicrotask(() => this.onmessage?.({ data: JSON.stringify({ id: message.id, result }) }));
}
}
window.RTCPeerConnection = class {
constructor(options) { this.options = options; this.channels = []; this.index = mockPeers.length; this.connectionState = 'new'; mockPeers.push(this); }
createDataChannel(label, options) { const ch = new Channel(this, label, options); this.channels.push(ch); return ch; }
async createOffer() { return { type: 'offer', sdp: 'local test offer' }; }
async setLocalDescription(offer) { this.localDescription = offer; }
async setRemoteDescription(answer) {
if (answer.type !== 'answer') throw Error('answer required');
this.connectionState = 'connected';
for (const ch of this.channels) { ch.readyState = 'open'; ch.onopen?.(); }
this.onconnectionstatechange?.();
}
close() {
this.connectionState = 'closed';
for (const ch of this.channels) { ch.readyState = 'closed'; ch.onclose?.(); }
this.onconnectionstatechange?.();
}
};
window.fetch = async (url, options) => {
if (url !== 'http://127.0.0.1:4279/rtc' || options.method !== 'POST' || options.body !== 'local test offer') throw Error('Unexpected signaling request');
return { ok: true, text: async () => 'local test answer' };
};
});
const page = await context.newPage(), errors = [];
page.on('pageerror', e => errors.push(e.message));
await page.route('http://127.0.0.1:4279/', r => r.fulfill({ contentType: 'text/html', body: html }));
try {
await page.goto('http://127.0.0.1:4279/');
await page.waitForFunction(() => window.c4fTest?.conn.state === 'open' && c4fTest.gamepadSources.size === 1);
assert.deepEqual(await page.evaluate(() => mockPeers[0].channels.map(ch => ({ id: ch.id, ordered: ch.ordered, maxRetransmits: ch.maxRetransmits ?? null }))),
[{ id: 0, ordered: true, maxRetransmits: null }, { id: 1, ordered: false, maxRetransmits: 0 }]);
await page.evaluate(() => { c4fTest.setGamepadPad(0, 0); c4fTest.openPad(0); });
await page.waitForFunction(() => c4fTest.status[0].mine && !c4fTest.gamepadSources.get(0).waitNeutral);
const burst = await page.evaluate(() => {
const before = mockSends.length;
for (const buttons of [0x4000, 0, 0x1000, 0, 0x2000, 0]) c4fTest.sendInput([0, buttons, 128, 128, 128, 128, 0, 0, 0]);
c4fTest.sendMotion([0, 0, 0, 0, 10000, 0, 0, 0, 0, 0, 0]);
return mockSends.slice(before);
});
assert.equal(burst.length, 7, 'all changes send in the same task, without a new pacing cap');
for (const record of burst) assert.equal(record.channel, 1);
const packets = burst.filter(r => r.message.method === 'i').map(r => r.message.params);
assert.deepEqual(packets.map(p => p[2]), [0x4000, 0, 0x1000, 0, 0x2000, 0]);
for (let i = 1; i < packets.length; i++) assert.equal(packets[i][1], packets[i - 1][1] + 1);
const tail = packets.at(-1), edges = Array.from({ length: tail[10] }, (_, i) => tail.slice(11 + 2 * i, 13 + 2 * i));
assert.equal(edges.length, 4);
assert.deepEqual(edges.map(e => e[1]), [0, 0x1000, 0, 0x2000]);
assert(edges.every((e, i) => e[0] < tail[1] && (!i || e[0] > edges[i - 1][0])));
assert.deepEqual(burst.at(-1).message.params, [0, 1, 0, 0, 0, 10000, 0, 0, 0, 0, 0, 0]);
await page.evaluate(() => { mockPad.buttons[0].value = 1; });
await page.waitForFunction(() => !!(c4fTest.mergedState(0).buttons & 0x4000));
await page.evaluate(() => { window.oldPeer = c4fTest.conn.pc; oldPeer.close(); });
await page.waitForFunction(() => mockPeers.length === 2 && c4fTest.conn.state === 'open' && c4fTest.status[0].mine);
assert.equal(await page.evaluate(() => c4fTest.gamepadSources.get(0).pad), 0, 'reconnect retains the gamepad selection');
assert.equal(await page.locator('#padView').isVisible(), true, 'reconnect retains the controller view');
assert.equal(await page.evaluate(() => c4fTest.mergedState(0).buttons & 0x4000), 0, 'a held button is not replayed');
await page.evaluate(() => oldPeer.channels[0].onmessage({ data: JSON.stringify({ method: 's', params: { pads: [{ pad: 0, mine: false }] } }) }));
assert.equal(await page.evaluate(() => c4fTest.status[0].mine), true, 'late messages from the old peer are ignored');
await page.evaluate(() => { mockPad.buttons[0].value = 0; });
await page.waitForFunction(() => !c4fTest.gamepadSources.get(0).waitNeutral);
await page.evaluate(() => { mockPad.buttons[0].value = 1; });
await page.waitForFunction(() => !!(c4fTest.mergedState(0).buttons & 0x4000));
assert.deepEqual(errors, []);
console.log('PASS RTC page: channel modes, immediate changes, four preceding edges, motion sequence, automatic reclaim and held-input recovery');
} finally { await context.close(); }
}
(async () => {
let browser;
try {
browser = await chromium.launch({ headless: true });
await checkConnectionLifecycle(browser);
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(recordRtcMessages);
await context.addInitScript(() => {
window.testPads = [0, 1, 2].map(index => ({ index, id: index < 2 ? 'Synthetic standard controller' : 'Synthetic wheel / pedals',
mapping: index < 2 ? 'standard' : '', connected: true, buttons: Array.from({ length: 24 }, () => ({ value: 0, pressed: false })), axes: Array(6).fill(0) }));
Object.defineProperty(navigator, 'getGamepads', { value: () => { if (window.failPolling) throw Error('SecurityError'); return window.testPads; } });
});
const page = await context.newPage(), errors = [];
page.on('pageerror', e => { errors.push(e.message); console.error('Page error:', e.stack); });
await relayOffer(page);
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 input 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 RTC 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 relayOffer(smoke);
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 relayOffer(filePage);
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; });
+16
View File
@@ -0,0 +1,16 @@
/* Host-only page fixtures use the real diagnostic renderer, without native
* startup, filesystem access or report writes. Unused paths are discarded by
* the art tool's section garbage collection. */
#include "../launcher/diag.c"
void c4fDiagPreviewPage(int page, const char *text)
{
if (page < 0 || page >= C4F_DIAG_PAGES) return;
char *copy = strdup(text ? text : "");
if (!copy) return;
pthread_mutex_lock(&c4fLock);
free(c4fPages[page]);
c4fPages[page] = copy;
c4fGeneration++;
pthread_mutex_unlock(&c4fLock);
}
+44
View File
@@ -0,0 +1,44 @@
/* Real diagnostic storage code; no console or kernel-log reader is contacted. */
#define C4F_DIAG_DIR "/tmp/c4f-diag-host-test"
#define C4F_DIAG 1
#include "../launcher/diag.c"
#include <assert.h>
static int fault, calls;
ssize_t __real_write(int fd, const void *buf, size_t n);
int __real_fsync(int fd);
ssize_t __wrap_write(int fd, const void *buf, size_t n)
{
if (fault == 1) { errno = ENOSPC; return -1; }
if (fault == 2) return 0;
if (fault == 3 && calls++ == 0) { errno = EINTR; return -1; }
return __real_write(fd, buf, n > 3 ? 3 : n);
}
int __wrap_fsync(int fd) { if (fault == 4) { errno = EIO; return -1; } return __real_fsync(fd); }
int sceKernelGetSystemSwVersion(OrbisKernelSwVersion *version)
{ snprintf(version->VersionString, sizeof(version->VersionString), "host test"); return 0; }
long c4fGoldHenCommand(unsigned long command, void *data) { (void)command; (void)data; return 0; }
const unsigned char *c4fAutorunBundled(size_t *size) { *size = 0; return NULL; }
uint64_t c4fLauncherTimeMs(void) { return 1000; }
int main(void)
{
assert(access(C4F_DIAG_DIR, F_OK) != 0);
c4fDiagStart(0, 0); c4fDiagStop();
struct stat info; assert(!stat(C4F_DIAG_DIR, &info) && S_ISDIR(info.st_mode));
fault = 3; calls = 0;
assert(!c4fWriteAll(C4F_DIAG_REPORT, "complete write", 0));
char *text = c4fReadTail(C4F_DIAG_REPORT, 100, NULL); assert(text && !strcmp(text, "complete write")); free(text);
for (fault = 1; fault <= 4; fault++) {
if (fault == 3) continue;
assert(c4fWriteAll(C4F_DIAG_REPORT, "failure", 0) == -1);
c4fSaveReport(); assert(strstr(c4fReportNote, "Internal save failed"));
assert(!strstr(c4fReportNote, "Saved /data"));
}
fault = 0; c4fSaveReport(); assert(strstr(c4fReportNote, "Saved /data"));
assert(!c4fWriteAll(C4F_DIAG_LOG ".1", "older segment\n", 0));
assert(!c4fWriteAll(C4F_DIAG_LOG, "latest segment\n", 0));
text = c4fCurrentLogPage(); assert(strstr(text, "older segment") && strstr(text, "latest segment")); free(text);
unlink(C4F_DIAG_KLOG); unlink(C4F_DIAG_REPORT); unlink(C4F_DIAG_LOG); unlink(C4F_DIAG_LOG ".1"); rmdir(C4F_DIAG_DIR);
puts("PASS independent diagnostic directory, EINTR/short writes, ENOSPC/zero/fsync failures and truthful report results");
}
+515
View File
@@ -0,0 +1,515 @@
// Settings → Experimental in a real browser, against the real service with the PS4
// stubbed (build/web-rtc-host-test). WebHID devices, gamepads and motion sensors are
// fakes that record what the page does. Test instrumentation is inserted into
// the page in memory, never shipped. Run in the Playwright Docker image:
// NODE_PATH=… node tests/experimental_test.cjs
const { chromium } = require('playwright');
const fs = require('node:fs'), assert = require('node:assert/strict'), { spawn } = require('node:child_process');
const { relayOffer, recordRtcMessages, copyService } = require('./rtc_browser_helpers.cjs');
const hook = '\nwindow.c4fTest = { experiments, hidLinks, setExperiment, hidReport, triggerEffect, phoneMotion, status, ' +
'gamepadSources, setGamepadPad, openPad, closePad, openSettings, showPage, conn, rumble };\n';
const html = fs.readFileSync('/src/client/index.html', 'utf8').replace('\n })();', hook + ' })();');
copyService('/tmp/c4f-experimental-service');
// An independent CRC-32 (zlib's), to check the Bluetooth reports' last four bytes.
function crc32(bytes) {
let c = ~0;
for (const b of bytes) {
c ^= b;
for (let k = 0; k < 8; k++) c = c & 1 ? (c >>> 1) ^ 0xedb88320 : c >>> 1;
}
return ~c >>> 0;
}
const le32 = (bytes, at) => (bytes[at] | bytes[at + 1] << 8 | bytes[at + 2] << 16 | bytes[at + 3] << 24) >>> 0;
// In the page before its own script: fake gamepads, WebHID and motion, and a
// record of the input messages and the page's sensor listeners.
function fakes({ insecure = false } = {}) {
Object.defineProperty(window, 'sentMessages', { get: () => controllerMessages });
window.motionListeners = 0;
const addListener = window.addEventListener;
window.addEventListener = function(type, ...rest) {
if (type === 'devicemotion') motionListeners++;
return addListener.call(this, type, ...rest);
};
window.effects = [];
const pad = (index, id, actuator) => ({ index, id, mapping: 'standard', connected: true, timestamp: 0,
buttons: Array.from({ length: 17 }, () => ({ value: 0, pressed: false })), axes: [0, 0, 0, 0], vibrationActuator: actuator });
const xbox = { type: 'dual-rumble', effects: ['dual-rumble', 'trigger-rumble'],
playEffect: async (type, params) => { effects.push([type, params]); return 'complete'; }, reset: async () => 'complete' };
window.testPads = [
pad(0, 'Wireless Controller (STANDARD GAMEPAD Vendor: 054c Product: 09cc)'),
pad(1, 'Xbox 360 Controller (XInput STANDARD GAMEPAD)', xbox),
pad(2, 'DualSense Wireless Controller (STANDARD GAMEPAD Vendor: 054c Product: 0ce6)'),
];
Object.defineProperty(navigator, 'getGamepads', { value: () => testPads });
if (insecure) {
Object.defineProperty(window, 'isSecureContext', { value: false });
delete Navigator.prototype.hid;
delete window.DeviceMotionEvent;
return;
}
class FakeDevice extends EventTarget {
constructor(productId, outputs, features) {
super();
Object.assign(this, { vendorId: 0x054c, productId, productName: 'Fake', opened: false, closes: 0, sent: [], features,
collections: [{ outputReports: outputs.map(reportId => ({ reportId })) }] });
}
async open() { this.opened = true; }
async close() { this.opened = false; this.closes++; }
async sendReport(id, data) { this.sent.push({ id, data: Array.from(data) }); }
async receiveFeatureReport(id) {
if (!this.features[id]) throw new DOMException('no such report', 'NotAllowedError');
return new DataView(Uint8Array.from(this.features[id]).buffer);
}
input(reportId, bytes) {
const ev = new Event('inputreport');
Object.assign(ev, { reportId, data: new DataView(Uint8Array.from(bytes).buffer) });
this.dispatchEvent(ev);
}
}
// DS4 calibration (report 0x02 over USB, ID included): biases 0, every gyro axis
// ±8192 for a speed of 1024 + 1024 (so 1/8 degree per second per step), and
// the accelerometer ±8192 for ±1 G.
const calibration = new Uint8Array(37);
const put = (at, v) => { calibration[at] = v & 0xff; calibration[at + 1] = (v >> 8) & 0xff; };
calibration[0] = 0x02;
for (const at of [7, 11, 15]) put(at, 8192);
for (const at of [9, 13, 17]) put(at, -8192);
put(19, 1024); put(21, 1024);
for (const at of [23, 27, 31]) put(at, 8192);
for (const at of [25, 29, 33]) put(at, -8192);
// DualSense firmware 0x20, update version 2.32 at byte 44.
const firmware = new Uint8Array(64);
firmware[0] = 0x20; firmware[44] = 0x32; firmware[45] = 0x02;
window.fakeHid = {
ds4: new FakeDevice(0x09cc, [0x05], { 0x02: Array.from(calibration) }),
ds4bt: new FakeDevice(0x09cc, [0x11], {}),
dualsense: new FakeDevice(0x0ce6, [0x02], { 0x20: Array.from(firmware) }),
dualsensebt: new FakeDevice(0x0ce6, [0x31], { 0x20: Array.from(firmware) }),
granted: [], next: null, calls: [],
};
Object.defineProperty(navigator, 'hid', { configurable: true, value: {
requestDevice: async options => { fakeHid.calls.push(['requestDevice', options]); return fakeHid.next ? [fakeHid.next] : []; },
getDevices: async () => { fakeHid.calls.push(['getDevices']); return fakeHid.granted; },
addEventListener: type => { fakeHid.calls.push(['addEventListener', type]); },
} });
}
// A fresh service for each scenario, so its controllers' handles start over:
// the first controller created is 11030d, the second 12030d.
let server = null;
async function startService() {
if (server) {
const old = server;
old.kill();
await new Promise(done => old.exitCode !== null || old.signalCode !== null ? done() : old.once('exit', done));
}
server = spawn('/tmp/c4f-experimental-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', () => {});
}
const feedback = (handle, large, small, r = 0, g = 0, b = 0) => server.stdin.write(`C4F-TEST-FEEDBACK ${handle} ${large} ${small} ${r} ${g} ${b}\n`);
async function open(browser, options) {
await startService();
const context = await browser.newContext({ viewport: { width: 1280, height: 900 } });
await context.grantPermissions(['local-network-access']);
await context.addInitScript(recordRtcMessages);
await context.addInitScript(fakes, options);
const page = await context.newPage();
page.on('pageerror', e => { console.error('Page error:', e.stack); process.exitCode = 1; });
await relayOffer(page);
await page.route(/^http:\/\/127\.0\.0\.1:4264\/(?:\?.*)?$/, async r => r.fulfill({ response: await r.fetch(), body: html }));
for (let tries = 0; ; tries++) {
try { await page.goto('http://127.0.0.1:4264/'); break; } catch (e) {
if (tries > 50) throw e;
await new Promise(done => setTimeout(done, 100)); // the service is still starting
}
}
await page.waitForFunction(() => window.c4fTest?.gamepadSources.size === 3);
await page.waitForFunction(() => document.querySelector('#conn').dataset.conn === 'open' && c4fTest.conn.motion);
return { context, page };
}
const lastReport = (page, device) => page.evaluate(d => fakeHid[d].sent.at(-1), device);
// Sony reports carry a digital trigger flag alongside the independent analog
// pressure byte. A partial pull must survive both fields being present.
function triggerInput(device, l2, r2) {
const ds4 = device.startsWith('ds4'), bt = device.endsWith('bt');
const bytes = new Array(bt ? 77 : 63).fill(0);
const sticksAt = ds4 ? (bt ? 2 : 0) : (bt ? 1 : 0);
const buttonsAt = ds4 ? sticksAt + 4 : (bt ? 8 : 7);
const triggersAt = ds4 ? sticksAt + 7 : (bt ? 5 : 4);
bytes.fill(128, sticksAt, sticksAt + 4);
bytes[buttonsAt] = 0x08; // D-pad released
bytes[buttonsAt + 1] = (l2 ? 0x04 : 0) | (r2 ? 0x08 : 0);
bytes[triggersAt] = l2; bytes[triggersAt + 1] = r2;
if (!ds4) { const touchAt = bt ? 33 : 32; bytes[touchAt] = bytes[touchAt + 4] = 0x80; }
return { device, reportId: ds4 ? (bt ? 0x11 : 0x01) : (bt ? 0x31 : 0x01), bytes };
}
async function inputAndRead(page, input, pad) {
return page.evaluate(async ({ input, pad }) => {
fakeHid[input.device].input(input.reportId, input.bytes);
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
return sentMessages.findLast(m => m.method === 'u' && m.params[0] === pad)?.params;
}, { input, pad });
}
async function checkTriggerPressure(page, device, pad) {
for (const [l2, r2] of [[0, 0], [32, 96], [96, 160], [160, 224], [224, 32], [0, 0]]) {
const state = await inputAndRead(page, triggerInput(device, l2, r2), pad);
assert(state, device + ': no controller state sent');
assert.deepEqual(state.slice(6, 8), [l2, r2], device + ': digital trigger flags must preserve partial analog pressure');
assert.equal(state[1] & 0x300, (l2 > 20 ? 0x100 : 0) | (r2 > 20 ? 0x200 : 0), device + ': trigger button flags stay independent of analog pressure');
}
}
(async () => {
let browser;
try {
browser = await chromium.launch({ headless: true });
// Off by default: the tab is there, nothing new runs.
let { context, page } = await open(browser);
await page.evaluate(() => { c4fTest.openSettings('experimental'); });
assert.deepEqual(await page.$$eval('#expRows .check', inputs => inputs.map(i => [i.dataset.experiment, i.checked, i.disabled])),
[['hid', false, false], ['triggers', false, false], ['motion', false, false]]);
assert.equal(await page.locator('#expSony').isHidden(), true);
assert.equal(await page.locator('#expMotion').isHidden(), true);
await page.evaluate(() => { c4fTest.setGamepadPad(0, 0); c4fTest.setGamepadPad(1, 0); c4fTest.openPad(0); });
await page.waitForFunction(() => c4fTest.status[0].mine);
feedback('11030d', 200, 40);
await page.waitForFunction(() => effects.length > 0);
await page.waitForTimeout(300);
assert.equal(await page.evaluate(() => fakeHid.calls.length), 0, 'WebHID was used while switched off');
assert.equal(await page.evaluate(() => motionListeners), 0, 'motion sensors were listened to while switched off');
assert.equal(await page.evaluate(() => sentMessages.some(m => m.method === 'm')), false, 'motion was sent while switched off');
assert.equal(await page.evaluate(() => effects.every(([type]) => type === 'dual-rumble')), true);
feedback('11030d', 0, 0);
console.log('PASS experimental: three switches, all off; nothing of them runs while off');
await context.close();
// A page the browser treats as insecure: the WebHID and motion switches explain why they can't be used.
({ context, page } = await open(browser, { insecure: true }));
await page.evaluate(() => c4fTest.openSettings('experimental'));
assert.deepEqual(await page.$$eval('#expRows .check', inputs => inputs.map(i => i.disabled)), [true, false, true]);
assert.equal(await page.locator('#expRows .blocked').count(), 2);
assert.match(await page.locator('#expRows .blocked').first().textContent(), /insecure.*How to turn it on/);
assert.match(await page.locator('#expRows .blocked a').first().getAttribute('href'), /troubleshooting\.md#experimental-features$/);
console.log('PASS experimental: insecure page: WebHID and motion off with the reason and a link; trigger rumble available');
await context.close();
// Lights and rumble on a DualShock 4 by USB, then by Bluetooth.
({ context, page } = await open(browser));
await page.evaluate(() => { c4fTest.setGamepadPad(0, 0); c4fTest.setGamepadPad(1, 0); c4fTest.setGamepadPad(2, 1); c4fTest.openSettings('experimental'); });
await page.waitForFunction(() => c4fTest.status[0].mine && c4fTest.status[1].mine);
await page.check('#expRows [data-experiment="hid"]');
await page.waitForFunction(() => fakeHid.calls.some(([call]) => call === 'getDevices'));
assert.equal(await page.locator('#expSony').isVisible(), true);
assert.equal(await page.locator('#expPads [data-link]').count(), 2, 'both Sony gamepads listed, the Xbox one not');
await page.evaluate(() => { fakeHid.next = fakeHid.ds4; });
await page.click('#expPads [data-link="0"]');
await page.waitForFunction(() => fakeHid.ds4.sent.length > 0);
assert.deepEqual(await page.evaluate(() => fakeHid.calls.find(([c]) => c === 'requestDevice')[1]),
{ filters: [{ vendorId: 0x054c, productId: 0x09cc }] });
let report = await lastReport(page, 'ds4');
assert.equal(report.id, 0x05);
assert.equal(report.data.length, 31);
assert.deepEqual(report.data.slice(0, 8), [0x03, 0, 0, 0, 0, 32, 96, 255], 'still, in controller 1\'s own colour');
await checkTriggerPressure(page, 'ds4', 0);
// A linked DS4 and ordinary Gamepad API source share this controller. The
// strongest pressure wins independently for each trigger, without a digital
// flag turning either partial pull into a full pull.
await page.evaluate(() => { testPads[1].buttons[6].value = 160 / 255; testPads[1].buttons[7].value = 32 / 255; });
const mixedTriggers = await inputAndRead(page, triggerInput('ds4', 32, 96), 0);
assert.deepEqual(mixedTriggers.slice(6, 8), [160, 96], 'linked HID and Gamepad API sources merge partial trigger pressure');
await page.evaluate(() => { testPads[1].buttons[6].value = testPads[1].buttons[7].value = 0; });
assert.deepEqual((await inputAndRead(page, triggerInput('ds4', 0, 0), 0)).slice(6, 8), [0, 0], 'both trigger sources release');
const effectsBefore = await page.evaluate(() => effects.length);
feedback('11030d', 200, 40, 64, 0, 0);
await page.waitForFunction(() => { const r = fakeHid.ds4.sent.at(-1).data; return r[3] === 40 && r[4] === 200 && r[5] === 255; });
report = await lastReport(page, 'ds4');
assert.deepEqual(report.data.slice(0, 8), [0x03, 0, 0, 40, 200, 255, 0, 0], 'small motor, large motor, the game\'s red');
await page.waitForFunction(n => effects.length > n, effectsBefore);
assert.equal(await page.evaluate(() => effects.every(([type]) => type === 'dual-rumble')), true, 'the Xbox pad keeps its own rumble');
// Unchanged state is not sent again every frame.
const count = await page.evaluate(() => fakeHid.ds4.sent.length);
await page.waitForTimeout(200);
assert.ok(await page.evaluate(() => fakeHid.ds4.sent.length) - count <= 1, 'unchanged reports were repeated');
// Lose the final stop on the unreliable channel: the local lease must
// still stop WebHID motors, as well as bounded Gamepad API effects.
await page.evaluate(() => {
const input = c4fTest.conn.input, received = input.onmessage;
input.onmessage = event => {
const m = JSON.parse(event.data);
if (m.method === 'v' && m.params[0] === 0 && !m.params[1] && !m.params[2]) return;
received(event);
};
window.restoreRumbleMessages = () => { input.onmessage = received; };
});
feedback('11030d', 0, 0, 64, 0, 0);
await page.waitForFunction(() => fakeHid.ds4.sent.at(-1).data[4] === 0);
await page.evaluate(() => restoreRumbleMessages());
// Unlinking leaves it still and dim, and closes it.
await page.click('#expPads [data-link="0"]');
await page.waitForFunction(() => fakeHid.ds4.closes === 1);
assert.deepEqual((await lastReport(page, 'ds4')).data.slice(0, 8), [0x03, 0, 0, 0, 0, 0, 0, 64]);
assert.equal(await page.evaluate(() => c4fTest.hidLinks.size), 0);
// Bluetooth: report 0x11, the CRC over 0xa2, the ID and the rest.
await page.evaluate(() => { fakeHid.next = fakeHid.ds4bt; });
await page.click('#expPads [data-link="0"]');
await page.waitForFunction(() => fakeHid.ds4bt.sent.length > 0);
report = await lastReport(page, 'ds4bt');
assert.equal(report.id, 0x11);
assert.equal(report.data.length, 77);
assert.deepEqual(report.data.slice(0, 10), [0xc4, 0, 0x03, 0, 0, 0, 0, 255, 0, 0]);
assert.equal(le32(report.data, 73), crc32([0xa2, 0x11, ...report.data.slice(0, 73)]), 'Bluetooth CRC');
await checkTriggerPressure(page, 'ds4bt', 0);
console.log('PASS experimental: DualShock 4 by USB and Bluetooth: colour, both motors, unchanged state not resent, CRC, unlink');
// DualSense by USB with trigger rumble: start-up light faded once, then the
// newer rumble (firmware 2.32), the colour, and trigger effects only on change.
await page.check('#expRows [data-experiment="triggers"]');
await page.evaluate(() => { fakeHid.next = fakeHid.dualsense; });
await page.click('#expPads [data-link="2"]');
await page.waitForFunction(() => fakeHid.dualsense.sent.length >= 2);
let sent = await page.evaluate(() => fakeHid.dualsense.sent);
assert.equal(sent[0].id, 0x02);
assert.equal(sent[0].data.length, 62);
assert.deepEqual([sent[0].data[0], sent[0].data[1], sent[0].data[38], sent[0].data[41]], [0, 0, 0x02, 0x02], 'light bar setup alone first');
report = sent[1];
assert.equal(report.data[0] & 0x03, 0x02, 'firmware 2.32: no old-style rumble flag');
assert.equal(report.data[38] & 0x04, 0x04, 'newer rumble flag');
assert.equal(report.data[1], 0x04);
assert.deepEqual(report.data.slice(44, 47), [255, 48, 64], 'controller 2\'s own colour');
await checkTriggerPressure(page, 'dualsense', 1);
// Its touchpad's fingers reach the PS4, its 1080-high pad scaled to the PS4's 942.
await page.evaluate(() => {
const bytes = new Array(63).fill(0);
bytes.fill(128, 0, 4); bytes[7] = 0x08; // sticks centred, D-pad released
bytes[32] = 7; bytes[33] = 960 & 0xff; bytes[34] = (960 >> 8) | (1079 & 0x0f) << 4; bytes[35] = 1079 >> 4;
bytes[36] = 0x80;
fakeHid.dualsense.input(0x01, bytes);
});
await page.waitForFunction(() => sentMessages.findLast(m => m.method === 'u' && m.params[0] === 1)?.params[8] === 1);
assert.deepEqual((await page.evaluate(() => sentMessages.findLast(m => m.method === 'u' && m.params[0] === 1).params)).slice(8),
[1, 7, 960, 941]);
await page.evaluate(() => {
const bytes = new Array(63).fill(0);
bytes.fill(128, 0, 4); bytes[7] = 0x08; bytes[32] = 0x87; bytes[36] = 0x80;
fakeHid.dualsense.input(0x01, bytes);
});
await page.waitForFunction(() => sentMessages.findLast(m => m.method === 'u' && m.params[0] === 1)?.params[8] === 0);
// From here it reports its triggers all the time, as a real one does.
await page.evaluate(() => {
window.dsTriggers = [0, 0];
window.dsTimer = setInterval(() => {
const bytes = new Array(63).fill(0);
bytes.fill(128, 0, 4); bytes[4] = dsTriggers[0]; bytes[5] = dsTriggers[1]; bytes[7] = 0x08;
bytes[8] = (dsTriggers[0] ? 0x04 : 0) | (dsTriggers[1] ? 0x08 : 0); bytes[32] = 0x80; bytes[36] = 0x80;
fakeHid.dualsense.input(0x01, bytes);
}, 10);
});
feedback('12030d', 255, 128);
await page.waitForFunction(() => fakeHid.dualsense.sent.at(-1).data[3] === 255);
report = await lastReport(page, 'dualsense');
assert.deepEqual([report.data[2], report.data[3]], [128, 255], 'small motor, large motor');
assert.equal(report.data[0] & 0x0c, 0, 'no trigger effect while neither trigger is pulled');
// The trigger being pulled vibrates, as strongly as the stronger motor (8),
// slowly for the large one (30 Hz); the other is off.
const lastEffects = () => page.evaluate(() => fakeHid.dualsense.sent.findLast(r => r.data[0] & 0x0c).data);
const VIBRATE = [0x26, 0xff, 0x03, 0xff, 0xff, 0xff, 0x3f, 0, 0, 30, 0], OFF = [0x05, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0];
await page.evaluate(() => { dsTriggers = [0, 200]; });
await page.waitForFunction(() => fakeHid.dualsense.sent.findLast(r => r.data[0] & 0x0c)?.data[10] === 0x26);
report = { data: await lastEffects() };
assert.deepEqual([report.data.slice(10, 21), report.data.slice(21, 32)], [VIBRATE, OFF], 'R2 pulled: right trigger');
// Aiming with L2 alone moves it there ...
await page.evaluate(() => { dsTriggers = [200, 0]; });
await page.waitForFunction(() => fakeHid.dualsense.sent.findLast(r => r.data[0] & 0x0c)?.data[21] === 0x26);
report = { data: await lastEffects() };
assert.deepEqual([report.data.slice(10, 21), report.data.slice(21, 32)], [OFF, VIBRATE], 'L2 only: left trigger');
// ... and firing with R2 while aiming moves it to R2, the last one pulled, where it stays.
await page.evaluate(() => { dsTriggers = [200, 200]; });
await page.waitForFunction(() => fakeHid.dualsense.sent.findLast(r => r.data[0] & 0x0c)?.data[10] === 0x26);
report = { data: await lastEffects() };
assert.deepEqual([report.data.slice(10, 21), report.data.slice(21, 32)], [VIBRATE, OFF], 'R2 pulled while aiming: right trigger');
const effectReports = await page.evaluate(() => fakeHid.dualsense.sent.filter(r => r.data[0] & 0x0c).length);
await page.waitForTimeout(150);
assert.equal(await page.evaluate(() => fakeHid.dualsense.sent.filter(r => r.data[0] & 0x0c).length), effectReports, 'unchanged effects re-sent');
// A stronger small motor makes it quick (60 Hz).
feedback('12030d', 64, 255);
await page.waitForFunction(() => fakeHid.dualsense.sent.findLast(r => r.data[0] & 0x0c)?.data[19] === 60);
// The light bar alone changing does not repeat the trigger effects.
feedback('12030d', 64, 255, 0, 64, 0);
await page.waitForFunction(() => fakeHid.dualsense.sent.at(-1).data[45] === 255);
assert.equal((await lastReport(page, 'dualsense')).data[0] & 0x0c, 0, 'trigger effects re-sent without a change');
feedback('12030d', 0, 0, 0, 64, 0);
await page.waitForFunction(() => fakeHid.dualsense.sent.at(-1).data[3] === 0);
report = await lastReport(page, 'dualsense');
assert.deepEqual([report.data[0] & 0x0c, report.data[10], report.data[21]], [0x0c, 0x05, 0x05], 'trigger effects off when the rumble stops');
await page.evaluate(() => { clearInterval(dsTimer); });
// The Xbox pad rumbles in the trigger being pulled too, while the switch is on.
const xboxTriggers = () => page.evaluate(() => effects.findLast(([type]) => type === 'trigger-rumble')?.[1]);
feedback('11030d', 200, 100);
await page.waitForFunction(() => effects.some(([type]) => type === 'trigger-rumble'));
let params = await xboxTriggers();
assert.deepEqual([params.leftTrigger, params.rightTrigger], [0, 0], 'neither trigger pulled: ' + JSON.stringify(params));
await page.evaluate(() => { testPads[1].buttons[7].value = .9; });
await page.waitForFunction(() => effects.findLast(([type]) => type === 'trigger-rumble')[1].rightTrigger > 0);
params = await xboxTriggers();
assert.ok(params.leftTrigger === 0 && Math.abs(params.rightTrigger - 200 / 255 * .6) < 1e-6, JSON.stringify(params));
await page.evaluate(() => { testPads[1].buttons[7].value = 0; });
feedback('11030d', 0, 0);
console.log('PASS experimental: DualSense setup, newer rumble, colour, effects in the pulled trigger on change; Xbox trigger rumble');
// DualSense over Bluetooth: report 0x31 with its sequence, tag and CRC. Once
// linked the browser's gamepad goes quiet (here: frozen at rest), so the
// controller's own reports carry its buttons, sticks and triggers.
await page.click('#expPads [data-link="2"]');
await page.waitForFunction(() => !c4fTest.hidLinks.has(2) && fakeHid.dualsense.closes === 1);
await page.evaluate(() => { fakeHid.next = fakeHid.dualsensebt; });
await page.click('#expPads [data-link="2"]');
await page.waitForFunction(() => fakeHid.dualsensebt.sent.length >= 2);
sent = await page.evaluate(() => fakeHid.dualsensebt.sent);
for (const [i, r] of sent.slice(0, 2).entries()) {
assert.equal(r.id, 0x31);
assert.equal(r.data.length, 77);
assert.deepEqual([r.data[0], r.data[1]], [i << 4, 0x10], 'sequence in the high nibble, then the tag');
assert.equal(le32(r.data, 73), crc32([0xa2, 0x31, ...r.data.slice(0, 73)]), 'Bluetooth CRC');
}
assert.deepEqual([sent[0].data[2 + 38], sent[0].data[2 + 41]], [0x02, 0x02], 'light bar setup first');
assert.deepEqual(sent[1].data.slice(2 + 44, 2 + 47), [0, 255, 0], 'the green the game set earlier');
await checkTriggerPressure(page, 'dualsensebt', 1);
const btInput = (b0, b1, b2, x, r2) => {
const bytes = new Array(77).fill(0);
bytes.fill(128, 1, 5);
bytes[1] = x; bytes[6] = r2; bytes[8] = b0; bytes[9] = b1 | (r2 ? 0x08 : 0); bytes[10] = b2;
return bytes;
};
const pad1 = () => page.evaluate(() => sentMessages.findLast(m => m.method === 'u' && m.params[0] === 1)?.params);
// Cross, D-pad up (hat 0), L1, PS; left stick fully left; R2 at 200.
await page.evaluate(bytes => fakeHid.dualsensebt.input(0x31, bytes), btInput(0x20, 0x01, 0x01, 0, 200));
await page.waitForFunction(() => (sentMessages.findLast(m => m.method === 'u' && m.params[0] === 1)?.params[1] & 0x4000) !== 0);
let u = await pad1();
assert.equal(u[1], 0x4000 | 0x0010 | 0x0400 | 0x10000 | 0x0200, 'Cross, up, L1, PS and R2 pulled: ' + u[1].toString(16));
assert.deepEqual([u[2], u[3], u[4], u[5], u[6], u[7]], [0, 128, 128, 128, 0, 200]);
await page.evaluate(bytes => fakeHid.dualsensebt.input(0x31, bytes), btInput(0x08, 0, 0, 128, 0));
await page.waitForFunction(() => sentMessages.findLast(m => m.method === 'u' && m.params[0] === 1)?.params[1] === 0);
// Unlinked, the browser's gamepad drives it again.
await page.click('#expPads [data-link="2"]');
await page.waitForFunction(() => !c4fTest.hidLinks.has(2));
await page.evaluate(() => { testPads[2].buttons[0].value = 1; testPads[2].buttons[0].pressed = true; });
await page.waitForFunction(() => (sentMessages.findLast(m => m.method === 'u' && m.params[0] === 1)?.params[1] & 0x4000) !== 0);
await page.evaluate(() => { testPads[2].buttons[0].value = 0; testPads[2].buttons[0].pressed = false; });
console.log('PASS experimental: DualSense by Bluetooth: reports and CRC; buttons, sticks and triggers from its own reports');
// Motion: a phone held sideways, then a linked DualShock 4's own sensors.
await page.check('#expRows [data-experiment="motion"]');
await page.waitForFunction(() => motionListeners === 1, null, { timeout: 3000 }).catch(async () => {
throw new Error('no devicemotion listener: ' + await page.evaluate(() => JSON.stringify({
secure: isSecureContext, motion: 'DeviceMotionEvent' in window, on: c4fTest.experiments.motion,
permission: typeof (window.DeviceMotionEvent && DeviceMotionEvent.requestPermission) })));
});
await page.evaluate(() => { c4fTest.openPad(0); });
const motions = () => page.evaluate(() => sentMessages.filter(m => m.method === 'm').map(m => m.params));
const shake = (beta, at) => page.evaluate(([beta, at]) => {
const ev = new DeviceMotionEvent('devicemotion', { accelerationIncludingGravity: { x: 0, y: 9.80665, z: 0 },
rotationRate: { alpha: 0, beta, gamma: 0 }, interval: 16 });
Object.defineProperty(ev, 'timeStamp', { value: at });
window.dispatchEvent(ev);
}, [beta, at]);
for (let i = 0; i < 6; i++) { await shake(0, 1000 + i * 20); await page.waitForTimeout(25); }
await page.waitForFunction(() => sentMessages.some(m => m.method === 'm' && m.params[0] === 0));
let m = (await motions()).filter(p => p[0] === 0).at(-1);
assert.deepEqual(m.slice(1, 5), [0, 0, 0, 10000], 'held still and sideways: level');
assert.deepEqual(m.slice(5, 8), [0, 1000, 0], 'gravity along y, like a DualShock 4 lying flat');
await shake(90, 1200);
await page.waitForFunction(() => sentMessages.findLast(m => m.method === 'm')?.params[8] === 1571);
m = (await motions()).at(-1);
assert.ok(m[1] > 0 && m[4] < 10000, 'turning about x tilts the orientation: ' + m);
// Leaving the controller view tells the PS4 the controller is still again.
await page.evaluate(() => c4fTest.closePad());
await page.waitForFunction(() => JSON.stringify(sentMessages.findLast(m => m.method === 'm')?.params) === '[0,0,0,0,10000,0,0,0,0,0,0]');
// A linked DualShock 4's own gyro and accelerometer, calibrated. The
// Bluetooth one is unlinked first; the USB one has calibration to read.
await page.evaluate(() => c4fTest.openSettings('experimental'));
assert.equal(await page.textContent('#expPads [data-link="0"]'), 'Unlink');
await page.click('#expPads [data-link="0"]');
await page.waitForFunction(() => !c4fTest.hidLinks.has(0) && fakeHid.ds4bt.closes === 1);
await page.evaluate(() => { fakeHid.next = fakeHid.ds4; });
await page.click('#expPads [data-link="0"]');
await page.waitForFunction(() => c4fTest.hidLinks.get(0)?.calib);
const report1 = (gyro, accel) => {
const bytes = new Array(63).fill(0);
bytes.fill(128, 0, 4); bytes[4] = 0x08; // sticks centred, D-pad released
[...gyro, ...accel].forEach((v, i) => { bytes[12 + 2 * i] = v & 0xff; bytes[13 + 2 * i] = (v >> 8) & 0xff; });
return bytes;
};
await page.evaluate(bytes => { for (let i = 0; i < 3; i++) fakeHid.ds4.input(0x01, bytes); }, report1([8 * 90, 0, 0], [0, 8192, 0]));
await page.waitForFunction(() => sentMessages.findLast(m => m.method === 'm' && m.params[0] === 0)?.params[8] === 1571);
m = (await motions()).filter(p => p[0] === 0).at(-1);
assert.deepEqual(m.slice(5, 11), [0, 1000, 0, 1571, 0, 0], '90 degrees per second about x, 1 G on y');
// Its touchpad's fingers, which browsers' gamepads leave out: the newest of a
// report's touch reports, after its timestamp byte.
const touchReport = (contact, x, y) => {
const bytes = new Array(63).fill(0);
bytes.fill(128, 0, 4); bytes[4] = 0x08;
bytes[32] = 1;
bytes[34] = contact; bytes[35] = x & 0xff; bytes[36] = (x >> 8) | (y & 0x0f) << 4; bytes[37] = y >> 4;
bytes[38] = 0x80;
return bytes;
};
const lastInput = () => page.evaluate(() => sentMessages.findLast(m => m.method === 'u' && m.params[0] === 0)?.params);
await page.evaluate(bytes => fakeHid.ds4.input(0x01, bytes), touchReport(5, 1000, 500));
await page.waitForFunction(() => sentMessages.findLast(m => m.method === 'u' && m.params[0] === 0)?.params[8] === 1);
assert.deepEqual((await lastInput()).slice(8), [1, 5, 1000, 500]);
await page.evaluate(bytes => fakeHid.ds4.input(0x01, bytes), touchReport(0x85, 1000, 500));
await page.waitForFunction(() => sentMessages.findLast(m => m.method === 'u' && m.params[0] === 0)?.params[8] === 0);
// Switching motion off: still again, and no more listening.
await page.uncheck('#expRows [data-experiment="motion"]');
await page.waitForFunction(() => JSON.stringify(sentMessages.findLast(m => m.method === 'm')?.params) === '[0,0,0,0,10000,0,0,0,0,0,0]');
console.log('PASS experimental: phone motion (level, gravity on y, gyro), still on leaving; DualShock 4 sensors calibrated and touches');
// A linked controller stays linked through a lost connection, but holding
// Cross across the reconnect must not start a new press on the PS4.
const heldReport = report1([0, 0, 0], [0, 8192, 0]);
heldReport[4] = 0x28; heldReport[5] = 0x0c; heldReport[7] = 96; heldReport[8] = 160;
await page.evaluate(bytes => fakeHid.ds4.input(0x01, bytes), heldReport);
await page.waitForFunction(() => !!(sentMessages.findLast(m => m.method === 'u' && m.params[0] === 0)?.params[1] & 0x4000));
assert.deepEqual((await lastInput()).slice(6, 8), [96, 160], 'partial trigger pulls before reconnect');
await page.evaluate(() => { window.previousPeer = c4fTest.conn.pc; previousPeer.close(); });
await page.waitForFunction(() => c4fTest.conn.pc !== previousPeer && c4fTest.conn.state === 'open' && c4fTest.status[0].mine);
await page.waitForFunction(() => sentMessages.findLast(m => m.method === 'u' && m.params[0] === 0)?.params[1] === 0);
assert.equal(await page.evaluate(() => c4fTest.hidLinks.get(0).device === fakeHid.ds4 && fakeHid.ds4.opened), true, 'the HID link survives reconnect');
await page.evaluate(bytes => fakeHid.ds4.input(0x01, bytes), heldReport);
await page.waitForFunction(() => c4fTest.gamepadSources.get(0).waitNeutral);
assert.equal((await lastInput())[1], 0, 'the held HID button stays gated');
assert.deepEqual((await lastInput()).slice(6, 8), [0, 0], 'held analog triggers stay gated after reconnect');
await page.evaluate(bytes => fakeHid.ds4.input(0x01, bytes), report1([0, 0, 0], [0, 8192, 0]));
await page.waitForFunction(() => !c4fTest.gamepadSources.get(0).waitNeutral);
await page.evaluate(bytes => fakeHid.ds4.input(0x01, bytes), heldReport);
await page.waitForFunction(() => !!(sentMessages.findLast(m => m.method === 'u' && m.params[0] === 0)?.params[1] & 0x4000));
assert.deepEqual((await lastInput()).slice(6, 8), [96, 160], 'partial trigger pressure returns after release and re-press');
// Leaving the visible page explicitly releases both input and motion.
await page.check('#expRows [data-experiment="motion"]');
await page.evaluate(bytes => fakeHid.ds4.input(0x01, bytes), report1([8 * 90, 0, 0], [0, 8192, 0]));
await page.waitForFunction(() => sentMessages.findLast(m => m.method === 'm' && m.params[0] === 0)?.params[8] === 1571);
await page.evaluate(() => {
Object.defineProperty(document, 'hidden', { configurable: true, value: true });
document.dispatchEvent(new Event('visibilitychange'));
});
assert.equal((await lastInput())[1], 0, 'hiding the page releases buttons');
assert.deepEqual((await motions()).filter(p => p[0] === 0).at(-1), [0, 0, 0, 0, 10000, 0, 0, 0, 0, 0, 0], 'hiding the page stops motion');
console.log('PASS experimental: linked HID reconnect keeps the link, gates held input until release, and hides safely with neutral motion');
await context.close();
} finally {
if (browser) await browser.close();
if (server) server.kill();
}
})().catch(error => { console.error(error); process.exitCode = 1; });
+160
View File
@@ -0,0 +1,160 @@
<!doctype html>
<html lang="en">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Control4Free local gamepad lab</title>
<style>
:root { color-scheme: dark; font: 15px/1.5 system-ui, sans-serif; background: #08152a; color: #f0f4fa; }
* { box-sizing: border-box; }
body { margin: 0; }
header { padding: 20px 24px; border-bottom: 1px solid #304158; }
h1 { font-size: 22px; margin: 0 0 4px; }
p { margin: 4px 0 12px; }
main { display: grid; grid-template-columns: 350px 1fr; min-height: calc(100vh - 125px); }
aside { padding: 20px; overflow: auto; max-height: calc(100vh - 125px); }
iframe { width: 100%; height: calc(100vh - 125px); border: 0; background: #04143a; }
label { display: block; margin-bottom: 6px; }
select, button, input { font: inherit; }
select, button { padding: 8px 10px; border: 1px solid #56708d; border-radius: 5px; background: #142b47; color: inherit; }
select { width: 100%; }
button { cursor: pointer; }
button:focus-visible, select:focus-visible, input:focus-visible { outline: 2px solid #a3caff; outline-offset: 3px; }
button:disabled { opacity: .5; cursor: default; }
.actions { display: flex; flex-wrap: wrap; gap: 8px; margin: 12px 0; }
.control { padding: 10px 0; border-bottom: 1px solid #304158; }
#controls { display: grid; grid-template-columns: 1fr 1fr; gap: 0 12px; }
.control input { width: 100%; }
.control small, #evidence { display: block; color: #b9c8da; font-size: 12px; }
details { margin: 16px 0; }
pre { font-size: 11px; white-space: pre-wrap; overflow-wrap: anywhere; }
#message { min-height: 24px; color: #ffcf9b; }
@media(max-width: 800px) { main { grid-template-columns: 1fr; } aside { max-height: 50vh; } iframe { height: 80vh; } }
</style>
<header>
<h1>Local gamepad lab</h1>
<p>Drive researched emulations through the existing controller page. Inferred values are test assumptions, not hardware results.</p>
</header>
<main>
<aside>
<label for="profile">Device and browser profile</label>
<select id="profile" disabled></select>
<div class="actions"><button id="connect" disabled>Connect</button><button id="disconnect" disabled>Disconnect</button><button id="reset" disabled>Let go of everything</button></div>
<p id="message" role="status">Loading local files…</p>
<p id="evidence"></p>
<div id="controls"></div>
<details><summary>Evidence and assumptions</summary><pre id="sources"></pre></details>
<details><summary>Opening from disk</summary><p>If the browser blocks local file reads, select the tests folder and the original controller page below.</p>
<label>Tests folder <input id="folder" type="file" webkitdirectory multiple></label>
<label>client/index.html <input id="pageFile" type="file" accept=".html"></label>
<button id="loadFiles">Load selected files</button>
</details>
<p><small>Run fake_gamepads_server.cjs in Docker for the local service and browser storage. This harness permits only the local test service. Choose Mapping on its gamepad row.</small></p>
</aside>
<iframe id="controller" title="Control4Free controller page with fake gamepads"></iframe>
</main>
<script>
(() => {
const $ = id => document.getElementById(id), frame = $('controller');
let profiles = [], index = null;
const message = text => { $('message').textContent = text; };
const api = () => frame.contentWindow.fakePads;
function act(fn) { try { fn(); message(''); } catch (error) { message(error.message); } }
function selected() { return profiles.find(p => p.name === $('profile').value); }
function button(parent, text, fn) {
const b = document.createElement('button'); b.type = 'button'; b.textContent = text;
b.addEventListener('click', () => act(fn)); parent.appendChild(b); return b;
}
function render() {
const profile = selected(); if (!profile) return;
$('sources').textContent = JSON.stringify(profile, null, 2);
const status = Object.values(profile.gamepad).filter(e => e?.status === 'inferred').length;
$('evidence').textContent = status + ' inferred report fields; control bindings may also be inferred. See Evidence and assumptions below.';
$('controls').textContent = '';
$('connect').disabled = index !== null; $('disconnect').disabled = $('reset').disabled = index === null;
$('profile').disabled = index !== null;
if (index === null) return;
const sampled = frame.contentWindow.navigator.getGamepads()[index];
for (const [name, c] of Object.entries(profile.controls)) {
const row = document.createElement('div'); row.className = 'control'; $('controls').appendChild(row);
const title = document.createElement('label'); title.textContent = name; row.appendChild(title);
if (c.kind === 'unreported') {
const info = document.createElement('small'); info.textContent = c.reason || 'Not independently exposed'; row.appendChild(info); continue;
}
if (c.kind === 'button' || c.kind === 'alias') {
if (c.analog) {
const pressure = document.createElement('input'); pressure.type = 'range'; pressure.min = 0; pressure.max = 1; pressure.step = c.steps ? 1 / c.steps : .01;
pressure.value = sampled.buttons[c.index].value; pressure.setAttribute('aria-label', name + ' pressure');
pressure.addEventListener('input', () => act(() => api().set(index, name, +pressure.value))); row.appendChild(pressure);
}
const b = button(row, 'Hold', () => {});
const up = () => act(() => api().release(index, name));
b.addEventListener('pointerdown', e => { b.setPointerCapture(e.pointerId); act(() => api().press(index, name)); });
b.addEventListener('pointerup', up); b.addEventListener('pointercancel', up); b.addEventListener('lostpointercapture', up);
b.addEventListener('keydown', e => { if ([' ', 'Enter'].includes(e.key)) { e.preventDefault(); act(() => api().press(index, name)); } });
b.addEventListener('keyup', e => { if ([' ', 'Enter'].includes(e.key)) up(); });
b.addEventListener('blur', up);
} else if (c.kind === 'hat') {
const select = document.createElement('select'); select.setAttribute('aria-label', name);
for (const direction of ['none', ...Object.keys(c.directions)]) select.add(new Option(direction, direction));
select.value = Object.keys(c.directions).find(direction => c.directions[direction] === sampled.axes[c.index]) || 'none';
select.addEventListener('change', () => act(() => api().hat(index, select.value, name))); row.appendChild(select);
} else {
const values = c.kind === 'stick' ? [sampled.axes[c.x], sampled.axes[c.y]] : [sampled.axes[c.index]];
values.forEach((value, part) => {
const input = document.createElement('input'); input.type = 'range'; input.min = c.min ?? -1; input.max = c.max ?? 1; input.step = .01; input.value = value;
input.setAttribute('aria-label', name + (c.kind === 'stick' ? [' horizontal', ' vertical'][part] : ''));
input.addEventListener('input', () => act(() => { values[part] = +input.value;
if (c.kind === 'stick') api().tilt(index, name, ...values); else api().set(index, name, values[0]); })); row.appendChild(input);
});
button(row, 'Release', () => { api().release(index, name); render(); });
}
const note = document.createElement('small'); note.textContent = c.evidence.status; row.appendChild(note);
}
for (const [name, spec] of Object.entries(profile.switches || {})) {
const row = document.createElement('div'); row.className = 'control'; $('controls').appendChild(row);
const label = document.createElement('label'); label.textContent = name; row.appendChild(label);
if (spec.kind === 'unreported') { const note = document.createElement('small'); note.textContent = spec.reason; row.appendChild(note); continue; }
const select = document.createElement('select'); select.setAttribute('aria-label', name); select.add(new Option('Choose…', ''));
for (const value of spec.kind === 'profile' ? Object.keys(spec.values) : spec.values) select.add(new Option(value, value));
if (spec.kind === 'state') select.value = api().switchState(index)[name] || '';
select.addEventListener('change', () => act(() => { api().switch(index, name, select.value);
$('profile').value = api().describe(index).name; render(); })); row.appendChild(select);
}
}
function install(html, script, data) {
profiles = data; index = null;
$('profile').textContent = '';
for (const profile of profiles) $('profile').add(new Option(profile.label + ' — ' + profile.name, profile.name));
const relay = location.protocol === 'http:' && location.hostname === '127.0.0.1' ? 'http://' + 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' +
'const localFetch = window.fetch.bind(window); window.fetch = async (address, options) => {\n' +
'const url = new URL(address); if (url.protocol !== "http:" || url.hostname !== "127.0.0.1" || url.port !== "4264" || url.pathname !== "/rtc") throw new Error("The gamepad lab only connects to the local test service");\n' +
'return localFetch(' + JSON.stringify(relay) + ' || address, options); };';
frame.srcdoc = html.replace(/<head>/i, '<head><script>' + bootstrap.replace(/<\/script/gi, '<\\/script') + '<\/script>');
frame.onload = () => { $('profile').disabled = false; render(); message('Choose a profile, then Connect.'); };
}
$('profile').addEventListener('change', render);
$('connect').addEventListener('click', () => act(() => { index = api().connect(selected().name); render(); }));
$('disconnect').addEventListener('click', () => act(() => { api().disconnect(index); index = null; render(); }));
$('reset').addEventListener('click', () => act(() => { api().reset(index); render(); }));
$('loadFiles').addEventListener('click', async () => {
try {
const files = [...$('folder').files], html = await $('pageFile').files[0].text();
const script = await files.find(f => f.name === 'fake_gamepads.js').text();
const data = await Promise.all(files.filter(f => /fake_gamepads\//.test(f.webkitRelativePath) && f.name.endsWith('.json') && f.name !== 'manifest.json').map(async f => JSON.parse(await f.text())));
if (!data.length) throw Error('Select the tests folder containing the profile JSON files'); install(html, script, data);
} catch (error) { message(error.message); }
});
(async () => {
const read = async path => { const response = await fetch(path); if (!response.ok) throw Error('Could not read ' + path); return response; };
try {
const manifest = await (await read('fake_gamepads/manifest.json')).json();
const [html, script, data] = await Promise.all([(await read('../client/index.html')).text(), (await read('fake_gamepads.js')).text(),
Promise.all(manifest.profiles.map(async file => (await read('fake_gamepads/' + file)).json()))]);
install(html, script, data);
} catch (error) { message('Serve this folder locally, or use Opening from disk below. ' + error.message); }
})();
})();
</script>
</html>
+214
View File
@@ -0,0 +1,214 @@
/* Local browser test double. This file is never loaded by client/index.html.
* Inject this script and call fakePads.loadProfiles(researched JSON profiles).
* Example: fakePads.press(fakePads.connect(profileName), 'Green fret').
* API contract: https://w3c.github.io/gamepad/#dom-navigator-getgamepads
* Slots retain null holes; button pressure/touch and timestamps update on polls.
* These snapshots emulate the documented profiles, not hardware compatibility.
*/
(() => {
'use strict';
// Chromium Gamepads::kItemsLengthCap and Windows XInput both cap at four.
// https://chromium.googlesource.com/chromium/src/+/main/device/gamepad/public/cpp/gamepads.h
const profiles = new Map(), devices = Array(4).fill(null), feedback = [];
const copy = value => JSON.parse(JSON.stringify(value));
const finite = value => {
if (!Number.isFinite(value)) throw new TypeError('Input must be a finite number');
return value;
};
const clamp = (value, min, max) => Math.max(min, Math.min(max, finite(value)));
const valueOf = entry => entry.value;
const evidence = (entry, label) => {
if (!entry || !['confirmed', 'inferred'].includes(entry.status) || !entry.sources?.length ||
entry.sources.some(source => !/^https?:\/\//.test(source.url) || !source.says) ||
entry.status === 'inferred' && !entry.reason) throw new Error('Missing evidence: ' + label);
};
function validate(profile) {
if (!profile?.name || !profile.label || !profile.environment || !profile.controls) throw new Error('Invalid profile');
for (const key of ['id', 'mapping', 'buttons', 'axesRest', 'vibration']) evidence(profile.gamepad?.[key], profile.name + '.' + key);
const pad = profile.gamepad, count = valueOf(pad.buttons), axes = valueOf(pad.axesRest);
if (typeof valueOf(pad.id) !== 'string' || !['', 'standard'].includes(valueOf(pad.mapping)) ||
!Number.isInteger(count) || count < 1 || !Array.isArray(axes) || axes.some(v => !Number.isFinite(v))) throw new Error('Invalid layout: ' + profile.name);
for (const [name, control] of Object.entries(profile.controls)) {
evidence(control.evidence, profile.name + '.' + name);
if (control.kind === 'button' && (!Number.isInteger(control.index) || control.index < 0 || control.index >= count)) throw new Error('Button out of range: ' + name);
if (['axis', 'hat'].includes(control.kind) && (!Number.isInteger(control.index) || control.index < 0 || control.index >= axes.length)) throw new Error('Axis out of range: ' + name);
if (control.kind === 'stick' && [control.x, control.y].some(index => !Number.isInteger(index) || index < 0 || index >= axes.length)) throw new Error('Stick out of range: ' + name);
if (control.kind === 'alias' && !profile.controls[control.target]) throw new Error('Unknown alias target: ' + name);
if (!['button', 'axis', 'stick', 'hat', 'alias', 'unreported'].includes(control.kind)) throw new Error('Unknown control kind: ' + name);
if (control.kind === 'hat' && (!Number.isFinite(control.neutral) || !control.directions)) throw new Error('Invalid hat: ' + name);
}
for (const [name, spec] of Object.entries(profile.switches || {})) evidence(spec.evidence, profile.name + '.' + name);
return copy(profile);
}
function device(index) {
const found = devices[index];
if (!found) throw new Error('No connected fake gamepad at index ' + index);
return found;
}
function controlFor(pad, name, seen = new Set()) {
const control = pad.profile.controls[name];
if (!control) throw new Error('Unknown control: ' + name);
if (seen.has(name)) throw new Error('Circular control alias: ' + name);
seen.add(name);
if (control.kind === 'alias') return controlFor(pad, control.target, seen);
if (control.kind === 'unreported') throw new Error(name + ' is not independently exposed: ' + (control.reason || pad.profile.notes?.join(' ') || 'see the profile evidence'));
return control;
}
function snapshot(pad, connected = true) {
const spec = pad.profile.gamepad;
pad.sample = { connected, timestamp: pad.timestamp, buttons: [...pad.buttons], axes: Object.freeze([...pad.axes]) };
if (!pad.public) {
const buttons = Object.freeze(pad.buttons.map((_, index) => {
const control = Object.values(pad.profile.controls).find(c => c.kind === 'button' && c.index === index);
const threshold = control?.pressedThreshold ?? .5;
return Object.freeze({
get pressed() { const value = pad.sample.buttons[index]; return control?.pressedInclusive ? value >= threshold : value > threshold; },
get touched() {
const value = pad.sample.buttons[index];
if (control?.touchedPolicy === 'pressed') return control.pressedInclusive ? value >= threshold : value > threshold;
return value > 0;
},
get value() { return pad.sample.buttons[index]; }, [Symbol.toStringTag]: 'GamepadButton'
});
}));
const result = {
id: valueOf(spec.id), index: pad.index, mapping: valueOf(spec.mapping),
get connected() { return pad.sample.connected; }, get timestamp() { return pad.sample.timestamp; },
buttons, get axes() { return pad.sample.axes; }, [Symbol.toStringTag]: 'Gamepad'
};
if (valueOf(spec.vibration)) result.vibrationActuator = pad.actuator;
else if (spec.vibration.presence === 'null') result.vibrationActuator = null;
pad.public = Object.freeze(result);
}
return pad.public;
}
function fire(type, pad, connected) {
// GamepadEvent's constructor rejects JS test doubles as native Gamepads.
// The page sees the same event name and read-only gamepad property.
const event = new Event(type);
Object.defineProperty(event, 'gamepad', { value: snapshot(pad, connected), enumerable: true });
window.dispatchEvent(event);
}
function connectAt(name, index) {
const profile = profiles.get(name);
if (!profile) throw new Error('Unknown profile: ' + name);
const pad = { profile, index, timestamp: performance.now(), dirty: false, contributions: new Map(), buttons: Array(valueOf(profile.gamepad.buttons)).fill(0),
axes: [...valueOf(profile.gamepad.axesRest)], switches: {} };
const vibration = valueOf(profile.gamepad.vibration);
if (vibration) {
pad.actuator = Object.freeze({ type: vibration.type, effects: Object.freeze([...vibration.effects]),
playEffect(type, parameters = {}) {
if (!['dual-rumble', 'trigger-rumble'].includes(type)) throw new TypeError('Unknown haptic effect');
if (!vibration.effects.includes(type)) return Promise.resolve('not-supported');
// Blink playEffect parameter ranges; defaults are zero.
// https://chromium.googlesource.com/chromium/src/+/main/third_party/blink/renderer/modules/gamepad/gamepad_haptic_actuator.cc
if (['duration', 'startDelay'].some(key => parameters[key] !== undefined && (!Number.isFinite(parameters[key]) || parameters[key] < 0)) ||
['strongMagnitude', 'weakMagnitude', 'leftTrigger', 'rightTrigger'].some(key => parameters[key] !== undefined &&
(!Number.isFinite(parameters[key]) || parameters[key] < 0 || parameters[key] > 1))) return Promise.resolve('invalid-parameter');
feedback.push({ index, type, parameters: copy(parameters) });
// Capture stub: no motor timing, preemption, or physical validation.
return Promise.resolve('complete');
},
reset() { feedback.push({ index, type: 'reset' }); return Promise.resolve('complete'); },
[Symbol.toStringTag]: 'GamepadHapticActuator' });
}
devices[index] = pad;
fire('gamepadconnected', pad, true);
return index;
}
function set(index, name, value) {
const pad = device(index), control = controlFor(pad, name);
if (control.kind === 'button') {
const pressure = clamp(value, 0, 1);
if (!control.analog && ![0, 1].includes(pressure)) throw new TypeError(name + ' is a digital button');
pad.contributions.set(name, { index: control.index, value: control.steps ? Math.round(pressure * control.steps) / control.steps : pressure });
const next = Math.max(0, ...[...pad.contributions.values()].filter(c => c.index === control.index).map(c => c.value));
pad.dirty ||= pad.buttons[control.index] !== next; pad.buttons[control.index] = next;
}
else if (control.kind === 'axis') axisValue(pad, control.index, clamp(value, control.min ?? -1, control.max ?? 1));
else throw new Error('Use tilt or hat for ' + name);
}
function axisValue(pad, index, value) { pad.dirty ||= pad.axes[index] !== value; pad.axes[index] = value; }
function release(index, name) {
const pad = device(index), control = controlFor(pad, name);
if (control.kind === 'button') set(index, name, 0);
else if (control.kind === 'axis') axisValue(pad, control.index, control.rest ?? valueOf(pad.profile.gamepad.axesRest)[control.index]);
else if (control.kind === 'stick') {
axisValue(pad, control.x, valueOf(pad.profile.gamepad.axesRest)[control.x]);
axisValue(pad, control.y, valueOf(pad.profile.gamepad.axesRest)[control.y]);
} else if (control.kind === 'hat') axisValue(pad, control.index, control.neutral);
}
function directionKey(direction) { return direction.toLowerCase().replace(/\s+/g, '-'); }
function hat(index, direction, name) {
const pad = device(index);
name ||= Object.keys(pad.profile.controls).find(key => pad.profile.controls[key].kind === 'hat') || 'D-pad';
const control = pad.profile.controls[name] ? controlFor(pad, name) : null, key = directionKey(direction);
if (control?.kind === 'hat') {
if (key === 'none') axisValue(pad, control.index, control.neutral);
else if (Object.hasOwn(control.directions, key)) axisValue(pad, control.index, control.directions[key]);
else throw new Error('Unknown hat direction: ' + direction);
return;
}
// A browser-standard D-pad is four buttons, including diagonals.
const names = ['up', 'right', 'down', 'left'];
if (!['none', ...names, 'up-right', 'down-right', 'down-left', 'up-left'].includes(key)) throw new Error('Unknown D-pad direction');
for (const part of names) {
const keyName = Object.keys(pad.profile.controls).find(n => n.toLowerCase() === 'd-pad ' + part);
if (!keyName) throw new Error('No D-pad control in this profile');
set(index, keyName, key.split('-').includes(part) ? 1 : 0);
}
}
const api = {
loadProfiles(list) {
if (!Array.isArray(list)) list = [list];
const checked = list.map(validate);
for (const profile of checked) profiles.set(profile.name, profile);
},
profiles: () => [...profiles.values()].map(copy),
describe: index => copy(device(index).profile),
connect(name) { const index = devices.findIndex(pad => !pad); if (index < 0) throw new Error('All four fake gamepad slots are occupied'); return connectAt(name, index); },
disconnect(index) { const pad = device(index); devices[index] = null; pad.timestamp = performance.now(); fire('gamepaddisconnected', pad, false); },
press(index, name) {
const pad = device(index), control = controlFor(pad, name);
if (control.kind === 'button') set(index, name, 1);
else if (control.kind === 'axis') set(index, name, control.full ?? (control.rest > 0 ? -1 : 1));
else throw new Error('Use tilt or hat for ' + name);
},
release, set,
tilt(index, name, x, y) {
const pad = device(index), control = controlFor(pad, name);
if (control.kind !== 'stick') throw new Error(name + ' is not a stick');
axisValue(pad, control.x, clamp(x, -1, 1)); axisValue(pad, control.y, clamp(y, -1, 1));
},
hat,
switch(index, name, value) {
const pad = device(index), spec = pad.profile.switches?.[name];
if (!spec) throw new Error('Unknown switch: ' + name);
if (spec.kind === 'unreported') throw new Error(name + ': ' + (spec.reason || 'browser effect not found; see evidence'));
if (spec.kind === 'profile') {
const target = spec.values[value];
if (!target || !profiles.has(target)) throw new Error('No researched profile for mode ' + value);
api.disconnect(index); return connectAt(target, index);
}
if (spec.kind !== 'state' || spec.effect !== 'none' || !spec.values.includes(value)) throw new Error('Unsupported switch value');
pad.switches[name] = value;
return index;
},
switchState: index => copy(device(index).switches),
feedback: () => copy(feedback),
reset(index) {
const pad = device(index);
pad.dirty ||= pad.buttons.some(v => v !== 0) || pad.axes.some((v, i) => v !== valueOf(pad.profile.gamepad.axesRest)[i]);
pad.contributions.clear();
pad.buttons.fill(0); pad.axes = [...valueOf(pad.profile.gamepad.axesRest)];
}
};
// New snapshots expose the latest input only when sampled, like getGamepads.
Object.defineProperty(navigator, 'getGamepads', { configurable: true, value: () => devices.map(pad => {
if (!pad) return null;
if (pad.dirty || valueOf(pad.profile.gamepad.timestampPolicy || { value: 'poll' }) === 'poll') pad.timestamp = Math.max(performance.now(), pad.timestamp + .001);
pad.dirty = false;
return snapshot(pad);
}) });
Object.defineProperty(window, 'fakePads', { value: Object.freeze(api), configurable: true });
})();
@@ -0,0 +1,545 @@
{
"name": "crkd-les-paul-chrome-win-dongle-mode1-pre3",
"label": "CRKD Gibson Les Paul — PC Mode 1 (earlier firmware, inferred)",
"gamepad": {
"id": {
"value": "Xbox 360 Controller (XInput STANDARD GAMEPAD)",
"status": "inferred",
"sources": [
{
"url": "https://github.com/sanjay900/xinputemu/pull/1",
"says": "Patch author tested two CRKD guitars; VID/PID match a first-party 360 gamepad (045e:028e in patch); whammy is Z/LT, converted to signed right-stick range for old PC guitar games."
},
{
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
}
],
"reason": "The hardware-tested original Mode 1 report uses the first-party 360 VID/PID and YARG identifies it as an XInput gamepad. Applying Chromium's subtype-1 name is a source-derived candidate, not a raw CRKD browser capture."
},
"mapping": {
"value": "standard",
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
}
]
},
"buttons": {
"value": 17,
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
}
]
},
"axesRest": {
"value": [
1.52587890625E-05,
-1.52587890625E-05,
1.52587890625E-05,
-1.52587890625E-05
],
"status": "inferred",
"sources": [
{
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
},
{
"url": "https://cdn.shopify.com/s/files/1/0679/3392/9791/files/Guitar_User_Guide_Multi_V1.4_08Jan26.pdf?v=1767902895",
"says": "CK25GM PC connections include USB cable and included USB wireless receiver; PC slider and mode dial select game preset; the hardware includes both sticks, View/Capture/Menu, CTRL, Home and D-pad."
}
],
"reason": "Nominal neutral signed XInput stick words are assumed to be zero. Chromium normalizes signed zero to +0.0000152587890625 and negates Y; physical CRKD neutral raw words were not captured."
},
"timestampPolicy": {
"value": "poll",
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
"says": "GetXInputPadData assigns CurrentTimeInMicroseconds to pad.timestamp after every successful XInput state poll, even if the input values did not change."
}
]
},
"vibration": {
"presence": "object",
"value": {
"type": "dual-rumble",
"effects": [
"dual-rumble"
]
},
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
}
]
}
},
"controls": {
"Green fret": {
"kind": "button",
"index": 0,
"pressedThreshold": 0,
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://github.com/YARC-Official/YARG/blob/f5bfbe6996b4af35722d4204a93c6cc5c8f52a45/Assets/Script/Input/Bindings/Defaults/BindingCollection.Gamepad.cs",
"says": "CrkdGuitar_Mode1 maps Green/Red/Yellow/Blue/Orange to A/B/Y/X/LB, strum up/down to D-pad, star power to Select, whammy to leftTrigger; no dedicated tilt axis. Menu includes left-stick and D-pad."
},
{
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
}
],
"reason": "YARG's CRKD Mode 1 Green fret binding is translated through Chromium's XInput button order. Factory button aliases are inferred, not a browser dump."
}
},
"Red fret": {
"kind": "button",
"index": 1,
"pressedThreshold": 0,
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://github.com/YARC-Official/YARG/blob/f5bfbe6996b4af35722d4204a93c6cc5c8f52a45/Assets/Script/Input/Bindings/Defaults/BindingCollection.Gamepad.cs",
"says": "CrkdGuitar_Mode1 maps Green/Red/Yellow/Blue/Orange to A/B/Y/X/LB, strum up/down to D-pad, star power to Select, whammy to leftTrigger; no dedicated tilt axis. Menu includes left-stick and D-pad."
},
{
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
}
],
"reason": "YARG's CRKD Mode 1 Red fret binding is translated through Chromium's XInput button order. Factory button aliases are inferred, not a browser dump."
}
},
"Yellow fret": {
"kind": "button",
"index": 3,
"pressedThreshold": 0,
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://github.com/YARC-Official/YARG/blob/f5bfbe6996b4af35722d4204a93c6cc5c8f52a45/Assets/Script/Input/Bindings/Defaults/BindingCollection.Gamepad.cs",
"says": "CrkdGuitar_Mode1 maps Green/Red/Yellow/Blue/Orange to A/B/Y/X/LB, strum up/down to D-pad, star power to Select, whammy to leftTrigger; no dedicated tilt axis. Menu includes left-stick and D-pad."
},
{
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
}
],
"reason": "YARG's CRKD Mode 1 Yellow fret binding is translated through Chromium's XInput button order. Factory button aliases are inferred, not a browser dump."
}
},
"Blue fret": {
"kind": "button",
"index": 2,
"pressedThreshold": 0,
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://github.com/YARC-Official/YARG/blob/f5bfbe6996b4af35722d4204a93c6cc5c8f52a45/Assets/Script/Input/Bindings/Defaults/BindingCollection.Gamepad.cs",
"says": "CrkdGuitar_Mode1 maps Green/Red/Yellow/Blue/Orange to A/B/Y/X/LB, strum up/down to D-pad, star power to Select, whammy to leftTrigger; no dedicated tilt axis. Menu includes left-stick and D-pad."
},
{
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
}
],
"reason": "YARG's CRKD Mode 1 Blue fret binding is translated through Chromium's XInput button order. Factory button aliases are inferred, not a browser dump."
}
},
"Orange fret": {
"kind": "button",
"index": 4,
"pressedThreshold": 0,
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://github.com/YARC-Official/YARG/blob/f5bfbe6996b4af35722d4204a93c6cc5c8f52a45/Assets/Script/Input/Bindings/Defaults/BindingCollection.Gamepad.cs",
"says": "CrkdGuitar_Mode1 maps Green/Red/Yellow/Blue/Orange to A/B/Y/X/LB, strum up/down to D-pad, star power to Select, whammy to leftTrigger; no dedicated tilt axis. Menu includes left-stick and D-pad."
},
{
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
}
],
"reason": "YARG's CRKD Mode 1 Orange fret binding is translated through Chromium's XInput button order. Factory button aliases are inferred, not a browser dump."
}
},
"Strum up": {
"kind": "button",
"index": 12,
"pressedThreshold": 0,
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://github.com/YARC-Official/YARG/blob/f5bfbe6996b4af35722d4204a93c6cc5c8f52a45/Assets/Script/Input/Bindings/Defaults/BindingCollection.Gamepad.cs",
"says": "CrkdGuitar_Mode1 maps Green/Red/Yellow/Blue/Orange to A/B/Y/X/LB, strum up/down to D-pad, star power to Select, whammy to leftTrigger; no dedicated tilt axis. Menu includes left-stick and D-pad."
},
{
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
}
],
"reason": "YARG's CRKD Mode 1 Strum up binding is translated through Chromium's XInput button order. Factory button aliases are inferred, not a browser dump."
}
},
"Strum down": {
"kind": "button",
"index": 13,
"pressedThreshold": 0,
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://github.com/YARC-Official/YARG/blob/f5bfbe6996b4af35722d4204a93c6cc5c8f52a45/Assets/Script/Input/Bindings/Defaults/BindingCollection.Gamepad.cs",
"says": "CrkdGuitar_Mode1 maps Green/Red/Yellow/Blue/Orange to A/B/Y/X/LB, strum up/down to D-pad, star power to Select, whammy to leftTrigger; no dedicated tilt axis. Menu includes left-stick and D-pad."
},
{
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
}
],
"reason": "YARG's CRKD Mode 1 Strum down binding is translated through Chromium's XInput button order. Factory button aliases are inferred, not a browser dump."
}
},
"CTRL": {
"kind": "button",
"index": 8,
"pressedThreshold": 0,
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://github.com/YARC-Official/YARG/blob/f5bfbe6996b4af35722d4204a93c6cc5c8f52a45/Assets/Script/Input/Bindings/Defaults/BindingCollection.Gamepad.cs",
"says": "CrkdGuitar_Mode1 maps Green/Red/Yellow/Blue/Orange to A/B/Y/X/LB, strum up/down to D-pad, star power to Select, whammy to leftTrigger; no dedicated tilt axis. Menu includes left-stick and D-pad."
},
{
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
}
],
"reason": "YARG's CRKD Mode 1 star-power/select binding is translated through Chromium's XInput button order. Factory button aliases are inferred, not a browser dump."
}
},
"View": {
"kind": "button",
"index": 8,
"pressedThreshold": 0,
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://github.com/YARC-Official/YARG/blob/f5bfbe6996b4af35722d4204a93c6cc5c8f52a45/Assets/Script/Input/Bindings/Defaults/BindingCollection.Gamepad.cs",
"says": "CrkdGuitar_Mode1 maps Green/Red/Yellow/Blue/Orange to A/B/Y/X/LB, strum up/down to D-pad, star power to Select, whammy to leftTrigger; no dedicated tilt axis. Menu includes left-stick and D-pad."
},
{
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
}
],
"reason": "YARG's CRKD Mode 1 View binding is translated through Chromium's XInput button order. Factory button aliases are inferred, not a browser dump."
}
},
"Menu": {
"kind": "button",
"index": 9,
"pressedThreshold": 0,
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://github.com/YARC-Official/YARG/blob/f5bfbe6996b4af35722d4204a93c6cc5c8f52a45/Assets/Script/Input/Bindings/Defaults/BindingCollection.Gamepad.cs",
"says": "CrkdGuitar_Mode1 maps Green/Red/Yellow/Blue/Orange to A/B/Y/X/LB, strum up/down to D-pad, star power to Select, whammy to leftTrigger; no dedicated tilt axis. Menu includes left-stick and D-pad."
},
{
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
}
],
"reason": "YARG's CRKD Mode 1 Menu binding is translated through Chromium's XInput button order. Factory button aliases are inferred, not a browser dump."
}
},
"D-pad up": {
"kind": "button",
"index": 12,
"pressedThreshold": 0,
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://github.com/YARC-Official/YARG/blob/f5bfbe6996b4af35722d4204a93c6cc5c8f52a45/Assets/Script/Input/Bindings/Defaults/BindingCollection.Gamepad.cs",
"says": "CrkdGuitar_Mode1 maps Green/Red/Yellow/Blue/Orange to A/B/Y/X/LB, strum up/down to D-pad, star power to Select, whammy to leftTrigger; no dedicated tilt axis. Menu includes left-stick and D-pad."
},
{
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
}
],
"reason": "YARG's CRKD Mode 1 D-pad up binding is translated through Chromium's XInput button order. Factory button aliases are inferred, not a browser dump."
}
},
"D-pad down": {
"kind": "button",
"index": 13,
"pressedThreshold": 0,
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://github.com/YARC-Official/YARG/blob/f5bfbe6996b4af35722d4204a93c6cc5c8f52a45/Assets/Script/Input/Bindings/Defaults/BindingCollection.Gamepad.cs",
"says": "CrkdGuitar_Mode1 maps Green/Red/Yellow/Blue/Orange to A/B/Y/X/LB, strum up/down to D-pad, star power to Select, whammy to leftTrigger; no dedicated tilt axis. Menu includes left-stick and D-pad."
},
{
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
}
],
"reason": "YARG's CRKD Mode 1 D-pad down binding is translated through Chromium's XInput button order. Factory button aliases are inferred, not a browser dump."
}
},
"D-pad left": {
"kind": "button",
"index": 14,
"pressedThreshold": 0,
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://github.com/YARC-Official/YARG/blob/f5bfbe6996b4af35722d4204a93c6cc5c8f52a45/Assets/Script/Input/Bindings/Defaults/BindingCollection.Gamepad.cs",
"says": "CrkdGuitar_Mode1 maps Green/Red/Yellow/Blue/Orange to A/B/Y/X/LB, strum up/down to D-pad, star power to Select, whammy to leftTrigger; no dedicated tilt axis. Menu includes left-stick and D-pad."
},
{
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
}
],
"reason": "YARG's CRKD Mode 1 D-pad left binding is translated through Chromium's XInput button order. Factory button aliases are inferred, not a browser dump."
}
},
"D-pad right": {
"kind": "button",
"index": 15,
"pressedThreshold": 0,
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://github.com/YARC-Official/YARG/blob/f5bfbe6996b4af35722d4204a93c6cc5c8f52a45/Assets/Script/Input/Bindings/Defaults/BindingCollection.Gamepad.cs",
"says": "CrkdGuitar_Mode1 maps Green/Red/Yellow/Blue/Orange to A/B/Y/X/LB, strum up/down to D-pad, star power to Select, whammy to leftTrigger; no dedicated tilt axis. Menu includes left-stick and D-pad."
},
{
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
}
],
"reason": "YARG's CRKD Mode 1 D-pad right binding is translated through Chromium's XInput button order. Factory button aliases are inferred, not a browser dump."
}
},
"Whammy": {
"kind": "button",
"index": 6,
"steps": 255,
"pressedThreshold": 0.11764705882352941,
"pressedInclusive": true,
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://github.com/YARC-Official/YARG/blob/f5bfbe6996b4af35722d4204a93c6cc5c8f52a45/Assets/Script/Input/Bindings/Defaults/BindingCollection.Gamepad.cs",
"says": "CrkdGuitar_Mode1 maps Green/Red/Yellow/Blue/Orange to A/B/Y/X/LB, strum up/down to D-pad, star power to Select, whammy to leftTrigger; no dedicated tilt axis. Menu includes left-stick and D-pad."
},
{
"url": "https://github.com/sanjay900/xinputemu/pull/1",
"says": "Patch author tested two CRKD guitars; VID/PID match a first-party 360 gamepad (045e:028e in patch); whammy is Z/LT, converted to signed right-stick range for old PC guitar games."
},
{
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
}
],
"reason": "YARG binds CRKD Mode 1 whammy to LT; Chromium exposes LT as analog button 6, divided by 255, pressed inclusively at byte 30. No -1 whammy axis is claimed."
},
"analog": true
},
"Left stick": {
"kind": "stick",
"x": 0,
"y": 1,
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://github.com/YARC-Official/YARG/blob/f5bfbe6996b4af35722d4204a93c6cc5c8f52a45/Assets/Script/Input/Bindings/Defaults/BindingCollection.Gamepad.cs",
"says": "CrkdGuitar_Mode1 maps Green/Red/Yellow/Blue/Orange to A/B/Y/X/LB, strum up/down to D-pad, star power to Select, whammy to leftTrigger; no dedicated tilt axis. Menu includes left-stick and D-pad."
},
{
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
}
],
"reason": "YARG's CRKD Mode 1 menu accepts the left stick; Chromium's XInput axes 0/1 carry LX/inverted LY."
}
},
"Right stick": {
"kind": "stick",
"x": 2,
"y": 3,
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://cdn.shopify.com/s/files/1/0679/3392/9791/files/Guitar_User_Guide_Multi_V1.4_08Jan26.pdf?v=1767902895",
"says": "CK25GM PC connections include USB cable and included USB wireless receiver; PC slider and mode dial select game preset; the hardware includes both sticks, View/Capture/Menu, CTRL, Home and D-pad."
},
{
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
}
],
"reason": "Manufacturer identifies a right analog stick. Standard XInput axes 2/3 carry RX/inverted RY; its factory operation in this earlier Mode 1 preset is inferred."
}
},
"L3": {
"kind": "button",
"index": 10,
"pressedThreshold": 0,
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://cdn.shopify.com/s/files/1/0679/3392/9791/files/Guitar_User_Guide_Multi_V1.4_08Jan26.pdf?v=1767902895",
"says": "CK25GM PC connections include USB cable and included USB wireless receiver; PC slider and mode dial select game preset; the hardware includes both sticks, View/Capture/Menu, CTRL, Home and D-pad."
},
{
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
}
],
"reason": "Manufacturer names this physical control; its normal XInput button correspondence is inferred from the standard browser path, not observed on hardware."
}
},
"R3": {
"kind": "button",
"index": 11,
"pressedThreshold": 0,
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://cdn.shopify.com/s/files/1/0679/3392/9791/files/Guitar_User_Guide_Multi_V1.4_08Jan26.pdf?v=1767902895",
"says": "CK25GM PC connections include USB cable and included USB wireless receiver; PC slider and mode dial select game preset; the hardware includes both sticks, View/Capture/Menu, CTRL, Home and D-pad."
},
{
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
}
],
"reason": "Manufacturer names this physical control; its normal XInput button correspondence is inferred from the standard browser path, not observed on hardware."
}
},
"CRKD Home": {
"kind": "button",
"index": 16,
"pressedThreshold": 0,
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://cdn.shopify.com/s/files/1/0679/3392/9791/files/Guitar_User_Guide_Multi_V1.4_08Jan26.pdf?v=1767902895",
"says": "CK25GM PC connections include USB cable and included USB wireless receiver; PC slider and mode dial select game preset; the hardware includes both sticks, View/Capture/Menu, CTRL, Home and D-pad."
},
{
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
}
],
"reason": "Manufacturer names this physical control; its normal XInput button correspondence is inferred from the standard browser path, not observed on hardware."
}
},
"Capture": {
"kind": "unreported",
"reason": "No independent XInput capture bit exists; the factory action or alias was not found.",
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://cdn.shopify.com/s/files/1/0679/3392/9791/files/Guitar_User_Guide_Multi_V1.4_08Jan26.pdf?v=1767902895",
"says": "CK25GM PC connections include USB cable and included USB wireless receiver; PC slider and mode dial select game preset; the hardware includes both sticks, View/Capture/Menu, CTRL, Home and D-pad."
},
{
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
}
],
"reason": "The manual confirms Capture exists physically but its PC report is not established; no browser index is invented."
}
}
},
"switches": {
"Mode dial": {
"kind": "unreported",
"reason": "Only the earlier PC Mode 1 mapping is sourced. Factory presets, firmware 3 and legacy Mode 8 change reports and need separately researched profiles.",
"evidence": {
"value": null,
"status": "confirmed",
"sources": [
{
"url": "https://cdn.shopify.com/s/files/1/0679/3392/9791/files/Guitar_User_Guide_Multi_V1.4_08Jan26.pdf?v=1767902895",
"says": "CK25GM PC connections include USB cable and included USB wireless receiver; PC slider and mode dial select game preset; the hardware includes both sticks, View/Capture/Menu, CTRL, Home and D-pad."
},
{
"url": "https://github.com/YARC-Official/YARG/blob/f5bfbe6996b4af35722d4204a93c6cc5c8f52a45/Assets/Script/Input/Bindings/Defaults/BindingCollection.Gamepad.cs",
"says": "CrkdGuitar_Mode1 maps Green/Red/Yellow/Blue/Orange to A/B/Y/X/LB, strum up/down to D-pad, star power to Select, whammy to leftTrigger; no dedicated tilt axis. Menu includes left-stick and D-pad."
}
]
}
},
"Platform slider": {
"kind": "unreported",
"reason": "PC Mode 1 only. NS, AN and P3 identifiers and raw reports have not been established.",
"evidence": {
"value": null,
"status": "confirmed",
"sources": [
{
"url": "https://cdn.shopify.com/s/files/1/0679/3392/9791/files/Guitar_User_Guide_Multi_V1.4_08Jan26.pdf?v=1767902895",
"says": "CK25GM PC connections include USB cable and included USB wireless receiver; PC slider and mode dial select game preset; the hardware includes both sticks, View/Capture/Menu, CTRL, Home and D-pad."
}
]
}
}
},
"notes": [
"Local source-derived emulation, not a hardware/browser capture. This profile models the earlier CRKD Mode 1 preset identified by YARG as CrkdGuitar_Mode1, before firmware 3 changed star power to D-pad left.",
"Use the Multi-platform Gibson Les Paul CK25GM; Nitro Deck is a separate Switch controller product.",
"Stock Windows XInputGetStateEx availability is assumed for button 16. Without it Chromium exposes 16 buttons; that separate variant is omitted.",
"All unpressed buttons, including whammy and unused RT, are modeled at zero; those physical resting bytes are inferred. The tested conversion patch supports whammy LT with rest zero, but no raw browser rest dump was located.",
"Whammy is analog button 6 (0..1), not an axis. D-pad and strum share standard button indices; there is no numeric hat axis or independent tilt axis in this preset.",
"Browser indices: 0 A/Green; 1 B/Red; 2 X/Blue; 3 Y/Yellow; 4 LB/Orange; 5 RB (physical alias unconfirmed); 6 LT/Whammy; 7 RT (physical alias unconfirmed); 8 View/Select/CTRL inferred; 9 Menu/Start; 10 L3; 11 R3; 12 D-pad/Strum up; 13 D-pad/Strum down; 14 D-pad left; 15 D-pad right; 16 Home/Guide. Axes 0/1 Left stick; 2/3 Right stick, nominal rest inferred.",
"The dual-rumble object is Chromium's unconditional XInput API exposure. It does not confirm browser-controlled physical vibration or equate to the guitar's local strum haptics.",
"Chromium XInput id hides VID/PID. Hardware-tested old Mode 1 uses 045e:028e; newer native CRKD IDs from Linux/SDL are not transplanted into this earlier profile.",
"Factory names whose report is unknown are omitted or explicitly unreported. Capture, firmware/current platform modes and remapped presets need real captures."
],
"environment": {
"browser": "Chrome / Edge (Chromium XInput path)",
"os": "Windows 10 / 11",
"connection": "2.4 GHz dongle",
"mode": "PC slider; Mode 1; earlier firmware preset"
}
}
@@ -0,0 +1,545 @@
{
"name": "crkd-les-paul-chrome-win-usb-mode1-pre3",
"label": "CRKD Gibson Les Paul — PC Mode 1 (earlier firmware, inferred)",
"gamepad": {
"id": {
"value": "Xbox 360 Controller (XInput STANDARD GAMEPAD)",
"status": "inferred",
"sources": [
{
"url": "https://github.com/sanjay900/xinputemu/pull/1",
"says": "Patch author tested two CRKD guitars; VID/PID match a first-party 360 gamepad (045e:028e in patch); whammy is Z/LT, converted to signed right-stick range for old PC guitar games."
},
{
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
}
],
"reason": "The hardware-tested original Mode 1 report uses the first-party 360 VID/PID and YARG identifies it as an XInput gamepad. Applying Chromium's subtype-1 name is a source-derived candidate, not a raw CRKD browser capture."
},
"mapping": {
"value": "standard",
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
}
]
},
"buttons": {
"value": 17,
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
}
]
},
"axesRest": {
"value": [
1.52587890625E-05,
-1.52587890625E-05,
1.52587890625E-05,
-1.52587890625E-05
],
"status": "inferred",
"sources": [
{
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
},
{
"url": "https://cdn.shopify.com/s/files/1/0679/3392/9791/files/Guitar_User_Guide_Multi_V1.4_08Jan26.pdf?v=1767902895",
"says": "CK25GM PC connections include USB cable and included USB wireless receiver; PC slider and mode dial select game preset; the hardware includes both sticks, View/Capture/Menu, CTRL, Home and D-pad."
}
],
"reason": "Nominal neutral signed XInput stick words are assumed to be zero. Chromium normalizes signed zero to +0.0000152587890625 and negates Y; physical CRKD neutral raw words were not captured."
},
"timestampPolicy": {
"value": "poll",
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
"says": "GetXInputPadData assigns CurrentTimeInMicroseconds to pad.timestamp after every successful XInput state poll, even if the input values did not change."
}
]
},
"vibration": {
"presence": "object",
"value": {
"type": "dual-rumble",
"effects": [
"dual-rumble"
]
},
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
}
]
}
},
"controls": {
"Green fret": {
"kind": "button",
"index": 0,
"pressedThreshold": 0,
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://github.com/YARC-Official/YARG/blob/f5bfbe6996b4af35722d4204a93c6cc5c8f52a45/Assets/Script/Input/Bindings/Defaults/BindingCollection.Gamepad.cs",
"says": "CrkdGuitar_Mode1 maps Green/Red/Yellow/Blue/Orange to A/B/Y/X/LB, strum up/down to D-pad, star power to Select, whammy to leftTrigger; no dedicated tilt axis. Menu includes left-stick and D-pad."
},
{
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
}
],
"reason": "YARG's CRKD Mode 1 Green fret binding is translated through Chromium's XInput button order. Factory button aliases are inferred, not a browser dump."
}
},
"Red fret": {
"kind": "button",
"index": 1,
"pressedThreshold": 0,
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://github.com/YARC-Official/YARG/blob/f5bfbe6996b4af35722d4204a93c6cc5c8f52a45/Assets/Script/Input/Bindings/Defaults/BindingCollection.Gamepad.cs",
"says": "CrkdGuitar_Mode1 maps Green/Red/Yellow/Blue/Orange to A/B/Y/X/LB, strum up/down to D-pad, star power to Select, whammy to leftTrigger; no dedicated tilt axis. Menu includes left-stick and D-pad."
},
{
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
}
],
"reason": "YARG's CRKD Mode 1 Red fret binding is translated through Chromium's XInput button order. Factory button aliases are inferred, not a browser dump."
}
},
"Yellow fret": {
"kind": "button",
"index": 3,
"pressedThreshold": 0,
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://github.com/YARC-Official/YARG/blob/f5bfbe6996b4af35722d4204a93c6cc5c8f52a45/Assets/Script/Input/Bindings/Defaults/BindingCollection.Gamepad.cs",
"says": "CrkdGuitar_Mode1 maps Green/Red/Yellow/Blue/Orange to A/B/Y/X/LB, strum up/down to D-pad, star power to Select, whammy to leftTrigger; no dedicated tilt axis. Menu includes left-stick and D-pad."
},
{
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
}
],
"reason": "YARG's CRKD Mode 1 Yellow fret binding is translated through Chromium's XInput button order. Factory button aliases are inferred, not a browser dump."
}
},
"Blue fret": {
"kind": "button",
"index": 2,
"pressedThreshold": 0,
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://github.com/YARC-Official/YARG/blob/f5bfbe6996b4af35722d4204a93c6cc5c8f52a45/Assets/Script/Input/Bindings/Defaults/BindingCollection.Gamepad.cs",
"says": "CrkdGuitar_Mode1 maps Green/Red/Yellow/Blue/Orange to A/B/Y/X/LB, strum up/down to D-pad, star power to Select, whammy to leftTrigger; no dedicated tilt axis. Menu includes left-stick and D-pad."
},
{
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
}
],
"reason": "YARG's CRKD Mode 1 Blue fret binding is translated through Chromium's XInput button order. Factory button aliases are inferred, not a browser dump."
}
},
"Orange fret": {
"kind": "button",
"index": 4,
"pressedThreshold": 0,
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://github.com/YARC-Official/YARG/blob/f5bfbe6996b4af35722d4204a93c6cc5c8f52a45/Assets/Script/Input/Bindings/Defaults/BindingCollection.Gamepad.cs",
"says": "CrkdGuitar_Mode1 maps Green/Red/Yellow/Blue/Orange to A/B/Y/X/LB, strum up/down to D-pad, star power to Select, whammy to leftTrigger; no dedicated tilt axis. Menu includes left-stick and D-pad."
},
{
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
}
],
"reason": "YARG's CRKD Mode 1 Orange fret binding is translated through Chromium's XInput button order. Factory button aliases are inferred, not a browser dump."
}
},
"Strum up": {
"kind": "button",
"index": 12,
"pressedThreshold": 0,
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://github.com/YARC-Official/YARG/blob/f5bfbe6996b4af35722d4204a93c6cc5c8f52a45/Assets/Script/Input/Bindings/Defaults/BindingCollection.Gamepad.cs",
"says": "CrkdGuitar_Mode1 maps Green/Red/Yellow/Blue/Orange to A/B/Y/X/LB, strum up/down to D-pad, star power to Select, whammy to leftTrigger; no dedicated tilt axis. Menu includes left-stick and D-pad."
},
{
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
}
],
"reason": "YARG's CRKD Mode 1 Strum up binding is translated through Chromium's XInput button order. Factory button aliases are inferred, not a browser dump."
}
},
"Strum down": {
"kind": "button",
"index": 13,
"pressedThreshold": 0,
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://github.com/YARC-Official/YARG/blob/f5bfbe6996b4af35722d4204a93c6cc5c8f52a45/Assets/Script/Input/Bindings/Defaults/BindingCollection.Gamepad.cs",
"says": "CrkdGuitar_Mode1 maps Green/Red/Yellow/Blue/Orange to A/B/Y/X/LB, strum up/down to D-pad, star power to Select, whammy to leftTrigger; no dedicated tilt axis. Menu includes left-stick and D-pad."
},
{
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
}
],
"reason": "YARG's CRKD Mode 1 Strum down binding is translated through Chromium's XInput button order. Factory button aliases are inferred, not a browser dump."
}
},
"CTRL": {
"kind": "button",
"index": 8,
"pressedThreshold": 0,
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://github.com/YARC-Official/YARG/blob/f5bfbe6996b4af35722d4204a93c6cc5c8f52a45/Assets/Script/Input/Bindings/Defaults/BindingCollection.Gamepad.cs",
"says": "CrkdGuitar_Mode1 maps Green/Red/Yellow/Blue/Orange to A/B/Y/X/LB, strum up/down to D-pad, star power to Select, whammy to leftTrigger; no dedicated tilt axis. Menu includes left-stick and D-pad."
},
{
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
}
],
"reason": "YARG's CRKD Mode 1 star-power/select binding is translated through Chromium's XInput button order. Factory button aliases are inferred, not a browser dump."
}
},
"View": {
"kind": "button",
"index": 8,
"pressedThreshold": 0,
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://github.com/YARC-Official/YARG/blob/f5bfbe6996b4af35722d4204a93c6cc5c8f52a45/Assets/Script/Input/Bindings/Defaults/BindingCollection.Gamepad.cs",
"says": "CrkdGuitar_Mode1 maps Green/Red/Yellow/Blue/Orange to A/B/Y/X/LB, strum up/down to D-pad, star power to Select, whammy to leftTrigger; no dedicated tilt axis. Menu includes left-stick and D-pad."
},
{
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
}
],
"reason": "YARG's CRKD Mode 1 View binding is translated through Chromium's XInput button order. Factory button aliases are inferred, not a browser dump."
}
},
"Menu": {
"kind": "button",
"index": 9,
"pressedThreshold": 0,
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://github.com/YARC-Official/YARG/blob/f5bfbe6996b4af35722d4204a93c6cc5c8f52a45/Assets/Script/Input/Bindings/Defaults/BindingCollection.Gamepad.cs",
"says": "CrkdGuitar_Mode1 maps Green/Red/Yellow/Blue/Orange to A/B/Y/X/LB, strum up/down to D-pad, star power to Select, whammy to leftTrigger; no dedicated tilt axis. Menu includes left-stick and D-pad."
},
{
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
}
],
"reason": "YARG's CRKD Mode 1 Menu binding is translated through Chromium's XInput button order. Factory button aliases are inferred, not a browser dump."
}
},
"D-pad up": {
"kind": "button",
"index": 12,
"pressedThreshold": 0,
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://github.com/YARC-Official/YARG/blob/f5bfbe6996b4af35722d4204a93c6cc5c8f52a45/Assets/Script/Input/Bindings/Defaults/BindingCollection.Gamepad.cs",
"says": "CrkdGuitar_Mode1 maps Green/Red/Yellow/Blue/Orange to A/B/Y/X/LB, strum up/down to D-pad, star power to Select, whammy to leftTrigger; no dedicated tilt axis. Menu includes left-stick and D-pad."
},
{
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
}
],
"reason": "YARG's CRKD Mode 1 D-pad up binding is translated through Chromium's XInput button order. Factory button aliases are inferred, not a browser dump."
}
},
"D-pad down": {
"kind": "button",
"index": 13,
"pressedThreshold": 0,
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://github.com/YARC-Official/YARG/blob/f5bfbe6996b4af35722d4204a93c6cc5c8f52a45/Assets/Script/Input/Bindings/Defaults/BindingCollection.Gamepad.cs",
"says": "CrkdGuitar_Mode1 maps Green/Red/Yellow/Blue/Orange to A/B/Y/X/LB, strum up/down to D-pad, star power to Select, whammy to leftTrigger; no dedicated tilt axis. Menu includes left-stick and D-pad."
},
{
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
}
],
"reason": "YARG's CRKD Mode 1 D-pad down binding is translated through Chromium's XInput button order. Factory button aliases are inferred, not a browser dump."
}
},
"D-pad left": {
"kind": "button",
"index": 14,
"pressedThreshold": 0,
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://github.com/YARC-Official/YARG/blob/f5bfbe6996b4af35722d4204a93c6cc5c8f52a45/Assets/Script/Input/Bindings/Defaults/BindingCollection.Gamepad.cs",
"says": "CrkdGuitar_Mode1 maps Green/Red/Yellow/Blue/Orange to A/B/Y/X/LB, strum up/down to D-pad, star power to Select, whammy to leftTrigger; no dedicated tilt axis. Menu includes left-stick and D-pad."
},
{
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
}
],
"reason": "YARG's CRKD Mode 1 D-pad left binding is translated through Chromium's XInput button order. Factory button aliases are inferred, not a browser dump."
}
},
"D-pad right": {
"kind": "button",
"index": 15,
"pressedThreshold": 0,
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://github.com/YARC-Official/YARG/blob/f5bfbe6996b4af35722d4204a93c6cc5c8f52a45/Assets/Script/Input/Bindings/Defaults/BindingCollection.Gamepad.cs",
"says": "CrkdGuitar_Mode1 maps Green/Red/Yellow/Blue/Orange to A/B/Y/X/LB, strum up/down to D-pad, star power to Select, whammy to leftTrigger; no dedicated tilt axis. Menu includes left-stick and D-pad."
},
{
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
}
],
"reason": "YARG's CRKD Mode 1 D-pad right binding is translated through Chromium's XInput button order. Factory button aliases are inferred, not a browser dump."
}
},
"Whammy": {
"kind": "button",
"index": 6,
"steps": 255,
"pressedThreshold": 0.11764705882352941,
"pressedInclusive": true,
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://github.com/YARC-Official/YARG/blob/f5bfbe6996b4af35722d4204a93c6cc5c8f52a45/Assets/Script/Input/Bindings/Defaults/BindingCollection.Gamepad.cs",
"says": "CrkdGuitar_Mode1 maps Green/Red/Yellow/Blue/Orange to A/B/Y/X/LB, strum up/down to D-pad, star power to Select, whammy to leftTrigger; no dedicated tilt axis. Menu includes left-stick and D-pad."
},
{
"url": "https://github.com/sanjay900/xinputemu/pull/1",
"says": "Patch author tested two CRKD guitars; VID/PID match a first-party 360 gamepad (045e:028e in patch); whammy is Z/LT, converted to signed right-stick range for old PC guitar games."
},
{
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
}
],
"reason": "YARG binds CRKD Mode 1 whammy to LT; Chromium exposes LT as analog button 6, divided by 255, pressed inclusively at byte 30. No -1 whammy axis is claimed."
},
"analog": true
},
"Left stick": {
"kind": "stick",
"x": 0,
"y": 1,
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://github.com/YARC-Official/YARG/blob/f5bfbe6996b4af35722d4204a93c6cc5c8f52a45/Assets/Script/Input/Bindings/Defaults/BindingCollection.Gamepad.cs",
"says": "CrkdGuitar_Mode1 maps Green/Red/Yellow/Blue/Orange to A/B/Y/X/LB, strum up/down to D-pad, star power to Select, whammy to leftTrigger; no dedicated tilt axis. Menu includes left-stick and D-pad."
},
{
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
}
],
"reason": "YARG's CRKD Mode 1 menu accepts the left stick; Chromium's XInput axes 0/1 carry LX/inverted LY."
}
},
"Right stick": {
"kind": "stick",
"x": 2,
"y": 3,
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://cdn.shopify.com/s/files/1/0679/3392/9791/files/Guitar_User_Guide_Multi_V1.4_08Jan26.pdf?v=1767902895",
"says": "CK25GM PC connections include USB cable and included USB wireless receiver; PC slider and mode dial select game preset; the hardware includes both sticks, View/Capture/Menu, CTRL, Home and D-pad."
},
{
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
}
],
"reason": "Manufacturer identifies a right analog stick. Standard XInput axes 2/3 carry RX/inverted RY; its factory operation in this earlier Mode 1 preset is inferred."
}
},
"L3": {
"kind": "button",
"index": 10,
"pressedThreshold": 0,
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://cdn.shopify.com/s/files/1/0679/3392/9791/files/Guitar_User_Guide_Multi_V1.4_08Jan26.pdf?v=1767902895",
"says": "CK25GM PC connections include USB cable and included USB wireless receiver; PC slider and mode dial select game preset; the hardware includes both sticks, View/Capture/Menu, CTRL, Home and D-pad."
},
{
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
}
],
"reason": "Manufacturer names this physical control; its normal XInput button correspondence is inferred from the standard browser path, not observed on hardware."
}
},
"R3": {
"kind": "button",
"index": 11,
"pressedThreshold": 0,
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://cdn.shopify.com/s/files/1/0679/3392/9791/files/Guitar_User_Guide_Multi_V1.4_08Jan26.pdf?v=1767902895",
"says": "CK25GM PC connections include USB cable and included USB wireless receiver; PC slider and mode dial select game preset; the hardware includes both sticks, View/Capture/Menu, CTRL, Home and D-pad."
},
{
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
}
],
"reason": "Manufacturer names this physical control; its normal XInput button correspondence is inferred from the standard browser path, not observed on hardware."
}
},
"CRKD Home": {
"kind": "button",
"index": 16,
"pressedThreshold": 0,
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://cdn.shopify.com/s/files/1/0679/3392/9791/files/Guitar_User_Guide_Multi_V1.4_08Jan26.pdf?v=1767902895",
"says": "CK25GM PC connections include USB cable and included USB wireless receiver; PC slider and mode dial select game preset; the hardware includes both sticks, View/Capture/Menu, CTRL, Home and D-pad."
},
{
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
}
],
"reason": "Manufacturer names this physical control; its normal XInput button correspondence is inferred from the standard browser path, not observed on hardware."
}
},
"Capture": {
"kind": "unreported",
"reason": "No independent XInput capture bit exists; the factory action or alias was not found.",
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://cdn.shopify.com/s/files/1/0679/3392/9791/files/Guitar_User_Guide_Multi_V1.4_08Jan26.pdf?v=1767902895",
"says": "CK25GM PC connections include USB cable and included USB wireless receiver; PC slider and mode dial select game preset; the hardware includes both sticks, View/Capture/Menu, CTRL, Home and D-pad."
},
{
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
}
],
"reason": "The manual confirms Capture exists physically but its PC report is not established; no browser index is invented."
}
}
},
"switches": {
"Mode dial": {
"kind": "unreported",
"reason": "Only the earlier PC Mode 1 mapping is sourced. Factory presets, firmware 3 and legacy Mode 8 change reports and need separately researched profiles.",
"evidence": {
"value": null,
"status": "confirmed",
"sources": [
{
"url": "https://cdn.shopify.com/s/files/1/0679/3392/9791/files/Guitar_User_Guide_Multi_V1.4_08Jan26.pdf?v=1767902895",
"says": "CK25GM PC connections include USB cable and included USB wireless receiver; PC slider and mode dial select game preset; the hardware includes both sticks, View/Capture/Menu, CTRL, Home and D-pad."
},
{
"url": "https://github.com/YARC-Official/YARG/blob/f5bfbe6996b4af35722d4204a93c6cc5c8f52a45/Assets/Script/Input/Bindings/Defaults/BindingCollection.Gamepad.cs",
"says": "CrkdGuitar_Mode1 maps Green/Red/Yellow/Blue/Orange to A/B/Y/X/LB, strum up/down to D-pad, star power to Select, whammy to leftTrigger; no dedicated tilt axis. Menu includes left-stick and D-pad."
}
]
}
},
"Platform slider": {
"kind": "unreported",
"reason": "PC Mode 1 only. NS, AN and P3 identifiers and raw reports have not been established.",
"evidence": {
"value": null,
"status": "confirmed",
"sources": [
{
"url": "https://cdn.shopify.com/s/files/1/0679/3392/9791/files/Guitar_User_Guide_Multi_V1.4_08Jan26.pdf?v=1767902895",
"says": "CK25GM PC connections include USB cable and included USB wireless receiver; PC slider and mode dial select game preset; the hardware includes both sticks, View/Capture/Menu, CTRL, Home and D-pad."
}
]
}
}
},
"notes": [
"Local source-derived emulation, not a hardware/browser capture. This profile models the earlier CRKD Mode 1 preset identified by YARG as CrkdGuitar_Mode1, before firmware 3 changed star power to D-pad left.",
"Use the Multi-platform Gibson Les Paul CK25GM; Nitro Deck is a separate Switch controller product.",
"Stock Windows XInputGetStateEx availability is assumed for button 16. Without it Chromium exposes 16 buttons; that separate variant is omitted.",
"All unpressed buttons, including whammy and unused RT, are modeled at zero; those physical resting bytes are inferred. The tested conversion patch supports whammy LT with rest zero, but no raw browser rest dump was located.",
"Whammy is analog button 6 (0..1), not an axis. D-pad and strum share standard button indices; there is no numeric hat axis or independent tilt axis in this preset.",
"Browser indices: 0 A/Green; 1 B/Red; 2 X/Blue; 3 Y/Yellow; 4 LB/Orange; 5 RB (physical alias unconfirmed); 6 LT/Whammy; 7 RT (physical alias unconfirmed); 8 View/Select/CTRL inferred; 9 Menu/Start; 10 L3; 11 R3; 12 D-pad/Strum up; 13 D-pad/Strum down; 14 D-pad left; 15 D-pad right; 16 Home/Guide. Axes 0/1 Left stick; 2/3 Right stick, nominal rest inferred.",
"The dual-rumble object is Chromium's unconditional XInput API exposure. It does not confirm browser-controlled physical vibration or equate to the guitar's local strum haptics.",
"Chromium XInput id hides VID/PID. Hardware-tested old Mode 1 uses 045e:028e; newer native CRKD IDs from Linux/SDL are not transplanted into this earlier profile.",
"Factory names whose report is unknown are omitted or explicitly unreported. Capture, firmware/current platform modes and remapped presets need real captures."
],
"environment": {
"browser": "Chrome / Edge (Chromium XInput path)",
"os": "Windows 10 / 11",
"connection": "USB",
"mode": "PC slider; Mode 1; earlier firmware preset"
}
}
@@ -0,0 +1,566 @@
{
"name": "elite2-chromium-win-adapter-wgi-conditional",
"label": "Xbox Elite Series 2 — Xbox Wireless Adapter / Chromium WGI (conditional paddles)",
"environment": {
"browser": "Chrome / Edge (Chromium WGI backend)",
"os": "Windows 10 RS1+ / Windows 11",
"connection": "Xbox Wireless Adapter",
"mode": "Default hardware profile; four WGI paddle labels assumed",
"backend": "Windows.Gaming.Input",
"status": "inferred",
"sources": [
{
"url": "https://www.xbox.com/en-US/accessories/controllers/elite-wireless-controller-series-2",
"says": "Windows 10/11 supports USB, Bluetooth and Xbox Wireless Adapter."
}
],
"reason": "Browser source is confirmed; controller-specific WGI paddle labels were not captured."
},
"hardwareIds": {
"value": {
"vendor": null,
"product": null
},
"status": "inferred",
"sources": [
{
"url": "https://www.xbox.com/en-US/accessories/controllers/elite-wireless-controller-series-2",
"says": "Xbox Wireless Adapter is a supported connection."
}
],
"reason": "No RawGameController VID/PID dump for adapter was found; adapter USB PID is not asserted to be the gamepad PID."
},
"gamepad": {
"timestampPolicy": {
"value": "poll",
"status": "confirmed",
"sources": [
{
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
"says": "GetGamepadData updates timestamp on each successful WGI reading."
}
]
},
"id": {
"value": "Xbox 360 Controller (XInput STANDARD GAMEPAD)",
"status": "inferred",
"sources": [
{
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
"says": "Known XInput devices retain this literal ID."
},
{
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/gamepad_id_list.cc",
"says": "045e:0b00 has XInput type XboxOne."
}
],
"reason": "Assumes the adapter exposes the physical Elite via a known-XInput product ID, as expected for GIP. No adapter browser dump was found."
},
"mapping": {
"value": "standard",
"status": "confirmed",
"sources": [
{
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
"says": "WGI fetcher always sets standard mapping."
}
]
},
"buttons": {
"value": 21,
"status": "inferred",
"sources": [
{
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
"says": "Length is standard count (17) plus number of non-None paddle labels."
},
{
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/gamepad_standard_mappings.h",
"says": "Standard button count is 17."
},
{
"url": "https://www.xbox.com/en-US/accessories/controllers/elite-wireless-controller-series-2",
"says": "Controller has four paddles."
}
],
"reason": "Conditional 17+4 branch. Four physical paddles do not prove four WGI labels; this is not an exact Elite hardware clone."
},
"axesRest": {
"value": [
0,
0,
0,
0
],
"status": "inferred",
"sources": [
{
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
"says": "Four axes contain the two sticks."
},
{
"url": "https://learn.microsoft.com/en-us/windows/uwp/gaming/gamepad-and-vibration",
"says": "Centered sticks approximately report zero and can drift."
}
],
"reason": "Ideal zero-center fixture; an actual resting sample varies and was not captured. Triggers are buttons 6/7, not extra axes."
},
"vibration": {
"value": {
"type": "dual-rumble",
"effects": [
"trigger-rumble",
"dual-rumble"
]
},
"status": "inferred",
"sources": [
{
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
"says": "WGI actuator is non-null, with internal trigger rumble for supported IDs."
},
{
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/gamepad_id_list.cc",
"says": "Elite Series 2 USB/Bluetooth IDs are in trigger-rumble support set."
},
{
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/third_party/blink/renderer/modules/gamepad/gamepad_haptic_actuator.cc",
"says": "Trigger rumble publishes type dual-rumble and both supported effects."
}
],
"reason": "Conditional on the adapter exposing a recognized Elite controller ID in Chromium's trigger-rumble set. The adapter's own USB PID is not used as a controller PID; an exact adapter browser dump was not found.",
"presence": "object"
}
},
"controls": {
"A": {
"kind": "button",
"index": 0,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
"says": "Browser backend sends A to standard button 0."
}
]
}
},
"B": {
"kind": "button",
"index": 1,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
"says": "Browser backend sends B to standard button 1."
}
]
}
},
"X": {
"kind": "button",
"index": 2,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
"says": "Browser backend sends X to standard button 2."
}
]
}
},
"Y": {
"kind": "button",
"index": 3,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
"says": "Browser backend sends Y to standard button 3."
}
]
}
},
"Left bumper": {
"kind": "button",
"index": 4,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
"says": "Browser backend sends Left bumper to standard button 4."
}
]
}
},
"Right bumper": {
"kind": "button",
"index": 5,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
"says": "Browser backend sends Right bumper to standard button 5."
}
]
}
},
"Left trigger": {
"kind": "button",
"index": 6,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
"says": "Trigger 6 copies WGI 0..1 reading; pressed above 30/255."
},
{
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/public/cpp/gamepad.h",
"says": "Chromium default pressed threshold is 30/255."
}
]
},
"analog": true,
"pressedThreshold": 0.11764705882352941,
"pressedInclusive": false
},
"Right trigger": {
"kind": "button",
"index": 7,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
"says": "Trigger 7 copies WGI 0..1 reading; pressed above 30/255."
},
{
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/public/cpp/gamepad.h",
"says": "Chromium default pressed threshold is 30/255."
}
]
},
"analog": true,
"pressedThreshold": 0.11764705882352941,
"pressedInclusive": false
},
"View": {
"kind": "button",
"index": 8,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
"says": "Browser backend sends View to standard button 8."
}
]
}
},
"Menu": {
"kind": "button",
"index": 9,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
"says": "Browser backend sends Menu to standard button 9."
}
]
}
},
"Left stick click": {
"kind": "button",
"index": 10,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
"says": "Browser backend sends Left stick click to standard button 10."
}
]
}
},
"Right stick click": {
"kind": "button",
"index": 11,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
"says": "Browser backend sends Right stick click to standard button 11."
}
]
}
},
"D-pad up": {
"kind": "button",
"index": 12,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
"says": "Browser backend sends D-pad up to standard button 12."
}
]
}
},
"D-pad down": {
"kind": "button",
"index": 13,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
"says": "Browser backend sends D-pad down to standard button 13."
}
]
}
},
"D-pad left": {
"kind": "button",
"index": 14,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
"says": "Browser backend sends D-pad left to standard button 14."
}
]
}
},
"D-pad right": {
"kind": "button",
"index": 15,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
"says": "Browser backend sends D-pad right to standard button 15."
}
]
}
},
"Xbox": {
"kind": "button",
"index": 16,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
"says": "Browser backend sends Xbox to standard button 16."
}
]
}
},
"Left stick": {
"kind": "stick",
"x": 0,
"y": 1,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
"says": "Axes 0/1 are left X/Y; Y is inverted for down-positive."
}
]
}
},
"Right stick": {
"kind": "stick",
"x": 2,
"y": 3,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
"says": "Axes 2/3 are right X/Y; Y is inverted for down-positive."
}
]
}
},
"P1": {
"kind": "button",
"index": 17,
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
"says": "Paddle1 is read at button 17 when exposed by WGI."
},
{
"url": "https://www.xbox.com/en-US/accessories/controllers/elite-wireless-controller-series-2",
"says": "Controller has four removable paddles."
}
],
"reason": "Conditional: physical Elite Series 2 paddle labels/readings from IGamepad2 were not found; this exercises the browser branch, not a verified hardware report."
}
},
"P2": {
"kind": "button",
"index": 18,
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
"says": "Paddle2 is read at button 18 when exposed by WGI."
},
{
"url": "https://www.xbox.com/en-US/accessories/controllers/elite-wireless-controller-series-2",
"says": "Controller has four removable paddles."
}
],
"reason": "Conditional: physical Elite Series 2 paddle labels/readings from IGamepad2 were not found; this exercises the browser branch, not a verified hardware report."
}
},
"P3": {
"kind": "button",
"index": 19,
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
"says": "Paddle3 is read at button 19 when exposed by WGI."
},
{
"url": "https://www.xbox.com/en-US/accessories/controllers/elite-wireless-controller-series-2",
"says": "Controller has four removable paddles."
}
],
"reason": "Conditional: physical Elite Series 2 paddle labels/readings from IGamepad2 were not found; this exercises the browser branch, not a verified hardware report."
}
},
"P4": {
"kind": "button",
"index": 20,
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
"says": "Paddle4 is read at button 20 when exposed by WGI."
},
{
"url": "https://www.xbox.com/en-US/accessories/controllers/elite-wireless-controller-series-2",
"says": "Controller has four removable paddles."
}
],
"reason": "Conditional: physical Elite Series 2 paddle labels/readings from IGamepad2 were not found; this exercises the browser branch, not a verified hardware report."
}
},
"Profile button": {
"kind": "unreported",
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
"says": "Backend maps standard controls, not an Elite profile selector."
},
{
"url": "https://news.xbox.com/en-us/2019/06/09/meet-the-xbox-elite-wireless-controller-series-2-over-30-new-ways-to-play-like-a-pro/",
"says": "Dedicated button switches default and three saved custom profiles."
}
]
},
"reason": "Stored profiles are programmable and their contents are unknown. The selector has no distinct button index here."
}
},
"switches": {
"Left trigger lock": {
"kind": "state",
"values": [
"full",
"medium",
"short"
],
"effect": "none",
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://news.xbox.com/en-us/2019/11/07/xbox-elite-wireless-controller-series-2-available-now/",
"says": "Elite Series 2 has three levels of trigger depth."
},
{
"url": "https://news.xbox.com/en-us/2019/06/09/meet-the-xbox-elite-wireless-controller-series-2-over-30-new-ways-to-play-like-a-pro/",
"says": "Controller automatically recognizes trigger-lock adjustments."
},
{
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
"says": "Browser exposes normalized trigger values, with no trigger-lock state field."
}
],
"reason": "This switch records physical travel only. set() supplies the browser's normalized value, so no guessed travel-to-value curve is applied."
}
},
"Right trigger lock": {
"kind": "state",
"values": [
"full",
"medium",
"short"
],
"effect": "none",
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://news.xbox.com/en-us/2019/11/07/xbox-elite-wireless-controller-series-2-available-now/",
"says": "Elite Series 2 has three levels of trigger depth."
},
{
"url": "https://news.xbox.com/en-us/2019/06/09/meet-the-xbox-elite-wireless-controller-series-2-over-30-new-ways-to-play-like-a-pro/",
"says": "Controller automatically recognizes trigger-lock adjustments."
},
{
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
"says": "Browser exposes normalized trigger values, with no trigger-lock state field."
}
],
"reason": "This switch records physical travel only. set() supplies the browser's normalized value, so no guessed travel-to-value curve is applied."
}
},
"Profile button": {
"kind": "unreported",
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://news.xbox.com/en-us/2019/06/09/meet-the-xbox-elite-wireless-controller-series-2-over-30-new-ways-to-play-like-a-pro/",
"says": "Dedicated button selects default or one of three programmable profiles."
}
]
},
"reason": "The stored remapping slots and firmware are not known. No invented paddle aliases or profile-button signal is emitted."
}
},
"notes": [
"Source-derived conditional browser fixture; no real-device Gamepad API dump was found. Never treat passing tests as hardware confirmation.",
"Buttons 0–16 and axes 0–3 follow the standard layout; P1–P4 conditionally occupy 17–20. No hat axis: D-pad is buttons 12–15.",
"All released button values are 0, pressed/touched false. Trigger value 0 is fully released and 1 fully pulled.",
"The Xbox/Guide button is redirected to the lowest-index WGI gamepad using XInput; tests connect this profile first.",
"Hardware trigger stops change travel; exact analog transfer curves and stored profile contents remain unknown.",
"Chromium source default enables WGI; Windows GameInput fetcher is feature-gated and disabled by default in the inspected source. Edge-specific overrides were not found."
]
}
@@ -0,0 +1,563 @@
{
"name": "elite2-chromium-win-usb-wgi-conditional",
"label": "Xbox Elite Series 2 — USB / Chromium WGI (conditional paddles)",
"environment": {
"browser": "Chrome / Edge (Chromium WGI backend)",
"os": "Windows 10 RS1+ / Windows 11",
"connection": "USB",
"mode": "Default hardware profile; four WGI paddle labels assumed",
"backend": "Windows.Gaming.Input",
"status": "inferred",
"sources": [
{
"url": "https://www.xbox.com/en-US/accessories/controllers/elite-wireless-controller-series-2",
"says": "Windows 10/11 supports USB, Bluetooth and Xbox Wireless Adapter."
}
],
"reason": "Browser source is confirmed; controller-specific WGI paddle labels were not captured."
},
"hardwareIds": {
"value": {
"vendor": "045e",
"product": "0b00"
},
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/gamepad_id_list.cc",
"says": "045e:0b00 is classified as Xbox One XInput; trigger-rumble list identifies Elite Series 2 USB."
}
]
},
"gamepad": {
"timestampPolicy": {
"value": "poll",
"status": "confirmed",
"sources": [
{
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
"says": "GetGamepadData updates timestamp on each successful WGI reading."
}
]
},
"id": {
"value": "Xbox 360 Controller (XInput STANDARD GAMEPAD)",
"status": "confirmed",
"sources": [
{
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
"says": "Known XInput devices retain this literal ID."
},
{
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/gamepad_id_list.cc",
"says": "045e:0b00 has XInput type XboxOne."
}
]
},
"mapping": {
"value": "standard",
"status": "confirmed",
"sources": [
{
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
"says": "WGI fetcher always sets standard mapping."
}
]
},
"buttons": {
"value": 21,
"status": "inferred",
"sources": [
{
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
"says": "Length is standard count (17) plus number of non-None paddle labels."
},
{
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/gamepad_standard_mappings.h",
"says": "Standard button count is 17."
},
{
"url": "https://www.xbox.com/en-US/accessories/controllers/elite-wireless-controller-series-2",
"says": "Controller has four paddles."
}
],
"reason": "Conditional 17+4 branch. Four physical paddles do not prove four WGI labels; this is not an exact Elite hardware clone."
},
"axesRest": {
"value": [
0,
0,
0,
0
],
"status": "inferred",
"sources": [
{
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
"says": "Four axes contain the two sticks."
},
{
"url": "https://learn.microsoft.com/en-us/windows/uwp/gaming/gamepad-and-vibration",
"says": "Centered sticks approximately report zero and can drift."
}
],
"reason": "Ideal zero-center fixture; an actual resting sample varies and was not captured. Triggers are buttons 6/7, not extra axes."
},
"vibration": {
"value": {
"type": "dual-rumble",
"effects": [
"trigger-rumble",
"dual-rumble"
]
},
"status": "confirmed",
"sources": [
{
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
"says": "WGI actuator is non-null, with internal trigger rumble for supported IDs."
},
{
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/gamepad_id_list.cc",
"says": "Elite Series 2 USB/Bluetooth IDs are in trigger-rumble support set."
},
{
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/third_party/blink/renderer/modules/gamepad/gamepad_haptic_actuator.cc",
"says": "Trigger rumble publishes type dual-rumble and both supported effects."
}
],
"presence": "object"
}
},
"controls": {
"A": {
"kind": "button",
"index": 0,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
"says": "Browser backend sends A to standard button 0."
}
]
}
},
"B": {
"kind": "button",
"index": 1,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
"says": "Browser backend sends B to standard button 1."
}
]
}
},
"X": {
"kind": "button",
"index": 2,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
"says": "Browser backend sends X to standard button 2."
}
]
}
},
"Y": {
"kind": "button",
"index": 3,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
"says": "Browser backend sends Y to standard button 3."
}
]
}
},
"Left bumper": {
"kind": "button",
"index": 4,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
"says": "Browser backend sends Left bumper to standard button 4."
}
]
}
},
"Right bumper": {
"kind": "button",
"index": 5,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
"says": "Browser backend sends Right bumper to standard button 5."
}
]
}
},
"Left trigger": {
"kind": "button",
"index": 6,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
"says": "Trigger 6 copies WGI 0..1 reading; pressed above 30/255."
},
{
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/public/cpp/gamepad.h",
"says": "Chromium default pressed threshold is 30/255."
}
]
},
"analog": true,
"pressedThreshold": 0.11764705882352941,
"pressedInclusive": false
},
"Right trigger": {
"kind": "button",
"index": 7,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
"says": "Trigger 7 copies WGI 0..1 reading; pressed above 30/255."
},
{
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/public/cpp/gamepad.h",
"says": "Chromium default pressed threshold is 30/255."
}
]
},
"analog": true,
"pressedThreshold": 0.11764705882352941,
"pressedInclusive": false
},
"View": {
"kind": "button",
"index": 8,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
"says": "Browser backend sends View to standard button 8."
}
]
}
},
"Menu": {
"kind": "button",
"index": 9,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
"says": "Browser backend sends Menu to standard button 9."
}
]
}
},
"Left stick click": {
"kind": "button",
"index": 10,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
"says": "Browser backend sends Left stick click to standard button 10."
}
]
}
},
"Right stick click": {
"kind": "button",
"index": 11,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
"says": "Browser backend sends Right stick click to standard button 11."
}
]
}
},
"D-pad up": {
"kind": "button",
"index": 12,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
"says": "Browser backend sends D-pad up to standard button 12."
}
]
}
},
"D-pad down": {
"kind": "button",
"index": 13,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
"says": "Browser backend sends D-pad down to standard button 13."
}
]
}
},
"D-pad left": {
"kind": "button",
"index": 14,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
"says": "Browser backend sends D-pad left to standard button 14."
}
]
}
},
"D-pad right": {
"kind": "button",
"index": 15,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
"says": "Browser backend sends D-pad right to standard button 15."
}
]
}
},
"Xbox": {
"kind": "button",
"index": 16,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
"says": "Browser backend sends Xbox to standard button 16."
}
]
}
},
"Left stick": {
"kind": "stick",
"x": 0,
"y": 1,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
"says": "Axes 0/1 are left X/Y; Y is inverted for down-positive."
}
]
}
},
"Right stick": {
"kind": "stick",
"x": 2,
"y": 3,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
"says": "Axes 2/3 are right X/Y; Y is inverted for down-positive."
}
]
}
},
"P1": {
"kind": "button",
"index": 17,
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
"says": "Paddle1 is read at button 17 when exposed by WGI."
},
{
"url": "https://www.xbox.com/en-US/accessories/controllers/elite-wireless-controller-series-2",
"says": "Controller has four removable paddles."
}
],
"reason": "Conditional: physical Elite Series 2 paddle labels/readings from IGamepad2 were not found; this exercises the browser branch, not a verified hardware report."
}
},
"P2": {
"kind": "button",
"index": 18,
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
"says": "Paddle2 is read at button 18 when exposed by WGI."
},
{
"url": "https://www.xbox.com/en-US/accessories/controllers/elite-wireless-controller-series-2",
"says": "Controller has four removable paddles."
}
],
"reason": "Conditional: physical Elite Series 2 paddle labels/readings from IGamepad2 were not found; this exercises the browser branch, not a verified hardware report."
}
},
"P3": {
"kind": "button",
"index": 19,
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
"says": "Paddle3 is read at button 19 when exposed by WGI."
},
{
"url": "https://www.xbox.com/en-US/accessories/controllers/elite-wireless-controller-series-2",
"says": "Controller has four removable paddles."
}
],
"reason": "Conditional: physical Elite Series 2 paddle labels/readings from IGamepad2 were not found; this exercises the browser branch, not a verified hardware report."
}
},
"P4": {
"kind": "button",
"index": 20,
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
"says": "Paddle4 is read at button 20 when exposed by WGI."
},
{
"url": "https://www.xbox.com/en-US/accessories/controllers/elite-wireless-controller-series-2",
"says": "Controller has four removable paddles."
}
],
"reason": "Conditional: physical Elite Series 2 paddle labels/readings from IGamepad2 were not found; this exercises the browser branch, not a verified hardware report."
}
},
"Profile button": {
"kind": "unreported",
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
"says": "Backend maps standard controls, not an Elite profile selector."
},
{
"url": "https://news.xbox.com/en-us/2019/06/09/meet-the-xbox-elite-wireless-controller-series-2-over-30-new-ways-to-play-like-a-pro/",
"says": "Dedicated button switches default and three saved custom profiles."
}
]
},
"reason": "Stored profiles are programmable and their contents are unknown. The selector has no distinct button index here."
}
},
"switches": {
"Left trigger lock": {
"kind": "state",
"values": [
"full",
"medium",
"short"
],
"effect": "none",
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://news.xbox.com/en-us/2019/11/07/xbox-elite-wireless-controller-series-2-available-now/",
"says": "Elite Series 2 has three levels of trigger depth."
},
{
"url": "https://news.xbox.com/en-us/2019/06/09/meet-the-xbox-elite-wireless-controller-series-2-over-30-new-ways-to-play-like-a-pro/",
"says": "Controller automatically recognizes trigger-lock adjustments."
},
{
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
"says": "Browser exposes normalized trigger values, with no trigger-lock state field."
}
],
"reason": "This switch records physical travel only. set() supplies the browser's normalized value, so no guessed travel-to-value curve is applied."
}
},
"Right trigger lock": {
"kind": "state",
"values": [
"full",
"medium",
"short"
],
"effect": "none",
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://news.xbox.com/en-us/2019/11/07/xbox-elite-wireless-controller-series-2-available-now/",
"says": "Elite Series 2 has three levels of trigger depth."
},
{
"url": "https://news.xbox.com/en-us/2019/06/09/meet-the-xbox-elite-wireless-controller-series-2-over-30-new-ways-to-play-like-a-pro/",
"says": "Controller automatically recognizes trigger-lock adjustments."
},
{
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
"says": "Browser exposes normalized trigger values, with no trigger-lock state field."
}
],
"reason": "This switch records physical travel only. set() supplies the browser's normalized value, so no guessed travel-to-value curve is applied."
}
},
"Profile button": {
"kind": "unreported",
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://news.xbox.com/en-us/2019/06/09/meet-the-xbox-elite-wireless-controller-series-2-over-30-new-ways-to-play-like-a-pro/",
"says": "Dedicated button selects default or one of three programmable profiles."
}
]
},
"reason": "The stored remapping slots and firmware are not known. No invented paddle aliases or profile-button signal is emitted."
}
},
"notes": [
"Source-derived conditional browser fixture; no real-device Gamepad API dump was found. Never treat passing tests as hardware confirmation.",
"Buttons 0–16 and axes 0–3 follow the standard layout; P1–P4 conditionally occupy 17–20. No hat axis: D-pad is buttons 12–15.",
"All released button values are 0, pressed/touched false. Trigger value 0 is fully released and 1 fully pulled.",
"The Xbox/Guide button is redirected to the lowest-index WGI gamepad using XInput; tests connect this profile first.",
"Hardware trigger stops change travel; exact analog transfer curves and stored profile contents remain unknown.",
"Chromium source default enables WGI; Windows GameInput fetcher is feature-gated and disabled by default in the inspected source. Edge-specific overrides were not found."
]
}
@@ -0,0 +1,530 @@
{
"name": "elite2-firefox-win-adapter-xinput",
"label": "Xbox Elite Series 2 — Xbox Wireless Adapter / Firefox XInput",
"environment": {
"browser": "Firefox",
"os": "Windows 10 / Windows 11",
"connection": "Xbox Wireless Adapter",
"mode": "XInput with unknown onboard paddle assignments",
"backend": "XInput",
"status": "inferred",
"sources": [
{
"url": "https://www.xbox.com/en-US/accessories/controllers/elite-wireless-controller-series-2",
"says": "All three connections are supported on Windows 10/11."
},
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Windows XInput backend handles any successful XInputGetState device."
}
],
"reason": "Conditional on Windows routing this connection through XInput. No per-connection physical browser dump was found."
},
"gamepad": {
"timestampPolicy": {
"value": "change",
"status": "inferred",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "The XInput poll emits events only for changed buttons/axes."
},
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/Gamepad.cpp",
"says": "Construction, input events and SyncState update the DOM timestamp using performance.now."
},
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/base/nsGlobalWindowInner.cpp",
"says": "GetGamepads returns existing objects without calling SyncState."
},
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/base/nsGlobalWindowOuter.cpp",
"says": "SetIsBackground invokes SyncGamepadState when returning to foreground and also some unchanged background-state calls."
}
],
"reason": "Change policy models ordinary foreground polling; construction starts at performance.now. Browser background/foreground synchronization and timer precision reduction are outside this synthetic fixture."
},
"id": {
"value": "xinput",
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "ScanForXInputDevices passes literal xinput to AddGamepad, with no VID/PID."
}
]
},
"mapping": {
"value": "standard",
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "XInput AddGamepad specifies Standard mapping."
}
]
},
"buttons": {
"value": 17,
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "XInput adds kStandardGamepadButtons and only standard masks, including Guide."
}
]
},
"axesRest": {
"value": [
0,
0,
0,
0
],
"status": "inferred",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "XInput exposes four normalized stick axes."
},
{
"url": "https://learn.microsoft.com/en-us/windows/uwp/gaming/gamepad-and-vibration",
"says": "Centered sticks approximately report zero and can drift."
}
],
"reason": "Ideal zero center, not a captured resting sample. Trigger release is button value 0 and no trigger axes are returned."
},
"vibration": {
"value": null,
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Windows backend supplies zero haptics and leaves a haptics TODO."
},
{
"url": "https://searchfox.org/firefox-main/source/dom/webidl/Gamepad.webidl",
"says": "Gamepad has hapticActuators but no vibrationActuator attribute."
}
],
"presence": "absent"
}
},
"controls": {
"A": {
"kind": "button",
"index": 0,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Browser backend sends A to standard button 0."
}
]
}
},
"B": {
"kind": "button",
"index": 1,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Browser backend sends B to standard button 1."
}
]
}
},
"X": {
"kind": "button",
"index": 2,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Browser backend sends X to standard button 2."
}
]
}
},
"Y": {
"kind": "button",
"index": 3,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Browser backend sends Y to standard button 3."
}
]
}
},
"Left bumper": {
"kind": "button",
"index": 4,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Browser backend sends Left bumper to standard button 4."
}
]
}
},
"Right bumper": {
"kind": "button",
"index": 5,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Browser backend sends Right bumper to standard button 5."
}
]
}
},
"Left trigger": {
"kind": "button",
"index": 6,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Trigger 6 copies XInput byte /255; pressed at byte >=30."
},
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/GamepadPlatformService.cpp",
"says": "The pressed/value overload copies pressed into touched, including XInput triggers below the threshold."
}
]
},
"analog": true,
"pressedThreshold": 0.11764705882352941,
"pressedInclusive": true,
"steps": 255,
"touchedPolicy": "pressed"
},
"Right trigger": {
"kind": "button",
"index": 7,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Trigger 7 copies XInput byte /255; pressed at byte >=30."
},
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/GamepadPlatformService.cpp",
"says": "The pressed/value overload copies pressed into touched, including XInput triggers below the threshold."
}
]
},
"analog": true,
"pressedThreshold": 0.11764705882352941,
"pressedInclusive": true,
"steps": 255,
"touchedPolicy": "pressed"
},
"View": {
"kind": "button",
"index": 8,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Browser backend sends View to standard button 8."
}
]
}
},
"Menu": {
"kind": "button",
"index": 9,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Browser backend sends Menu to standard button 9."
}
]
}
},
"Left stick click": {
"kind": "button",
"index": 10,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Browser backend sends Left stick click to standard button 10."
}
]
}
},
"Right stick click": {
"kind": "button",
"index": 11,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Browser backend sends Right stick click to standard button 11."
}
]
}
},
"D-pad up": {
"kind": "button",
"index": 12,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Browser backend sends D-pad up to standard button 12."
}
]
}
},
"D-pad down": {
"kind": "button",
"index": 13,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Browser backend sends D-pad down to standard button 13."
}
]
}
},
"D-pad left": {
"kind": "button",
"index": 14,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Browser backend sends D-pad left to standard button 14."
}
]
}
},
"D-pad right": {
"kind": "button",
"index": 15,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Browser backend sends D-pad right to standard button 15."
}
]
}
},
"Xbox": {
"kind": "button",
"index": 16,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Browser backend sends Xbox to standard button 16."
}
]
}
},
"Left stick": {
"kind": "stick",
"x": 0,
"y": 1,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Axes 0/1 are left X/Y; Y is inverted for down-positive."
}
]
}
},
"Right stick": {
"kind": "stick",
"x": 2,
"y": 3,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Axes 2/3 are right X/Y; Y is inverted for down-positive."
}
]
}
},
"P1": {
"kind": "unreported",
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "XInput backend only maps the standard 17 buttons; it has no paddle mask."
}
]
},
"reason": "No independent paddle index in this backend. An onboard assignment may repeat another control; this fixture does not guess those assignments."
},
"P2": {
"kind": "unreported",
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "XInput backend only maps the standard 17 buttons; it has no paddle mask."
}
]
},
"reason": "No independent paddle index in this backend. An onboard assignment may repeat another control; this fixture does not guess those assignments."
},
"P3": {
"kind": "unreported",
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "XInput backend only maps the standard 17 buttons; it has no paddle mask."
}
]
},
"reason": "No independent paddle index in this backend. An onboard assignment may repeat another control; this fixture does not guess those assignments."
},
"P4": {
"kind": "unreported",
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "XInput backend only maps the standard 17 buttons; it has no paddle mask."
}
]
},
"reason": "No independent paddle index in this backend. An onboard assignment may repeat another control; this fixture does not guess those assignments."
},
"Profile button": {
"kind": "unreported",
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Backend maps standard controls, not an Elite profile selector."
},
{
"url": "https://news.xbox.com/en-us/2019/06/09/meet-the-xbox-elite-wireless-controller-series-2-over-30-new-ways-to-play-like-a-pro/",
"says": "Dedicated button switches default and three saved custom profiles."
}
]
},
"reason": "Stored profiles are programmable and their contents are unknown. The selector has no distinct button index here."
}
},
"switches": {
"Left trigger lock": {
"kind": "state",
"values": [
"full",
"medium",
"short"
],
"effect": "none",
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://news.xbox.com/en-us/2019/11/07/xbox-elite-wireless-controller-series-2-available-now/",
"says": "Elite Series 2 has three levels of trigger depth."
},
{
"url": "https://news.xbox.com/en-us/2019/06/09/meet-the-xbox-elite-wireless-controller-series-2-over-30-new-ways-to-play-like-a-pro/",
"says": "Controller automatically recognizes trigger-lock adjustments."
},
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Browser exposes normalized trigger values, with no trigger-lock state field."
}
],
"reason": "This switch records physical travel only. set() supplies the browser's normalized value, so no guessed travel-to-value curve is applied."
}
},
"Right trigger lock": {
"kind": "state",
"values": [
"full",
"medium",
"short"
],
"effect": "none",
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://news.xbox.com/en-us/2019/11/07/xbox-elite-wireless-controller-series-2-available-now/",
"says": "Elite Series 2 has three levels of trigger depth."
},
{
"url": "https://news.xbox.com/en-us/2019/06/09/meet-the-xbox-elite-wireless-controller-series-2-over-30-new-ways-to-play-like-a-pro/",
"says": "Controller automatically recognizes trigger-lock adjustments."
},
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Browser exposes normalized trigger values, with no trigger-lock state field."
}
],
"reason": "This switch records physical travel only. set() supplies the browser's normalized value, so no guessed travel-to-value curve is applied."
}
},
"Profile button": {
"kind": "unreported",
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://news.xbox.com/en-us/2019/06/09/meet-the-xbox-elite-wireless-controller-series-2-over-30-new-ways-to-play-like-a-pro/",
"says": "Dedicated button selects default or one of three programmable profiles."
}
]
},
"reason": "The stored remapping slots and firmware are not known. No invented paddle aliases or profile-button signal is emitted."
}
},
"notes": [
"Exact browser backend layout, with connection routing inferred; no real-device Gamepad API dump was found.",
"Buttons 0–16 and axes 0–3 follow the standard layout. No hat axis; D-pad is buttons 12–15.",
"All released button values are 0, pressed/touched false. Trigger values are quantized byte/255.",
"P1–P4 cannot be bound as independent browser inputs here. Onboard remappings may repeat existing controls, but their stored assignments are deliberately unknown.",
"Profile button and trigger-lock switches have no independent Gamepad fields. Physical travel is recorded, not transformed with invented ratios.",
"vibrationActuator is absent; null in profile data means omit the property. Firefox hapticActuators is not implemented by this Windows backend.",
"Firefox trigger touched mirrors pressed: byte values 1 through 29 have value greater than zero, pressed false and touched false.",
"Timestamp starts at performance.now and changes with input during ordinary foreground polling. Firefox also updates on window synchronization; background/foreground scheduling and clock precision reduction are not reproduced."
]
}
@@ -0,0 +1,530 @@
{
"name": "elite2-firefox-win-bt-xinput",
"label": "Xbox Elite Series 2 — Bluetooth / Firefox XInput",
"environment": {
"browser": "Firefox",
"os": "Windows 10 / Windows 11",
"connection": "Bluetooth",
"mode": "XInput with unknown onboard paddle assignments",
"backend": "XInput",
"status": "inferred",
"sources": [
{
"url": "https://www.xbox.com/en-US/accessories/controllers/elite-wireless-controller-series-2",
"says": "All three connections are supported on Windows 10/11."
},
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Windows XInput backend handles any successful XInputGetState device."
}
],
"reason": "Conditional on Windows routing this connection through XInput. No per-connection physical browser dump was found."
},
"gamepad": {
"timestampPolicy": {
"value": "change",
"status": "inferred",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "The XInput poll emits events only for changed buttons/axes."
},
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/Gamepad.cpp",
"says": "Construction, input events and SyncState update the DOM timestamp using performance.now."
},
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/base/nsGlobalWindowInner.cpp",
"says": "GetGamepads returns existing objects without calling SyncState."
},
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/base/nsGlobalWindowOuter.cpp",
"says": "SetIsBackground invokes SyncGamepadState when returning to foreground and also some unchanged background-state calls."
}
],
"reason": "Change policy models ordinary foreground polling; construction starts at performance.now. Browser background/foreground synchronization and timer precision reduction are outside this synthetic fixture."
},
"id": {
"value": "xinput",
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "ScanForXInputDevices passes literal xinput to AddGamepad, with no VID/PID."
}
]
},
"mapping": {
"value": "standard",
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "XInput AddGamepad specifies Standard mapping."
}
]
},
"buttons": {
"value": 17,
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "XInput adds kStandardGamepadButtons and only standard masks, including Guide."
}
]
},
"axesRest": {
"value": [
0,
0,
0,
0
],
"status": "inferred",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "XInput exposes four normalized stick axes."
},
{
"url": "https://learn.microsoft.com/en-us/windows/uwp/gaming/gamepad-and-vibration",
"says": "Centered sticks approximately report zero and can drift."
}
],
"reason": "Ideal zero center, not a captured resting sample. Trigger release is button value 0 and no trigger axes are returned."
},
"vibration": {
"value": null,
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Windows backend supplies zero haptics and leaves a haptics TODO."
},
{
"url": "https://searchfox.org/firefox-main/source/dom/webidl/Gamepad.webidl",
"says": "Gamepad has hapticActuators but no vibrationActuator attribute."
}
],
"presence": "absent"
}
},
"controls": {
"A": {
"kind": "button",
"index": 0,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Browser backend sends A to standard button 0."
}
]
}
},
"B": {
"kind": "button",
"index": 1,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Browser backend sends B to standard button 1."
}
]
}
},
"X": {
"kind": "button",
"index": 2,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Browser backend sends X to standard button 2."
}
]
}
},
"Y": {
"kind": "button",
"index": 3,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Browser backend sends Y to standard button 3."
}
]
}
},
"Left bumper": {
"kind": "button",
"index": 4,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Browser backend sends Left bumper to standard button 4."
}
]
}
},
"Right bumper": {
"kind": "button",
"index": 5,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Browser backend sends Right bumper to standard button 5."
}
]
}
},
"Left trigger": {
"kind": "button",
"index": 6,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Trigger 6 copies XInput byte /255; pressed at byte >=30."
},
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/GamepadPlatformService.cpp",
"says": "The pressed/value overload copies pressed into touched, including XInput triggers below the threshold."
}
]
},
"analog": true,
"pressedThreshold": 0.11764705882352941,
"pressedInclusive": true,
"steps": 255,
"touchedPolicy": "pressed"
},
"Right trigger": {
"kind": "button",
"index": 7,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Trigger 7 copies XInput byte /255; pressed at byte >=30."
},
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/GamepadPlatformService.cpp",
"says": "The pressed/value overload copies pressed into touched, including XInput triggers below the threshold."
}
]
},
"analog": true,
"pressedThreshold": 0.11764705882352941,
"pressedInclusive": true,
"steps": 255,
"touchedPolicy": "pressed"
},
"View": {
"kind": "button",
"index": 8,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Browser backend sends View to standard button 8."
}
]
}
},
"Menu": {
"kind": "button",
"index": 9,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Browser backend sends Menu to standard button 9."
}
]
}
},
"Left stick click": {
"kind": "button",
"index": 10,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Browser backend sends Left stick click to standard button 10."
}
]
}
},
"Right stick click": {
"kind": "button",
"index": 11,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Browser backend sends Right stick click to standard button 11."
}
]
}
},
"D-pad up": {
"kind": "button",
"index": 12,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Browser backend sends D-pad up to standard button 12."
}
]
}
},
"D-pad down": {
"kind": "button",
"index": 13,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Browser backend sends D-pad down to standard button 13."
}
]
}
},
"D-pad left": {
"kind": "button",
"index": 14,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Browser backend sends D-pad left to standard button 14."
}
]
}
},
"D-pad right": {
"kind": "button",
"index": 15,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Browser backend sends D-pad right to standard button 15."
}
]
}
},
"Xbox": {
"kind": "button",
"index": 16,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Browser backend sends Xbox to standard button 16."
}
]
}
},
"Left stick": {
"kind": "stick",
"x": 0,
"y": 1,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Axes 0/1 are left X/Y; Y is inverted for down-positive."
}
]
}
},
"Right stick": {
"kind": "stick",
"x": 2,
"y": 3,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Axes 2/3 are right X/Y; Y is inverted for down-positive."
}
]
}
},
"P1": {
"kind": "unreported",
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "XInput backend only maps the standard 17 buttons; it has no paddle mask."
}
]
},
"reason": "No independent paddle index in this backend. An onboard assignment may repeat another control; this fixture does not guess those assignments."
},
"P2": {
"kind": "unreported",
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "XInput backend only maps the standard 17 buttons; it has no paddle mask."
}
]
},
"reason": "No independent paddle index in this backend. An onboard assignment may repeat another control; this fixture does not guess those assignments."
},
"P3": {
"kind": "unreported",
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "XInput backend only maps the standard 17 buttons; it has no paddle mask."
}
]
},
"reason": "No independent paddle index in this backend. An onboard assignment may repeat another control; this fixture does not guess those assignments."
},
"P4": {
"kind": "unreported",
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "XInput backend only maps the standard 17 buttons; it has no paddle mask."
}
]
},
"reason": "No independent paddle index in this backend. An onboard assignment may repeat another control; this fixture does not guess those assignments."
},
"Profile button": {
"kind": "unreported",
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Backend maps standard controls, not an Elite profile selector."
},
{
"url": "https://news.xbox.com/en-us/2019/06/09/meet-the-xbox-elite-wireless-controller-series-2-over-30-new-ways-to-play-like-a-pro/",
"says": "Dedicated button switches default and three saved custom profiles."
}
]
},
"reason": "Stored profiles are programmable and their contents are unknown. The selector has no distinct button index here."
}
},
"switches": {
"Left trigger lock": {
"kind": "state",
"values": [
"full",
"medium",
"short"
],
"effect": "none",
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://news.xbox.com/en-us/2019/11/07/xbox-elite-wireless-controller-series-2-available-now/",
"says": "Elite Series 2 has three levels of trigger depth."
},
{
"url": "https://news.xbox.com/en-us/2019/06/09/meet-the-xbox-elite-wireless-controller-series-2-over-30-new-ways-to-play-like-a-pro/",
"says": "Controller automatically recognizes trigger-lock adjustments."
},
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Browser exposes normalized trigger values, with no trigger-lock state field."
}
],
"reason": "This switch records physical travel only. set() supplies the browser's normalized value, so no guessed travel-to-value curve is applied."
}
},
"Right trigger lock": {
"kind": "state",
"values": [
"full",
"medium",
"short"
],
"effect": "none",
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://news.xbox.com/en-us/2019/11/07/xbox-elite-wireless-controller-series-2-available-now/",
"says": "Elite Series 2 has three levels of trigger depth."
},
{
"url": "https://news.xbox.com/en-us/2019/06/09/meet-the-xbox-elite-wireless-controller-series-2-over-30-new-ways-to-play-like-a-pro/",
"says": "Controller automatically recognizes trigger-lock adjustments."
},
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Browser exposes normalized trigger values, with no trigger-lock state field."
}
],
"reason": "This switch records physical travel only. set() supplies the browser's normalized value, so no guessed travel-to-value curve is applied."
}
},
"Profile button": {
"kind": "unreported",
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://news.xbox.com/en-us/2019/06/09/meet-the-xbox-elite-wireless-controller-series-2-over-30-new-ways-to-play-like-a-pro/",
"says": "Dedicated button selects default or one of three programmable profiles."
}
]
},
"reason": "The stored remapping slots and firmware are not known. No invented paddle aliases or profile-button signal is emitted."
}
},
"notes": [
"Exact browser backend layout, with connection routing inferred; no real-device Gamepad API dump was found.",
"Buttons 0–16 and axes 0–3 follow the standard layout. No hat axis; D-pad is buttons 12–15.",
"All released button values are 0, pressed/touched false. Trigger values are quantized byte/255.",
"P1–P4 cannot be bound as independent browser inputs here. Onboard remappings may repeat existing controls, but their stored assignments are deliberately unknown.",
"Profile button and trigger-lock switches have no independent Gamepad fields. Physical travel is recorded, not transformed with invented ratios.",
"vibrationActuator is absent; null in profile data means omit the property. Firefox hapticActuators is not implemented by this Windows backend.",
"Firefox trigger touched mirrors pressed: byte values 1 through 29 have value greater than zero, pressed false and touched false.",
"Timestamp starts at performance.now and changes with input during ordinary foreground polling. Firefox also updates on window synchronization; background/foreground scheduling and clock precision reduction are not reproduced."
]
}
@@ -0,0 +1,530 @@
{
"name": "elite2-firefox-win-usb-xinput",
"label": "Xbox Elite Series 2 — USB / Firefox XInput",
"environment": {
"browser": "Firefox",
"os": "Windows 10 / Windows 11",
"connection": "USB",
"mode": "XInput with unknown onboard paddle assignments",
"backend": "XInput",
"status": "inferred",
"sources": [
{
"url": "https://www.xbox.com/en-US/accessories/controllers/elite-wireless-controller-series-2",
"says": "All three connections are supported on Windows 10/11."
},
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Windows XInput backend handles any successful XInputGetState device."
}
],
"reason": "Conditional on Windows routing this connection through XInput. No per-connection physical browser dump was found."
},
"gamepad": {
"timestampPolicy": {
"value": "change",
"status": "inferred",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "The XInput poll emits events only for changed buttons/axes."
},
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/Gamepad.cpp",
"says": "Construction, input events and SyncState update the DOM timestamp using performance.now."
},
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/base/nsGlobalWindowInner.cpp",
"says": "GetGamepads returns existing objects without calling SyncState."
},
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/base/nsGlobalWindowOuter.cpp",
"says": "SetIsBackground invokes SyncGamepadState when returning to foreground and also some unchanged background-state calls."
}
],
"reason": "Change policy models ordinary foreground polling; construction starts at performance.now. Browser background/foreground synchronization and timer precision reduction are outside this synthetic fixture."
},
"id": {
"value": "xinput",
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "ScanForXInputDevices passes literal xinput to AddGamepad, with no VID/PID."
}
]
},
"mapping": {
"value": "standard",
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "XInput AddGamepad specifies Standard mapping."
}
]
},
"buttons": {
"value": 17,
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "XInput adds kStandardGamepadButtons and only standard masks, including Guide."
}
]
},
"axesRest": {
"value": [
0,
0,
0,
0
],
"status": "inferred",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "XInput exposes four normalized stick axes."
},
{
"url": "https://learn.microsoft.com/en-us/windows/uwp/gaming/gamepad-and-vibration",
"says": "Centered sticks approximately report zero and can drift."
}
],
"reason": "Ideal zero center, not a captured resting sample. Trigger release is button value 0 and no trigger axes are returned."
},
"vibration": {
"value": null,
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Windows backend supplies zero haptics and leaves a haptics TODO."
},
{
"url": "https://searchfox.org/firefox-main/source/dom/webidl/Gamepad.webidl",
"says": "Gamepad has hapticActuators but no vibrationActuator attribute."
}
],
"presence": "absent"
}
},
"controls": {
"A": {
"kind": "button",
"index": 0,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Browser backend sends A to standard button 0."
}
]
}
},
"B": {
"kind": "button",
"index": 1,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Browser backend sends B to standard button 1."
}
]
}
},
"X": {
"kind": "button",
"index": 2,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Browser backend sends X to standard button 2."
}
]
}
},
"Y": {
"kind": "button",
"index": 3,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Browser backend sends Y to standard button 3."
}
]
}
},
"Left bumper": {
"kind": "button",
"index": 4,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Browser backend sends Left bumper to standard button 4."
}
]
}
},
"Right bumper": {
"kind": "button",
"index": 5,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Browser backend sends Right bumper to standard button 5."
}
]
}
},
"Left trigger": {
"kind": "button",
"index": 6,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Trigger 6 copies XInput byte /255; pressed at byte >=30."
},
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/GamepadPlatformService.cpp",
"says": "The pressed/value overload copies pressed into touched, including XInput triggers below the threshold."
}
]
},
"analog": true,
"pressedThreshold": 0.11764705882352941,
"pressedInclusive": true,
"steps": 255,
"touchedPolicy": "pressed"
},
"Right trigger": {
"kind": "button",
"index": 7,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Trigger 7 copies XInput byte /255; pressed at byte >=30."
},
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/GamepadPlatformService.cpp",
"says": "The pressed/value overload copies pressed into touched, including XInput triggers below the threshold."
}
]
},
"analog": true,
"pressedThreshold": 0.11764705882352941,
"pressedInclusive": true,
"steps": 255,
"touchedPolicy": "pressed"
},
"View": {
"kind": "button",
"index": 8,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Browser backend sends View to standard button 8."
}
]
}
},
"Menu": {
"kind": "button",
"index": 9,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Browser backend sends Menu to standard button 9."
}
]
}
},
"Left stick click": {
"kind": "button",
"index": 10,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Browser backend sends Left stick click to standard button 10."
}
]
}
},
"Right stick click": {
"kind": "button",
"index": 11,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Browser backend sends Right stick click to standard button 11."
}
]
}
},
"D-pad up": {
"kind": "button",
"index": 12,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Browser backend sends D-pad up to standard button 12."
}
]
}
},
"D-pad down": {
"kind": "button",
"index": 13,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Browser backend sends D-pad down to standard button 13."
}
]
}
},
"D-pad left": {
"kind": "button",
"index": 14,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Browser backend sends D-pad left to standard button 14."
}
]
}
},
"D-pad right": {
"kind": "button",
"index": 15,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Browser backend sends D-pad right to standard button 15."
}
]
}
},
"Xbox": {
"kind": "button",
"index": 16,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Browser backend sends Xbox to standard button 16."
}
]
}
},
"Left stick": {
"kind": "stick",
"x": 0,
"y": 1,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Axes 0/1 are left X/Y; Y is inverted for down-positive."
}
]
}
},
"Right stick": {
"kind": "stick",
"x": 2,
"y": 3,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Axes 2/3 are right X/Y; Y is inverted for down-positive."
}
]
}
},
"P1": {
"kind": "unreported",
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "XInput backend only maps the standard 17 buttons; it has no paddle mask."
}
]
},
"reason": "No independent paddle index in this backend. An onboard assignment may repeat another control; this fixture does not guess those assignments."
},
"P2": {
"kind": "unreported",
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "XInput backend only maps the standard 17 buttons; it has no paddle mask."
}
]
},
"reason": "No independent paddle index in this backend. An onboard assignment may repeat another control; this fixture does not guess those assignments."
},
"P3": {
"kind": "unreported",
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "XInput backend only maps the standard 17 buttons; it has no paddle mask."
}
]
},
"reason": "No independent paddle index in this backend. An onboard assignment may repeat another control; this fixture does not guess those assignments."
},
"P4": {
"kind": "unreported",
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "XInput backend only maps the standard 17 buttons; it has no paddle mask."
}
]
},
"reason": "No independent paddle index in this backend. An onboard assignment may repeat another control; this fixture does not guess those assignments."
},
"Profile button": {
"kind": "unreported",
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Backend maps standard controls, not an Elite profile selector."
},
{
"url": "https://news.xbox.com/en-us/2019/06/09/meet-the-xbox-elite-wireless-controller-series-2-over-30-new-ways-to-play-like-a-pro/",
"says": "Dedicated button switches default and three saved custom profiles."
}
]
},
"reason": "Stored profiles are programmable and their contents are unknown. The selector has no distinct button index here."
}
},
"switches": {
"Left trigger lock": {
"kind": "state",
"values": [
"full",
"medium",
"short"
],
"effect": "none",
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://news.xbox.com/en-us/2019/11/07/xbox-elite-wireless-controller-series-2-available-now/",
"says": "Elite Series 2 has three levels of trigger depth."
},
{
"url": "https://news.xbox.com/en-us/2019/06/09/meet-the-xbox-elite-wireless-controller-series-2-over-30-new-ways-to-play-like-a-pro/",
"says": "Controller automatically recognizes trigger-lock adjustments."
},
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Browser exposes normalized trigger values, with no trigger-lock state field."
}
],
"reason": "This switch records physical travel only. set() supplies the browser's normalized value, so no guessed travel-to-value curve is applied."
}
},
"Right trigger lock": {
"kind": "state",
"values": [
"full",
"medium",
"short"
],
"effect": "none",
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://news.xbox.com/en-us/2019/11/07/xbox-elite-wireless-controller-series-2-available-now/",
"says": "Elite Series 2 has three levels of trigger depth."
},
{
"url": "https://news.xbox.com/en-us/2019/06/09/meet-the-xbox-elite-wireless-controller-series-2-over-30-new-ways-to-play-like-a-pro/",
"says": "Controller automatically recognizes trigger-lock adjustments."
},
{
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
"says": "Browser exposes normalized trigger values, with no trigger-lock state field."
}
],
"reason": "This switch records physical travel only. set() supplies the browser's normalized value, so no guessed travel-to-value curve is applied."
}
},
"Profile button": {
"kind": "unreported",
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://news.xbox.com/en-us/2019/06/09/meet-the-xbox-elite-wireless-controller-series-2-over-30-new-ways-to-play-like-a-pro/",
"says": "Dedicated button selects default or one of three programmable profiles."
}
]
},
"reason": "The stored remapping slots and firmware are not known. No invented paddle aliases or profile-button signal is emitted."
}
},
"notes": [
"Exact browser backend layout, with connection routing inferred; no real-device Gamepad API dump was found.",
"Buttons 0–16 and axes 0–3 follow the standard layout. No hat axis; D-pad is buttons 12–15.",
"All released button values are 0, pressed/touched false. Trigger values are quantized byte/255.",
"P1–P4 cannot be bound as independent browser inputs here. Onboard remappings may repeat existing controls, but their stored assignments are deliberately unknown.",
"Profile button and trigger-lock switches have no independent Gamepad fields. Physical travel is recorded, not transformed with invented ratios.",
"vibrationActuator is absent; null in profile data means omit the property. Firefox hapticActuators is not implemented by this Windows backend.",
"Firefox trigger touched mirrors pressed: byte values 1 through 29 have value greater than zero, pressed false and touched false.",
"Timestamp starts at performance.now and changes with input during ordinary foreground polling. Firefox also updates on window synchronization; background/foreground scheduling and clock precision reduction are not reproduced."
]
}
+12
View File
@@ -0,0 +1,12 @@
{
"profiles": [
"crkd-les-paul-chrome-win-dongle-mode1-pre3.json",
"crkd-les-paul-chrome-win-usb-mode1-pre3.json",
"elite2-chromium-win-adapter-wgi-conditional.json",
"elite2-chromium-win-usb-wgi-conditional.json",
"elite2-firefox-win-adapter-xinput.json",
"elite2-firefox-win-bt-xinput.json",
"elite2-firefox-win-usb-xinput.json",
"wheel-g920-chrome-win-usb.json"
]
}
@@ -0,0 +1,575 @@
{
"name": "wheel-g920-chrome-win-usb",
"label": "Logitech G920 — Chrome Windows USB, pedals unplugged (inferred)",
"environment": {
"browser": "Chrome",
"os": "Windows",
"connection": "USB",
"mode": "Native HID, separate pedals; driver version unknown"
},
"environmentEvidence": {
"status": "inferred",
"sources": [
{
"url": "https://www.reddit.com/r/Stadia/comments/fdc7sh/",
"says": "A G920 owner pasted a Chrome Gamepad object on 2020-03-04: this exact ID, empty mapping, 18 buttons, 10 axes with the saved resting vector, and vibrationActuator null. The report does not specify the OS, driver version, or whether pedals were disconnected."
},
{
"url": "https://github.com/chromium/chromium/blob/main/device/gamepad/raw_input_gamepad_device_win.cc",
"says": "Windows RawInput assigns a button index from HID button usage minus one, and an axis index from HID Generic Desktop usage minus 0x30. It normalizes to [-1,+1], preserving an out-of-range hat null state."
},
{
"url": "https://www.logitech.com/assets/54557/2/g920-driving-forcetm-racing-wheel.pdf",
"says": "The G920 manual identifies A/B/X/Y, directional pad, left/right bumpers and stick buttons, View/Menu, Xbox, USB, and separate pedal/shifter connectors; its numbered callouts are physical labels, not browser indices."
}
],
"reason": "Chrome is explicit in the dump; Windows is inferred from its sparse 10-axis HID usage layout. USB is the wheel's physical host connection. This historical report does not prove current Edge/Chrome behavior under every Logitech driver."
},
"gamepad": {
"id": {
"value": "G920 Driving Force Racing Wheel for Xbox One (Vendor: 046d Product: c262)",
"status": "confirmed",
"sources": [
{
"url": "https://www.reddit.com/r/Stadia/comments/fdc7sh/",
"says": "A G920 owner pasted a Chrome Gamepad object on 2020-03-04: this exact ID, empty mapping, 18 buttons, 10 axes with the saved resting vector, and vibrationActuator null. The report does not specify the OS, driver version, or whether pedals were disconnected."
}
]
},
"mapping": {
"value": "",
"status": "confirmed",
"sources": [
{
"url": "https://www.reddit.com/r/Stadia/comments/fdc7sh/",
"says": "A G920 owner pasted a Chrome Gamepad object on 2020-03-04: this exact ID, empty mapping, 18 buttons, 10 axes with the saved resting vector, and vibrationActuator null. The report does not specify the OS, driver version, or whether pedals were disconnected."
}
]
},
"buttons": {
"value": 18,
"status": "confirmed",
"sources": [
{
"url": "https://www.reddit.com/r/Stadia/comments/fdc7sh/",
"says": "A G920 owner pasted a Chrome Gamepad object on 2020-03-04: this exact ID, empty mapping, 18 buttons, 10 axes with the saved resting vector, and vibrationActuator null. The report does not specify the OS, driver version, or whether pedals were disconnected."
},
{
"url": "https://forum.scssoft.com/viewtopic.php?p=1313821",
"says": "A real 046d:c262 G920 HID dump has 18 button usages, X usage 0x30 (0..65535), Y 0x31/Z 0x32/Rz 0x35 (0..255), and hat 0x39 (0..7). The dump is from macOS native HID, not Safari."
}
]
},
"axesRest": {
"value": [
-0.0013580322265625,
1,
1,
0,
0,
1,
0,
0,
0,
1.2857143878936768
],
"status": "inferred",
"sources": [
{
"url": "https://www.reddit.com/r/Stadia/comments/fdc7sh/",
"says": "A G920 owner pasted a Chrome Gamepad object on 2020-03-04: this exact ID, empty mapping, 18 buttons, 10 axes with the saved resting vector, and vibrationActuator null. The report does not specify the OS, driver version, or whether pedals were disconnected."
},
{
"url": "https://spenibus.net/b/p/F/PC-steering-wheel-viewer-prototype-in-html-javascript",
"says": "The author's 2018 G920 Firefox/Chrome browser prototype reads steering axis 0, throttle 1, brake 2, and clutch 3 or 5; its pedal conversion treats +1 as released and -1 as pressed."
},
{
"url": "https://forum.scssoft.com/viewtopic.php?p=1313821",
"says": "A real 046d:c262 G920 HID dump has 18 button usages, X usage 0x30 (0..65535), Y 0x31/Z 0x32/Rz 0x35 (0..255), and hat 0x39 (0..7). The dump is from macOS native HID, not Safari."
}
],
"reason": "The complete vector is copied exactly from the real Chrome snapshot, including zeros at HID axis gaps and steering sensor offset. The snapshot never says pedals were unplugged; using its released-pedal +1 values for unplugged pedals is explicitly inferred."
},
"vibration": {
"value": null,
"status": "confirmed",
"sources": [
{
"url": "https://www.reddit.com/r/Stadia/comments/fdc7sh/",
"says": "A G920 owner pasted a Chrome Gamepad object on 2020-03-04: this exact ID, empty mapping, 18 buttons, 10 axes with the saved resting vector, and vibrationActuator null. The report does not specify the OS, driver version, or whether pedals were disconnected."
}
],
"presence": "null"
}
},
"controls": {
"A": {
"kind": "button",
"index": 0,
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://steamcommunity.com/sharedfiles/filedetails/?id=2489644810",
"says": "The author's G920 Windows DirectInput setup lists raw indices 0 A, 1 B, 2 X, 3 Y, 4 right paddle, 5 left paddle, 6 Menu, 7 View, 8 RSB, and 9 LSB. The guide warns the G HUB product name differs."
},
{
"url": "https://github.com/chromium/chromium/blob/main/device/gamepad/raw_input_gamepad_device_win.cc",
"says": "Windows RawInput assigns a button index from HID button usage minus one, and an axis index from HID Generic Desktop usage minus 0x30. It normalizes to [-1,+1], preserving an out-of-range hat null state."
}
],
"reason": "The G920 owner's native Windows indices are projected to Chrome's unremapped HID button order; no Chrome dump of this individual button was found."
}
},
"B": {
"kind": "button",
"index": 1,
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://steamcommunity.com/sharedfiles/filedetails/?id=2489644810",
"says": "The author's G920 Windows DirectInput setup lists raw indices 0 A, 1 B, 2 X, 3 Y, 4 right paddle, 5 left paddle, 6 Menu, 7 View, 8 RSB, and 9 LSB. The guide warns the G HUB product name differs."
},
{
"url": "https://github.com/chromium/chromium/blob/main/device/gamepad/raw_input_gamepad_device_win.cc",
"says": "Windows RawInput assigns a button index from HID button usage minus one, and an axis index from HID Generic Desktop usage minus 0x30. It normalizes to [-1,+1], preserving an out-of-range hat null state."
}
],
"reason": "The G920 owner's native Windows indices are projected to Chrome's unremapped HID button order; no Chrome dump of this individual button was found."
}
},
"X": {
"kind": "button",
"index": 2,
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://steamcommunity.com/sharedfiles/filedetails/?id=2489644810",
"says": "The author's G920 Windows DirectInput setup lists raw indices 0 A, 1 B, 2 X, 3 Y, 4 right paddle, 5 left paddle, 6 Menu, 7 View, 8 RSB, and 9 LSB. The guide warns the G HUB product name differs."
},
{
"url": "https://github.com/chromium/chromium/blob/main/device/gamepad/raw_input_gamepad_device_win.cc",
"says": "Windows RawInput assigns a button index from HID button usage minus one, and an axis index from HID Generic Desktop usage minus 0x30. It normalizes to [-1,+1], preserving an out-of-range hat null state."
}
],
"reason": "The G920 owner's native Windows indices are projected to Chrome's unremapped HID button order; no Chrome dump of this individual button was found."
}
},
"Y": {
"kind": "button",
"index": 3,
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://steamcommunity.com/sharedfiles/filedetails/?id=2489644810",
"says": "The author's G920 Windows DirectInput setup lists raw indices 0 A, 1 B, 2 X, 3 Y, 4 right paddle, 5 left paddle, 6 Menu, 7 View, 8 RSB, and 9 LSB. The guide warns the G HUB product name differs."
},
{
"url": "https://github.com/chromium/chromium/blob/main/device/gamepad/raw_input_gamepad_device_win.cc",
"says": "Windows RawInput assigns a button index from HID button usage minus one, and an axis index from HID Generic Desktop usage minus 0x30. It normalizes to [-1,+1], preserving an out-of-range hat null state."
}
],
"reason": "The G920 owner's native Windows indices are projected to Chrome's unremapped HID button order; no Chrome dump of this individual button was found."
}
},
"Right paddle shifter": {
"kind": "button",
"index": 4,
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://steamcommunity.com/sharedfiles/filedetails/?id=2489644810",
"says": "The author's G920 Windows DirectInput setup lists raw indices 0 A, 1 B, 2 X, 3 Y, 4 right paddle, 5 left paddle, 6 Menu, 7 View, 8 RSB, and 9 LSB. The guide warns the G HUB product name differs."
},
{
"url": "https://github.com/chromium/chromium/blob/main/device/gamepad/raw_input_gamepad_device_win.cc",
"says": "Windows RawInput assigns a button index from HID button usage minus one, and an axis index from HID Generic Desktop usage minus 0x30. It normalizes to [-1,+1], preserving an out-of-range hat null state."
}
],
"reason": "The G920 owner's native Windows indices are projected to Chrome's unremapped HID button order; no Chrome dump of this individual button was found."
}
},
"Left paddle shifter": {
"kind": "button",
"index": 5,
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://steamcommunity.com/sharedfiles/filedetails/?id=2489644810",
"says": "The author's G920 Windows DirectInput setup lists raw indices 0 A, 1 B, 2 X, 3 Y, 4 right paddle, 5 left paddle, 6 Menu, 7 View, 8 RSB, and 9 LSB. The guide warns the G HUB product name differs."
},
{
"url": "https://github.com/chromium/chromium/blob/main/device/gamepad/raw_input_gamepad_device_win.cc",
"says": "Windows RawInput assigns a button index from HID button usage minus one, and an axis index from HID Generic Desktop usage minus 0x30. It normalizes to [-1,+1], preserving an out-of-range hat null state."
}
],
"reason": "The G920 owner's native Windows indices are projected to Chrome's unremapped HID button order; no Chrome dump of this individual button was found."
}
},
"Menu": {
"kind": "button",
"index": 6,
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://steamcommunity.com/sharedfiles/filedetails/?id=2489644810",
"says": "The author's G920 Windows DirectInput setup lists raw indices 0 A, 1 B, 2 X, 3 Y, 4 right paddle, 5 left paddle, 6 Menu, 7 View, 8 RSB, and 9 LSB. The guide warns the G HUB product name differs."
},
{
"url": "https://github.com/chromium/chromium/blob/main/device/gamepad/raw_input_gamepad_device_win.cc",
"says": "Windows RawInput assigns a button index from HID button usage minus one, and an axis index from HID Generic Desktop usage minus 0x30. It normalizes to [-1,+1], preserving an out-of-range hat null state."
}
],
"reason": "The G920 owner's native Windows indices are projected to Chrome's unremapped HID button order; no Chrome dump of this individual button was found."
}
},
"View": {
"kind": "button",
"index": 7,
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://steamcommunity.com/sharedfiles/filedetails/?id=2489644810",
"says": "The author's G920 Windows DirectInput setup lists raw indices 0 A, 1 B, 2 X, 3 Y, 4 right paddle, 5 left paddle, 6 Menu, 7 View, 8 RSB, and 9 LSB. The guide warns the G HUB product name differs."
},
{
"url": "https://github.com/chromium/chromium/blob/main/device/gamepad/raw_input_gamepad_device_win.cc",
"says": "Windows RawInput assigns a button index from HID button usage minus one, and an axis index from HID Generic Desktop usage minus 0x30. It normalizes to [-1,+1], preserving an out-of-range hat null state."
}
],
"reason": "The G920 owner's native Windows indices are projected to Chrome's unremapped HID button order; no Chrome dump of this individual button was found."
}
},
"RSB": {
"kind": "button",
"index": 8,
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://steamcommunity.com/sharedfiles/filedetails/?id=2489644810",
"says": "The author's G920 Windows DirectInput setup lists raw indices 0 A, 1 B, 2 X, 3 Y, 4 right paddle, 5 left paddle, 6 Menu, 7 View, 8 RSB, and 9 LSB. The guide warns the G HUB product name differs."
},
{
"url": "https://github.com/chromium/chromium/blob/main/device/gamepad/raw_input_gamepad_device_win.cc",
"says": "Windows RawInput assigns a button index from HID button usage minus one, and an axis index from HID Generic Desktop usage minus 0x30. It normalizes to [-1,+1], preserving an out-of-range hat null state."
}
],
"reason": "The G920 owner's native Windows indices are projected to Chrome's unremapped HID button order; no Chrome dump of this individual button was found."
}
},
"LSB": {
"kind": "button",
"index": 9,
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://steamcommunity.com/sharedfiles/filedetails/?id=2489644810",
"says": "The author's G920 Windows DirectInput setup lists raw indices 0 A, 1 B, 2 X, 3 Y, 4 right paddle, 5 left paddle, 6 Menu, 7 View, 8 RSB, and 9 LSB. The guide warns the G HUB product name differs."
},
{
"url": "https://github.com/chromium/chromium/blob/main/device/gamepad/raw_input_gamepad_device_win.cc",
"says": "Windows RawInput assigns a button index from HID button usage minus one, and an axis index from HID Generic Desktop usage minus 0x30. It normalizes to [-1,+1], preserving an out-of-range hat null state."
}
],
"reason": "The G920 owner's native Windows indices are projected to Chrome's unremapped HID button order; no Chrome dump of this individual button was found."
}
},
"Xbox": {
"kind": "button",
"index": 10,
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://www.logitech.com/assets/54557/2/g920-driving-forcetm-racing-wheel.pdf",
"says": "The G920 manual identifies A/B/X/Y, directional pad, left/right bumpers and stick buttons, View/Menu, Xbox, USB, and separate pedal/shifter connectors; its numbered callouts are physical labels, not browser indices."
},
{
"url": "https://www.reddit.com/r/LogitechG/comments/lzmpf3/",
"says": "A G920 and G HUB owner reports Windows joy.cpl buttons 1-11 on the wheel, button 12 for reverse, and buttons 13-18 for forward gears; pushing the lever down and moving it bottom right selects reverse."
},
{
"url": "https://github.com/chromium/chromium/blob/main/device/gamepad/raw_input_gamepad_device_win.cc",
"says": "Windows RawInput assigns a button index from HID button usage minus one, and an axis index from HID Generic Desktop usage minus 0x30. It normalizes to [-1,+1], preserving an out-of-range hat null state."
}
],
"reason": "The manual identifies Xbox as the remaining wheel button; the owner's Windows report puts wheel buttons at one-based 1..11. Assigning Xbox to zero-based 10 is inferred."
}
},
"Reverse gear": {
"kind": "button",
"index": 11,
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://www.reddit.com/r/LogitechG/comments/lzmpf3/",
"says": "A G920 and G HUB owner reports Windows joy.cpl buttons 1-11 on the wheel, button 12 for reverse, and buttons 13-18 for forward gears; pushing the lever down and moving it bottom right selects reverse."
},
{
"url": "https://github.com/chromium/chromium/blob/main/device/gamepad/raw_input_gamepad_device_win.cc",
"says": "Windows RawInput assigns a button index from HID button usage minus one, and an axis index from HID Generic Desktop usage minus 0x30. It normalizes to [-1,+1], preserving an out-of-range hat null state."
}
],
"reason": "One-based Windows button 12 is projected to unremapped browser index 11. LGS documentation uses a conflicting 19-button order; this is only a G HUB-style candidate."
}
},
"Gear 1": {
"kind": "button",
"index": 12,
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://www.reddit.com/r/LogitechG/comments/lzmpf3/",
"says": "A G920 and G HUB owner reports Windows joy.cpl buttons 1-11 on the wheel, button 12 for reverse, and buttons 13-18 for forward gears; pushing the lever down and moving it bottom right selects reverse."
},
{
"url": "https://github.com/chromium/chromium/blob/main/device/gamepad/raw_input_gamepad_device_win.cc",
"says": "Windows RawInput assigns a button index from HID button usage minus one, and an axis index from HID Generic Desktop usage minus 0x30. It normalizes to [-1,+1], preserving an out-of-range hat null state."
}
],
"reason": "The owner's one-based Windows forward-gear buttons 13..18 are projected to browser indices 12..17. The optional shifter is assumed disconnected by default."
}
},
"Gear 2": {
"kind": "button",
"index": 13,
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://www.reddit.com/r/LogitechG/comments/lzmpf3/",
"says": "A G920 and G HUB owner reports Windows joy.cpl buttons 1-11 on the wheel, button 12 for reverse, and buttons 13-18 for forward gears; pushing the lever down and moving it bottom right selects reverse."
},
{
"url": "https://github.com/chromium/chromium/blob/main/device/gamepad/raw_input_gamepad_device_win.cc",
"says": "Windows RawInput assigns a button index from HID button usage minus one, and an axis index from HID Generic Desktop usage minus 0x30. It normalizes to [-1,+1], preserving an out-of-range hat null state."
}
],
"reason": "The owner's one-based Windows forward-gear buttons 13..18 are projected to browser indices 12..17. The optional shifter is assumed disconnected by default."
}
},
"Gear 3": {
"kind": "button",
"index": 14,
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://www.reddit.com/r/LogitechG/comments/lzmpf3/",
"says": "A G920 and G HUB owner reports Windows joy.cpl buttons 1-11 on the wheel, button 12 for reverse, and buttons 13-18 for forward gears; pushing the lever down and moving it bottom right selects reverse."
},
{
"url": "https://github.com/chromium/chromium/blob/main/device/gamepad/raw_input_gamepad_device_win.cc",
"says": "Windows RawInput assigns a button index from HID button usage minus one, and an axis index from HID Generic Desktop usage minus 0x30. It normalizes to [-1,+1], preserving an out-of-range hat null state."
}
],
"reason": "The owner's one-based Windows forward-gear buttons 13..18 are projected to browser indices 12..17. The optional shifter is assumed disconnected by default."
}
},
"Gear 4": {
"kind": "button",
"index": 15,
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://www.reddit.com/r/LogitechG/comments/lzmpf3/",
"says": "A G920 and G HUB owner reports Windows joy.cpl buttons 1-11 on the wheel, button 12 for reverse, and buttons 13-18 for forward gears; pushing the lever down and moving it bottom right selects reverse."
},
{
"url": "https://github.com/chromium/chromium/blob/main/device/gamepad/raw_input_gamepad_device_win.cc",
"says": "Windows RawInput assigns a button index from HID button usage minus one, and an axis index from HID Generic Desktop usage minus 0x30. It normalizes to [-1,+1], preserving an out-of-range hat null state."
}
],
"reason": "The owner's one-based Windows forward-gear buttons 13..18 are projected to browser indices 12..17. The optional shifter is assumed disconnected by default."
}
},
"Gear 5": {
"kind": "button",
"index": 16,
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://www.reddit.com/r/LogitechG/comments/lzmpf3/",
"says": "A G920 and G HUB owner reports Windows joy.cpl buttons 1-11 on the wheel, button 12 for reverse, and buttons 13-18 for forward gears; pushing the lever down and moving it bottom right selects reverse."
},
{
"url": "https://github.com/chromium/chromium/blob/main/device/gamepad/raw_input_gamepad_device_win.cc",
"says": "Windows RawInput assigns a button index from HID button usage minus one, and an axis index from HID Generic Desktop usage minus 0x30. It normalizes to [-1,+1], preserving an out-of-range hat null state."
}
],
"reason": "The owner's one-based Windows forward-gear buttons 13..18 are projected to browser indices 12..17. The optional shifter is assumed disconnected by default."
}
},
"Gear 6": {
"kind": "button",
"index": 17,
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://www.reddit.com/r/LogitechG/comments/lzmpf3/",
"says": "A G920 and G HUB owner reports Windows joy.cpl buttons 1-11 on the wheel, button 12 for reverse, and buttons 13-18 for forward gears; pushing the lever down and moving it bottom right selects reverse."
},
{
"url": "https://github.com/chromium/chromium/blob/main/device/gamepad/raw_input_gamepad_device_win.cc",
"says": "Windows RawInput assigns a button index from HID button usage minus one, and an axis index from HID Generic Desktop usage minus 0x30. It normalizes to [-1,+1], preserving an out-of-range hat null state."
}
],
"reason": "The owner's one-based Windows forward-gear buttons 13..18 are projected to browser indices 12..17. The optional shifter is assumed disconnected by default."
}
},
"Wheel": {
"kind": "axis",
"index": 0,
"rest": -0.0013580322265625,
"min": -1,
"max": 1,
"evidence": {
"status": "confirmed",
"sources": [
{
"url": "https://www.reddit.com/r/Stadia/comments/fdc7sh/",
"says": "A G920 owner pasted a Chrome Gamepad object on 2020-03-04: this exact ID, empty mapping, 18 buttons, 10 axes with the saved resting vector, and vibrationActuator null. The report does not specify the OS, driver version, or whether pedals were disconnected."
},
{
"url": "https://spenibus.net/b/p/F/PC-steering-wheel-viewer-prototype-in-html-javascript",
"says": "The author's 2018 G920 Firefox/Chrome browser prototype reads steering axis 0, throttle 1, brake 2, and clutch 3 or 5; its pedal conversion treats +1 as released and -1 as pressed."
}
],
"reason": "The dump preserves a small observed steering offset; the author's browser code identifies axis 0 as steering. Positive values turn right by the browser axis convention."
}
},
"Accelerator pedal": {
"kind": "axis",
"index": 1,
"rest": 1,
"min": -1,
"max": 1,
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://www.reddit.com/r/Stadia/comments/fdc7sh/",
"says": "A G920 owner pasted a Chrome Gamepad object on 2020-03-04: this exact ID, empty mapping, 18 buttons, 10 axes with the saved resting vector, and vibrationActuator null. The report does not specify the OS, driver version, or whether pedals were disconnected."
},
{
"url": "https://spenibus.net/b/p/F/PC-steering-wheel-viewer-prototype-in-html-javascript",
"says": "The author's 2018 G920 Firefox/Chrome browser prototype reads steering axis 0, throttle 1, brake 2, and clutch 3 or 5; its pedal conversion treats +1 as released and -1 as pressed."
},
{
"url": "https://forum.scssoft.com/viewtopic.php?p=1313821",
"says": "A real 046d:c262 G920 HID dump has 18 button usages, X usage 0x30 (0..65535), Y 0x31/Z 0x32/Rz 0x35 (0..255), and hat 0x39 (0..7). The dump is from macOS native HID, not Safari."
}
],
"reason": "The real Chrome snapshot has +1 here and the owner's browser prototype supplies the pedal identity/range. Retaining +1 with the pedal connector unplugged is a scenario assumption, not an observed disconnected-pedal capture."
}
},
"Brake pedal": {
"kind": "axis",
"index": 2,
"rest": 1,
"min": -1,
"max": 1,
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://www.reddit.com/r/Stadia/comments/fdc7sh/",
"says": "A G920 owner pasted a Chrome Gamepad object on 2020-03-04: this exact ID, empty mapping, 18 buttons, 10 axes with the saved resting vector, and vibrationActuator null. The report does not specify the OS, driver version, or whether pedals were disconnected."
},
{
"url": "https://spenibus.net/b/p/F/PC-steering-wheel-viewer-prototype-in-html-javascript",
"says": "The author's 2018 G920 Firefox/Chrome browser prototype reads steering axis 0, throttle 1, brake 2, and clutch 3 or 5; its pedal conversion treats +1 as released and -1 as pressed."
},
{
"url": "https://forum.scssoft.com/viewtopic.php?p=1313821",
"says": "A real 046d:c262 G920 HID dump has 18 button usages, X usage 0x30 (0..65535), Y 0x31/Z 0x32/Rz 0x35 (0..255), and hat 0x39 (0..7). The dump is from macOS native HID, not Safari."
}
],
"reason": "The real Chrome snapshot has +1 here and the owner's browser prototype supplies the pedal identity/range. Retaining +1 with the pedal connector unplugged is a scenario assumption, not an observed disconnected-pedal capture."
}
},
"Clutch pedal": {
"kind": "axis",
"index": 5,
"rest": 1,
"min": -1,
"max": 1,
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://www.reddit.com/r/Stadia/comments/fdc7sh/",
"says": "A G920 owner pasted a Chrome Gamepad object on 2020-03-04: this exact ID, empty mapping, 18 buttons, 10 axes with the saved resting vector, and vibrationActuator null. The report does not specify the OS, driver version, or whether pedals were disconnected."
},
{
"url": "https://spenibus.net/b/p/F/PC-steering-wheel-viewer-prototype-in-html-javascript",
"says": "The author's 2018 G920 Firefox/Chrome browser prototype reads steering axis 0, throttle 1, brake 2, and clutch 3 or 5; its pedal conversion treats +1 as released and -1 as pressed."
},
{
"url": "https://forum.scssoft.com/viewtopic.php?p=1313821",
"says": "A real 046d:c262 G920 HID dump has 18 button usages, X usage 0x30 (0..65535), Y 0x31/Z 0x32/Rz 0x35 (0..255), and hat 0x39 (0..7). The dump is from macOS native HID, not Safari."
}
],
"reason": "The real Chrome snapshot has +1 here and the owner's browser prototype supplies the pedal identity/range. Retaining +1 with the pedal connector unplugged is a scenario assumption, not an observed disconnected-pedal capture."
}
},
"D-pad": {
"kind": "hat",
"index": 9,
"neutral": 1.2857143878936768,
"directions": {
"up": -1,
"up-right": -0.7142857313156128,
"right": -0.4285714030265808,
"down-right": -0.1428571343421936,
"down": 0.14285719394683838,
"down-left": 0.4285714626312256,
"left": 0.7142857313156128,
"up-left": 1
},
"evidence": {
"status": "inferred",
"sources": [
{
"url": "https://www.reddit.com/r/Stadia/comments/fdc7sh/",
"says": "A G920 owner pasted a Chrome Gamepad object on 2020-03-04: this exact ID, empty mapping, 18 buttons, 10 axes with the saved resting vector, and vibrationActuator null. The report does not specify the OS, driver version, or whether pedals were disconnected."
},
{
"url": "https://github.com/chromium/chromium/blob/main/device/gamepad/raw_input_gamepad_device_win.cc",
"says": "Windows RawInput assigns a button index from HID button usage minus one, and an axis index from HID Generic Desktop usage minus 0x30. It normalizes to [-1,+1], preserving an out-of-range hat null state."
},
{
"url": "https://forum.scssoft.com/viewtopic.php?p=1313821",
"says": "A real 046d:c262 G920 HID dump has 18 button usages, X usage 0x30 (0..65535), Y 0x31/Z 0x32/Rz 0x35 (0..255), and hat 0x39 (0..7). The dump is from macOS native HID, not Safari."
}
],
"reason": "Hat axis 9 and neutral 1.2857143878936768 are confirmed by the Chrome dump. Direction values are derived using Chromium float normalization of the G920 HID hat range 0..7, clockwise from north."
}
}
},
"switches": {},
"notes": [
"This profile is a historical Chrome/HID report plus named-control inferences, not a claim of hardware compatibility.",
"Axes 3,4,6,7,8 are preserved zero-valued gaps in the reported array, not imaginary sticks.",
"The hat neutral is 1.2857143878936768 for this dump; do not replace it with the often-seen 3.2857.",
"Pedal axes remain in the fixed HID report descriptor when the separate pedal unit is absent. Their +1 unplugged rests are unconfirmed and intentionally inferred.",
"Buttons rest at value 0, pressed false, touched false, using Chromium's binary raw HID button semantics; pedals are axes, not analog buttons.",
"No user-operated platform/mode switch is documented for G920. Driver-generated XInput and combined-pedal variants are outside this profile.",
"Optional shifter indices use a firsthand G HUB native report; Logitech's LGS shifter guide disagrees. Do not silently apply that LGS layout to this 18-button snapshot.",
"No Firefox, Safari, Edge, current-G-HUB Chrome, or second-wheel profile is emitted without a full matching browser report."
]
}
+65
View File
@@ -0,0 +1,65 @@
// Local-only manual lab: static files plus a fixed host-stub SDP relay.
// Run in Docker after tests/run.py has built build/web-rtc-host-test.
const http = require('node:http'), fs = require('node:fs'), path = require('node:path');
const { spawn } = require('node:child_process');
const { address, copyService } = require('./rtc_browser_helpers.cjs');
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');
if (request.method === 'POST' && url.pathname === '/local-service') {
const expected = 'http://127.0.0.1:' + server.address().port;
if (request.headers.origin !== expected || !address) { response.writeHead(403).end(); return; }
const chunks = []; let size = 0;
request.on('data', chunk => {
size += chunk.length;
if (size > 16384) { response.writeHead(413).end(); request.destroy(); }
else chunks.push(chunk);
});
request.on('end', () => {
if (size > 16384) return;
const body = Buffer.concat(chunks), host = address + ':4264';
// A fixed destination, never a caller-provided URL. Using the real
// interface makes the SDP answer usable by Chromium's RTC stack.
const upstream = http.request({ hostname: address, port: 4264, path: '/rtc', method: 'POST',
headers: { Host: host, Origin: 'http://' + host, 'Content-Type': 'text/plain', 'Content-Length': body.length } }, reply => {
response.writeHead(reply.statusCode, { 'Content-Type': 'text/plain', 'Cache-Control': 'no-store' });
reply.pipe(response);
});
upstream.on('error', () => { if (!response.headersSent) response.writeHead(502); response.end(); });
upstream.end(body);
});
return;
}
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.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-rtc-host-test');
if (!fs.existsSync(binary)) { console.error('Run tests/run.py in Docker first.'); process.exitCode = 1; }
else {
copyService('/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(); });
}
}
+468
View File
@@ -0,0 +1,468 @@
// 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 { relayOffer, copyService } = require('./rtc_browser_helpers.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();
copyService('/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 }));
await relayOffer(page);
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(async () => {
try { await fetch('https://example.invalid/rtc', { method: 'POST' }); return ''; } catch (error) { return error.message; }
});
assert.match(refused, /only connects to the local test service/, 'the local manual harness rejects external signaling destinations');
await frame.click('.gp-row [data-focus="configure"]');
await changeControl(frame, 'CROSS');
await frame.waitForFunction(() => c4fTest.mapper.capture?.ready);
const name = controlAt(chosen, 0), index = Object.keys(chosen.controls).indexOf(name);
const hold = page.locator('#controls .control').nth(index).getByRole('button', { name: 'Hold', exact: true });
await hold.focus(); await page.keyboard.down('Space');
await frame.waitForFunction(() => !c4fTest.mapper.capture);
await page.keyboard.up('Space');
assert.equal(await frame.evaluate(() => c4fTest.mapper.draft.bindings.find(binding => binding.output === 'CROSS').input.index), 0, 'manual Hold drives the actual quick capture');
await page.click('#reset');
assert.deepEqual(await frame.evaluate(() => [...navigator.getGamepads()[0].axes]), chosen.gamepad.axesRest.value);
await page.screenshot({ path: root + '/build/fake-manual-harness.png', fullPage: true });
await page.click('#disconnect');
await frame.waitForFunction(() => c4fTest.gamepadSources.size === 0);
assert.equal(await frame.evaluate(() => navigator.getGamepads()[0]), null);
assert.deepEqual(errors, []);
console.log('PASS fake manual harness: local profile loading, iframe connection, Hold capture, reset and disconnect');
} finally { await context.close(); }
}
async function screenshotOverview(browser) {
const page = await browser.newPage({ viewport: { width: 1600, height: 1000 }, deviceScaleFactor: 1 });
try {
const cards = profiles.flatMap(profile => ['home', 'mapping'].map(screen => {
const image = fs.readFileSync(root + '/build/fake-' + profile.name + '-' + screen + '.png').toString('base64');
const label = (profile.name + ' — ' + screen).replace(/[&<>"']/g, character =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[character]);
return '<figure><figcaption>' + label + '</figcaption><img src="data:image/png;base64,' + image + '"></figure>';
})).join('');
await page.setContent('<!doctype html><html><head><meta charset="utf-8"><title>Fake gamepad screenshots</title>' +
'<style>*{box-sizing:border-box}body{margin:0;background:#101827;color:#f3f6fa;font:13px system-ui}' +
'main{display:grid;grid-template-columns:repeat(4,400px)}figure{margin:0;padding:0 0 12px}' +
'figcaption{padding:10px;min-height:54px;overflow-wrap:anywhere}img{display:block;width:400px;height:auto;object-fit:contain}</style>' +
'</head><body><main>' + cards + '</main></body></html>');
await page.evaluate(() => Promise.all([...document.images].map(image => image.decode())));
await page.screenshot({ path: root + '/build/fake-profiles-overview.png', fullPage: true });
} finally { await page.close(); }
}
(async () => {
let browser;
try {
await new Promise((resolve, reject) => { lab.once('error', reject); lab.listen(8000, '127.0.0.1', resolve); });
browser = await chromium.launch({ headless: true });
if (serviceError) throw serviceError;
for (const profile of profiles) await runProfile(browser, profile);
await runManualHarness(browser);
await screenshotOverview(browser);
if (!profiles.some(profile => /crkd|guitar/i.test(profile.name))) console.log('NOT FOUND: no sourced CRKD guitar profile; fret/strum hardware scenarios were not fabricated');
} finally {
if (browser) await browser.close();
await lab.stop();
service.kill();
}
})().catch(error => { console.error(error); process.exitCode = 1; });
+6 -5
View File
@@ -25,15 +25,16 @@ int main(void)
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. */
/* A game's hit: the large 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);
assert(f.large == 0xff && f.small == 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 };
/* Each motor on its own from the PS4 app's controller test: the large motor
* in [3], the small one in [4], the reverse of the DS4 report's order. */
static const uint8_t both[] = { 0x01, 0, 0, 0xc0, 0x30, 0x20, 0, 0x20 };
f = parse(both, sizeof(both));
assert(f.small == 0x30 && f.large == 0xc0 && f.r == 0x20 && f.b == 0x20);
assert(f.large == 0xc0 && f.small == 0x30 && 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 };
+69 -3
View File
@@ -15,10 +15,35 @@ 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 () => {
// Echo timing remains the page's own measurement, with optional telemetry
// only for a recognized probe and an advertised supporting service.
const sentRtt = [], pingCtx = {
conn: { echoes: new Map([[7, 100]]), pending: new Map(), latency: true },
now: () => 116.6, pingMs: 0, send: message => sentRtt.push(message), showVersion() {},
};
vm.createContext(pingCtx);
vm.runInContext(extract(' function onMessage(data)', ' function applyStatus('), pingCtx);
vm.runInContext('onMessage(JSON.stringify({ method: "e", params: [7, 100] }))', pingCtx);
assert.equal(pingCtx.pingMs, 17);
assert.deepEqual(JSON.parse(JSON.stringify(sentRtt)), [{ method: 'latency', params: [7, 17] }]);
vm.runInContext('onMessage(JSON.stringify({ method: "e", params: [7, 100] }))', pingCtx);
assert.equal(sentRtt.length, 1, 'duplicate and unrecognized probes produce no report');
pingCtx.conn.latency = false; pingCtx.conn.echoes.set(8, 100);
vm.runInContext('onMessage(JSON.stringify({ method: "e", params: [8, 100] }))', pingCtx);
assert.equal(sentRtt.length, 1, 'older services get no new method');
const routed = [], routeCtx = { conn: { state: 'open',
input: { readyState: 'open', bufferedAmount: 0, send: data => routed.push(['input', JSON.parse(data)]) },
control: { readyState: 'open', bufferedAmount: 0, send: data => routed.push(['control', JSON.parse(data)]) } } };
vm.createContext(routeCtx);
vm.runInContext(extract(' function send(obj)', ' function call('), routeCtx);
vm.runInContext('send({ method: "latency", params: [7, 17] })', routeCtx);
assert.equal(routed[0][0], 'input');
console.log('PASS exact input-channel RTT telemetry, optional capability and duplicate suppression');
// A claim is answered as a whole. One controller taken on another device must
// not disconnect the gamepad that was playing as a different one.
const claims = [];
@@ -26,7 +51,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 +72,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 +119,9 @@ function padStatus(fields) {
navigator: { getGamepads() { throw Error('SecurityError'); } },
gamepadSources: new Map([[0, { pad: 0, buttons: 0x4000, lx: 0, ly: 255, rx: 0, ry: 0, l2: 255, r2: 255, touches: [{}] }]]),
blankSource: pad => ({ pad, buttons: 0, lx: 128, ly: 128, rx: 128, ry: 128, l2: 0, r2: 0, touches: [] }),
renderGamepads() {}, sendClaim() {}, $: () => null,
renderGamepads() {}, sendClaim() {}, $: () => ({ textContent: '' }),
clearDevice(src) { Object.assign(src, pads.blankSource(src.pad)); },
mapper: { index: null, cancelCapture() {}, status() {} },
};
vm.createContext(pads);
vm.runInContext(pollSource, pads);
+6
View File
@@ -0,0 +1,6 @@
#ifndef C4F_TEST_ORBIS_KERNEL_H
#define C4F_TEST_ORBIS_KERNEL_H
#include <stdint.h>
typedef struct { int Size; char VersionString[28]; uint32_t Version; } OrbisKernelSwVersion;
int sceKernelGetSystemSwVersion(OrbisKernelSwVersion *version);
#endif
+9
View File
@@ -30,6 +30,15 @@ int main(int argc, char **argv)
printf("%d %d %d %s|%s\n", result, s.controllers, s.stopping, s.version, c4fLauncherProblem());
return 0;
}
if (!strcmp(argv[1], "latency")) {
C4fServiceStatus s;
int result = c4fLauncherProbe(&s);
printf("%d %d", result, s.latencyAvailable);
for (int i = 0; i < C4F_SERVICE_PADS; i++)
printf(" %d:%u:%d:%d", s.latency[i].userKnown, s.latency[i].userId, s.latency[i].rttMs, s.latency[i].ageMs);
printf("\n");
return 0;
}
if (!strcmp(argv[1], "start") && argc == 3) {
size_t size = 0;
const unsigned char *payload = c4fAutorunLoadBundled(argv[2]) ? NULL : c4fAutorunBundled(&size);
+569
View File
@@ -0,0 +1,569 @@
/* Launcher input and navigation using the real modules and simulated native
* readers. No controller service, network or console is contacted. */
#include <assert.h>
#include <stdio.h>
#include <string.h>
#include "autorun.h"
#include "input.h"
#include "navigation.h"
#include "screen.h"
typedef struct {
int handle, owned, openError, readError, connected;
int opens, reads, closes;
uint32_t buttons;
} FakeSource;
typedef struct {
int initialResult, initialCalls;
int32_t user;
FakeSource source[2];
} FakeInput;
static int fakeInitial(void *context, int32_t *user)
{
FakeInput *fake = context;
fake->initialCalls++;
*user = fake->user;
return fake->initialResult;
}
static int fakeOpen(void *context, int32_t user, int port, int *owned)
{
FakeInput *fake = context;
int index;
if (port == C4F_INPUT_PORT_STANDARD) {
assert(user == fake->user && user >= 0);
index = C4F_INPUT_STANDARD;
} else {
assert(port == C4F_INPUT_PORT_REMOTE && user == C4F_INPUT_SYSTEM_USER);
index = C4F_INPUT_REMOTE;
}
FakeSource *source = &fake->source[index];
source->opens++;
*owned = source->owned;
return source->openError ? -1 : source->handle;
}
static FakeSource *fakeHandle(FakeInput *fake, int handle)
{
for (int i = 0; i < 2; i++) if (fake->source[i].handle == handle) return &fake->source[i];
assert(!"unknown handle");
return NULL;
}
static int fakeRead(void *context, int handle, uint32_t *buttons, int *connected)
{
FakeSource *source = fakeHandle(context, handle);
source->reads++;
/* Poison even a failed sample: a failed native call must never leak it. */
*buttons = source->buttons;
*connected = source->connected;
return source->readError ? -1 : 0;
}
static void fakeClose(void *context, int handle)
{
FakeSource *source = fakeHandle(context, handle);
assert(source->owned);
source->closes++;
assert(source->closes <= source->opens);
}
static void reader(FakeInput *fake, C4fInput *input)
{
memset(fake, 0, sizeof(*fake));
fake->user = 42;
fake->source[C4F_INPUT_STANDARD] = (FakeSource){ .handle = 11, .owned = 1, .connected = 1 };
fake->source[C4F_INPUT_REMOTE] = (FakeSource){ .handle = 22, .owned = 1, .connected = 1 };
C4fInputOps ops = { fake, fakeInitial, fakeOpen, fakeRead, fakeClose };
c4fInputInit(input, &ops);
}
static C4fInputFrame poll(C4fInput *input, uint64_t now)
{
C4fInputFrame frame;
/* Poll must fill both sources even if one cannot be opened. */
memset(&frame, 0xff, sizeof(frame));
c4fInputPoll(input, now, &frame);
return frame;
}
static void remoteKeyNormalization(void)
{
/* These are Sony remote packet key codes, not HDMI-CEC wire opcodes.
* The native wrapper's remote-port gate is reviewed separately in main;
* this host test checks only the helper and its downstream reader contract. */
const uint32_t raw = UINT32_C(0x25a500f0);
assert(c4fInputRemoteButtons(raw, 13) == (raw | C4F_INPUT_BUTTON_CROSS));
assert(c4fInputRemoteButtons(raw, 15) == (raw | C4F_INPUT_BUTTON_CIRCLE));
assert(c4fInputRemoteButtons(raw, 18) == (raw | C4F_INPUT_BUTTON_OPTIONS));
assert(c4fInputRemoteButtons(C4F_INPUT_BUTTON_CROSS, 13) == C4F_INPUT_BUTTON_CROSS);
for (unsigned code = 0; code <= UINT8_MAX; code++) {
if (code == 13 || code == 15 || code == 18) continue;
assert(c4fInputRemoteButtons(raw, (uint8_t)code) == raw);
assert(c4fInputRemoteButtons(0, (uint8_t)code) == 0);
}
assert(c4fInputRemoteButtons(C4F_INPUT_BUTTON_INTERCEPTED, 13) == C4F_INPUT_BUTTON_INTERCEPTED);
assert(c4fInputRemoteButtons(C4F_INPUT_BUTTON_INTERCEPTED, 15) == C4F_INPUT_BUTTON_INTERCEPTED);
assert(c4fInputRemoteButtons(C4F_INPUT_BUTTON_INTERCEPTED, 18) == C4F_INPUT_BUTTON_INTERCEPTED);
puts("PASS Sony remote key normalization preserves raw buttons, ignores unknown codes and respects interception");
}
static void remoteOnly(void)
{
FakeInput fake;
C4fInput input;
reader(&fake, &input);
fake.source[C4F_INPUT_STANDARD].openError = 1;
fake.source[C4F_INPUT_REMOTE].buttons = C4F_INPUT_BUTTON_CROSS;
C4fInputFrame frame = poll(&input, 0);
assert(!frame.standard.available && !frame.standard.buttons && !frame.standard.pressed);
assert(frame.remote.available && !frame.remote.pressed);
fake.source[C4F_INPUT_REMOTE].buttons = 0;
poll(&input, 10);
fake.source[C4F_INPUT_REMOTE].buttons = C4F_INPUT_BUTTON_CROSS;
frame = poll(&input, 20);
assert(frame.remote.pressed == C4F_INPUT_BUTTON_CROSS);
assert(!poll(&input, 30).remote.pressed);
assert(fake.source[C4F_INPUT_STANDARD].opens == 1);
c4fInputClose(&input);
assert(!fake.source[C4F_INPUT_STANDARD].closes && fake.source[C4F_INPUT_REMOTE].closes == 1);
c4fInputClose(&input);
assert(fake.source[C4F_INPUT_REMOTE].closes == 1);
puts("PASS remote-only launch, held OK baseline, no repeated activation and owned-handle cleanup");
}
static void sourceEdges(void)
{
FakeInput fake;
C4fInput input;
reader(&fake, &input);
C4fInputFrame frame = poll(&input, 0);
assert(frame.standard.available && frame.remote.available);
fake.source[C4F_INPUT_STANDARD].buttons = C4F_INPUT_BUTTON_CROSS;
frame = poll(&input, 10);
assert(frame.standard.pressed == C4F_INPUT_BUTTON_CROSS && !frame.remote.pressed);
fake.source[C4F_INPUT_REMOTE].buttons = C4F_INPUT_BUTTON_CROSS;
frame = poll(&input, 20);
assert(!frame.standard.pressed && frame.remote.pressed == C4F_INPUT_BUTTON_CROSS);
fake.source[C4F_INPUT_STANDARD].buttons = 0;
frame = poll(&input, 30);
assert(!frame.standard.buttons && frame.remote.buttons == C4F_INPUT_BUTTON_CROSS);
assert(!frame.standard.pressed && !frame.remote.pressed);
fake.source[C4F_INPUT_STANDARD].buttons = C4F_INPUT_BUTTON_CROSS;
frame = poll(&input, 40);
assert(frame.standard.pressed == C4F_INPUT_BUTTON_CROSS && !frame.remote.pressed);
fake.source[C4F_INPUT_REMOTE].buttons = 0;
poll(&input, 50);
fake.source[C4F_INPUT_REMOTE].buttons = C4F_INPUT_BUTTON_CROSS;
frame = poll(&input, 60);
assert(!frame.standard.pressed && frame.remote.pressed == C4F_INPUT_BUTTON_CROSS);
c4fInputClose(&input);
assert(fake.source[0].closes == 1 && fake.source[1].closes == 1);
puts("PASS independent controller/remote edges while either source holds or releases the same button");
}
static void remoteOpenRetry(void)
{
FakeInput fake;
C4fInput input;
reader(&fake, &input);
fake.source[1].openError = 1;
C4fInputFrame frame = poll(&input, 0);
assert(frame.standard.available && !frame.remote.available);
fake.source[0].buttons = C4F_INPUT_BUTTON_CROSS;
frame = poll(&input, 10);
assert(frame.standard.pressed == C4F_INPUT_BUTTON_CROSS && !frame.remote.buttons);
fake.source[1].openError = 0;
fake.source[1].buttons = C4F_INPUT_BUTTON_CROSS;
poll(&input, 999);
assert(fake.source[1].opens == 1);
frame = poll(&input, 1000);
assert(frame.remote.available && !frame.remote.buttons && !frame.remote.pressed);
assert(fake.source[1].opens == 2 && !frame.standard.pressed);
fake.source[1].buttons = 0;
poll(&input, 1010);
fake.source[1].buttons = C4F_INPUT_BUTTON_CROSS;
assert(poll(&input, 1020).remote.pressed == C4F_INPUT_BUTTON_CROSS);
c4fInputClose(&input);
assert(fake.source[0].closes == 1 && fake.source[1].closes == 1);
puts("PASS unavailable remote leaves DS4 operational and retry waits for a new neutral baseline");
}
static void readerRecovery(void)
{
FakeInput fake;
C4fInput input;
reader(&fake, &input);
poll(&input, 0);
fake.source[0].buttons = C4F_INPUT_BUTTON_CROSS;
fake.source[1].buttons = C4F_INPUT_BUTTON_RIGHT;
poll(&input, 10);
fake.source[1].readError = 1;
C4fInputFrame frame = poll(&input, 20);
assert(frame.standard.available && frame.standard.buttons == C4F_INPUT_BUTTON_CROSS);
assert(!frame.remote.available && !frame.remote.buttons && !frame.remote.pressed);
assert(fake.source[1].closes == 1);
fake.source[1].readError = 0;
poll(&input, 1019);
assert(fake.source[1].opens == 1);
frame = poll(&input, 1020);
assert(fake.source[1].opens == 2 && frame.remote.available && !frame.remote.pressed);
fake.source[1].buttons = 0;
poll(&input, 1030);
fake.source[1].buttons = C4F_INPUT_BUTTON_RIGHT;
assert(poll(&input, 1040).remote.pressed == C4F_INPUT_BUTTON_RIGHT);
fake.source[0].connected = 0;
frame = poll(&input, 1050);
assert(!frame.standard.available && !frame.standard.buttons && !frame.standard.pressed);
assert(frame.remote.available && frame.remote.buttons == C4F_INPUT_BUTTON_RIGHT);
assert(fake.source[0].closes == 1);
fake.source[0].connected = 1;
poll(&input, 2049);
assert(fake.source[0].opens == 1);
frame = poll(&input, 2050);
assert(frame.standard.available && !frame.standard.pressed && fake.source[0].opens == 2);
fake.source[0].buttons = 0;
poll(&input, 2060);
fake.source[0].buttons = C4F_INPUT_BUTTON_CROSS;
assert(poll(&input, 2070).standard.pressed == C4F_INPUT_BUTTON_CROSS);
c4fInputReset(&input, 2080);
frame = poll(&input, 2080);
assert(!frame.standard.pressed && !frame.remote.pressed);
fake.source[0].buttons = fake.source[1].buttons = 0;
poll(&input, 4000);
fake.source[0].buttons = C4F_INPUT_BUTTON_CROSS;
assert(poll(&input, 4010).standard.pressed == C4F_INPUT_BUTTON_CROSS);
c4fInputClose(&input);
assert(fake.source[0].closes == fake.source[0].opens);
assert(fake.source[1].closes == fake.source[1].opens);
puts("PASS read failures/disconnects clear stale activity, bounded retry and neutral-gated recovery/reset");
}
static void unavailableUserAndBorrowedHandle(void)
{
FakeInput fake;
C4fInput input;
reader(&fake, &input);
fake.initialResult = -1;
C4fInputFrame frame = poll(&input, 0);
assert(!frame.standard.available && frame.remote.available);
assert(fake.initialCalls == 1 && !fake.source[0].opens);
fake.initialResult = 0;
fake.user = -1;
poll(&input, 1000);
assert(fake.initialCalls == 2 && !fake.source[0].opens);
fake.user = 42;
fake.source[0].owned = 0; /* get-handle result owned by another subsystem */
frame = poll(&input, 2000);
assert(frame.standard.available && fake.source[0].opens == 1);
fake.source[0].readError = 1;
frame = poll(&input, 2010);
assert(!frame.standard.available && !frame.standard.buttons);
assert(!fake.source[0].closes && frame.remote.available);
fake.source[0].readError = 0;
frame = poll(&input, 3010);
assert(frame.standard.available && fake.source[0].opens == 2);
c4fInputClose(&input);
assert(!fake.source[0].closes && fake.source[1].closes == 1);
puts("PASS unavailable/invalid starting user does not block remote, borrowed handles remain open");
}
static C4fLauncherScreen stopped(void)
{
C4fLauncherScreen screen;
memset(&screen, 0, sizeof(screen));
screen.autorun = C4F_AUTORUN_ON;
return screen;
}
static int navigate(C4fLauncherScreen *screen, uint32_t standard, uint32_t remote, int diagnostic)
{
uint32_t standardUnhandled = 0, remoteUnhandled = 0;
return c4fNavigate(screen, standard, remote, diagnostic, &standardUnhandled, &remoteUnhandled);
}
static int remoteFrame(FakeInput *fake, C4fInput *input, C4fLauncherScreen *screen,
uint64_t now, uint32_t buttons)
{
fake->source[1].buttons = buttons;
C4fInputFrame frame = poll(input, now);
return navigate(screen, frame.standard.pressed, frame.remote.pressed, 0);
}
static int remoteKeyFrame(FakeInput *fake, C4fInput *input, C4fLauncherScreen *screen,
uint64_t now, uint8_t keyCode, int diagnostic)
{
/* The native reader supplies this normalized snapshot only for the remote
* port. Keep the fixture's standard controller snapshots unchanged. */
fake->source[C4F_INPUT_REMOTE].buttons = c4fInputRemoteButtons(0, keyCode);
C4fInputFrame frame = poll(input, now);
return navigate(screen, frame.standard.pressed, frame.remote.pressed, diagnostic);
}
static void extendedRemoteNavigation(int diagnostic)
{
FakeInput fake;
C4fInput input;
reader(&fake, &input);
fake.source[C4F_INPUT_STANDARD].openError = 1;
C4fLauncherScreen screen = stopped();
assert(remoteKeyFrame(&fake, &input, &screen, 0, 13, diagnostic) == C4F_ACTION_NONE);
assert(!screen.actionMenu);
assert(remoteKeyFrame(&fake, &input, &screen, 10, 13, diagnostic) == C4F_ACTION_NONE);
assert(!screen.actionMenu);
remoteKeyFrame(&fake, &input, &screen, 20, 0, diagnostic);
assert(remoteKeyFrame(&fake, &input, &screen, 30, 13, diagnostic) == C4F_ACTION_NONE);
assert(screen.actionMenu && screen.actionFocus == C4F_ACTION_START);
assert(remoteKeyFrame(&fake, &input, &screen, 40, 13, diagnostic) == C4F_ACTION_NONE);
assert(screen.actionMenu);
remoteKeyFrame(&fake, &input, &screen, 50, 0, diagnostic);
assert(remoteKeyFrame(&fake, &input, &screen, 60, 13, diagnostic) == C4F_ACTION_START);
assert(!screen.actionMenu);
assert(remoteKeyFrame(&fake, &input, &screen, 70, 13, diagnostic) == C4F_ACTION_NONE);
remoteKeyFrame(&fake, &input, &screen, 80, 0, diagnostic);
remoteKeyFrame(&fake, &input, &screen, 90, 13, diagnostic);
remoteKeyFrame(&fake, &input, &screen, 100, 0, diagnostic);
assert(remoteKeyFrame(&fake, &input, &screen, 110, 15, diagnostic) == C4F_ACTION_RESUME);
assert(!screen.actionMenu);
assert(remoteKeyFrame(&fake, &input, &screen, 120, 15, diagnostic) == C4F_ACTION_NONE);
remoteKeyFrame(&fake, &input, &screen, 130, 0, diagnostic);
assert(remoteKeyFrame(&fake, &input, &screen, 140, 18, diagnostic) == C4F_ACTION_NONE);
assert(screen.actionMenu);
assert(remoteKeyFrame(&fake, &input, &screen, 150, 18, diagnostic) == C4F_ACTION_NONE);
assert(screen.actionMenu);
remoteKeyFrame(&fake, &input, &screen, 160, 0, diagnostic);
assert(remoteKeyFrame(&fake, &input, &screen, 170, 15, diagnostic) == C4F_ACTION_RESUME);
fake.source[C4F_INPUT_REMOTE].connected = 0;
assert(remoteKeyFrame(&fake, &input, &screen, 180, 13, diagnostic) == C4F_ACTION_NONE);
assert(!screen.actionMenu && fake.source[C4F_INPUT_REMOTE].closes == 1);
fake.source[C4F_INPUT_REMOTE].connected = 1;
assert(remoteKeyFrame(&fake, &input, &screen, 1179, 13, diagnostic) == C4F_ACTION_NONE);
assert(fake.source[C4F_INPUT_REMOTE].opens == 1);
assert(remoteKeyFrame(&fake, &input, &screen, 1180, 13, diagnostic) == C4F_ACTION_NONE);
assert(fake.source[C4F_INPUT_REMOTE].opens == 2 && !screen.actionMenu);
assert(remoteKeyFrame(&fake, &input, &screen, 1181, 13, diagnostic) == C4F_ACTION_NONE);
remoteKeyFrame(&fake, &input, &screen, 1190, 0, diagnostic);
assert(remoteKeyFrame(&fake, &input, &screen, 1200, 13, diagnostic) == C4F_ACTION_NONE);
assert(screen.actionMenu);
remoteKeyFrame(&fake, &input, &screen, 1210, 0, diagnostic);
assert(remoteKeyFrame(&fake, &input, &screen, 1220, 15, diagnostic) == C4F_ACTION_RESUME);
assert(!screen.actionMenu);
c4fInputClose(&input);
assert(fake.source[C4F_INPUT_REMOTE].closes == 2);
puts(diagnostic ? "PASS extended remote OK/Back/Options, held-key safety and reconnect in diagnostics"
: "PASS extended remote OK/Back/Options, held-key safety and reconnect in the launcher");
}
static void heldRemoteConfirmation(void)
{
FakeInput fake;
C4fInput input;
reader(&fake, &input);
fake.source[0].openError = 1;
C4fLauncherScreen screen = stopped();
screen.running = 1;
assert(remoteFrame(&fake, &input, &screen, 0, 0) == C4F_ACTION_NONE);
assert(remoteFrame(&fake, &input, &screen, 10, C4F_INPUT_BUTTON_CROSS) == C4F_ACTION_NONE);
assert(screen.actionMenu);
assert(remoteFrame(&fake, &input, &screen, 20, C4F_INPUT_BUTTON_CROSS) == C4F_ACTION_NONE);
assert(screen.actionMenu);
remoteFrame(&fake, &input, &screen, 30, 0);
remoteFrame(&fake, &input, &screen, 40, C4F_INPUT_BUTTON_UP);
remoteFrame(&fake, &input, &screen, 50, 0);
remoteFrame(&fake, &input, &screen, 60, C4F_INPUT_BUTTON_UP);
assert(screen.actionFocus == C4F_ACTION_TESTER);
remoteFrame(&fake, &input, &screen, 62, 0);
remoteFrame(&fake, &input, &screen, 64, C4F_INPUT_BUTTON_UP);
assert(screen.actionFocus == C4F_ACTION_STOP);
remoteFrame(&fake, &input, &screen, 70, 0);
assert(remoteFrame(&fake, &input, &screen, 80, C4F_INPUT_BUTTON_CROSS) == C4F_ACTION_NONE);
assert(screen.confirmStop && !screen.confirmChoice);
assert(remoteFrame(&fake, &input, &screen, 90, C4F_INPUT_BUTTON_CROSS) == C4F_ACTION_NONE);
assert(screen.confirmStop && !screen.confirmChoice);
remoteFrame(&fake, &input, &screen, 100, 0);
remoteFrame(&fake, &input, &screen, 110, C4F_INPUT_BUTTON_RIGHT);
assert(screen.confirmChoice);
remoteFrame(&fake, &input, &screen, 120, 0);
assert(remoteFrame(&fake, &input, &screen, 130, C4F_INPUT_BUTTON_CROSS) == C4F_ACTION_STOP);
assert(remoteFrame(&fake, &input, &screen, 140, C4F_INPUT_BUTTON_CROSS) == C4F_ACTION_NONE);
c4fInputClose(&input);
puts("PASS combined native-reader/navigation flow never selects or confirms Stop from held OK");
}
static void menuActions(void)
{
C4fLauncherScreen screen = stopped();
assert(navigate(&screen, 0, C4F_INPUT_BUTTON_CROSS, 0) == C4F_ACTION_NONE);
assert(screen.actionMenu && screen.actionFocus == C4F_ACTION_START);
assert(navigate(&screen, 0, 0, 0) == C4F_ACTION_NONE && screen.actionMenu);
assert(navigate(&screen, 0, C4F_INPUT_BUTTON_CROSS, 0) == C4F_ACTION_START);
assert(!screen.actionMenu);
assert(navigate(&screen, 0, C4F_INPUT_BUTTON_CROSS, 0) == C4F_ACTION_NONE);
assert(navigate(&screen, 0, C4F_INPUT_BUTTON_DOWN, 0) == C4F_ACTION_NONE);
assert(screen.actionFocus == C4F_ACTION_AUTORUN);
assert(navigate(&screen, 0, C4F_INPUT_BUTTON_CROSS, 0) == C4F_ACTION_AUTORUN);
assert(!screen.actionMenu);
assert(navigate(&screen, 0, C4F_INPUT_BUTTON_CROSS, 0) == C4F_ACTION_NONE);
assert(navigate(&screen, 0, C4F_INPUT_BUTTON_UP, 0) == C4F_ACTION_NONE);
assert(screen.actionFocus == C4F_ACTION_RESUME);
assert(navigate(&screen, 0, C4F_INPUT_BUTTON_CROSS, 0) == C4F_ACTION_RESUME);
assert(!screen.actionMenu);
screen.running = 1;
assert(navigate(&screen, 0, C4F_INPUT_BUTTON_CROSS, 0) == C4F_ACTION_NONE);
assert(screen.actionMenu && screen.actionFocus == C4F_ACTION_RESUME);
assert(navigate(&screen, 0, C4F_INPUT_BUTTON_UP, 0) == C4F_ACTION_NONE);
assert(screen.actionFocus == C4F_ACTION_CLOSE);
assert(navigate(&screen, 0, C4F_INPUT_BUTTON_CROSS, 0) == C4F_ACTION_CLOSE);
assert(!screen.actionMenu);
assert(navigate(&screen, 0, C4F_INPUT_BUTTON_CIRCLE, 0) == C4F_ACTION_CLOSE);
screen = stopped();
navigate(&screen, 0, C4F_INPUT_BUTTON_CROSS, 0);
assert(navigate(&screen, 0, C4F_INPUT_BUTTON_CIRCLE, 0) == C4F_ACTION_RESUME);
assert(!screen.actionMenu);
puts("PASS remote arrows/OK reach Start, Auto-start, Resume and Close; opening OK is consumed");
}
static void stopConfirmation(void)
{
C4fLauncherScreen screen = stopped();
screen.running = 1;
navigate(&screen, 0, C4F_INPUT_BUTTON_CROSS, 0);
navigate(&screen, 0, C4F_INPUT_BUTTON_UP, 0);
navigate(&screen, 0, C4F_INPUT_BUTTON_UP, 0);
assert(screen.actionFocus == C4F_ACTION_TESTER);
navigate(&screen, 0, C4F_INPUT_BUTTON_UP, 0);
assert(screen.actionFocus == C4F_ACTION_STOP);
assert(navigate(&screen, 0, C4F_INPUT_BUTTON_CROSS, 0) == C4F_ACTION_NONE);
assert(screen.confirmStop && screen.confirmMenu && !screen.confirmChoice && !screen.actionMenu);
assert(navigate(&screen, 0, 0, 0) == C4F_ACTION_NONE && screen.confirmStop);
assert(navigate(&screen, 0, C4F_INPUT_BUTTON_CROSS, 0) == C4F_ACTION_RESUME);
assert(!screen.confirmStop);
navigate(&screen, 0, C4F_INPUT_BUTTON_CROSS, 0);
navigate(&screen, 0, C4F_INPUT_BUTTON_UP, 0);
navigate(&screen, 0, C4F_INPUT_BUTTON_UP, 0);
navigate(&screen, 0, C4F_INPUT_BUTTON_UP, 0);
navigate(&screen, 0, C4F_INPUT_BUTTON_CROSS, 0);
assert(navigate(&screen, 0, C4F_INPUT_BUTTON_RIGHT, 0) == C4F_ACTION_NONE);
assert(screen.confirmChoice);
assert(navigate(&screen, 0, C4F_INPUT_BUTTON_CROSS, 0) == C4F_ACTION_STOP);
assert(!screen.confirmStop);
/* Existing DS4 shortcuts keep their separate confirmation flow. */
assert(navigate(&screen, C4F_INPUT_BUTTON_SQUARE, 0, 0) == C4F_ACTION_NONE);
assert(screen.confirmStop && !screen.confirmMenu);
assert(navigate(&screen, C4F_INPUT_BUTTON_CROSS, 0, 0) == C4F_ACTION_STOP);
assert(!screen.confirmStop);
navigate(&screen, C4F_INPUT_BUTTON_SQUARE, 0, 0);
assert(navigate(&screen, C4F_INPUT_BUTTON_CIRCLE, 0, 0) == C4F_ACTION_RESUME);
assert(!screen.confirmStop);
navigate(&screen, C4F_INPUT_BUTTON_SQUARE, 0, 0);
assert(navigate(&screen, 0, C4F_INPUT_BUTTON_CROSS, 0) == C4F_ACTION_NONE);
assert(screen.confirmStop && screen.confirmMenu && !screen.confirmChoice);
assert(navigate(&screen, 0, C4F_INPUT_BUTTON_CROSS, 0) == C4F_ACTION_RESUME);
assert(!screen.confirmStop);
navigate(&screen, C4F_INPUT_BUTTON_SQUARE, 0, 0);
assert(navigate(&screen, C4F_INPUT_BUTTON_CROSS, C4F_INPUT_BUTTON_CROSS, 0) == C4F_ACTION_NONE);
assert(screen.confirmStop && screen.confirmMenu && !screen.confirmChoice);
assert(navigate(&screen, C4F_INPUT_BUTTON_CROSS, 0, 0) == C4F_ACTION_RESUME);
navigate(&screen, C4F_INPUT_BUTTON_SQUARE, 0, 0);
assert(navigate(&screen, C4F_INPUT_BUTTON_LEFT, 0, 0) == C4F_ACTION_NONE);
assert(screen.confirmStop && screen.confirmMenu && !screen.confirmChoice);
assert(navigate(&screen, C4F_INPUT_BUTTON_CROSS, 0, 0) == C4F_ACTION_RESUME);
puts("PASS remote Stop requires a separate fresh confirmation, defaults Cancel and preserves DS4 shortcuts");
}
static void unavailableActions(void)
{
C4fLauncherScreen screen = stopped();
screen.autorun = C4F_AUTORUN_UNKNOWN;
assert(!c4fActionAvailable(&screen, C4F_ACTION_AUTORUN, 0));
assert(navigate(&screen, C4F_INPUT_BUTTON_TRIANGLE, 0, 0) == C4F_ACTION_NONE);
screen.locked = 1;
assert(!c4fActionAvailable(&screen, C4F_ACTION_START, 0));
assert(navigate(&screen, C4F_INPUT_BUTTON_CROSS, 0, 0) == C4F_ACTION_NONE);
assert(navigate(&screen, C4F_INPUT_BUTTON_CIRCLE, 0, 0) == C4F_ACTION_CLOSE);
screen.busy = 1;
assert(navigate(&screen, C4F_INPUT_BUTTON_CROSS | C4F_INPUT_BUTTON_TRIANGLE | C4F_INPUT_BUTTON_SQUARE,
C4F_INPUT_BUTTON_CROSS, 0) == C4F_ACTION_NONE);
assert(screen.actionMenu && screen.actionFocus == C4F_ACTION_RESUME);
assert(navigate(&screen, C4F_INPUT_BUTTON_CIRCLE, C4F_INPUT_BUTTON_CIRCLE, 0) == C4F_ACTION_RESUME);
assert(navigate(&screen, C4F_INPUT_BUTTON_CIRCLE, C4F_INPUT_BUTTON_CIRCLE, 0) == C4F_ACTION_NONE);
screen = stopped();
navigate(&screen, 0, C4F_INPUT_BUTTON_CROSS, 0);
assert(screen.actionFocus == C4F_ACTION_START);
screen.running = 1; /* a status refresh while the user is choosing */
assert(navigate(&screen, 0, C4F_INPUT_BUTTON_CROSS, 0) == C4F_ACTION_NONE);
assert(screen.actionMenu && screen.actionFocus != C4F_ACTION_START);
/* A direction and OK arriving together must only move the focus. */
screen = stopped();
navigate(&screen, 0, C4F_INPUT_BUTTON_CROSS, 0);
assert(navigate(&screen, 0, C4F_INPUT_BUTTON_DOWN | C4F_INPUT_BUTTON_CROSS, 0) == C4F_ACTION_NONE);
assert(screen.actionMenu && screen.actionFocus == C4F_ACTION_AUTORUN);
assert(navigate(&screen, C4F_INPUT_BUTTON_UP, C4F_INPUT_BUTTON_DOWN | C4F_INPUT_BUTTON_CROSS, 0)
== C4F_ACTION_NONE);
assert(screen.actionMenu && screen.actionFocus == C4F_ACTION_AUTORUN);
assert(navigate(&screen, 0, C4F_INPUT_BUTTON_UP | C4F_INPUT_BUTTON_DOWN | C4F_INPUT_BUTTON_CROSS, 0)
== C4F_ACTION_NONE);
assert(screen.actionMenu && screen.actionFocus == C4F_ACTION_AUTORUN);
screen.running = 1;
screen.actionMenu = 0;
screen.confirmStop = screen.confirmMenu = screen.confirmChoice = 1;
assert(navigate(&screen, C4F_INPUT_BUTTON_UP, C4F_INPUT_BUTTON_DOWN | C4F_INPUT_BUTTON_CROSS, 0)
== C4F_ACTION_NONE);
assert(screen.confirmStop && screen.confirmChoice);
puts("PASS unavailable/busy actions, status-change focus repair and simultaneous direction/OK safety");
}
static void diagnosticNavigation(void)
{
C4fLauncherScreen screen = stopped();
uint32_t standardUnhandled, remoteUnhandled;
uint32_t directions = C4F_INPUT_BUTTON_UP | C4F_INPUT_BUTTON_RIGHT;
assert(c4fNavigate(&screen, directions, directions, 1, &standardUnhandled, &remoteUnhandled) == C4F_ACTION_NONE);
assert(standardUnhandled == directions && remoteUnhandled == directions);
assert(!screen.actionMenu);
assert(c4fNavigate(&screen, C4F_INPUT_BUTTON_OPTIONS, 0, 1,
&standardUnhandled, &remoteUnhandled) == C4F_ACTION_NONE);
assert(standardUnhandled == C4F_INPUT_BUTTON_OPTIONS && !remoteUnhandled);
assert(!c4fActionAvailable(&screen, C4F_ACTION_CAPTURE, 0));
assert(c4fActionAvailable(&screen, C4F_ACTION_CAPTURE, 1));
navigate(&screen, 0, C4F_INPUT_BUTTON_CROSS, 1);
/* The action list defines display order; navigating through every row
* ensures capture is reachable using only the TV remote. */
C4fLauncherAction actions[8];
int count = c4fActionList(&screen, 1, actions, 8), reached = 0;
assert(count > 0 && count <= 8);
for (int i = 0; i <= count; i++) {
if (screen.actionFocus == C4F_ACTION_CAPTURE) { reached = 1; break; }
assert(c4fNavigate(&screen, 0, C4F_INPUT_BUTTON_DOWN, 1,
&standardUnhandled, &remoteUnhandled) == C4F_ACTION_NONE);
assert(!remoteUnhandled);
}
assert(reached);
assert(navigate(&screen, 0, C4F_INPUT_BUTTON_CROSS, 1) == C4F_ACTION_CAPTURE);
assert(!screen.actionMenu);
puts("PASS diagnostic arrows remain page/scroll inputs outside Actions; remote can choose Capture");
}
int main(void)
{
remoteKeyNormalization();
remoteOnly();
sourceEdges();
remoteOpenRetry();
readerRecovery();
unavailableUserAndBorrowedHandle();
menuActions();
extendedRemoteNavigation(0);
extendedRemoteNavigation(1);
heldRemoteConfirmation();
stopConfirmation();
unavailableActions();
diagnosticNavigation();
return 0;
}
+310
View File
@@ -0,0 +1,310 @@
/* The real sampler and visible-state comparison from main.cpp, with only native
* APIs replaced. The display thread never polls input in these scenarios. */
#include <assert.h>
#include <atomic>
#include <chrono>
#include <pthread.h>
#include <stdio.h>
#include <string.h>
#include <thread>
#include <unistd.h>
#include "autorun.h"
#include "input.h"
#include "navigation.h"
#include "screen.h"
#include "service.h"
static pthread_mutex_t c4fMutex = PTHREAD_MUTEX_INITIALIZER;
static C4fLauncherScreen c4fScreen;
static int c4fCommand, c4fQuit, c4fCloseRequested;
static uint64_t c4fRenderAt, c4fInputAt;
/* Native API context is opaque to this test; its handles are owned by sampler. */
typedef struct { int handles[2]; } C4fNativeInput;
static std::atomic<uint32_t> standardButtons{0}, remoteButtons{0};
static std::atomic<int> polls{0}, closes{0}, largeMotor{0};
static std::atomic<bool> standardOpen{false}, lightOn{false};
static std::atomic<bool> blockRead{false}, readBlocked{false}, releaseRead{false};
uint64_t c4fLauncherTimeMs(void)
{
return (uint64_t)std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now().time_since_epoch()).count();
}
static int c4fNativeInitialUser(void *, int32_t *user) { *user = 1; return 0; }
static int c4fNativeOpen(void *, int32_t, int port, int *owned)
{
*owned = 1;
if (port != 16) standardOpen = true;
return port == 16 ? 11 : 10;
}
static int c4fNativeRead(void *, int handle, uint32_t *buttons, int *connected)
{
polls++;
*buttons = handle == 10 ? standardButtons.load() : remoteButtons.load();
*connected = 1;
return 0;
}
static void c4fNativeClose(void *, int handle)
{
if (handle == 10) {
assert(largeMotor.load() == 0 && !lightOn.load());
standardOpen = false;
}
closes++;
}
static int c4fTesterLoginUsers(void *, int32_t *users) { users[0] = 1; return 1; }
static void c4fTesterUserName(void *, int32_t, char *name, int size) { snprintf(name, size, "Alex"); }
static int c4fTesterOpenPad(void *, int32_t, int *owned) { *owned = 0; return 10; }
static int c4fTesterRead(void *, int, C4fTesterSample *sample)
{
if (blockRead.exchange(false)) {
readBlocked = true;
while (!releaseRead) usleep(1000);
readBlocked = false;
}
memset(sample, 0, sizeof(*sample));
sample->buttons = standardButtons.load(); sample->connected = 1;
sample->lx = sample->ly = sample->rx = sample->ry = 128;
return 0;
}
static void c4fTesterClosePad(void *, int) { assert(!"borrowed handle closed"); }
static int c4fTesterVibrate(void *, int, uint8_t large, uint8_t)
{
assert(standardOpen.load());
largeMotor = large;
return 0;
}
static int c4fTesterLight(void *, int, uint8_t, uint8_t, uint8_t) { assert(standardOpen.load()); lightOn = true; return 0; }
static int c4fTesterResetLight(void *, int) { assert(standardOpen.load()); lightOn = false; return 0; }
static int c4fTesterResetOrientation(void *, int) { return 0; }
static int c4fTesterMotion(void *, int, int) { return 0; }
static void c4fTesterLogState(void *, int, int, uint32_t) {}
static void c4fNativeInputNote(const C4fNativeInput *, char *note, size_t size) { snprintf(note, size, "test input"); }
#include "launcher-sampling-source.inc"
template <typename Predicate> static void until(Predicate predicate)
{
uint64_t deadline = c4fLauncherTimeMs() + 1000;
while (!predicate()) { assert(c4fLauncherTimeMs() < deadline); usleep(1000); }
}
static C4fTesterView view()
{
pthread_mutex_lock(&c4fMutex);
C4fTesterView result = c4fScreen.tester;
pthread_mutex_unlock(&c4fMutex);
return result;
}
static void tap(std::atomic<uint32_t> &buttons, uint32_t mask)
{
buttons = mask; usleep(16000); buttons = 0; usleep(16000);
}
static void noConnectionPads(C4fTesterConnection connection, int available)
{
assert(connection.available == available && connection.pads == 0);
for (int p = 0; p < C4F_SERVICE_PADS; p++) assert(connection.rttMs[p] == -1);
}
static void connectionView()
{
C4fServiceStatus status = {};
status.latencyAvailable = 1;
C4fTesterView tester = {};
tester.active = 1; tester.selected = 2;
tester.user[0].used = tester.user[2].used = 1;
tester.user[0].state = tester.user[2].state = C4F_TESTER_READY;
tester.user[0].id = 1; tester.user[2].id = 0x1A2B3C4D;
snprintf(tester.user[0].name, sizeof(tester.user[0].name), "Alex");
snprintf(tester.user[2].name, sizeof(tester.user[2].name), "Alex");
status.latency[0] = { 1, 1, 4, 0 };
status.latency[1] = { 1, 0x1A2B3C4D, 8, 100 };
status.latency[2] = { 0, 0x1A2B3C4D, 0, 0 };
status.latency[3] = { 1, 0x1A2B3C4D, 12, 200 };
const uint64_t checkedAt = 10000;
C4fTesterConnection connection = c4fTesterConnectionView(&status, C4F_SERVICE_RUNNING, checkedAt, checkedAt, &tester);
assert(connection.available && connection.pads == ((1U << 1) | (1U << 3)));
assert(connection.rttMs[0] == -1 && connection.rttMs[1] == 8 && connection.rttMs[2] == -1 && connection.rttMs[3] == 12);
/* Equal display names and equal slot indexes never establish ownership. */
tester.selected = 0;
connection = c4fTesterConnectionView(&status, C4F_SERVICE_RUNNING, checkedAt, checkedAt, &tester);
assert(connection.pads == 1 && connection.rttMs[0] == 4 && connection.rttMs[1] == -1 && connection.rttMs[3] == -1);
tester.selected = 2;
tester.user[2].id = 99;
noConnectionPads(c4fTesterConnectionView(&status, C4F_SERVICE_RUNNING, checkedAt, checkedAt, &tester), 1);
tester.user[2].id = 0x1A2B3C4D;
printf("PASS connection view: selected user ID matches every owned virtual slot, independently of names and indexes\n");
/* The cached service reply also ages while the launcher renders it. */
status.latency[1].ageMs = C4F_SERVICE_LATENCY_STALE_MS - 1000;
status.latency[3].ageMs = C4F_SERVICE_LATENCY_STALE_MS - 999;
connection = c4fTesterConnectionView(&status, C4F_SERVICE_RUNNING, checkedAt, checkedAt + 1000, &tester);
assert(connection.pads == ((1U << 1) | (1U << 3)) && connection.rttMs[1] == 8 && connection.rttMs[3] == -1);
connection = c4fTesterConnectionView(&status, C4F_SERVICE_RUNNING, checkedAt, checkedAt + 1001, &tester);
assert(connection.pads == ((1U << 1) | (1U << 3)) && connection.rttMs[1] == -1 && connection.rttMs[3] == -1);
status.latency[1].ageMs = status.latency[3].ageMs = 0;
connection = c4fTesterConnectionView(&status, C4F_SERVICE_RUNNING, checkedAt, checkedAt + C4F_SERVICE_LATENCY_STALE_MS, &tester);
assert(connection.available && connection.rttMs[1] == 8 && connection.rttMs[3] == 12);
noConnectionPads(c4fTesterConnectionView(&status, C4F_SERVICE_RUNNING, checkedAt,
checkedAt + C4F_SERVICE_LATENCY_STALE_MS + 1, &tester), 0);
noConnectionPads(c4fTesterConnectionView(&status, C4F_SERVICE_RUNNING, checkedAt, checkedAt - 1, &tester), 0);
printf("PASS connection view: each sample expires at its own age plus local cache age; stale or future cache is unavailable\n");
status.latency[1].rttMs = -1;
status.latency[3].ageMs = -1;
connection = c4fTesterConnectionView(&status, C4F_SERVICE_RUNNING, checkedAt, checkedAt, &tester);
assert(connection.available && connection.pads == ((1U << 1) | (1U << 3)));
for (int p = 0; p < C4F_SERVICE_PADS; p++) assert(connection.rttMs[p] == -1);
status.latency[1].rttMs = 0;
connection = c4fTesterConnectionView(&status, C4F_SERVICE_RUNNING, checkedAt, checkedAt, &tester);
assert(connection.rttMs[1] == 0 && connection.rttMs[3] == -1);
const int serviceStates[] = { C4F_SERVICE_UNKNOWN, C4F_SERVICE_DOWN };
for (int running : serviceStates)
noConnectionPads(c4fTesterConnectionView(&status, running, checkedAt, checkedAt, &tester), 0);
status.latencyAvailable = 0; /* A service without telemetry support. */
noConnectionPads(c4fTesterConnectionView(&status, C4F_SERVICE_RUNNING, checkedAt, checkedAt, &tester), 0);
status.latencyAvailable = 1;
printf("PASS connection view: unknown RTT stays -1, measured zero stays zero, unknown/stopped/older service has no readings\n");
tester.selected = -1;
noConnectionPads(c4fTesterConnectionView(&status, C4F_SERVICE_RUNNING, checkedAt, checkedAt, &tester), 1);
tester.selected = C4F_TESTER_USERS;
noConnectionPads(c4fTesterConnectionView(&status, C4F_SERVICE_RUNNING, checkedAt, checkedAt, &tester), 1);
tester.selected = 2; tester.user[2].used = 0;
noConnectionPads(c4fTesterConnectionView(&status, C4F_SERVICE_RUNNING, checkedAt, checkedAt, &tester), 1);
tester.user[2].used = 1;
const int readerStates[] = { C4F_TESTER_OPENING, C4F_TESTER_NO_CONTROLLER, C4F_TESTER_FAILED, C4F_TESTER_SIGNED_OUT };
for (int state : readerStates) {
tester.user[2].state = state;
noConnectionPads(c4fTesterConnectionView(&status, C4F_SERVICE_RUNNING, checkedAt, checkedAt, &tester), 1);
}
printf("PASS connection view: no selected ready user means no virtual-controller association\n");
}
int main()
{
connectionView();
c4fScreen.running = 1; c4fScreen.autorun = C4F_AUTORUN_ON;
c4fRenderAt = c4fLauncherTimeMs();
pthread_t worker;
assert(!pthread_create(&worker, NULL, c4fInputWorker, NULL));
until([] { return polls.load() >= 4; });
tap(standardButtons, C4F_INPUT_BUTTON_OPTIONS);
until([] { C4fTesterView v = view(); return v.active && v.user[0].state == C4F_TESTER_READY; });
tap(remoteButtons, C4F_INPUT_BUTTON_CROSS);
until([] { return view().menu; });
std::thread press([] {
usleep(20000); standardButtons = C4F_INPUT_BUTTON_DOWN;
usleep(16000); standardButtons = 0;
});
int before = polls;
usleep(100000); /* A display stall cannot swallow this complete short tap. */
press.join();
assert(view().focus == C4F_TESTER_RUMBLE_LARGE);
assert(polls.load() - before >= 8);
printf("PASS sampler: a short navigation tap survives a 100 ms display pause\n");
assert(view().focus == C4F_TESTER_RUMBLE_LARGE);
tap(standardButtons, C4F_INPUT_BUTTON_CROSS);
until([] { return largeMotor.load() == 255; });
blockRead = true;
until([] { return readBlocked.load(); });
assert(!pthread_mutex_trylock(&c4fMutex));
c4fScreen.controllers = 3;
pthread_mutex_unlock(&c4fMutex);
releaseRead = true;
until([] { return !readBlocked.load(); });
printf("PASS sampler: blocked native reads leave the display/status lock available\n");
// Keep the display alive while the input thread stalls. This must exercise
// the input-gap resume branch, not the stale-display pause branch. Both
// outputs use a handle borrowed from the ordinary launcher input reader.
tap(standardButtons, C4F_INPUT_BUTTON_UP);
tap(standardButtons, C4F_INPUT_BUTTON_LEFT);
assert(view().focus == C4F_TESTER_LIGHT_COLOR);
tap(standardButtons, C4F_INPUT_BUTTON_CROSS);
until([] { return lightOn.load(); });
tap(standardButtons, C4F_INPUT_BUTTON_CIRCLE);
tap(standardButtons, C4F_INPUT_BUTTON_DOWN);
tap(standardButtons, C4F_INPUT_BUTTON_RIGHT);
assert(view().focus == C4F_TESTER_RUMBLE_LARGE);
tap(standardButtons, C4F_INPUT_BUTTON_CROSS);
until([] { return largeMotor.load() == 255; });
releaseRead = false;
blockRead = true;
until([] { return readBlocked.load(); });
int beforeResume = closes.load();
for (int i = 0; i < 12; i++) {
pthread_mutex_lock(&c4fMutex);
c4fRenderAt = c4fLauncherTimeMs();
pthread_mutex_unlock(&c4fMutex);
usleep(100000);
}
pthread_mutex_lock(&c4fMutex);
c4fRenderAt = c4fLauncherTimeMs();
pthread_mutex_unlock(&c4fMutex);
releaseRead = true;
until([beforeResume] { return closes.load() >= beforeResume + 2 && !view().menu &&
view().user[0].state == C4F_TESTER_READY; });
assert(!largeMotor.load() && !lightOn.load());
printf("PASS sampler: fresh-display input-gap resume quiets borrowed-handle outputs before closing their owner\n");
// Restore active output so the independent stale-display safety check
// below cannot pass merely because the resume case already stopped it.
tap(remoteButtons, C4F_INPUT_BUTTON_CROSS);
until([] { return view().menu; });
tap(standardButtons, C4F_INPUT_BUTTON_DOWN);
assert(view().focus == C4F_TESTER_RUMBLE_LARGE);
tap(standardButtons, C4F_INPUT_BUTTON_CROSS);
until([] { return largeMotor.load() == 255; });
int beforeDisplayPause = closes.load();
pthread_mutex_lock(&c4fMutex);
c4fRenderAt = c4fLauncherTimeMs() - 1100;
pthread_mutex_unlock(&c4fMutex);
until([] { return largeMotor.load() == 0 && view().menu == 0; });
assert(closes.load() >= beforeDisplayPause + 2);
pthread_mutex_lock(&c4fMutex);
assert(c4fScreen.controllers == 3);
c4fRenderAt = c4fLauncherTimeMs();
pthread_mutex_unlock(&c4fMutex);
until([] { return view().user[0].state == C4F_TESTER_READY; });
pthread_mutex_lock(&c4fMutex); c4fQuit = 1; pthread_mutex_unlock(&c4fMutex);
pthread_join(worker, NULL);
assert(largeMotor.load() == 0 && closes.load() >= 4);
printf("PASS sampler: stalled display stops output; recovery and owned-handle cleanup are safe\n");
C4fLauncherScreen a = {}, b;
a.tester.active = a.tester.menu = 1; a.tester.selected = 0;
b = a; b.controllers = 4; b.tester.l2 = 170; b.tester.gyro[2] = 230;
C4fLauncherScreen va = c4fVisibleScreen(&a), vb = c4fVisibleScreen(&b);
assert(!memcmp(&va, &vb, sizeof(va)));
b.tester.focus = 1; vb = c4fVisibleScreen(&b); assert(memcmp(&va, &vb, sizeof(va)));
b = a; b.tester.rumble = 1; vb = c4fVisibleScreen(&b); assert(memcmp(&va, &vb, sizeof(va)));
a.connection.available = 1; a.connection.pads = 1U << 1;
for (int p = 0; p < C4F_SERVICE_PADS; p++) a.connection.rttMs[p] = -1;
a.connection.rttMs[1] = 8;
b = a; b.connection.rttMs[1] = 9;
va = c4fVisibleScreen(&a); vb = c4fVisibleScreen(&b); assert(memcmp(&va, &vb, sizeof(va)));
b = a; b.connection.rttMs[1] = -1;
vb = c4fVisibleScreen(&b); assert(memcmp(&va, &vb, sizeof(va)));
printf("PASS tester redraw key: fresh and expired connection RTT changes remain visible in tools\n");
a.tester.colorMenu = 1;
va = c4fVisibleScreen(&a);
b = a; b.tester.gyro[2] = 190;
vb = c4fVisibleScreen(&b); assert(!memcmp(&va, &vb, sizeof(va)));
b.tester.colorChannel = 1;
vb = c4fVisibleScreen(&b); assert(memcmp(&va, &vb, sizeof(va)));
b = a; b.tester.colorFine = 1;
vb = c4fVisibleScreen(&b); assert(memcmp(&va, &vb, sizeof(va)));
b = a; b.tester.light[0] = 128;
vb = c4fVisibleScreen(&b); assert(memcmp(&va, &vb, sizeof(va)));
printf("PASS RGB picker redraw key: selection, precision and color visible; hidden live motion ignored\n");
b = a;
a.tester.menu = b.tester.menu = 0; b.tester.gyro[2] = 200;
va = c4fVisibleScreen(&a); vb = c4fVisibleScreen(&b); assert(memcmp(&va, &vb, sizeof(va)));
printf("PASS tester redraw key: hidden readings ignored by tools; live view and output stay visible\n");
}
+759
View File
@@ -0,0 +1,759 @@
/* The controller test's logic with simulated users and pads. No console, no
* screen: the real tester.c and navigation.c against fake native calls. */
#include <assert.h>
#include <math.h>
#include <stdio.h>
#include <string.h>
#include "autorun.h"
#include "input.h"
#include "navigation.h"
#include "screen.h"
#include "tester.h"
#define STICKS (C4F_INPUT_BUTTON_L3 | C4F_INPUT_BUTTON_R3)
typedef struct {
int32_t user;
int handle, owned, openError, readError, connected;
int opens, closes, reads, motionOn, lightResets, orientationResets, vibrations, lightCalls, lightError;
int vibrating, large, small, lit;
uint8_t rgb[3];
C4fTesterSample sample;
} FakePad;
typedef struct {
int32_t users[4];
int count, listError;
FakePad pads[4];
int stateCalls;
} Fake;
static FakePad *byUser(Fake *f, int32_t user)
{
for (int i = 0; i < 4; i++) if (f->pads[i].user == user) return &f->pads[i];
assert(!"unknown user");
return NULL;
}
static FakePad *byHandle(Fake *f, int handle)
{
for (int i = 0; i < 4; i++) if (f->pads[i].handle == handle) return &f->pads[i];
assert(!"unknown handle");
return NULL;
}
static int fakeUsers(void *context, int32_t *users)
{
Fake *f = context;
if (f->listError) return f->listError;
for (int i = 0; i < f->count; i++) users[i] = f->users[i];
return f->count;
}
static void fakeName(void *context, int32_t user, char *name, int size)
{
(void)context;
/* User 3 has no name the app can draw: the tester falls back. */
if (user == 3) { name[0] = 0; return; }
snprintf(name, (size_t)size, user == 1 ? "Alex" : user == 2 ? "Sam\x01" : "Kim");
}
static int fakeOpen(void *context, int32_t user, int *owned)
{
FakePad *p = byUser(context, user);
p->opens++;
*owned = p->owned;
return p->openError ? p->openError : p->handle;
}
static int fakeRead(void *context, int handle, C4fTesterSample *sample)
{
FakePad *p = byHandle(context, handle);
p->reads++;
*sample = p->sample;
sample->connected = p->connected;
return p->readError;
}
static void fakeClose(void *context, int handle)
{
FakePad *p = byHandle(context, handle);
assert(p->owned);
p->closes++;
assert(p->closes <= p->opens);
}
static int fakeVibrate(void *context, int handle, uint8_t large, uint8_t small)
{
FakePad *p = byHandle(context, handle);
p->vibrations++;
p->large = large;
p->small = small;
p->vibrating = large || small;
return 0;
}
static int fakeLight(void *context, int handle, uint8_t r, uint8_t g, uint8_t b)
{
FakePad *p = byHandle(context, handle);
p->lightCalls++;
if (p->lightError) return p->lightError;
p->lit = 1;
p->rgb[0] = r; p->rgb[1] = g; p->rgb[2] = b;
return 0;
}
static int fakeResetLight(void *context, int handle)
{
FakePad *p = byHandle(context, handle);
p->lightResets++;
p->lit = 0;
return 0;
}
static int fakeResetOrientation(void *context, int handle)
{
byHandle(context, handle)->orientationResets++;
return 0;
}
static int fakeMotion(void *context, int handle, int on)
{
byHandle(context, handle)->motionOn = on;
return 0;
}
static void fakeState(void *context, int slot, int state, uint32_t error)
{
(void)slot; (void)state; (void)error;
((Fake *)context)->stateCalls++;
}
static void setup(Fake *f, C4fTester *t)
{
memset(f, 0, sizeof(*f));
f->count = 2;
f->users[0] = 1;
f->users[1] = 2;
for (int i = 0; i < 4; i++) {
f->pads[i] = (FakePad){ .user = i + 1, .handle = 100 + i, .owned = 1, .connected = 1 };
f->pads[i].sample.lx = f->pads[i].sample.ly = f->pads[i].sample.rx = f->pads[i].sample.ry = 128;
}
/* The launcher's own reader already holds the first user's handle. */
f->pads[0].owned = 0;
C4fTesterOps ops = { f, fakeUsers, fakeName, fakeOpen, fakeRead, fakeClose, fakeVibrate, fakeLight,
fakeResetLight, fakeResetOrientation, fakeMotion, fakeState };
c4fTesterInit(t, &ops);
}
/* A frame with the given buttons held on the first user's controller. */
static int frame(Fake *f, C4fTester *t, uint64_t now, uint32_t held, uint32_t remote)
{
f->pads[0].sample.buttons = held;
return c4fTesterFrame(t, now, remote);
}
static int tap(Fake *f, C4fTester *t, uint64_t *now, uint32_t button)
{
int active = frame(f, t, *now += 10, button, 0);
if (active) frame(f, t, *now += 10, 0, 0);
return active;
}
static void holdTiming(void)
{
C4fTesterHold hold = { 0 };
assert(!c4fTesterHoldUpdate(&hold, C4F_INPUT_BUTTON_L3, 0));
assert(!c4fTesterHoldUpdate(&hold, STICKS, 100));
assert(!c4fTesterHoldUpdate(&hold, STICKS | C4F_INPUT_BUTTON_CROSS, 2099));
assert(c4fTesterHoldUpdate(&hold, STICKS, 2100));
assert(!c4fTesterHoldUpdate(&hold, STICKS, 5000));
assert(!c4fTesterHoldUpdate(&hold, 0, 5001));
assert(!c4fTesterHoldUpdate(&hold, STICKS, 6000));
assert(c4fTesterHoldUpdate(&hold, STICKS, 8000));
puts("PASS L3+R3 must be held together for two seconds, once per hold");
}
static void readsUsers(void)
{
Fake f;
static C4fTester t;
setup(&f, &t);
c4fTesterOpen(&t, 1000);
assert(t.active && t.selected == 0);
f.pads[0].sample.lx = 12; f.pads[0].sample.l2 = 200; f.pads[0].sample.touches = 3;
f.pads[0].sample.touch[1].x = 1919; f.pads[0].sample.touch[1].y = 941;
f.pads[0].sample.accel[1] = 0.987f; f.pads[0].sample.gyro[0] = -1.234f; f.pads[0].sample.quat[3] = 0.5f;
f.pads[0].sample.quat[0] = NAN;
assert(frame(&f, &t, 1000, C4F_INPUT_BUTTON_CROSS, 0));
assert(f.pads[0].opens == 1 && f.pads[1].opens == 1 && f.pads[0].motionOn && f.pads[1].motionOn);
C4fTesterView v;
c4fTesterView(&t, &v);
assert(v.active && !v.menu && v.selected == 0);
assert(v.user[0].used && v.user[1].used && !v.user[2].used);
assert(v.user[0].id == 1 && v.user[1].id == 2 && v.user[2].id == -1 && v.user[3].id == -1);
assert(v.user[v.selected].id == 1);
assert(!strcmp(v.user[0].name, "Alex") && !strcmp(v.user[1].name, "Sam?"));
assert(v.user[0].state == C4F_TESTER_READY && v.user[1].state == C4F_TESTER_READY);
assert(v.buttons == C4F_INPUT_BUTTON_CROSS && v.lx == 12 && v.l2 == 200);
assert(v.touches == 2 && v.touchX[1] == 1919 && v.touchY[1] == 941);
assert(v.accel[1] == 99 && v.gyro[0] == -123 && v.quat[3] == 500 && v.quat[0] == 0);
/* The Cross held when reading started is shown, but is not a press. */
assert(t.readers[0].pressed == 0);
t.selected = 1;
c4fTesterView(&t, &v);
assert(v.selected == 1 && v.user[v.selected].id == 2);
t.selected = 0;
frame(&f, &t, 1010, C4F_INPUT_BUTTON_CROSS | C4F_INPUT_BUTTON_CIRCLE, 0);
assert(t.readers[0].pressed == C4F_INPUT_BUTTON_CIRCLE && t.active && !t.menu);
/* A third user with no drawable name gets a stand-in. */
f.count = 3; f.users[2] = 3;
frame(&f, &t, 2010, 0, 0);
c4fTesterView(&t, &v);
assert(!strcmp(v.user[2].name, "User 3"));
assert(v.user[2].id == 3);
c4fTesterClose(&t);
assert(!t.active && f.pads[0].closes == 0 && f.pads[1].closes == 1 && f.pads[2].closes == 1);
c4fTesterView(&t, &v);
assert(!v.active);
for (int s = 0; s < C4F_TESTER_USERS; s++) assert(!v.user[s].used && v.user[s].id == -1);
setup(&f, &t);
f.count = 0;
c4fTesterOpen(&t, 0);
frame(&f, &t, 0, 0, 0);
c4fTesterView(&t, &v);
assert(v.active && v.selected == -1);
for (int s = 0; s < C4F_TESTER_USERS; s++) assert(!v.user[s].used && v.user[s].id == -1);
c4fTesterClose(&t);
puts("PASS readers for every signed-in user, borrowed handle kept, values in display units");
}
/* Opens the test, lets the opening hold go, then holds again until the menu opens. */
static uint64_t openMenu(Fake *f, C4fTester *t)
{
c4fTesterOpen(t, 0);
/* Still holding the L3+R3 that opened it: no menu, however long. */
for (uint64_t now = 0; now <= 4000; now += 500) assert(frame(f, t, now, STICKS, 0) && !t->menu);
frame(f, t, 4100, 0, 0);
frame(f, t, 4200, STICKS, 0);
frame(f, t, 6199, STICKS, 0);
assert(!t->menu);
frame(f, t, 6200, STICKS, 0);
assert(t->menu && !t->armed && t->focus == C4F_TESTER_RESUME);
return 6200;
}
static void menuActions(void)
{
Fake f;
static C4fTester t;
setup(&f, &t);
uint64_t now = openMenu(&f, &t);
frame(&f, &t, now += 10, 0, 0);
assert(t.armed);
tap(&f, &t, &now, C4F_INPUT_BUTTON_DOWN);
assert(t.focus == C4F_TESTER_RUMBLE_LARGE);
tap(&f, &t, &now, C4F_INPUT_BUTTON_CROSS);
assert(f.pads[0].vibrating && f.pads[0].large == 255 && f.pads[0].small == 0);
C4fTesterView v;
c4fTesterView(&t, &v);
assert(v.rumble == 1 && strstr(v.note, "Large motor"));
now = t.rumbleUntil - 1;
frame(&f, &t, now, 0, 0);
assert(f.pads[0].vibrating);
frame(&f, &t, ++now, 0, 0);
assert(!f.pads[0].vibrating && !t.rumble);
/* Small motor, then the light bar and orientation. */
tap(&f, &t, &now, C4F_INPUT_BUTTON_RIGHT);
assert(t.focus == C4F_TESTER_RUMBLE_SMALL);
tap(&f, &t, &now, C4F_INPUT_BUTTON_CROSS);
assert(f.pads[0].vibrating && f.pads[0].large == 0 && f.pads[0].small == 255);
/* A move and a select arriving together only move. */
frame(&f, &t, now += 10, C4F_INPUT_BUTTON_DOWN | C4F_INPUT_BUTTON_CROSS, 0);
assert(t.active && t.focus == C4F_TESTER_USER && !f.pads[0].lit);
frame(&f, &t, now += 10, 0, 0);
tap(&f, &t, &now, C4F_INPUT_BUTTON_RIGHT);
tap(&f, &t, &now, C4F_INPUT_BUTTON_RIGHT);
assert(t.focus == C4F_TESTER_LIGHT_COLOR);
tap(&f, &t, &now, C4F_INPUT_BUTTON_CROSS);
assert(t.colorMenu && f.pads[0].lit && f.pads[0].rgb[0] == 255 && f.pads[0].rgb[1] == 255);
c4fTesterView(&t, &v);
assert(v.lightSet && v.light[0] == 255);
tap(&f, &t, &now, C4F_INPUT_BUTTON_CIRCLE);
tap(&f, &t, &now, C4F_INPUT_BUTTON_LEFT);
assert(t.focus == C4F_TESTER_ORIENTATION);
tap(&f, &t, &now, C4F_INPUT_BUTTON_CROSS);
assert(f.pads[0].orientationResets == 1);
/* Back to the launcher leaves and cleans up. */
tap(&f, &t, &now, C4F_INPUT_BUTTON_RIGHT);
tap(&f, &t, &now, C4F_INPUT_BUTTON_DOWN);
assert(t.focus == C4F_TESTER_EXIT);
assert(!tap(&f, &t, &now, C4F_INPUT_BUTTON_CROSS));
assert(!t.active);
assert(f.pads[0].lightResets == 1 && !f.pads[0].lit && !f.pads[0].vibrating);
assert(f.pads[0].closes == 0 && f.pads[1].closes == 1);
puts("PASS menu after a fresh hold and release; rumble stops after a second; light bar reset on leaving");
}
static void switchUsers(void)
{
Fake f;
static C4fTester t;
setup(&f, &t);
uint64_t now = openMenu(&f, &t);
frame(&f, &t, now += 10, 0, 0);
tap(&f, &t, &now, C4F_INPUT_BUTTON_RIGHT);
assert(t.focus == C4F_TESTER_USER);
tap(&f, &t, &now, C4F_INPUT_BUTTON_CROSS);
assert(t.selected == 1);
tap(&f, &t, &now, C4F_INPUT_BUTTON_CROSS);
assert(t.selected == 0);
/* The second user's own controller drives the menu as well. */
f.pads[1].sample.buttons = C4F_INPUT_BUTTON_CROSS;
frame(&f, &t, now += 10, 0, 0);
assert(t.selected == 1);
f.pads[1].sample.buttons = 0;
frame(&f, &t, now += 10, 0, 0);
/* Rumble goes to the selected user's controller. */
tap(&f, &t, &now, C4F_INPUT_BUTTON_DOWN);
tap(&f, &t, &now, C4F_INPUT_BUTTON_LEFT);
tap(&f, &t, &now, C4F_INPUT_BUTTON_CROSS);
assert(f.pads[1].vibrating && !f.pads[0].vibrating);
/* Back closes the menu; the test stays open. */
tap(&f, &t, &now, C4F_INPUT_BUTTON_CIRCLE);
assert(t.active && !t.menu);
c4fTesterClose(&t);
assert(!f.pads[1].vibrating && f.pads[1].closes == 1);
puts("PASS user choice from either controller, output to the chosen user, Back resumes testing");
}
static void menuOpeningSafety(void)
{
Fake f;
static C4fTester t;
setup(&f, &t);
/* Another controller held Down before the menu opened. It must neither
* scroll by itself nor prevent fresh input on the opening controller. */
f.pads[1].sample.buttons = C4F_INPUT_BUTTON_DOWN;
uint64_t now = openMenu(&f, &t);
frame(&f, &t, now + 10, STICKS | C4F_INPUT_BUTTON_RIGHT, 0);
assert(!t.armed && t.focus == C4F_TESTER_USER);
frame(&f, &t, now + 20, STICKS | C4F_INPUT_BUTTON_RIGHT | C4F_INPUT_BUTTON_CROSS, 0);
assert(t.selected == 0 && t.menu);
/* Releasing the opener arms activation even though Cross and another
* controller's Down remain held. The old Cross still is not a fresh click. */
frame(&f, &t, now + 30, C4F_INPUT_BUTTON_CROSS, 0);
assert(t.armed && t.selected == 0);
frame(&f, &t, now + 40, C4F_INPUT_BUTTON_CROSS, 0);
assert(t.selected == 0);
frame(&f, &t, now + 50, 0, 0);
frame(&f, &t, now + 60, C4F_INPUT_BUTTON_CROSS, 0);
assert(t.selected == 1 && t.focus == C4F_TESTER_USER);
frame(&f, &t, now + 1000, C4F_INPUT_BUTTON_CROSS, 0);
assert(t.selected == 1 && t.focus == C4F_TESTER_USER);
c4fTesterClose(&t);
setup(&f, &t);
now = openMenu(&f, &t);
/* No neutral frame is required between the opening gesture and a new
* direction: release L3/R3 and press Down in the same sample. */
frame(&f, &t, now + 10, C4F_INPUT_BUTTON_DOWN, 0);
assert(t.armed && t.focus == C4F_TESTER_RUMBLE_LARGE);
frame(&f, &t, now + 20, 0, 0);
frame(&f, &t, now + 30, C4F_INPUT_BUTTON_CROSS, 0);
assert(f.pads[0].vibrating);
c4fTesterClose(&t);
setup(&f, &t);
f.pads[1].sample.buttons = STICKS;
now = openMenu(&f, &t);
frame(&f, &t, now + 10, C4F_INPUT_BUTTON_RIGHT, 0);
assert(t.armed && t.focus == C4F_TESTER_USER); /* unrelated held sticks do not block */
frame(&f, &t, now + 20, C4F_INPUT_BUTTON_CROSS, 0);
assert(t.selected == 1);
c4fTesterClose(&t);
setup(&f, &t);
c4fTesterOpen(&t, 0);
frame(&f, &t, 0, 0, 0);
frame(&f, &t, 100, STICKS, 0);
f.pads[1].sample.buttons = STICKS;
frame(&f, &t, 1000, 0, 0);
frame(&f, &t, 2100, 0, 0);
assert(!t.menu); /* a different reader cannot inherit elapsed hold time */
frame(&f, &t, 2999, 0, 0);
assert(!t.menu);
frame(&f, &t, 3000, 0, 0);
assert(t.menu && t.menuOpeners == (1u << 1));
c4fTesterClose(&t);
puts("PASS menu release gate belongs only to its opener; fresh navigation survives; held activation never replays");
}
static void gridNavigationAndRepeat(void)
{
Fake f;
static C4fTester t;
setup(&f, &t);
uint64_t now = openMenu(&f, &t);
assert(C4F_TESTER_MENU_COLUMNS == 4 && C4F_TESTER_MENU_ROWS == 2);
frame(&f, &t, now + 10, 0, 0);
frame(&f, &t, now + 20, C4F_INPUT_BUTTON_RIGHT, 0);
assert(t.focus == C4F_TESTER_USER);
frame(&f, &t, now + 369, C4F_INPUT_BUTTON_RIGHT, 0);
assert(t.focus == C4F_TESTER_USER);
frame(&f, &t, now + 370, C4F_INPUT_BUTTON_RIGHT, 0);
assert(t.focus == C4F_TESTER_ORIENTATION);
frame(&f, &t, now + 469, C4F_INPUT_BUTTON_RIGHT, 0);
assert(t.focus == C4F_TESTER_ORIENTATION);
frame(&f, &t, now + 470, C4F_INPUT_BUTTON_RIGHT, 0);
assert(t.focus == C4F_TESTER_LIGHT_COLOR);
frame(&f, &t, now + 570, C4F_INPUT_BUTTON_RIGHT, 0);
assert(t.focus == C4F_TESTER_RESUME);
frame(&f, &t, now + 670, C4F_INPUT_BUTTON_RIGHT, 0);
assert(t.focus == C4F_TESTER_USER); /* held Right wraps within its row */
frame(&f, &t, now + 680, 0, 0);
frame(&f, &t, now + 690, C4F_INPUT_BUTTON_DOWN, 0);
assert(t.focus == C4F_TESTER_RUMBLE_SMALL);
frame(&f, &t, now + 1040, C4F_INPUT_BUTTON_DOWN, 0);
assert(t.focus == C4F_TESTER_USER); /* held Down wraps within its column */
frame(&f, &t, now + 1050, C4F_INPUT_BUTTON_LEFT, 0);
assert(t.focus == C4F_TESTER_RESUME); /* direction change moves immediately */
frame(&f, &t, now + 1060, C4F_INPUT_BUTTON_UP | C4F_INPUT_BUTTON_DOWN, 0);
assert(t.focus == C4F_TESTER_RESUME);
frame(&f, &t, now + 1070, C4F_INPUT_BUTTON_UP, 0);
assert(t.focus == C4F_TESTER_RESUME); /* old held half is not a fresh press */
frame(&f, &t, now + 1080, 0, 0);
frame(&f, &t, now + 1090, C4F_INPUT_BUTTON_UP, 0);
assert(t.focus == C4F_TESTER_RUMBLE_LARGE); /* top wraps to the bottom of its column */
frame(&f, &t, now + 1100, 0, 0);
frame(&f, &t, now + 1110, C4F_INPUT_BUTTON_UP, 0);
assert(t.focus == C4F_TESTER_RESUME);
now += 1120;
frame(&f, &t, now, 0, 0);
for (int i = 0; i < 4; i++) tap(&f, &t, &now, C4F_INPUT_BUTTON_LEFT);
assert(t.focus == C4F_TESTER_RESUME);
tap(&f, &t, &now, C4F_INPUT_BUTTON_LEFT);
assert(t.focus == C4F_TESTER_LIGHT_COLOR); /* first column wraps to last */
/* Every tile has the same spatial neighbors for a controller tap and a
* fresh HDMI-CEC direction. Row ends wrap within the row, never snake. */
static const uint32_t directions[] = { C4F_INPUT_BUTTON_UP, C4F_INPUT_BUTTON_DOWN,
C4F_INPUT_BUTTON_LEFT, C4F_INPUT_BUTTON_RIGHT };
static const int neighbors[C4F_TESTER_ITEMS][4] = {
{ 4, 4, 3, 1 }, { 5, 5, 0, 2 }, { 6, 6, 1, 3 }, { 7, 7, 2, 0 },
{ 0, 0, 7, 5 }, { 1, 1, 4, 6 }, { 2, 2, 5, 7 }, { 3, 3, 6, 4 }
};
for (int item = 0; item < C4F_TESTER_ITEMS; item++) {
for (int direction = 0; direction < 4; direction++) {
t.focus = item;
tap(&f, &t, &now, directions[direction]);
assert(t.focus == neighbors[item][direction]);
t.focus = item;
frame(&f, &t, now += 10, 0, directions[direction]);
assert(t.focus == neighbors[item][direction]);
frame(&f, &t, now += 10, 0, 0);
assert(t.focus == neighbors[item][direction]);
}
}
c4fTesterSuspend(&t, now + 1000);
assert(!t.menu && !t.menuOpeners);
for (int s = 0; s < C4F_TESTER_USERS; s++) assert(!t.readers[s].repeatDirection && !t.readers[s].repeatAt);
c4fTesterClose(&t);
puts("PASS 4-by-2 spatial wrap for controller/TV remote, immediate taps, 350/100 ms hold repeat and reset on focus loss");
}
static void colorPicker(void)
{
Fake f;
static C4fTester t;
setup(&f, &t);
uint64_t now = openMenu(&f, &t);
frame(&f, &t, now += 10, 0, 0);
t.focus = C4F_TESTER_LIGHT_COLOR;
frame(&f, &t, now += 10, C4F_INPUT_BUTTON_CROSS, 0);
assert(t.colorMenu && !t.colorFine && f.pads[0].lit);
assert(f.pads[0].rgb[0] == 255 && f.pads[0].rgb[1] == 255 && f.pads[0].rgb[2] == 255);
frame(&f, &t, now += 500, C4F_INPUT_BUTTON_CROSS, 0);
assert(!t.colorFine); /* The opening Cross is not also a precision toggle. */
frame(&f, &t, now += 10, 0, 0);
for (int i = 0; i < 18; i++) frame(&f, &t, now += 10, 0, C4F_INPUT_BUTTON_LEFT);
assert(f.pads[0].rgb[0] == 0 && f.pads[0].rgb[1] == 255 && f.pads[0].rgb[2] == 255);
frame(&f, &t, now += 10, 0, C4F_INPUT_BUTTON_CROSS);
assert(t.colorFine);
/* Every exact byte value can be selected by the TV remote, without a mouse. */
for (int value = 1; value <= 255; value++) {
frame(&f, &t, now += 10, 0, C4F_INPUT_BUTTON_RIGHT);
assert(f.pads[0].rgb[0] == value);
}
frame(&f, &t, now += 10, 0, C4F_INPUT_BUTTON_RIGHT);
assert(f.pads[0].rgb[0] == 255); /* clamp, never wrap */
frame(&f, &t, now += 10, 0, C4F_INPUT_BUTTON_DOWN);
assert(t.colorChannel == 1);
for (int i = 0; i < 10; i++) frame(&f, &t, now += 10, 0, C4F_INPUT_BUTTON_LEFT);
assert(f.pads[0].rgb[1] == 245 && f.pads[0].rgb[0] == 255);
frame(&f, &t, now += 10, 0, C4F_INPUT_BUTTON_DOWN);
assert(t.colorChannel == 2);
for (int i = 0; i < 255; i++) frame(&f, &t, now += 10, 0, C4F_INPUT_BUTTON_LEFT);
assert(f.pads[0].rgb[2] == 0 && !f.pads[1].lit);
C4fTesterView v;
c4fTesterView(&t, &v);
assert(v.colorMenu && v.colorChannel == 2 && v.colorFine && v.lightSet);
assert(v.light[0] == 255 && v.light[1] == 245 && v.light[2] == 0);
frame(&f, &t, now += 10, 0, C4F_INPUT_BUTTON_CIRCLE);
assert(t.menu && !t.colorMenu && f.pads[0].lit);
frame(&f, &t, now += 10, 0, C4F_INPUT_BUTTON_CROSS);
assert(t.colorMenu && t.colorChannel == 2 && f.pads[0].rgb[1] == 245);
/* Hardware refusal never fabricates a successful preview or stored value. */
f.pads[0].lightError = -5;
frame(&f, &t, now += 10, 0, C4F_INPUT_BUTTON_RIGHT);
assert(f.pads[0].rgb[2] == 0 && t.readers[0].light[2] == 0 && strstr(t.note, "refused"));
f.pads[0].lightError = 0;
frame(&f, &t, now += 10, 0, C4F_INPUT_BUTTON_RIGHT);
assert(f.pads[0].rgb[2] == 1 && !t.note[0]);
/* Held controller input keeps its normal repeat timing in the picker. */
frame(&f, &t, now += 10, C4F_INPUT_BUTTON_RIGHT, 0);
assert(f.pads[0].rgb[2] == 2);
frame(&f, &t, now += 349, C4F_INPUT_BUTTON_RIGHT, 0);
assert(f.pads[0].rgb[2] == 2);
frame(&f, &t, ++now, C4F_INPUT_BUTTON_RIGHT, 0);
assert(f.pads[0].rgb[2] == 3);
frame(&f, &t, now += 10, 0, 0);
frame(&f, &t, now += 10, 0, C4F_INPUT_BUTTON_CIRCLE);
t.focus = C4F_TESTER_LIGHT_RESET;
frame(&f, &t, now += 10, 0, C4F_INPUT_BUTTON_CROSS);
assert(!t.readers[0].lightSet && !f.pads[0].lit);
t.selected = 1;
t.focus = C4F_TESTER_LIGHT_COLOR;
frame(&f, &t, now += 10, 0, C4F_INPUT_BUTTON_CROSS);
assert(t.colorMenu && f.pads[1].lit && !f.pads[0].lit);
f.count = 1;
frame(&f, &t, now = t.usersAt, 0, 0);
assert(!t.colorMenu && !f.pads[1].lit && t.readers[1].state == C4F_TESTER_SIGNED_OUT);
c4fTesterClose(&t);
setup(&f, &t);
now = openMenu(&f, &t);
frame(&f, &t, now += 10, 0, 0);
t.focus = C4F_TESTER_LIGHT_COLOR;
f.pads[0].lightError = -5;
tap(&f, &t, &now, C4F_INPUT_BUTTON_CROSS);
assert(!t.colorMenu && !t.readers[0].lightSet && !f.pads[0].lit);
f.pads[0].lightError = 0;
tap(&f, &t, &now, C4F_INPUT_BUTTON_CROSS);
f.pads[0].connected = 0;
frame(&f, &t, now += 10, 0, 0);
assert(!t.colorMenu && !f.pads[0].lit && !t.readers[0].lightSet);
c4fTesterClose(&t);
puts("PASS RGB picker: all byte strengths, coarse/fine, controller repeat, remote control, selected user, refusal and cleanup");
}
static void menuAvailability(void)
{
Fake f;
static C4fTester t;
setup(&f, &t);
f.count = 1;
c4fTesterOpen(&t, 0);
frame(&f, &t, 0, 0, 0);
C4fTesterView view;
c4fTesterView(&t, &view);
assert(c4fTesterItemAvailable(&view, C4F_TESTER_RESUME));
assert(c4fTesterItemAvailable(&view, C4F_TESTER_EXIT));
assert(!c4fTesterItemAvailable(&view, C4F_TESTER_USER));
assert(c4fTesterItemAvailable(&view, C4F_TESTER_ORIENTATION));
t.menu = t.armed = 1; t.focus = C4F_TESTER_USER;
frame(&f, &t, 10, C4F_INPUT_BUTTON_CROSS, 0);
assert(t.selected == 0 && strstr(t.note, "No other user"));
frame(&f, &t, 20, 0, 0);
f.pads[0].connected = 0;
frame(&f, &t, 30, 0, 0);
c4fTesterView(&t, &view);
assert(!c4fTesterItemAvailable(&view, C4F_TESTER_RUMBLE_LARGE));
assert(!c4fTesterItemAvailable(&view, C4F_TESTER_LIGHT_COLOR));
assert(c4fTesterItemAvailable(&view, C4F_TESTER_RESUME));
assert(c4fTesterItemAvailable(&view, C4F_TESTER_EXIT));
/* A signed-out user may stay selected for its status screen. The one
* remaining signed-in user must still be available as a switch target. */
view.user[0].state = C4F_TESTER_SIGNED_OUT;
view.user[1].used = 1;
view.user[1].state = C4F_TESTER_READY;
assert(c4fTesterItemAvailable(&view, C4F_TESTER_USER));
c4fTesterClose(&t);
puts("PASS menu availability matches activation: user count, disconnected hardware tools, Resume and Exit");
}
static void failures(void)
{
Fake f;
static C4fTester t;
setup(&f, &t);
c4fTesterOpen(&t, 0);
frame(&f, &t, 0, 0, 0);
/* A read failure releases the handle and retries a second later. */
f.pads[1].readError = (int)0x80920003u;
frame(&f, &t, 10, 0, 0);
assert(t.readers[1].state == C4F_TESTER_FAILED && t.readers[1].handle < 0 && f.pads[1].closes == 1);
f.pads[1].readError = 0;
frame(&f, &t, 1009, 0, 0);
assert(f.pads[1].opens == 1);
frame(&f, &t, 1010, 0, 0);
assert(f.pads[1].opens == 2 && t.readers[1].state == C4F_TESTER_READY);
/* An open failure is shown with its code and retried. */
c4fTesterClose(&t);
setup(&f, &t);
f.pads[1].openError = (int)0x80920001u;
c4fTesterOpen(&t, 0);
frame(&f, &t, 0, 0, 0);
C4fTesterView v;
c4fTesterView(&t, &v);
assert(v.user[1].state == C4F_TESTER_FAILED && v.user[1].error == 0x80920001u);
f.pads[1].openError = 0;
frame(&f, &t, 1000, 0, 0);
assert(t.readers[1].state == C4F_TESTER_READY);
/* No controller: shown as such, the handle kept, nothing pressed. */
f.pads[0].connected = 0;
frame(&f, &t, 1010, C4F_INPUT_BUTTON_CROSS, 0);
c4fTesterView(&t, &v);
assert(v.user[0].state == C4F_TESTER_NO_CONTROLLER && !v.buttons && t.readers[0].handle >= 0);
/* An action needs a controller to act on. */
f.pads[0].connected = 1;
frame(&f, &t, 1020, 0, 0);
f.pads[0].connected = 0;
t.menu = t.armed = 1;
t.focus = C4F_TESTER_RUMBLE_LARGE;
frame(&f, &t, 1030, 0, 0);
f.pads[1].sample.buttons = C4F_INPUT_BUTTON_CROSS;
frame(&f, &t, 1040, 0, 0);
assert(!f.pads[0].vibrating && strstr(t.note, "no controller"));
f.pads[1].sample.buttons = 0;
c4fTesterClose(&t);
puts("PASS read/open failures retried after a second, no-controller state, actions need a controller");
}
static void logout(void)
{
Fake f;
static C4fTester t;
setup(&f, &t);
f.count = 3; f.users[2] = 3;
c4fTesterOpen(&t, 0);
frame(&f, &t, 0, 0, 0);
t.selected = 1;
/* The user on screen signs out: kept, marked, its handle closed. */
f.count = 2; f.users[1] = 3;
frame(&f, &t, 1000, 0, 0);
assert(t.selected == 1 && t.readers[1].state == C4F_TESTER_SIGNED_OUT && f.pads[1].closes == 1);
C4fTesterView v;
c4fTesterView(&t, &v);
assert(v.user[1].used && v.user[1].state == C4F_TESTER_SIGNED_OUT);
assert(v.user[v.selected].id == 2);
/* Signs in again: read again. */
f.count = 3; f.users[1] = 2; f.users[2] = 3;
frame(&f, &t, 2000, 0, 0);
frame(&f, &t, 2001, 0, 0);
assert(t.readers[1].state == C4F_TESTER_READY && f.pads[1].opens == 2);
c4fTesterView(&t, &v);
assert(v.user[v.selected].id == 2);
/* A user not on screen signs out: dropped at once. */
f.count = 2;
frame(&f, &t, 3000, 0, 0);
assert(t.readers[2].user < 0 && f.pads[2].closes == 1);
c4fTesterView(&t, &v);
assert(!v.user[2].used && v.user[2].id == -1);
/* A failed user list keeps what is known. */
f.listError = -1;
frame(&f, &t, 4000, 0, 0);
assert(t.readers[0].user == 1 && t.readers[1].user == 2);
c4fTesterClose(&t);
puts("PASS sign-out keeps the user on screen as signed out; others dropped; sign-in reads again");
}
static void remoteAndSuspend(void)
{
Fake f;
static C4fTester t;
setup(&f, &t);
c4fTesterOpen(&t, 0);
frame(&f, &t, 0, 0, 0);
/* The remote's OK opens the menu, its Back closes it, and Back again leaves. */
frame(&f, &t, 10, 0, C4F_INPUT_BUTTON_CROSS);
assert(t.menu);
frame(&f, &t, 20, 0, 0);
assert(t.armed);
frame(&f, &t, 30, 0, C4F_INPUT_BUTTON_RIGHT);
assert(t.focus == C4F_TESTER_USER);
frame(&f, &t, 40, 0, C4F_INPUT_BUTTON_CIRCLE);
assert(t.active && !t.menu);
/* A DS4's Circle is under test and does not leave. */
frame(&f, &t, 50, C4F_INPUT_BUTTON_CIRCLE, 0);
assert(t.active && !t.menu);
/* Losing focus stops output and reopens every reader. */
t.menu = t.armed = 1;
t.focus = C4F_TESTER_RUMBLE_LARGE;
frame(&f, &t, 60, 0, 0);
frame(&f, &t, 70, C4F_INPUT_BUTTON_CROSS, 0);
t.focus = C4F_TESTER_LIGHT_COLOR;
frame(&f, &t, 80, 0, 0);
frame(&f, &t, 90, C4F_INPUT_BUTTON_CROSS, 0);
assert(f.pads[0].vibrating && f.pads[0].lit && f.pads[0].rgb[2] == 255);
c4fTesterSuspend(&t, 5000);
assert(!f.pads[0].vibrating && !f.pads[0].lit && !t.menu && t.active);
assert(t.readers[0].handle < 0 && t.readers[1].handle < 0 && f.pads[1].closes == 1);
frame(&f, &t, 5000, 0, 0);
assert(t.readers[0].state == C4F_TESTER_READY && t.readers[1].state == C4F_TESTER_READY);
assert(!frame(&f, &t, 5010, 0, C4F_INPUT_BUTTON_CIRCLE));
assert(!t.active && f.pads[1].closes == 2);
puts("PASS TV remote opens/closes the menu and leaves; focus loss stops output and reopens readers");
}
static void launcherEntry(void)
{
C4fLauncherScreen screen;
memset(&screen, 0, sizeof(screen));
screen.autorun = C4F_AUTORUN_ON;
assert(c4fActionAvailable(&screen, C4F_ACTION_TESTER, 0));
assert(!strcmp(c4fActionLabel(&screen, C4F_ACTION_TESTER), "Test controllers"));
C4fLauncherAction actions[8];
int count = c4fActionList(&screen, 0, actions, 8), found = 0;
for (int i = 0; i < count; i++) found |= actions[i] == C4F_ACTION_TESTER;
assert(found && actions[count - 1] == C4F_ACTION_CLOSE);
screen.busy = 1;
assert(c4fActionAvailable(&screen, C4F_ACTION_TESTER, 0));
screen.locked = 1;
assert(!c4fActionAvailable(&screen, C4F_ACTION_TESTER, 0));
for (int i = 0; i < C4F_TESTER_ITEMS; i++) assert(c4fTesterItemLabel(i)[0]);
/* A DS4's Options opens it straight from the launcher, even while busy; the
* diagnostic build keeps Options for its kernel-log capture. */
memset(&screen, 0, sizeof(screen));
screen.autorun = C4F_AUTORUN_ON;
uint32_t standard, remote;
assert(c4fNavigate(&screen, C4F_INPUT_BUTTON_OPTIONS, 0, 0, &standard, &remote) == C4F_ACTION_TESTER && !standard);
assert(c4fNavigate(&screen, C4F_INPUT_BUTTON_OPTIONS, 0, 1, &standard, &remote) == C4F_ACTION_NONE &&
standard == C4F_INPUT_BUTTON_OPTIONS);
screen.busy = 1;
assert(c4fNavigate(&screen, C4F_INPUT_BUTTON_OPTIONS, 0, 0, &standard, &remote) == C4F_ACTION_TESTER);
screen.locked = 1;
assert(c4fNavigate(&screen, C4F_INPUT_BUTTON_OPTIONS, 0, 0, &standard, &remote) == C4F_ACTION_NONE);
puts("PASS Test controllers: in Actions unless locked, Options opens it, every menu item has a label");
}
int main(void)
{
holdTiming();
readsUsers();
menuActions();
switchUsers();
menuOpeningSafety();
gridNavigationAndRepeat();
colorPicker();
menuAvailability();
failures();
logout();
remoteAndSuspend();
launcherEntry();
return 0;
}
+11
View File
@@ -3,6 +3,7 @@
#include <stdarg.h>
#include <string.h>
#include <time.h>
#include <sys/stat.h>
#include <unistd.h>
#include "c4f_log.h"
@@ -32,5 +33,15 @@ int main(void)
file = fopen(C4F_LOG_PATH ".previous", "r"); assert(file);
char previous[8192]; size_t m = fread(previous, 1, sizeof(previous), file); fclose(file);
assert(m == n && memcmp(data, previous, n) == 0);
c4fLogOpen();
for (int i = 0; i < 2400; i++) c4fLog("%s", large);
c4fLogClose();
struct stat active, rollover;
assert(!stat(C4F_LOG_PATH, &active) && !stat(C4F_LOG_PATH ".1", &rollover));
assert(active.st_size <= 1024 * 1024 && rollover.st_size <= 1024 * 1024);
assert(active.st_size > 0 && rollover.st_size > 0);
c4fLogOpen(); c4fLogClose();
assert(access(C4F_LOG_PATH ".1", F_OK) != 0);
puts("PASS bounded active/rollover segments and stale rollover removal on restart");
puts("PASS elapsed timestamps, multiline/truncated messages and previous-log preservation");
}
+157
View File
@@ -0,0 +1,157 @@
// 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);
}
// HID reports carry independent digital trigger flags and pressure bytes. A
// pressed flag must not turn a partial analog pull into full pressure.
for (let byte = 0; byte <= 255; byte++) {
const hid = { buttons: B.L2 | B.R2, lx: 128, ly: 128, rx: 128, ry: 128, l2: byte, r2: 255 - byte };
const result = M.toSource(M.fromSource(hid, true));
assert.equal(result.l2, byte, 'HID L2 pressure with its digital flag set');
assert.equal(result.r2, 255 - byte, 'HID R2 pressure with its digital flag set');
assert.equal(result.buttons, B.L2 | B.R2, 'HID digital flags remain independent');
}
const triggerSource = { buttons: B.L2 | B.R2, lx: 128, ly: 128, rx: 128, ry: 128, l2: 64, r2: 160 };
const digitalTriggers = M.toSource(M.fromSource({ ...triggerSource, l2: 0, r2: 0 }));
assert.equal(digitalTriggers.l2, 255, 'touch and keyboard L2 stay digital');
assert.equal(digitalTriggers.r2, 255, 'touch and keyboard R2 stay digital');
const triggerMerge = M.toSource(M.merge([M.fromSource(triggerSource, true),
M.fromSource({ ...triggerSource, l2: 192, r2: 32 }, true)]));
assert.equal(triggerMerge.l2, 192); assert.equal(triggerMerge.r2, 160);
const mixedTrigger = M.toSource(M.merge([M.fromSource(triggerSource, true),
M.fromSource({ ...triggerSource, buttons: B.L2, l2: 0, r2: 0 })]));
assert.equal(mixedTrigger.l2, 255); assert.equal(mixedTrigger.r2, 160);
console.log('PASS HID trigger pressure with digital flags, digital touch/keys and mixed-source maximum');
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');
+30
View File
@@ -0,0 +1,30 @@
"""The vendored Mbed TLS, built once for the host tests: build/mbedtls-host/libmbedtls.a.
It is rebuilt only when a source or the configuration is newer than the library."""
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
import subprocess
ROOT = Path(__file__).resolve().parents[1]
LIBRARY = ROOT / 'build/mbedtls-host/libmbedtls.a'
FLAGS = ['-Iinclude', '-Ivendor/mbedtls/include', '-DMBEDTLS_CONFIG_FILE=<c4f_mbedtls_config.h>']
def library():
sources = sorted((ROOT / 'vendor/mbedtls/library').glob('*.c'))
inputs = sources + list((ROOT / 'vendor/mbedtls').rglob('*.h')) + [ROOT / 'include/c4f_mbedtls_config.h']
newest = max(p.stat().st_mtime for p in inputs)
if LIBRARY.exists() and LIBRARY.stat().st_mtime >= newest:
return str(LIBRARY)
LIBRARY.parent.mkdir(parents=True, exist_ok=True)
def compile_one(source):
target = LIBRARY.parent / (source.stem + '.o')
subprocess.run(['clang-18', '-std=gnu11', '-O2', '-g', *FLAGS, '-c', str(source), '-o', str(target)],
check=True, cwd=ROOT)
return str(target)
with ThreadPoolExecutor() as pool:
objects = list(pool.map(compile_one, sources))
LIBRARY.unlink(missing_ok=True)
subprocess.run(['ar', 'rcs', str(LIBRARY), *objects], check=True)
return str(LIBRARY)
+23
View File
@@ -0,0 +1,23 @@
"""Tag-specific release notes keep documentation links usable on GitHub Releases."""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'tools'))
from release_notes import release_notes
text = '''# Changelog
## 2.3.4 — Unreleased
[Guide](docs/playing.md#gamepads) [External](https://example.com/) [Anchor](#top)
## 2.3.3 — Before
older notes
'''
result = release_notes(text, '2.3.4', 'example/project')
assert '[Guide](https://github.com/example/project/blob/v2.3.4/docs/playing.md#gamepads)' in result
assert '[External](https://example.com/)' in result and '[Anchor](#top)' in result
assert 'older notes' not in result
try:
release_notes(text, 'missing', 'example/project')
except ValueError:
pass
else:
raise AssertionError('missing changelog version accepted')
print('PASS release-note extraction and tag-specific links')
+53
View File
@@ -0,0 +1,53 @@
// Browser integration stays on loopback for secure-context APIs (WebHID and
// motion). Only the SDP exchange goes through the container's real interface:
// Chromium excludes remote loopback candidates from WebRTC connectivity.
const os = require('node:os');
const fs = require('node:fs');
const address = Object.values(os.networkInterfaces()).flat().find(i => i.family === 'IPv4' && !i.internal)?.address;
const serviceBinary = '/src/build/web-rtc-host-test';
async function relayOffer(page) {
if (!address) throw new Error('The RTC browser test needs a non-loopback container interface');
await page.route('http://127.0.0.1:4264/rtc', async route => {
const host = address + ':4264';
const original = route.request().headers();
const headers = { ...original, host, origin: 'http://' + host };
const response = await route.fetch({ url: 'http://' + host + '/rtc', headers });
await route.fulfill({ response, headers: { ...response.headers(), 'access-control-allow-origin': original.origin || '*' } });
});
}
// Keep the existing controller-behavior assertions readable while recording the
// actual wire messages separately for sequence/history/channel assertions.
function recordRtcMessages() {
window.rtcMessages = [];
window.controllerMessages = [];
window.testClaims = [];
window.testInputs = [];
const dataSend = RTCDataChannel.prototype.send;
RTCDataChannel.prototype.send = function(data) {
try {
const message = JSON.parse(data);
rtcMessages.push({ channel: this.id, message });
let normalized = message;
if (message.method === 'i') {
const p = message.params;
normalized = { ...message, method: 'u', params: [p[0], ...p.slice(2, 10 + 3 * p[9])] };
} else if (message.method === 'm') {
normalized = { ...message, params: [message.params[0], ...message.params.slice(2)] };
}
controllerMessages.push(normalized);
if (normalized.method === 'claim') testClaims.push(normalized.params);
if (normalized.method === 'u') testInputs.push(normalized.params);
} catch {}
return dataSend.call(this, data);
};
}
function copyService(destination) {
if (!fs.existsSync(serviceBinary)) throw new Error('Build the production RTC host service with tests/run.py first');
fs.copyFileSync(serviceBinary, destination);
fs.chmodSync(destination, 0o755);
}
module.exports = { address, relayOffer, recordRtcMessages, copyService };
+101
View File
@@ -0,0 +1,101 @@
// The WebRTC transport against real browsers: Chromium and Firefox open the two
// data channels to the service (build/web-rtc-host-test, the PS4 stubbed) and talk
// to it. The production-only HTTP surface refuses WebSocket. The page is
// opened by the container's own address, not 127.0.0.1:
// browsers leave loopback out of WebRTC. Run in the Playwright Docker image:
// NODE_PATH=… node tests/rtc_e2e.cjs
const pw = require('playwright');
const fs = require('node:fs'), os = require('node:os'), assert = require('node:assert/strict'), { spawn } = require('node:child_process');
const address = Object.values(os.networkInterfaces()).flat().find(i => i.family === 'IPv4' && !i.internal).address;
fs.copyFileSync('/src/build/web-rtc-host-test', '/tmp/c4f-rtc-service');
fs.chmodSync('/tmp/c4f-rtc-service', 0o755);
let server, log = '';
function startService(env = {}) {
server = spawn('/tmp/c4f-rtc-service', [], { env: { ...process.env, C4F_TEST_KLOG_FD: '0', ...env }, stdio: ['pipe', 'pipe', 'pipe'] });
server.stderr.on('data', d => { log += d; });
server.stdout.on('data', d => { log += d; });
}
async function stopService() {
const old = server;
if (!old) return;
server = null;
old.kill();
await new Promise(done => old.exitCode !== null || old.signalCode !== null ? done() : old.once('exit', done));
}
// In the page: an offer to POST /rtc, the two negotiated channels, then a few
// round trips on each. Returns what happened.
async function talk(page, { echoes = 20 } = {}) {
return page.evaluate(async ({ echoes }) => {
const pc = new RTCPeerConnection();
try {
const control = pc.createDataChannel('control', { negotiated: true, id: 0 });
const input = pc.createDataChannel('input', { negotiated: true, id: 1, ordered: false, maxRetransmits: 0 });
const pushed = [];
const pending = new Map();
control.onmessage = ev => {
const m = JSON.parse(ev.data);
if (m.id && pending.has(m.id)) { pending.get(m.id)(m); pending.delete(m.id); } else pushed.push(m);
};
const started = performance.now();
await pc.setLocalDescription(await pc.createOffer());
const reply = await fetch('/rtc', { method: 'POST', headers: { 'Content-Type': 'text/plain' }, body: pc.localDescription.sdp });
if (!reply.ok) return { error: 'offer refused: ' + reply.status + ' ' + await reply.text() };
const answer = await reply.text();
await pc.setRemoteDescription({ type: 'answer', sdp: answer });
const open = ch => new Promise((done, fail) => {
if (ch.readyState === 'open') return done();
ch.onopen = done; ch.onerror = fail;
setTimeout(() => fail(new Error(ch.label + ' did not open: ' + pc.connectionState + '/' + pc.iceConnectionState)), 10000);
});
try { await Promise.all([open(control), open(input)]); } catch (e) { return { error: e.message, answer }; }
const openedMs = performance.now() - started;
const call = (id, method, params = []) => new Promise((done, fail) => {
const timer = setTimeout(() => { pending.delete(id); fail(new Error(method + ' reply timed out')); }, 5000);
pending.set(id, value => { clearTimeout(timer); done(value); });
control.send(JSON.stringify({ id, method, params }));
});
const info = await call(1, 'info');
// The service's first push is the status, sent the moment the channel opened.
await new Promise(done => setTimeout(done, 200));
const status = pushed.find(m => m.method === 's');
// Echoes on the unordered channel.
const back = new Map();
input.onmessage = ev => { const m = JSON.parse(ev.data); if (m.method === 'e') back.set(m.params[0], performance.now() - m.params[1]); };
for (let i = 1; i <= echoes; i++) { input.send(JSON.stringify({ method: 'e', params: [i, Math.round(performance.now())] })); await new Promise(d => setTimeout(d, 20)); }
await new Promise(done => setTimeout(done, 500));
// A big reply (the invite QR code) comes in pieces over the reliable channel.
const invite = await call(2, 'invite', []);
return { openedMs, info: info.result, status: status && status.params, echoes: back.size, invite: invite.result || invite.error };
} finally { pc.close(); }
}, { echoes });
}
(async () => {
startService();
try {
const results = {};
for (const name of ['chromium', 'firefox']) {
const browser = await pw[name].launch(name === 'firefox' ? { firefoxUserPrefs: { 'media.peerconnection.ice.loopback': true } } : {});
try {
const page = await browser.newPage();
page.on('pageerror', e => console.error(name, 'page error:', e.message));
for (let tries = 0; ; tries++) {
try { await page.goto(`http://${address}:4264/`); break; } catch (e) { if (tries > 50) throw e; await new Promise(d => setTimeout(d, 100)); }
}
const r = await talk(page);
results[name] = r;
if (r.error) { console.error(log); throw new Error(name + ': ' + r.error + '\n' + (r.answer || '')); }
assert.equal(r.info.protocol, 3, name);
assert.equal(r.info.input, true, name);
assert.ok(r.status && r.status.pads.length === 4, name + ': status pushed on open');
assert.equal(r.echoes, 20, name + ': healthy-link echoes');
assert.ok(r.invite && r.invite.size > 20, name + ': invite ' + JSON.stringify(r.invite));
console.log(`PASS rtc ${name}: open in ${r.openedMs.toFixed(0)} ms, info, status push, ${r.echoes}/20 echoes, big reply in pieces`);
} finally { await browser.close(); }
}
if (!/rtc: peer \d+ open/.test(log)) { console.error(log); throw new Error('the service did not log an open peer'); }
} finally { await stopService(); }
})().catch(e => { console.error(e); process.exit(1); });
+199
View File
@@ -0,0 +1,199 @@
// Real production RTC application behavior through a controllable local UDP
// relay. All sockets stay inside the Docker test container; no console access.
const { chromium } = require('playwright');
const assert = require('node:assert/strict');
const dgram = require('node:dgram');
const { spawn } = require('node:child_process');
const { address, copyService, relayOffer } = require('./rtc_browser_helpers.cjs');
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
async function relay() {
const down = dgram.createSocket('udp4'), up = dgram.createSocket('udp4');
await Promise.all([new Promise(resolve => down.bind(0, '0.0.0.0', resolve)),
new Promise(resolve => up.bind(0, '0.0.0.0', resolve))]);
let client, mode = 'pass';
const held = [];
function forward(socket, data, port, host) {
if (mode === 'drop') return;
const deliver = () => socket.send(data, port, host);
if (mode === 'hold') held.push(deliver); else deliver();
}
down.on('message', (data, from) => {
client = from;
forward(up, Buffer.from(data), 4264, address);
});
up.on('message', data => { if (client) forward(down, Buffer.from(data), client.port, client.address); });
return { port: down.address().port,
mode(value) { mode = value; },
release() { for (const deliver of held.splice(0)) deliver(); },
async close() { held.length = 0; await Promise.all([new Promise(r => down.close(r)), new Promise(r => up.close(r))]); }
};
}
async function connect(page, port) {
return page.evaluate(async port => {
const pc = new RTCPeerConnection({ iceServers: [] });
const control = pc.createDataChannel('control', { negotiated: true, id: 0 });
const input = pc.createDataChannel('input', { negotiated: true, id: 1, ordered: false, maxRetransmits: 0 });
const pending = new Map(), pushes = [];
let nextId = 1;
control.onmessage = event => {
const msg = JSON.parse(event.data), request = pending.get(msg.id);
if (request) { clearTimeout(request.timer); pending.delete(msg.id); request.resolve(msg); }
else pushes.push(msg);
};
input.onmessage = event => pushes.push(JSON.parse(event.data));
function request(method, params = [], onInput = false) {
return new Promise((resolve, reject) => {
const id = nextId++;
const timer = setTimeout(() => { pending.delete(id); reject(new Error('Request timed out: ' + method)); }, 5000);
pending.set(id, { resolve, timer });
(onInput ? input : control).send(JSON.stringify({ id, method, params }));
});
}
await pc.setLocalDescription(await pc.createOffer());
const response = await fetch('/rtc', { method: 'POST', headers: { 'Content-Type': 'text/plain' }, body: pc.localDescription.sdp });
if (!response.ok) throw new Error('Offer refused: ' + response.status);
let answer = await response.text();
if (port) answer = answer.replace(/(a=candidate:\S+ \d+ \S+ \d+ \S+ )\d+/g, (_match, prefix) => prefix + port);
await pc.setRemoteDescription({ type: 'answer', sdp: answer });
await Promise.all([control, input].map(channel => new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('Channel did not open')), 10000);
if (channel.readyState === 'open') { clearTimeout(timer); resolve(); }
else channel.onopen = () => { clearTimeout(timer); resolve(); };
})));
window.rtcInputTest = { pc, control, input, pushes, request,
state(seq, buttons = 0, lx = 128, history = []) {
input.send(JSON.stringify({ method: 'i', params: [0, seq, buttons, lx, 128, 128, 128, 0, 0, 0, history.length, ...history.flat()] }));
},
motion(seq, acceleration) {
input.send(JSON.stringify({ method: 'm', params: [0, seq, 0, 0, 0, 10000, acceleration, 0, 0, 0, 0, 0] }));
}
};
return request('info');
}, port);
}
(async () => {
let service, browser, proxy;
let trace = '', partial = '';
const frames = [];
const frameAt = (buttons, lx = undefined) => rows => rows.some(row => +row[3] === buttons && (lx === undefined || +row[4] === lx));
async function waitFrames(start, condition, timeout = 2500) {
const deadline = Date.now() + timeout;
while (!condition(frames.slice(start))) {
if (Date.now() > deadline) throw new Error('Expected controller report not seen\n' + trace.slice(-5000));
await sleep(10);
}
}
try {
if (!address) throw new Error('Run the browser checks with a Docker network interface; --network none has only loopback');
copyService('/tmp/c4f-rtc-input-service');
service = spawn('/tmp/c4f-rtc-input-service', [], { env: { ...process.env, C4F_TEST_KLOG_FD: '0' }, stdio: ['pipe', 'pipe', 'pipe'] });
service.stdout.on('data', data => {
trace += data; partial += data;
const lines = partial.split('\n'); partial = lines.pop();
for (const line of lines) if (line.startsWith('FRAME ')) frames.push(line.split(' '));
});
service.stderr.on('data', data => { trace += data; });
browser = await chromium.launch();
const context = await browser.newContext();
await context.grantPermissions(['local-network-access']);
const page = await context.newPage();
await relayOffer(page);
// Supply an empty same-origin document: this test owns the RTC peers.
await page.route('http://127.0.0.1:4264/', async route => route.fulfill({ response: await route.fetch(), body: '<!doctype html><title>RTC input test</title>' }));
await page.goto('http://127.0.0.1:4264/');
for (let tries = 0; ; tries++) {
try { if ((await page.request.get(`http://${address}:4264/`)).ok()) break; }
catch (error) { if (tries > 50) throw error; }
await sleep(100);
}
proxy = await relay();
const info = await connect(page, proxy.port);
assert.equal(info.result.protocol, 3);
const claim = await page.evaluate(() => rtcInputTest.request('claim', [0]));
assert.equal(claim.result.pads[0].mine, true);
let start = frames.length;
await page.evaluate(() => rtcInputTest.state(1));
await waitFrames(start, frameAt(0));
// The press and release packets were absent, but their bounded history
// repairs a quick tap. Analog state is current, not reconstructed history.
start = frames.length;
await page.evaluate(() => rtcInputTest.state(4, 0, 17, [[2, 0x4000], [3, 0]]));
await waitFrames(start, rows => frameAt(0x4000, 17)(rows) && frameAt(0, 17)(rows));
await page.evaluate(() => { rtcInputTest.state(3, 0x2000); rtcInputTest.state(4, 0x2000); });
await sleep(80);
assert.ok(frames.slice(start).every(row => +row[3] !== 0x2000), 'late or duplicate state restored an old button');
const wrongControl = await page.evaluate(() => rtcInputTest.request('ping', [], true));
assert.equal(wrongControl.error.code, 400);
const wrongInput = await page.evaluate(() => rtcInputTest.request('i', [0, 5, 0, 128, 128, 128, 128, 0, 0, 0, 0]));
assert.equal(wrongInput.error.code, 400);
const malformedHistory = await page.evaluate(() => rtcInputTest.request('i', [0, 5, 0, 128, 128, 128, 128, 0, 0, 0, 2, 4, 0, 3, 0], true));
assert.equal(malformedHistory.error.code, 400);
console.log('PASS RTC input: explicit claim, recovered tap, ordered history, duplicate/late rejection, channel separation');
start = frames.length;
await page.evaluate(() => { rtcInputTest.motion(10, 1000); rtcInputTest.motion(9, -1000); rtcInputTest.state(5, 0, 33); });
await waitFrames(start, rows => rows.some(row => +row[4] === 33 && +row[15] === 1));
assert.ok(frames.slice(start).every(row => +row[15] !== -1), 'old motion overtook new motion');
// A genuinely held datagram can arrive after a newer one. No retransmit
// setting can remove it from this queue; receiver sequences must reject it.
proxy.mode('hold');
await page.evaluate(() => rtcInputTest.state(6, 0x2000));
await sleep(100);
proxy.mode('pass');
start = frames.length;
await page.evaluate(() => rtcInputTest.state(7, 0, 44));
await waitFrames(start, frameAt(0, 44));
proxy.release();
await sleep(100);
assert.ok(frames.slice(start).every(row => +row[3] !== 0x2000), 'held old datagram regressed input');
// Drop both directions for 900ms, including several full-state messages.
// Afterward repeat recent events and exact final state. Timing lost during
// the outage is not claimed to be recoverable.
proxy.mode('drop');
start = frames.length;
await page.evaluate(() => { rtcInputTest.state(8, 0x4000); rtcInputTest.state(9, 0); });
await sleep(900);
assert.ok(frames.slice(start).every(row => +row[3] === 0), 'dropped traffic reached the service');
proxy.mode('pass');
await page.evaluate(() => rtcInputTest.state(10, 0, 55, [[8, 0x4000], [9, 0]]));
await waitFrames(start, rows => frameAt(0x4000, 55)(rows) && frameAt(0, 55)(rows));
console.log('PASS RTC impairment: stale held datagram rejected, 900ms bidirectional loss, bounded tap recovery and exact final state');
// A stale reset must not forget its sequence floor. Motion by itself must
// not keep an old held button alive.
await page.evaluate(() => rtcInputTest.state(11, 0x20));
await waitFrames(frames.length, frameAt(0x20));
start = frames.length;
for (let i = 11; i < 24; i++) {
await page.evaluate(seq => rtcInputTest.motion(seq, 0), i);
await sleep(250);
}
await waitFrames(start, frameAt(0));
const status = await page.evaluate(() => rtcInputTest.request('status'));
assert.equal(status.result.pads[0].state, 'paused');
start = frames.length;
await page.evaluate(() => rtcInputTest.state(10, 0x4000));
await sleep(100);
assert.ok(frames.slice(start).every(row => +row[3] === 0), 'stale reset discarded sequence floor');
await page.evaluate(() => rtcInputTest.state(12, 0));
await waitFrames(start, frameAt(0));
assert.equal((await page.evaluate(() => rtcInputTest.request('status'))).result.pads[0].state, 'select');
console.log('PASS RTC freshness: motion cannot renew held buttons; stale reset preserves sequence floor; newer input resumes');
await page.evaluate(() => rtcInputTest.pc.close());
} finally {
if (browser) await browser.close();
if (proxy) await proxy.close();
if (service) {
service.kill();
await new Promise(resolve => service.exitCode !== null || service.signalCode !== null ? resolve() : service.once('exit', resolve));
}
}
})().catch(error => { console.error(error); process.exitCode = 1; });
+116
View File
@@ -0,0 +1,116 @@
/* Deterministic tests of the real RTC datagram boundary. No sockets or PS4:
* force the sendto outcomes that are difficult to reproduce on a healthy LAN. */
#include <errno.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
static ssize_t c4fTestSendto(int, const void *, size_t, int, const struct sockaddr *, socklen_t);
#define sendto c4fTestSendto
#include "../src/rtc.c"
#undef sendto
#define CHECK(cond) do { if (!(cond)) { fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, #cond); exit(1); } } while (0)
static uint64_t fakeNow = 1000;
static int sendError;
static unsigned writes;
static uint8_t wire[256][C4F_RTC_MTU];
static size_t wireLen[256];
static uint16_t wirePort[256];
uint64_t c4fTimeMs(void) { return fakeNow; }
void c4fLog(const char *fmt, ...) { (void)fmt; }
static ssize_t c4fTestSendto(int fd, const void *data, size_t len, int flags,
const struct sockaddr *to, socklen_t tolen)
{
(void)fd; (void)flags; (void)tolen;
if (sendError) { errno = sendError; return -1; }
CHECK(writes < 256 && len <= C4F_RTC_MTU);
memcpy(wire[writes], data, len); wireLen[writes] = len;
wirePort[writes] = ((const struct sockaddr_in *)to)->sin_port;
writes++;
return (ssize_t)len;
}
static C4fRtcPeer *peer(C4fRtc *rtc, unsigned slot)
{
C4fRtcPeer *p = calloc(1, sizeof(*p));
CHECK(p);
p->rtc = rtc; p->addrKnown = 1; p->addr.sin_family = AF_INET; p->addr.sin_port = htons((uint16_t)(6000 + slot));
rtc->peers[slot] = p;
return p;
}
static void dispose(C4fRtc *rtc)
{
for (unsigned i = 0; i < C4F_RTC_PEERS; i++) {
C4fRtcPeer *p = rtc->peers[i];
if (!p) continue;
while (p->out) { C4fRtcDatagram *d = p->out; p->out = d->next; free(d); }
free(p); rtc->peers[i] = NULL;
}
}
int main(void)
{
C4fRtc rtc = { .fd = 77 };
C4fRtcPeer *p = peer(&rtc, 0);
uint8_t a[] = { 1, 2, 3 }, b[] = { 4, 5, 6, 7 };
sendError = EAGAIN;
CHECK(c4fRtcBioSend(p, a, sizeof(a)) == sizeof(a));
CHECK(c4fRtcBioSend(p, b, sizeof(b)) == sizeof(b));
CHECK(p->outPackets == 2 && !p->datagramsOut && c4fRtcWantsWrite(&rtc));
memset(a, 99, sizeof(a)); memset(b, 99, sizeof(b)); /* caller's buffers can be reused immediately */
c4fRtcWritable(&rtc);
CHECK(p->outPackets == 2 && !writes);
sendError = 0;
c4fRtcWritable(&rtc);
CHECK(writes == 2 && wireLen[0] == 3 && wire[0][0] == 1 && wire[1][0] == 4);
CHECK(!p->outPackets && p->datagramsOut == 2 && !c4fRtcWantsWrite(&rtc));
CHECK(wirePort[0] == htons(6000));
CHECK(c4fRtcBioSend(p, a, sizeof(a)) == sizeof(a) && writes == 3 && wire[2][0] == 99);
/* ENOBUFS can occur on a writable UDP socket. Back off briefly instead of
* spinning select continuously or pretending that the datagram was sent. */
sendError = ENOBUFS;
CHECK(c4fRtcBioSend(p, a, sizeof(a)) == sizeof(a) && !c4fRtcWantsWrite(&rtc));
CHECK(c4fRtcWait(&rtc, fakeNow, 50) <= 2);
fakeNow += 2;
CHECK(c4fRtcWantsWrite(&rtc));
CHECK(c4fRtcWait(&rtc, fakeNow, 50) > 0); /* expired backoff must not force a busy select loop */
sendError = 0; c4fRtcWritable(&rtc);
CHECK(!p->out && writes == 4);
/* Bounded queues fail explicitly, and permanent socket failures are not
* counted as successful datagrams. */
sendError = EAGAIN;
for (unsigned i = 0; i < C4F_RTC_OUT_PACKETS; i++) CHECK(c4fRtcBioSend(p, a, sizeof(a)) == sizeof(a));
CHECK(c4fRtcBioSend(p, a, sizeof(a)) < 0 && p->failed && p->outPackets == C4F_RTC_OUT_PACKETS);
CHECK(p->datagramsOut == 4);
dispose(&rtc);
p = peer(&rtc, 0);
sendError = ECONNREFUSED;
CHECK(c4fRtcBioSend(p, a, sizeof(a)) < 0 && p->failed && !p->out && !p->datagramsOut);
dispose(&rtc);
/* Each peer gets a bounded drain and the first served peer rotates. */
p = peer(&rtc, 0);
C4fRtcPeer *other = peer(&rtc, 1);
sendError = EAGAIN;
for (unsigned i = 0; i < 12; i++) {
CHECK(c4fRtcBioSend(p, a, sizeof(a)) == sizeof(a));
CHECK(c4fRtcBioSend(other, b, sizeof(b)) == sizeof(b));
}
sendError = 0; c4fRtcWritable(&rtc);
CHECK(p->outPackets == 4 && other->outPackets == 4);
c4fRtcWritable(&rtc);
CHECK(!p->out && !other->out);
dispose(&rtc);
puts("PASS rtc I/O: owned ciphertext retry/order, EAGAIN/ENOBUFS, bounded queues, fatal errors and fair peer drains");
return 0;
}
+433
View File
@@ -0,0 +1,433 @@
/* STUN and SCTP, as the WebRTC transport uses them, on the host. STUN against the
* RFC 5769 test vectors; SCTP driven packet by packet the way a browser would. */
#include <arpa/inet.h>
#include <assert.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "c4f_sctp.h"
#include "c4f_stun.h"
#define CHECK(cond) do { if (!(cond)) { fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, #cond); exit(1); } } while (0)
/* ------------------------------------------------------------ STUN */
static const uint8_t rfcRequest[] = {
0x00,0x01,0x00,0x58, 0x21,0x12,0xa4,0x42, 0xb7,0xe7,0xa7,0x01, 0xbc,0x34,0xd6,0x86, 0xfa,0x87,0xdf,0xae,
0x80,0x22,0x00,0x10, 0x53,0x54,0x55,0x4e, 0x20,0x74,0x65,0x73, 0x74,0x20,0x63,0x6c, 0x69,0x65,0x6e,0x74,
0x00,0x24,0x00,0x04, 0x6e,0x00,0x01,0xff,
0x80,0x29,0x00,0x08, 0x93,0x2f,0xf9,0xb1, 0x51,0x26,0x3b,0x36,
0x00,0x06,0x00,0x09, 0x65,0x76,0x74,0x6a, 0x3a,0x68,0x36,0x76, 0x59,0x20,0x20,0x20,
0x00,0x08,0x00,0x14, 0x9a,0xea,0xa7,0x0c, 0xbf,0xd8,0xcb,0x56, 0x78,0x1e,0xf2,0xb5, 0xb2,0xd3,0xf2,0x49, 0xc1,0xb5,0x71,0xa2,
0x80,0x28,0x00,0x04, 0xe5,0x7a,0x3b,0xcf,
};
static const uint8_t rfcResponse[] = {
0x01,0x01,0x00,0x3c, 0x21,0x12,0xa4,0x42, 0xb7,0xe7,0xa7,0x01, 0xbc,0x34,0xd6,0x86, 0xfa,0x87,0xdf,0xae,
0x80,0x22,0x00,0x0b, 0x74,0x65,0x73,0x74, 0x20,0x76,0x65,0x63, 0x74,0x6f,0x72,0x20,
0x00,0x20,0x00,0x08, 0x00,0x01,0xa1,0x47, 0xe1,0x12,0xa6,0x43,
0x00,0x08,0x00,0x14, 0x2b,0x91,0xf5,0x99, 0xfd,0x9e,0x90,0xc3, 0x8c,0x74,0x89,0xf9, 0x2a,0xf9,0xba,0x53, 0xf0,0x6b,0xe7,0xd7,
0x80,0x28,0x00,0x04, 0xc0,0x7d,0x4c,0x96,
};
static const char rfcPassword[] = "VOkJxbRl1RmTxUk/WvJxBt";
static void testStun(void)
{
CHECK(c4fCrc32((const uint8_t *)"123456789", 9) == 0xCBF43926u);
CHECK(c4fCrc32c((const uint8_t *)"123456789", 9) == 0xE3069283u);
CHECK(c4fIsStun(rfcRequest, sizeof(rfcRequest)) && !c4fIsDtls(rfcRequest, sizeof(rfcRequest)));
size_t size;
const uint8_t *user = c4fStunUsername(rfcRequest, sizeof(rfcRequest), &size);
CHECK(user && size == 9 && !memcmp(user, "evtj:h6vY", 9));
CHECK(c4fStunVerify(rfcRequest, sizeof(rfcRequest), rfcPassword));
CHECK(!c4fStunVerify(rfcRequest, sizeof(rfcRequest), "wrong password"));
CHECK(c4fStunVerify(rfcResponse, sizeof(rfcResponse), rfcPassword));
/* A changed byte breaks the integrity check. */
uint8_t bent[sizeof(rfcRequest)];
memcpy(bent, rfcRequest, sizeof(bent));
bent[47] ^= 1;
CHECK(!c4fStunVerify(bent, sizeof(bent), rfcPassword));
CHECK(!c4fStunUsername(rfcResponse, sizeof(rfcResponse), &size)); /* a response, not a request */
/* Our answer to the RFC's request, from 192.0.2.1:32853, carries the same
* XOR-MAPPED-ADDRESS as the RFC's sample response and checks out. */
uint8_t answer[C4F_STUN_ANSWER_SIZE];
int nominate = -1;
uint32_t address = inet_addr("192.0.2.1");
size_t n = c4fStunAnswer(rfcRequest, sizeof(rfcRequest), rfcPassword, address, htons(32853), answer, &nominate);
CHECK(n == C4F_STUN_ANSWER_SIZE && nominate == 0);
CHECK(answer[0] == 0x01 && answer[1] == 0x01 && !memcmp(answer + 4, rfcRequest + 4, 16));
CHECK(!memcmp(answer + 20, rfcResponse + 36, 12));
CHECK(c4fStunVerify(answer, n, rfcPassword));
CHECK(!c4fStunAnswer(rfcRequest, sizeof(rfcRequest), "another", address, htons(32853), answer, &nominate));
puts("PASS stun: RFC 5769 request and response, integrity, fingerprint, our XOR-MAPPED-ADDRESS answer");
}
/* ------------------------------------------------------------ SCTP */
typedef struct { uint8_t data[2048]; size_t len; } Blob;
static Blob sent[512], delivered[64];
static uint16_t deliveredStream[64];
static int sentCount, deliveredCount;
static int rejectSend;
static uint32_t randomState = 0x12345678;
static int opSend(void *user, const uint8_t *p, size_t len)
{
(void)user;
if (rejectSend) return -1;
CHECK(sentCount < 512 && len <= C4F_SCTP_MTU);
/* Every packet we send carries a correct CRC32c, least significant byte first. */
uint8_t copy[2048];
memcpy(copy, p, len);
memset(copy + 8, 0, 4);
uint32_t crc = c4fCrc32c(copy, len);
CHECK(p[8] == (uint8_t)crc && p[9] == (uint8_t)(crc >> 8) && p[10] == (uint8_t)(crc >> 16) && p[11] == (uint8_t)(crc >> 24));
memcpy(sent[sentCount].data, p, len); sent[sentCount++].len = len;
return 0;
}
static void opDeliver(void *user, uint16_t stream, uint32_t ppid, const uint8_t *d, size_t len)
{
(void)user;
CHECK(deliveredCount < 64 && ppid == C4F_SCTP_PPID_STRING);
memcpy(delivered[deliveredCount].data, d, len); delivered[deliveredCount].len = len;
deliveredStream[deliveredCount++] = stream;
}
static void opRandom(void *user, void *out, size_t len)
{
(void)user;
for (size_t i = 0; i < len; i++) { randomState = randomState * 1103515245u + 12345u; ((uint8_t *)out)[i] = (uint8_t)(randomState >> 16); }
}
static uint16_t g16(const uint8_t *p) { return (uint16_t)(p[0] << 8 | p[1]); }
static uint32_t g32(const uint8_t *p) { return (uint32_t)p[0] << 24 | (uint32_t)p[1] << 16 | (uint32_t)p[2] << 8 | p[3]; }
static void p16(uint8_t *p, uint16_t v) { p[0] = v >> 8; p[1] = (uint8_t)v; }
static void p32(uint8_t *p, uint32_t v) { p[0] = v >> 24; p[1] = (uint8_t)(v >> 16); p[2] = (uint8_t)(v >> 8); p[3] = (uint8_t)v; }
/* A browser-side packet under construction. */
typedef struct { uint8_t b[2048]; size_t len; } Pkt;
static void pkStart(Pkt *k, uint32_t tag) { memset(k, 0, sizeof(*k)); p16(k->b, 5000); p16(k->b + 2, 5000); p32(k->b + 4, tag); k->len = 12; }
static uint8_t *pkChunk(Pkt *k, uint8_t type, uint8_t flags, size_t vlen)
{
uint8_t *c = k->b + k->len;
c[0] = type; c[1] = flags; p16(c + 2, (uint16_t)(4 + vlen));
k->len += (4 + vlen + 3) & ~(size_t)3;
return c + 4;
}
static void pkData(Pkt *k, uint8_t flags, uint32_t tsn, uint16_t stream, uint16_t ssn, const char *text)
{
size_t n = strlen(text);
uint8_t *v = pkChunk(k, 0, flags, 12 + n);
p32(v, tsn); p16(v + 4, stream); p16(v + 6, ssn); p32(v + 8, C4F_SCTP_PPID_STRING); memcpy(v + 12, text, n);
}
static void pkSend(C4fSctp *s, Pkt *k, uint64_t now)
{
uint32_t crc = c4fCrc32c(k->b, k->len);
k->b[8] = (uint8_t)crc; k->b[9] = (uint8_t)(crc >> 8); k->b[10] = (uint8_t)(crc >> 16); k->b[11] = (uint8_t)(crc >> 24);
c4fSctpReceive(s, k->b, k->len, now);
}
/* The first chunk of type `type` in a sent packet, or NULL. */
static const uint8_t *findChunk(const Blob *b, uint8_t type)
{
for (size_t at = 12; at + 4 <= b->len; at += (g16(b->data + at + 2) + 3) & ~3u)
if (b->data[at] == type) return b->data + at;
return NULL;
}
static int isDelivered(int i, uint16_t stream, const char *text)
{
return i < deliveredCount && deliveredStream[i] == stream && delivered[i].len == strlen(text) &&
!memcmp(delivered[i].data, text, delivered[i].len);
}
static void testSctp(void)
{
C4fSctp s;
C4fSctpOps ops = { NULL, opSend, opDeliver, opRandom };
c4fSctpInit(&s, &ops);
uint64_t now = 1000;
const uint32_t browserTag = 0x11223344, browserTsn = 1000;
/* INIT -> INIT-ACK with a cookie, partial reliability and stream resets. */
Pkt k;
pkStart(&k, 0);
uint8_t *v = pkChunk(&k, 1, 0, 16);
p32(v, browserTag); p32(v + 4, 131072); p16(v + 8, 65535); p16(v + 10, 65535); p32(v + 12, browserTsn);
pkSend(&s, &k, now);
CHECK(sentCount == 1 && g32(sent[0].data + 4) == browserTag);
const uint8_t *ack = findChunk(&sent[0], 2);
CHECK(ack && s.state == C4F_SCTP_COOKIE_WAIT);
uint32_t ourTag = g32(ack + 4), ourTsn = g32(ack + 16);
CHECK(ourTag == s.localTag && g16(ack + 20) == 7 && g16(ack + 22) == 20);
uint8_t cookie[16];
memcpy(cookie, ack + 24, 16);
CHECK(g16(ack + 40) == 0x8008 && ack[44] == 0xC0 && ack[45] == 0x82 && g16(ack + 48) == 0xC000);
/* A wrong cookie is ignored; the right one establishes. */
pkStart(&k, ourTag); v = pkChunk(&k, 10, 0, 16); memset(v, 0xAA, 16); pkSend(&s, &k, now);
CHECK(s.state == C4F_SCTP_COOKIE_WAIT && sentCount == 1);
pkStart(&k, ourTag); v = pkChunk(&k, 10, 0, 16); memcpy(v, cookie, 16); pkSend(&s, &k, now);
CHECK(s.state == C4F_SCTP_ESTABLISHED && sentCount == 2 && findChunk(&sent[1], 11));
/* A packet under another tag, or with a bad checksum, is dropped. */
pkStart(&k, 0x99999999); pkData(&k, 3, browserTsn, 0, 0, "nope"); pkSend(&s, &k, now);
pkStart(&k, ourTag); pkData(&k, 3, browserTsn, 0, 0, "nope"); k.b[k.len - 1] ^= 0x55;
c4fSctpReceive(&s, k.b, k.len, now);
CHECK(deliveredCount == 0 && s.badChecksums == 1);
/* In order on stream 0, then a gap, then unordered on stream 1 delivered at once. */
pkStart(&k, ourTag); pkData(&k, 3, browserTsn, 0, 0, "hello"); pkSend(&s, &k, now);
CHECK(isDelivered(0, 0, "hello") && s.cumTsn == browserTsn);
const uint8_t *sack = findChunk(&sent[sentCount - 1], 3);
CHECK(sack && g32(sack + 4) == browserTsn && g16(sack + 12) == 0);
pkStart(&k, ourTag); pkData(&k, 7, browserTsn + 2, 1, 0, "fast"); pkSend(&s, &k, now);
CHECK(isDelivered(1, 1, "fast") && s.cumTsn == browserTsn);
sack = findChunk(&sent[sentCount - 1], 3);
CHECK(g32(sack + 4) == browserTsn && g16(sack + 12) == 1 && g16(sack + 16) == 2 && g16(sack + 18) == 2);
pkStart(&k, ourTag); pkData(&k, 3, browserTsn + 1, 0, 1, "second"); pkSend(&s, &k, now);
CHECK(isDelivered(2, 0, "second") && s.cumTsn == browserTsn + 2);
/* The same TSN again: acknowledged, not delivered twice. */
pkStart(&k, ourTag); pkData(&k, 3, browserTsn + 1, 0, 1, "second"); pkSend(&s, &k, now);
CHECK(deliveredCount == 3 && s.duplicates == 1);
/* Out of order on the ordered stream: held until its turn. */
pkStart(&k, ourTag); pkData(&k, 3, browserTsn + 4, 0, 3, "four"); pkSend(&s, &k, now);
CHECK(deliveredCount == 3);
pkStart(&k, ourTag); pkData(&k, 3, browserTsn + 3, 0, 2, "three"); pkSend(&s, &k, now);
CHECK(isDelivered(3, 0, "three") && isDelivered(4, 0, "four") && s.cumTsn == browserTsn + 4);
/* A message in three pieces, arriving last, first, middle. */
pkStart(&k, ourTag); pkData(&k, 1, browserTsn + 7, 0, 4, "ghi"); pkSend(&s, &k, now);
pkStart(&k, ourTag); pkData(&k, 2, browserTsn + 5, 0, 4, "abc"); pkSend(&s, &k, now);
CHECK(deliveredCount == 5);
pkStart(&k, ourTag); pkData(&k, 0, browserTsn + 6, 0, 4, "def"); pkSend(&s, &k, now);
CHECK(isDelivered(5, 0, "abcdefghi") && s.inBytes == 0);
/* FORWARD-TSN skips an unordered piece that will never be finished. */
pkStart(&k, ourTag); pkData(&k, 6, browserTsn + 8, 1, 0, "lost-fir"); pkSend(&s, &k, now);
pkStart(&k, ourTag); v = pkChunk(&k, 192, 0, 4); p32(v, browserTsn + 9); pkSend(&s, &k, now);
CHECK(s.cumTsn == browserTsn + 9 && s.inBytes == 0 && deliveredCount == 6);
pkStart(&k, ourTag); pkData(&k, 7, browserTsn + 10, 1, 0, "after"); pkSend(&s, &k, now);
CHECK(isDelivered(6, 1, "after"));
/* Sending: reliable and in order on stream 0, acknowledged by a SACK. */
int before = sentCount;
CHECK(c4fSctpSend(&s, 0, C4F_SCTP_PPID_STRING, (const uint8_t *)"reply", 5, now) == 0);
CHECK(sentCount == before + 1);
const uint8_t *data = findChunk(&sent[before], 0);
CHECK(data && data[1] == 3 && g32(data + 4) == ourTsn && g16(data + 8) == 0 && g16(data + 10) == 0 &&
g32(data + 12) == C4F_SCTP_PPID_STRING && !memcmp(data + 16, "reply", 5));
CHECK(c4fSctpDeadline(&s) == now + 200);
pkStart(&k, ourTag); v = pkChunk(&k, 3, 0, 12); p32(v, ourTsn); p32(v + 4, 131072); pkSend(&s, &k, now + 5);
CHECK(s.out == NULL && c4fSctpDeadline(&s) == 0);
/* Not acknowledged: resent, same TSN, after the timeout. */
now += 1000;
before = sentCount;
CHECK(c4fSctpSend(&s, 0, C4F_SCTP_PPID_STRING, (const uint8_t *)"again", 5, now) == 0);
uint64_t due = c4fSctpDeadline(&s);
CHECK(due > now && due <= now + 200);
c4fSctpTick(&s, due - 1);
CHECK(sentCount == before + 1);
c4fSctpTick(&s, due);
CHECK(sentCount == before + 2 && s.resent == 1);
data = findChunk(&sent[before + 1], 0);
CHECK(data && g32(data + 4) == ourTsn + 1 && !memcmp(data + 16, "again", 5));
pkStart(&k, ourTag); v = pkChunk(&k, 3, 0, 12); p32(v, ourTsn + 1); p32(v + 4, 131072); pkSend(&s, &k, due + 1);
CHECK(s.out == NULL);
/* Unordered on stream 1 is never resent: given up on, then skipped with FORWARD-TSN. */
now += 1000;
before = sentCount;
CHECK(c4fSctpSend(&s, 1, C4F_SCTP_PPID_STRING, (const uint8_t *)"rumble", 6, now) == 0);
data = findChunk(&sent[before], 0);
CHECK(data && data[1] == 7);
due = c4fSctpDeadline(&s);
c4fSctpTick(&s, due);
const uint8_t *fwd = findChunk(&sent[sentCount - 1], 192);
CHECK(s.abandoned == 1 && fwd && g32(fwd + 4) == ourTsn + 2 && !findChunk(&sent[sentCount - 1], 0));
pkStart(&k, ourTag); v = pkChunk(&k, 3, 0, 12); p32(v, ourTsn + 2); p32(v + 4, 131072); pkSend(&s, &k, due + 1);
CHECK(s.out == NULL && c4fSctpDeadline(&s) == 0);
/* Three newly acknowledged later TSNs trigger fast retransmit. Repeated
* copies of an old SACK do not manufacture more evidence of a hole. */
now += 1000;
c4fSctpSend(&s, 0, C4F_SCTP_PPID_STRING, (const uint8_t *)"A", 1, now);
c4fSctpSend(&s, 0, C4F_SCTP_PPID_STRING, (const uint8_t *)"B", 1, now);
c4fSctpSend(&s, 0, C4F_SCTP_PPID_STRING, (const uint8_t *)"C", 1, now);
c4fSctpSend(&s, 0, C4F_SCTP_PPID_STRING, (const uint8_t *)"D", 1, now);
before = sentCount;
for (int i = 0; i < 3; i++) {
pkStart(&k, ourTag); v = pkChunk(&k, 3, 0, 16); p32(v, ourTsn + 2); p32(v + 4, 131072); p16(v + 8, 1); p16(v + 12, 2); p16(v + 14, 2);
pkSend(&s, &k, now + 10);
}
CHECK(s.resent == 1 && sentCount == before);
for (int i = 3; i <= 4; i++) {
pkStart(&k, ourTag); v = pkChunk(&k, 3, 0, 16); p32(v, ourTsn + 2); p32(v + 4, 131072);
p16(v + 8, 1); p16(v + 12, 2); p16(v + 14, (uint16_t)i); pkSend(&s, &k, now + 10);
}
CHECK(s.resent == 2 && sentCount == before + 1);
data = findChunk(&sent[before], 0);
CHECK(data && g32(data + 4) == ourTsn + 3 && data[16] == 'A');
pkStart(&k, ourTag); v = pkChunk(&k, 3, 0, 12); p32(v, ourTsn + 6); p32(v + 4, 131072); pkSend(&s, &k, now + 20);
CHECK(s.out == NULL);
/* A message bigger than a packet goes in pieces, the same SSN on each. */
char big[3000];
for (size_t i = 0; i < sizeof(big); i++) big[i] = (char)('a' + i % 26);
before = sentCount;
CHECK(c4fSctpSend(&s, 0, C4F_SCTP_PPID_STRING, (const uint8_t *)big, sizeof(big), now) == 0);
CHECK(sentCount == before + 3);
size_t total = 0;
for (int i = 0; i < 3; i++) {
data = findChunk(&sent[before + i], 0);
CHECK(data && g16(data + 10) == 6);
CHECK((data[1] & 3) == (i == 0 ? 2 : i == 2 ? 1 : 0));
total += g16(data + 2) - 16u;
}
CHECK(total == sizeof(big));
pkStart(&k, ourTag); v = pkChunk(&k, 3, 0, 12); p32(v, ourTsn + 9); p32(v + 4, 131072); pkSend(&s, &k, now + 30);
CHECK(s.out == NULL);
/* HEARTBEAT is echoed; a stream reset is performed and answered. */
pkStart(&k, ourTag); v = pkChunk(&k, 4, 0, 8); p16(v, 1); p16(v + 2, 8); memcpy(v + 4, "beat", 4); pkSend(&s, &k, now);
const uint8_t *hb = findChunk(&sent[sentCount - 1], 5);
CHECK(hb && !memcmp(hb + 8, "beat", 4));
pkStart(&k, ourTag); v = pkChunk(&k, 130, 0, 18);
p16(v, 13); p16(v + 2, 18); p32(v + 4, 777); p32(v + 8, 0); p32(v + 12, browserTsn + 10); p16(v + 16, 0);
pkSend(&s, &k, now);
const uint8_t *rc = findChunk(&sent[sentCount - 1], 130);
CHECK(rc && g16(rc + 4) == 16 && g32(rc + 8) == 777 && g32(rc + 12) == 1 && s.nextSsn[0] == 0);
/* ABORT ends it. */
pkStart(&k, ourTag); pkChunk(&k, 6, 0, 0); pkSend(&s, &k, now);
CHECK(s.state == C4F_SCTP_ENDED && c4fSctpSend(&s, 0, 51, (const uint8_t *)"x", 1, now) == -1);
c4fSctpFree(&s);
puts("PASS sctp: INIT/cookie, tags, checksums, ordering, gaps, reassembly, FORWARD-TSN, resend, give-up, fast resend, fragments, heartbeat, reset, abort");
}
static void openAssociation(C4fSctp *s, uint64_t now)
{
C4fSctpOps ops = { NULL, opSend, opDeliver, opRandom };
sentCount = deliveredCount = rejectSend = 0;
c4fSctpInit(s, &ops);
Pkt k;
pkStart(&k, 0);
uint8_t *v = pkChunk(&k, 1, 0, 16);
p32(v, 0xabcdef01); p32(v + 4, 131072); p16(v + 8, 2); p16(v + 10, 2); p32(v + 12, 1000);
pkSend(s, &k, now);
pkStart(&k, s->localTag); v = pkChunk(&k, 10, 0, 16); memcpy(v, s->cookie, 16); pkSend(s, &k, now);
CHECK(s->state == C4F_SCTP_ESTABLISHED);
sentCount = 0;
}
static void acknowledge(C4fSctp *s, uint32_t cum, uint32_t window, uint64_t now)
{
Pkt k;
pkStart(&k, s->localTag);
uint8_t *v = pkChunk(&k, 3, 0, 12);
p32(v, cum); p32(v + 4, window); pkSend(s, &k, now);
}
static void testWindows(void)
{
C4fSctp s;
uint64_t now = 1000;
openAssociation(&s, now);
uint32_t first = s.nextTsn;
char big[C4F_SCTP_MAX_MESSAGE];
memset(big, 'q', sizeof(big));
CHECK(c4fSctpSend(&s, 0, 51, (const uint8_t *)big, sizeof(big), now) == 0);
CHECK(sentCount > 0 && sentCount <= 4 && s.flightBytes <= s.cwnd && s.outBytes == sizeof(big));
CHECK(s.highestSent < s.nextTsn - 1); /* remaining fragments queued, not falsely sent */
uint32_t beforeSeq = s.nextTsn;
CHECK(c4fSctpSend(&s, 1, 51, (const uint8_t *)"stale", 5, now) == -1 && s.nextTsn == beforeSeq);
int before = sentCount;
acknowledge(&s, s.nextTsn - 1, 131072, now + 1); /* cannot ACK unsent queued data */
CHECK(sentCount == before && s.peerCumTsn == first - 1);
size_t bytes = 0;
for (unsigned tries = 0; s.out && tries < 32; tries++) {
acknowledge(&s, s.highestSent, 131072, ++now);
CHECK(s.flightBytes <= s.cwnd);
}
CHECK(!s.out && s.outBytes == 0 && s.flightBytes == 0 && !c4fSctpDeadline(&s));
for (int i = 0; i < sentCount; i++) {
const uint8_t *data = findChunk(&sent[i], 0);
CHECK(data); bytes += g16(data + 2) - 16u;
}
CHECK(bytes == sizeof(big));
c4fSctpFree(&s);
/* A large already-open window still emits a bounded burst, but schedules
* its continuation promptly instead of waiting for the retransmit timer. */
openAssociation(&s, now);
s.cwnd = 30000;
CHECK(c4fSctpSend(&s, 0, 51, (const uint8_t *)big, sizeof(big), now) == 0 && sentCount == 4);
CHECK(c4fSctpDeadline(&s) == now + 1);
c4fSctpTick(&s, now + 1); CHECK(sentCount == 8);
c4fSctpFree(&s);
/* Receive-window closure buffers reliable control and rejects replaceable
* state. A timed probe prevents a lost window update from deadlocking it. */
openAssociation(&s, now);
acknowledge(&s, s.highestSent, 0, now);
first = s.nextTsn;
CHECK(c4fSctpSend(&s, 0, 51, (const uint8_t *)big, 2000, now) == 0 && sentCount == 0);
CHECK(c4fSctpSend(&s, 1, 51, (const uint8_t *)"stale", 5, now) == -1);
uint64_t due = c4fSctpDeadline(&s);
CHECK(due == now + s.rto);
c4fSctpTick(&s, due - 1); CHECK(sentCount == 0);
c4fSctpTick(&s, due); CHECK(sentCount == 1 && s.highestSent == first);
acknowledge(&s, first - 1, 0, due + 1); CHECK(sentCount == 1);
acknowledge(&s, first, 131072, due + 2); CHECK(sentCount == 2);
acknowledge(&s, s.highestSent, 131072, due + 3); CHECK(!s.out && !s.flightBytes);
/* A too-small positive receive window is also respected before the probe. */
acknowledge(&s, s.highestSent, 20, due + 4);
before = sentCount;
CHECK(c4fSctpSend(&s, 0, 51, (const uint8_t *)big, 500, due + 4) == 0 && sentCount == before);
acknowledge(&s, s.highestSent, 131072, due + 5); CHECK(sentCount == before + 1);
acknowledge(&s, s.highestSent, 131072, due + 6);
c4fSctpFree(&s);
openAssociation(&s, now);
CHECK(c4fSctpSend(&s, 0, 51, (const uint8_t *)big, 3000, now) == 0);
before = sentCount; due = c4fSctpDeadline(&s);
c4fSctpTick(&s, due);
CHECK(s.cwnd == C4F_SCTP_MTU && sentCount == before + 1 && s.resent == 1);
c4fSctpFree(&s);
/* The output callback cannot silently reject reliable DATA. */
openAssociation(&s, now);
CHECK(c4fSctpSend(&s, 0, 51, (const uint8_t *)"keep", 4, now) == 0);
uint32_t savedTag = s.localTag, savedNext = s.nextTsn;
size_t savedBytes = s.outBytes;
Pkt retry;
pkStart(&retry, 0);
uint8_t *init = pkChunk(&retry, 1, 0, 16);
p32(init, 0xabcdef01); p32(init + 4, 131072); p16(init + 8, 2); p16(init + 10, 2); p32(init + 12, 1000);
pkSend(&s, &retry, now + 1);
const uint8_t *initAck = findChunk(&sent[sentCount - 1], 2);
CHECK(initAck && s.state == C4F_SCTP_ESTABLISHED && s.localTag == savedTag && s.nextTsn == savedNext &&
s.outBytes == savedBytes && g32(initAck + 16) == s.localInitialTsn);
/* A declared chunk beyond the datagram boundary cannot reset the peer. */
pkStart(&retry, 0); init = pkChunk(&retry, 1, 0, 16); p16(init - 2, 65535);
pkSend(&s, &retry, now + 2);
CHECK(s.state == C4F_SCTP_ESTABLISHED && s.localTag == savedTag && s.outBytes == savedBytes);
rejectSend = 1;
CHECK(c4fSctpSend(&s, 0, 51, (const uint8_t *)"fatal", 5, now) == -1 && s.state == C4F_SCTP_ENDED);
rejectSend = 0;
c4fSctpFree(&s);
puts("PASS sctp windows: bounded congestion, queued fragments, receive credit/probes, stale-send rejection, timeout burst and send failures");
}
int main(void)
{
testStun();
testSctp();
testWindows();
return 0;
}
+1 -1
View File
@@ -10,7 +10,7 @@ import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
SUITES = ['test_logging', 'test_frontend', 'test_web', 'test_recovery', 'test_launcher']
SUITES = ['test_logging', 'test_frontend', 'test_web', 'test_recovery', 'test_launcher', 'test_rtc']
def main():
+2
View File
@@ -4,6 +4,8 @@ import subprocess
def main():
subprocess.run(['node', 'tests/frontend_test.cjs'], check=True, timeout=30)
subprocess.run(['node', 'tests/mapping_test.cjs'], check=True, timeout=30)
subprocess.run(['python3', '-B', 'tests/release_notes_test.py'], check=True, timeout=10)
if __name__ == '__main__':
Loaded 100 of 237 files, more files were not shown because too many files have changed in this diff. Show more