Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b5ea114581 | ||
|
|
d555484826 | ||
|
|
b572cd9133 | ||
|
|
73437fe5c6 | ||
|
|
d1b67cd8d6 | ||
|
|
3d27495dcd | ||
|
|
5b1dbd0062 | ||
|
|
e3ab3925b9 | ||
|
|
b3a725c56a | ||
|
|
c4ff621d7e | ||
|
|
b7976e8b1f | ||
|
|
dba861bb7f | ||
|
|
6a0e6925e5 | ||
|
|
aa45b9b123 | ||
|
|
9890560867 | ||
|
|
7e503478c4 | ||
|
|
5a8f9e3d0e | ||
|
|
75743ef24b | ||
|
|
0b3b448ca5 | ||
|
|
ed5e828701 | ||
|
|
1242d7346e | ||
|
|
252e26d2b7 | ||
|
|
41c476b2e2 | ||
|
|
017b8e2473 | ||
|
|
96326c71b0 |
No files matched your search
@@ -1 +1 @@
|
||||
ko_fi: mohadishibli
|
||||
liberapay: MoHadiShibli
|
||||
@@ -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 && NODE_PATH=/tmp/c4f-browser/node_modules node tests/i18n_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
|
||||
@@ -1,7 +1,89 @@
|
||||
# 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.1 — 2026-10-24
|
||||
|
||||
- Arabic and Brazilian Portuguese for the web interface and PS4 app.
|
||||
- Move, resize and hide face buttons individually, or keep them grouped.
|
||||
- Reset individual controls in the layout editor.
|
||||
- Updated Xbox-button troubleshooting for Chrome and Edge on Windows.
|
||||
|
||||
## 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.
|
||||
|
||||

|
||||
|
||||
- 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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 $< $@
|
||||
|
||||
|
||||
@@ -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,61 @@ 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.
|
||||
- **English, Brazilian Portuguese and Arabic.** The page follows the browser's language; change it at the bottom of
|
||||
the home screen or in **Settings → This device**. The PS4 app follows the console's system language; press
|
||||
**L1** on its main screen to switch between automatic, English, Português (Brasil) and العربية.
|
||||
Arabic menus read from right to left while controller positions stay unchanged.
|
||||
See [Languages](docs/languages.md) for font loading and offline use.
|
||||
|
||||
## 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 +104,51 @@ New to GoldHEN packages? The **[installation guide](docs/installation.md)** walk
|
||||
|---|---|---|
|
||||
|  |  |  |
|
||||
|
||||
| Controller test | Controller tools |
|
||||
|---|---|
|
||||
|  |  |
|
||||
|
||||
## 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 +156,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 +164,13 @@ 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/).
|
||||
|
||||
## Contributors
|
||||
|
||||
- [@NotDev-Kike](https://github.com/NotDev-Kike) — HDMI-CEC testing and feedback.
|
||||
- [@lucasmaraia](https://github.com/lucasmaraia) — Brazilian Portuguese translation for the web interface
|
||||
and PS4 app.
|
||||
|
||||
## Credits
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,30 @@
|
||||
- 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.
|
||||
|
||||
- Arabic text shaping uses [HarfBuzz 11.2.1](https://github.com/harfbuzz/harfbuzz/tree/11.2.1).
|
||||
Its copyright notices and permissive license terms are retained in
|
||||
`vendor/harfbuzz/COPYING` and `vendor/harfbuzz/src/ms-use/COPYING`.
|
||||
- Mixed-direction native text uses [SheenBidi 2.9.0](https://github.com/Tehreer/SheenBidi/tree/v2.9.0),
|
||||
Apache-2.0; see `vendor/sheenbidi/LICENSE`.
|
||||
- The offline fallback Arabic fonts are [Noto Sans Arabic 2.013](https://github.com/notofonts/arabic/releases/tag/NotoSansArabic-v2.013),
|
||||
Light and Regular, under the SIL Open Font License 1.1 in `vendor/noto-sans-arabic/OFL.txt`.
|
||||
Their provenance and hashes are recorded in that directory's `README.control4free.md`.
|
||||
- The PS4 app reads SST Arabic Light and Roman from the console's installed system
|
||||
fonts; the web page requests those same fonts from the local PS4 service. SST is
|
||||
[Sony's typeface, developed with Monotype](https://www.sony.com/en/SonyInfo/design/stories/sst-font/).
|
||||
No SST font files are included in the
|
||||
source, packages or standalone page. The bundled Noto fonts remain available if
|
||||
the console's fonts cannot be loaded.
|
||||
|
||||
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.
|
||||
@@ -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
|
||||
# Use the official registry; the public ECR mirror can reject CI pulls with 429.
|
||||
FROM docker.io/library/ubuntu:24.04@sha256:534baea6a22c03a63003dbc8dbe78fe34bc0d7e595d9a9dc9834884ff530eb55
|
||||
|
||||
ARG SDK_VERSION=v0.9
|
||||
|
||||
|
||||
@@ -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 and verify the archive checksum.
|
||||
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
|
||||
# Keep packaging compatibility pinned while using the official registry.
|
||||
FROM docker.io/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
|
||||
|
||||
@@ -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.
|
||||
|
||||

|
||||
|
||||
## Change a button
|
||||
|
||||
1. Connect the gamepad to the phone or computer and press a button on it, so it shows up under **Gamepads on
|
||||
this device**.
|
||||
2. Press **Mapping** on its row. You can also go to **☰ → All settings → Gamepads** and press **Mapping**.
|
||||
3. The **DS4 Mapping** tab shows a DualShock 4. Each control is labelled with what drives it: **B3** is button 3 on your
|
||||
gamepad, **A0** is axis 0, and **—** means nothing. Press anything on the gamepad and the control it drives
|
||||
lights up.
|
||||
4. Click a control, press **Change**, then press the gamepad button you want for it.
|
||||
5. Press **Save**.
|
||||
|
||||
**Add another** gives a DS4 control another input, for example a back paddle that also presses Cross.
|
||||
Cross stays pressed until both inputs are released. **Remove** takes a binding away. Choosing **Change**
|
||||
moves the selected input from its previous control. The engine also accepts profiles with one input
|
||||
driving multiple outputs; see [Advanced profiles](#advanced-profiles).
|
||||
|
||||
While you map a gamepad, it stops playing. Its controller stays connected, and everyone else keeps playing.
|
||||
When you leave, a button you're still holding does nothing until you let go of it, so nothing gets pressed by
|
||||
accident. **Back** with changes that aren't saved asks you to press it again before it throws them away.
|
||||
|
||||
On a phone, the picture is small, so a list of the controls sits under it: tap one there instead.
|
||||
|
||||
## Sticks and triggers
|
||||
|
||||
Click a stick, press **Change**, and move the stick you want all the way to the right, then all the way down.
|
||||
If you move it the other way, that direction is reversed for you. A wheel works the same way: turn it right,
|
||||
then press **Cancel** when the page asks for down.
|
||||
|
||||
A stick's panel shows where it is: the grey dot is the stick itself, the lit dot is what the PS4 gets. Under
|
||||
**Response**:
|
||||
|
||||
- **Dead zone**: how far the stick moves before it counts. The ring around the centre shows it. Raise it if a
|
||||
worn stick drifts.
|
||||
- **Curve**: **straight** follows the stick exactly; **gentler** gives finer control near the centre.
|
||||
- **Reverse** turns a direction round.
|
||||
|
||||
For L2 and R2, the bar shows how far the trigger is pulled. A trigger or pedal that the browser reports as an
|
||||
axis is set to its whole travel when you press **Change** and push it.
|
||||
|
||||
## Gamepads the browser doesn't know
|
||||
|
||||
When the browser doesn't know a gamepad's layout, its row says *buttons may be mixed up*, and the page starts
|
||||
from the standard layout as a guess. Wheels, guitars, arcade sticks and many cheap USB gamepads are like this.
|
||||
Click each control on the picture that's wrong and change it.
|
||||
|
||||
A D-pad that the browser reports as a single axis (a "hat") works the same way: click a D-pad direction, press
|
||||
**Change**, and press that direction.
|
||||
|
||||
## Gamepad Controls
|
||||
|
||||
The **Gamepad Controls** tab shows every button and axis on the gamepad, live, and what each one drives.
|
||||
Press a button or move a stick to identify its tile or bar. This view starts with the physical input;
|
||||
**DS4 Mapping** starts with the DS4 control you want to change. The guide panel follows the selected tab.
|
||||
The screenshots use an emulated gamepad to illustrate the editor.
|
||||
|
||||

|
||||
|
||||
Click one to:
|
||||
|
||||
- **Make it drive…** another control: click that control on the picture. For a stick, choose the direction.
|
||||
- set its **Dead zone**, **Curve** and **Reverse**; a small graph shows the curve. For an axis that presses a
|
||||
button, **Presses at** sets how far.
|
||||
- **Set its range**, for an axis that doesn't use its whole range. It asks what the axis is: a stick or wheel
|
||||
(let go, then all the way one way, then the other), a pedal or trigger (let go, then all the way), or a D-pad
|
||||
hat (let go, then up, right, down and left).
|
||||
|
||||
The hat wizard infers diagonal values from the four directions. Test every diagonal afterward. A hat with
|
||||
different encoding needs its actual neutral and eight direction values in an advanced profile.
|
||||
|
||||
## Profiles
|
||||
|
||||
A mapping is saved as a profile, named in the **Name** box. Choose that profile for each connected gamepad
|
||||
that should use it. **Save** updates every connected gamepad already using that profile; the editor lists
|
||||
the others affected. **Save as a new profile** branches a profile for only the selected gamepad.
|
||||
The browser remembers the last saved profile choice for matching device IDs, mapping types and input
|
||||
counts when a gamepad reconnects. The **Profile** list switches between your profiles and the
|
||||
**Standard layout**, which is immutable: editing and saving it creates a named profile.
|
||||
|
||||
The **⋯** menu has **Save as a new profile**, **New empty profile**, **Back to the standard layout**, **Export to a
|
||||
file**, **Import from a file**, and **Delete this profile**, which asks you to press it twice. A profile made for a
|
||||
different gamepad says so: press **Fit it to this gamepad** to drop the inputs this one doesn't have.
|
||||
|
||||
Imports open as drafts and do not overwrite an existing profile. Review bindings and calibration before
|
||||
**Save**, especially after fitting a different device. Missing inputs must be removed or corrected.
|
||||
Profiles use browser storage under `c4f.gamepadProfiles`; export them as a backup. Invalid data falls back
|
||||
safely, and a storage warning means changes will last only for this session.
|
||||
|
||||
Several gamepads can play as the same controller, for example a wheel and a separate set of pedals: pick the
|
||||
same controller on both rows.
|
||||
|
||||
## Advanced profiles
|
||||
|
||||
Export a profile, edit its JSON, then import it for review. The file has
|
||||
`format: "control4free-controller-profile"`, `schema: 1`, and a `profile` containing its ID, name,
|
||||
device signature, bindings and calibration. Unknown file versions are rejected. Unknown versions already
|
||||
in browser storage are preserved, with changes limited to the session.
|
||||
|
||||
Bindings can use a digital or pressure-sensitive button, a full axis, its positive or negative half,
|
||||
a calibrated pedal, or a hat direction. Outputs are DS4 buttons, stick axes or directions, and L2/R2
|
||||
pressure. This permits button-to-stick, button-to-trigger, axis-to-button and axis-to-trigger mapping.
|
||||
Duplicate a binding with a different `output` to make one input drive multiple actions.
|
||||
|
||||
Each binding includes `invert`, `deadzone`, `saturation`, `exponent`, `activate` and `release`.
|
||||
The response curve is a power curve with exponent 1 for linear response. Analog-to-button bindings default
|
||||
to 50% activation and 40% release to avoid jitter. Default stick bindings retain the previous transfer
|
||||
behavior through `legacy`; remove that property when opting into tuning.
|
||||
|
||||
Centered-axis calibration stores `min`, `center` and `max`, including asymmetric ranges. Pedal calibration
|
||||
stores `released` and `full`, which may be reversed. For combined pedals, use opposite halves of the axis.
|
||||
Hat calibration stores `values` in the order neutral, up, up-right, right, down-right, down, down-left,
|
||||
left, up-left, plus a `tolerance` that separates those values.
|
||||
|
||||
Inputs sharing a controller are merged: buttons use OR, triggers use maximum pressure, opposing digital
|
||||
stick directions cancel, and analog sticks use the strongest displacement. Equal opposing analog
|
||||
contributions cancel. Values are converted to DS4 bytes after merging.
|
||||
|
||||
## The keyboard
|
||||
|
||||
**Keyboard keys** at the bottom of the home screen, or **All settings → Keyboard → Choose keys**, opens the same
|
||||
picture with the keyboard's keys on it. Click a control, then the key, and press its new key. Sticks and the
|
||||
touchpad have one key per direction or corner. **Standard keys** puts them all back. Keys with Ctrl, Alt or the
|
||||
Windows key can't be used.
|
||||
|
||||
## Limits
|
||||
|
||||
- Only what the browser can see can be mapped. Some extra buttons, like paddles, either aren't shown to
|
||||
browsers or send the same button as another one.
|
||||
- The PS4 always sees a DualShock 4. Native wheel/guitar identity and wheel force feedback are not provided.
|
||||
Native accessory emulation is unproven and outside this release; existing supported gamepad rumble remains.
|
||||
- A combined pedal axis can drive separate outputs, but simultaneous pedal positions lost by the device
|
||||
cannot be recovered.
|
||||
- The page reads gamepads about 60 times a second, so a press shorter than that can be missed.
|
||||
- DS4 output uses 8-bit stick and trigger values; extra precision in a physical device is reduced.
|
||||
- Macros, turbo, toggles, chords and shifted layers are outside this release.
|
||||
|
||||
When reporting compatibility, include the controller model, browser and OS versions, connection mode,
|
||||
exposed button/axis counts, working mappings and remaining limitations. Emulated-input tests are not
|
||||
real-device compatibility reports.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 1.6 MiB |
|
Before Width: | Height: | Size: 537 KiB After Width: | Height: | Size: 642 KiB |
|
After Width: | Height: | Size: 640 KiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 151 KiB |
|
Before Width: | Height: | Size: 840 KiB After Width: | Height: | Size: 859 KiB |
|
After Width: | Height: | Size: 672 KiB |
|
After Width: | Height: | Size: 587 KiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 967 KiB |
|
After Width: | Height: | Size: 895 KiB |
@@ -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. |
|
||||
@@ -0,0 +1,52 @@
|
||||
# Languages
|
||||
|
||||
The web interface and PS4 app include English, Brazilian Portuguese and Arabic.
|
||||
Translations are included with Control4Free. They work without an internet connection
|
||||
and do not use an online translation service.
|
||||
|
||||
## Choose a language
|
||||
|
||||
On the web, use the language picker at the bottom of the home screen or in
|
||||
**Settings → This device**. Automatic follows the browser's primary language.
|
||||
The choice is saved on that phone or PC and applies to saved copies of the page too.
|
||||
|
||||
In the PS4 app, press **L1** on the launcher home screen to cycle through automatic,
|
||||
English, Português (Brasil) and العربية. Automatic follows the PS4 system language.
|
||||
The app saves its choice separately from the browser.
|
||||
|
||||
Arabic menus and text read from right to left. Numbers use **0–9**. The controller
|
||||
diagram, physical button positions, stick directions, RGB slider direction, addresses
|
||||
and technical readings keep their usual orientation. Menu navigation follows the
|
||||
visible positions, including wrapping at the edges of the tester's tool grid.
|
||||
|
||||
Names are displayed as entered. Changing languages does not rename users, connected
|
||||
gamepads or saved profiles. Errors that Control4Free does not recognize stay readable
|
||||
in English.
|
||||
|
||||
## Arabic fonts
|
||||
|
||||
The PS4 app reads the console's installed **SST Arabic Light and Roman** fonts.
|
||||
The Arabic web page loads those same fonts from the local PS4 service. This uses the
|
||||
home network, without contacting a font website. A saved HTML page requests them
|
||||
from the console address you enter.
|
||||
|
||||
Both interfaces also include **Noto Sans Arabic Light and Regular**. These display
|
||||
Arabic immediately while the PS4 font loads and remain available if it cannot be
|
||||
read or the console is unreachable. Sony's font files are not included in downloads.
|
||||
|
||||
## Implementation
|
||||
|
||||
Interface text uses explicit message keys. Dynamic messages translate their template
|
||||
before inserting names and other values. Values are plain text and use directional
|
||||
isolation in mixed Arabic/Latin messages. Translations and names are never interpreted
|
||||
as HTML.
|
||||
|
||||
The native app uses statically linked HarfBuzz for Arabic joining and mark positioning,
|
||||
SheenBidi for mixed-direction text, and the existing stb rasterizer for glyph drawing.
|
||||
Text measurement, wrapping and drawing use the same layout. Bounded layout and glyph
|
||||
caches belong to the drawing thread; controller input is sampled independently.
|
||||
|
||||
Only the two fixed font endpoints are available: `/fonts/arabic-light.otf` and
|
||||
`/fonts/arabic-regular.otf`. They read a bounded pair of system font files at service
|
||||
startup. Requests cannot select filesystem paths. See [Third-party material](../THIRD_PARTY.md)
|
||||
for the bundled fonts and text library licenses.
|
||||
@@ -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
|
||||
@@ -41,13 +49,22 @@ The controller screen is a DualShock 4 laid out for your screen, best held sidew
|
||||
## Your own layout
|
||||
|
||||
Open the menu (**☰**) and choose **Edit the button layout**. Drag a button to move it, change its size, or
|
||||
hide it. **Reset to default** puts everything back; tap it twice, so a stray tap can't throw a layout away. Each device keeps its own layout, separately for portrait
|
||||
and landscape.
|
||||
hide it. Select the face buttons to reveal **Move buttons** beside **Size**. Choose **Separately** to move,
|
||||
resize or hide Cross, Square, Circle and Triangle individually, or **Together** to adjust the group.
|
||||
Switching between the two keeps your arrangement. **Done** saves it; **Cancel** discards the current edits.
|
||||
|
||||
The selected control has its own **Reset** button beside **Hide**. It restores that control's default
|
||||
position, size and visibility. In separate mode, resetting a face button leaves the other three in place;
|
||||
in together mode, it restores the face-button group. **Done** saves a reset, and **Cancel** undoes it.
|
||||
|
||||
**Reset to default** puts everything back; tap it twice, so a stray tap can't throw a layout away.
|
||||
Each device keeps its own layout, separately for portrait 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 +82,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 +122,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 +148,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.
|
||||
|
||||

|
||||
|
||||
| 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.
|
||||
|
||||

|
||||
- 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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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,69 @@ 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.** By default these
|
||||
browsers read Xbox controllers through Windows.Gaming.Input, which has no Xbox/Guide button. The browser
|
||||
reads that button separately for all Xbox controllers at once and reports the combined press on its
|
||||
lowest-numbered one, whichever controller it came from. It happens with any two Xbox controllers, not
|
||||
only mixed generations. Other buttons 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, and Control4Free cannot recover it from the reported input. See the
|
||||
[Chromium implementation](https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc).
|
||||
|
||||
On Windows 11, Chrome and Edge also have an experimental **Windows GameInput** backend that reports each
|
||||
Xbox button on its own gamepad. It is off by default. Find your browser's version under
|
||||
`chrome://settings/help` (`edge://settings/help` in Edge), then turn GameInput on:
|
||||
|
||||
- **Version 155 or older:** open `chrome://flags/#enable-windows-gameinput-data-fetcher`
|
||||
(`edge://flags/#enable-windows-gameinput-data-fetcher` in Edge), set it to **Enabled**, and relaunch.
|
||||
- **Version 156 or 157:** the option is hidden because it has expired. Set
|
||||
`chrome://flags/#temporary-unexpire-flags-m155` (`edge://flags/#temporary-unexpire-flags-m155`) to
|
||||
**Enabled** and relaunch, then enable the GameInput option as above and relaunch again.
|
||||
- **Any version, including 158 and newer:** start the browser with
|
||||
`--enable-features=EnableWindowsGameInputDataFetcher`. Right-click the browser's shortcut, choose
|
||||
**Properties**, add a space and the option to the end of **Target**, and press **OK**. Close every
|
||||
browser window, check the notification area for a browser still running in the background, and open
|
||||
the browser from that shortcut. `chrome://version` (`edge://version`) lists the option under
|
||||
**Command Line** when it was applied.
|
||||
|
||||
Chromium hides expired options from the flags page, but the command-line option keeps working as long
|
||||
as the browser still contains the GameInput backend. See Chromium's
|
||||
[backend selection](https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/gamepad_platform_data_fetcher.h),
|
||||
[per-device Guide handling](https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/gameinput_data_fetcher.cc)
|
||||
and [flag expiry](https://chromium.googlesource.com/chromium/src/+/refs/heads/main/docs/flag_expiry.md).
|
||||
|
||||
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
|
||||
backend 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** or remove the option from the shortcut.
|
||||
|
||||
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.
|
||||
|
||||
On Windows 10, or if none of these work, 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 +184,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 +248,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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
@@ -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];
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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);
|
||||
|
||||
@@ -20,9 +20,14 @@ 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
|
||||
CFLAGS += -I../vendor/harfbuzz/src -I../vendor/sheenbidi/Headers
|
||||
OBJECTS += $(OUT)/main.o $(OUT)/input.o $(OUT)/navigation.o $(OUT)/screen.o $(OUT)/tester.o $(OUT)/tester_draw.o $(OUT)/draw.o $(OUT)/language.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)/text.o
|
||||
OBJECTS += $(OUT)/message.o
|
||||
TEXT_LIBS := ../build/text/libharfbuzz.a ../build/text/libsheenbidi.a
|
||||
LIBS := -lc -lkernel -lSceVideoOut -lScePad -lSceUserService -lSceSystemService -lSceNetCtl
|
||||
FONTS := ../vendor/roboto/Roboto-Light.ttf ../vendor/roboto/Roboto-Regular.ttf
|
||||
FONTS += ../vendor/noto-sans-arabic/NotoSansArabic-Light.ttf ../vendor/noto-sans-arabic/NotoSansArabic-Regular.ttf
|
||||
.PHONY: all package
|
||||
.DEFAULT_GOAL := all
|
||||
-include $(OBJECTS:.o=.d)
|
||||
@@ -40,10 +45,12 @@ $(OUT)/qrcodegen.o: ../vendor/qrcodegen/qrcodegen.c | $(OUT)
|
||||
$(OUT)/stb_truetype.o: stb_truetype.c | $(OUT)
|
||||
$(CC) $(CFLAGS) -std=gnu11 -w -c $< -o $@
|
||||
$(OUT)/fonts.c: $(FONTS) ../tools/embed_file.py | $(OUT)
|
||||
python3 ../tools/embed_file.py $@ c4fFontLight=../vendor/roboto/Roboto-Light.ttf c4fFontRegular=../vendor/roboto/Roboto-Regular.ttf
|
||||
python3 ../tools/embed_file.py $@ c4fFontLight=../vendor/roboto/Roboto-Light.ttf c4fFontRegular=../vendor/roboto/Roboto-Regular.ttf c4fFontArabicLight=../vendor/noto-sans-arabic/NotoSansArabic-Light.ttf c4fFontArabicRegular=../vendor/noto-sans-arabic/NotoSansArabic-Regular.ttf
|
||||
$(OUT)/fonts.o: $(OUT)/fonts.c
|
||||
$(CC) $(CFLAGS) -std=gnu11 -c $< -o $@
|
||||
$(OUT)/launcher.elf: $(OBJECTS)
|
||||
$(LD) -m elf_x86_64 -pie --script $(SDK)/link.x --eh-frame-hdr -o $@ $(OBJECTS) -L$(SDK)/lib $(LIBS) $(SDK)/lib/crt1.o
|
||||
$(TEXT_LIBS) &: ../tools/build_text.py $(shell find ../vendor/harfbuzz/src ../vendor/sheenbidi -type f)
|
||||
python3 ../tools/build_text.py --out ../build/text
|
||||
$(OUT)/launcher.elf: $(OBJECTS) $(TEXT_LIBS)
|
||||
$(LD) -m elf_x86_64 -pie --script $(SDK)/link.x --eh-frame-hdr -o $@ $(OBJECTS) $(TEXT_LIBS) -L$(SDK)/lib $(LIBS) $(SDK)/lib/crt1.o
|
||||
package: $(OUT)/launcher.elf $(PAYLOAD)
|
||||
python3 ../tools/package_launcher.py --sdk $(SDK) $(if $(C4F_DIAG),--diag)
|
||||
@@ -163,6 +163,9 @@ static char *c4fIniEdited(const char *text, int enable)
|
||||
return out;
|
||||
}
|
||||
|
||||
static C4fMessage c4fAutorunText;
|
||||
const C4fMessage *c4fAutorunMessage(void) { return &c4fAutorunText; }
|
||||
|
||||
/* ---- public ---- */
|
||||
|
||||
int c4fAutorunCheck(char *problem, size_t size)
|
||||
@@ -170,15 +173,26 @@ int c4fAutorunCheck(char *problem, size_t size)
|
||||
char path[300];
|
||||
size_t length = 0;
|
||||
problem[0] = 0;
|
||||
if (!c4fBundled) { snprintf(problem, size, "the bundled payload could not be read"); return C4F_AUTORUN_UNKNOWN; }
|
||||
if (c4fSandboxLeave()) { snprintf(problem, size, "GoldHEN did not let the app check (errno %d)", errno); return C4F_AUTORUN_UNKNOWN; }
|
||||
c4fMessageClear(&c4fAutorunText);
|
||||
if (!c4fBundled) { c4fMessageWrite(&c4fAutorunText, problem, size, "the bundled payload could not be read"); return C4F_AUTORUN_UNKNOWN; }
|
||||
if (c4fSandboxLeave()) { c4fMessageWrite(&c4fAutorunText, 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) {
|
||||
c4fMessageWrite(&c4fAutorunText, 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;
|
||||
c4fMessageWrite(&c4fAutorunText, problem, size, "Enabled, but the payload is missing; choose Update auto-start in Actions or press Triangle");
|
||||
} else {
|
||||
state = C4F_AUTORUN_UNKNOWN;
|
||||
c4fMessageWrite(&c4fAutorunText, problem, size, "Enabled, but the payload cannot be inspected (errno %d)", errno);
|
||||
}
|
||||
free(installed);
|
||||
}
|
||||
free(ini);
|
||||
@@ -189,13 +203,13 @@ int c4fAutorunEnable(char *message, size_t size)
|
||||
{
|
||||
char directory[300], path[300];
|
||||
size_t length = 0;
|
||||
if (!c4fBundled) { snprintf(message, size, "The bundled payload could not be read. Reinstall the Control4Free package."); return -1; }
|
||||
if (c4fSandboxLeave()) { snprintf(message, size, "GoldHEN did not let the app set up auto-start (errno %d).", errno); return -1; }
|
||||
if (!c4fBundled) { c4fMessageWrite(&c4fAutorunText, message, size, "The bundled payload could not be read. Reinstall the Control4Free package."); return -1; }
|
||||
if (c4fSandboxLeave()) { c4fMessageWrite(&c4fAutorunText, message, size, "GoldHEN did not let the app set up auto-start (errno %d).", errno); return -1; }
|
||||
int failed = c4fPath(directory, sizeof(directory), "payloads") || c4fPath(path, sizeof(path), "payloads/control4free.elf");
|
||||
if (!failed && mkdir(directory, 0777) && errno != EEXIST) failed = 1;
|
||||
if (!failed && c4fWriteFile(path, c4fBundled, c4fBundledSize)) failed = 1;
|
||||
if (failed) {
|
||||
snprintf(message, size, "Could not copy Control4Free to /data/payloads (errno %d).", errno);
|
||||
c4fMessageWrite(&c4fAutorunText, message, size, "Could not copy Control4Free to /data/payloads (errno %d).", errno);
|
||||
return -1;
|
||||
}
|
||||
failed = c4fPath(directory, sizeof(directory), "GoldHEN") || c4fPath(path, sizeof(path), "GoldHEN/payloads.ini");
|
||||
@@ -204,8 +218,8 @@ int c4fAutorunEnable(char *message, size_t size)
|
||||
if (!failed && !old && errno != ENOENT) failed = 1;
|
||||
char *edited = failed ? NULL : c4fIniEdited(old ? old : "", 1);
|
||||
if (!failed && (!edited || c4fWriteFile(path, edited, strlen(edited)))) failed = 1;
|
||||
if (failed) snprintf(message, size, "Could not add Control4Free to GoldHEN's AutoRun list (errno %d).", errno);
|
||||
else snprintf(message, size, "Auto-start is on. GoldHEN starts Control4Free each time it loads.");
|
||||
if (failed) c4fMessageWrite(&c4fAutorunText, message, size, "Could not add Control4Free to GoldHEN's AutoRun list (errno %d).", errno);
|
||||
else c4fMessageWrite(&c4fAutorunText, message, size, "Auto-start is on. GoldHEN starts Control4Free each time it loads.");
|
||||
free(old);
|
||||
free(edited);
|
||||
return failed ? -1 : 0;
|
||||
@@ -215,14 +229,14 @@ int c4fAutorunDisable(char *message, size_t size)
|
||||
{
|
||||
char path[300];
|
||||
size_t length = 0;
|
||||
if (c4fSandboxLeave()) { snprintf(message, size, "GoldHEN did not let the app change auto-start (errno %d).", errno); return -1; }
|
||||
if (c4fSandboxLeave()) { c4fMessageWrite(&c4fAutorunText, message, size, "GoldHEN did not let the app change auto-start (errno %d).", errno); return -1; }
|
||||
int failed = c4fPath(path, sizeof(path), "GoldHEN/payloads.ini");
|
||||
char *old = failed ? NULL : (char *)c4fReadFile(path, &length, 1u << 20);
|
||||
char *edited = old ? c4fIniEdited(old, 0) : NULL;
|
||||
if (!failed && old && (!edited || c4fWriteFile(path, edited, strlen(edited)))) failed = 1;
|
||||
if (!failed && !old && errno != ENOENT) failed = 1;
|
||||
if (failed) snprintf(message, size, "Could not change GoldHEN's AutoRun list (errno %d).", errno);
|
||||
else snprintf(message, size, "Auto-start is off. Start Control4Free here with Cross, or from GoldHEN's Payloader LaunchPad.");
|
||||
if (failed) c4fMessageWrite(&c4fAutorunText, message, size, "Could not change GoldHEN's AutoRun list (errno %d).", errno);
|
||||
else c4fMessageWrite(&c4fAutorunText, message, size, "Auto-start is off. Start Control4Free here with Cross, or from GoldHEN's Payloader LaunchPad.");
|
||||
free(old);
|
||||
free(edited);
|
||||
return failed ? -1 : 0;
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
* entry in /data/GoldHEN/payloads.ini, the files GoldHEN's Payloader LaunchPad
|
||||
* uses. The app leaves its sandbox for this through GoldHEN's SDK call. */
|
||||
#include <stddef.h>
|
||||
#include "message.h"
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
@@ -19,6 +20,7 @@ const unsigned char *c4fAutorunBundled(size_t *size);
|
||||
int c4fAutorunCheck(char *problem, size_t size);
|
||||
int c4fAutorunEnable(char *message, size_t size);
|
||||
int c4fAutorunDisable(char *message, size_t size);
|
||||
const C4fMessage *c4fAutorunMessage(void);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
@@ -5,19 +5,41 @@
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include "stb_truetype.h"
|
||||
#include "text.h"
|
||||
|
||||
extern const unsigned char c4fFontLight[], c4fFontRegular[];
|
||||
|
||||
typedef struct { unsigned char *bitmap; int w, h, x0, y0, ready; float advance; } C4fGlyph;
|
||||
typedef struct { int font, size; float scale; C4fGlyph glyph[95]; } C4fFace;
|
||||
/* Printable ASCII, then Latin-1's letters and signs (160-255). */
|
||||
enum { C4F_GLYPHS = 95 + 96 };
|
||||
typedef struct { int font, size; float scale; C4fGlyph glyph[C4F_GLYPHS]; } C4fFace;
|
||||
|
||||
static stbtt_fontinfo c4fFonts[C4F_FONT_COUNT];
|
||||
static C4fFace c4fFaces[24];
|
||||
static int c4fFaceCount;
|
||||
static uint8_t c4fTextGamma[256];
|
||||
static const char *(*c4fTranslate)(const char *text);
|
||||
static unsigned c4fTranslation;
|
||||
|
||||
void c4fSetTextTranslation(const char *(*translate)(const char *text))
|
||||
{
|
||||
c4fTranslate = translate;
|
||||
c4fTranslation++;
|
||||
}
|
||||
|
||||
unsigned c4fTextTranslationGeneration(void) { return c4fTranslation; }
|
||||
void c4fTextFontChanged(void) { c4fTranslation++; }
|
||||
|
||||
/* The text as it is shown: its translation when there is one. */
|
||||
static const char *c4fShown(const char *text)
|
||||
{
|
||||
const char *shown = c4fTranslate && text ? c4fTranslate(text) : NULL;
|
||||
return shown ? shown : text;
|
||||
}
|
||||
|
||||
int c4fDrawInit(void)
|
||||
{
|
||||
if (c4fShapedTextInit() < 0) return -1;
|
||||
const unsigned char *data[C4F_FONT_COUNT] = { c4fFontLight, c4fFontRegular };
|
||||
for (int i = 0; i < C4F_FONT_COUNT; i++)
|
||||
if (!stbtt_InitFont(&c4fFonts[i], data[i], stbtt_GetFontOffsetForIndex(data[i], 0))) return -1;
|
||||
@@ -446,8 +468,11 @@ static C4fFace *c4fFace(int font, float size)
|
||||
int pixels = (int)(size + 0.5f);
|
||||
for (int i = 0; i < c4fFaceCount; i++)
|
||||
if (c4fFaces[i].font == font && c4fFaces[i].size == pixels) return &c4fFaces[i];
|
||||
if (font < 0 || font >= C4F_FONT_COUNT || c4fFaceCount == (int)(sizeof(c4fFaces) / sizeof(c4fFaces[0]))) return NULL;
|
||||
C4fFace *face = &c4fFaces[c4fFaceCount++];
|
||||
if (font < 0 || font >= C4F_FONT_COUNT) return NULL;
|
||||
static unsigned replace;
|
||||
const int capacity = (int)(sizeof(c4fFaces) / sizeof(c4fFaces[0]));
|
||||
C4fFace *face = c4fFaceCount < capacity ? &c4fFaces[c4fFaceCount++] : &c4fFaces[replace++ % capacity];
|
||||
for (int i = 0; i < C4F_GLYPHS; i++) free(face->glyph[i].bitmap);
|
||||
memset(face, 0, sizeof(*face));
|
||||
face->font = font;
|
||||
face->size = pixels;
|
||||
@@ -456,11 +481,24 @@ static C4fFace *c4fFace(int font, float size)
|
||||
return face;
|
||||
}
|
||||
|
||||
static int c4fCodepoint(unsigned char ch) { return ch < 32 || ch > 126 ? '?' : ch; }
|
||||
/* The next character of UTF-8 text, as one the faces carry: printable ASCII
|
||||
* or Latin-1. Anything else, or a broken sequence, shows as '?'. */
|
||||
static int c4fCodepoint(const char *text, int length, int *at)
|
||||
{
|
||||
const unsigned char *s = (const unsigned char *)text;
|
||||
int i = *at, ch = s[i++];
|
||||
if (ch >= 0xc0 && ch < 0xe0 && i < length && (s[i] & 0xc0) == 0x80) ch = ((ch & 0x1f) << 6) | (s[i++] & 0x3f);
|
||||
else if (ch >= 0x80) {
|
||||
while (i < length && (s[i] & 0xc0) == 0x80) i++;
|
||||
ch = '?';
|
||||
}
|
||||
*at = i;
|
||||
return ch < 32 || (ch > 126 && ch < 160) || ch > 255 ? '?' : ch;
|
||||
}
|
||||
|
||||
static C4fGlyph *c4fGlyph(C4fFace *face, int ch)
|
||||
{
|
||||
C4fGlyph *g = &face->glyph[ch - 32];
|
||||
C4fGlyph *g = &face->glyph[ch < 127 ? ch - 32 : ch - 160 + 95];
|
||||
if (!g->ready) {
|
||||
const stbtt_fontinfo *font = &c4fFonts[face->font];
|
||||
int advance, bearing, x0, y0, x1, y1;
|
||||
@@ -480,12 +518,14 @@ static C4fGlyph *c4fGlyph(C4fFace *face, int ch)
|
||||
|
||||
static float c4fTextWidthN(int font, float size, const char *text, int length)
|
||||
{
|
||||
if (c4fTextNeedsShaping(text, length))
|
||||
return c4fShapedText(NULL, font, size, 0, 0, (C4fColor){0}, text, length);
|
||||
C4fFace *face = c4fFace(font, size);
|
||||
float width = 0;
|
||||
int previous = 0;
|
||||
if (!face) return 0;
|
||||
for (int i = 0; i < length && text[i]; i++) {
|
||||
int ch = c4fCodepoint((unsigned char)text[i]);
|
||||
for (int i = 0; i < length && text[i];) {
|
||||
int ch = c4fCodepoint(text, length, &i);
|
||||
if (previous) width += stbtt_GetCodepointKernAdvance(&c4fFonts[font], previous, ch) * face->scale;
|
||||
width += c4fGlyph(face, ch)->advance;
|
||||
previous = ch;
|
||||
@@ -494,18 +534,25 @@ static float c4fTextWidthN(int font, float size, const char *text, int length)
|
||||
}
|
||||
|
||||
float c4fTextWidth(int font, float size, const char *text)
|
||||
{
|
||||
text = c4fShown(text);
|
||||
return c4fTextWidthRaw(font, size, text);
|
||||
}
|
||||
|
||||
float c4fTextWidthRaw(int font, float size, const char *text)
|
||||
{
|
||||
return c4fTextWidthN(font, size, text, (int)strlen(text));
|
||||
}
|
||||
|
||||
float c4fTextN(C4fCanvas *c, int font, float size, float x, float y, C4fColor color, const char *text, int length)
|
||||
{
|
||||
if (c4fTextNeedsShaping(text, length)) return c4fShapedText(c, font, size, x, y, color, text, length);
|
||||
C4fFace *face = c4fFace(font, size);
|
||||
float pen = x;
|
||||
int previous = 0, baseline = (int)floorf(y + 0.5f);
|
||||
if (!face) return 0;
|
||||
for (int i = 0; i < length && text[i]; i++) {
|
||||
int ch = c4fCodepoint((unsigned char)text[i]);
|
||||
for (int i = 0; i < length && text[i];) {
|
||||
int ch = c4fCodepoint(text, length, &i);
|
||||
if (previous) pen += stbtt_GetCodepointKernAdvance(&c4fFonts[font], previous, ch) * face->scale;
|
||||
C4fGlyph *g = c4fGlyph(face, ch);
|
||||
if (g->bitmap) {
|
||||
@@ -523,6 +570,12 @@ float c4fTextN(C4fCanvas *c, int font, float size, float x, float y, C4fColor co
|
||||
}
|
||||
|
||||
float c4fText(C4fCanvas *c, int font, float size, float x, float y, C4fColor color, const char *text)
|
||||
{
|
||||
text = c4fShown(text);
|
||||
return c4fTextRaw(c, font, size, x, y, color, text);
|
||||
}
|
||||
|
||||
float c4fTextRaw(C4fCanvas *c, int font, float size, float x, float y, C4fColor color, const char *text)
|
||||
{
|
||||
return c4fTextN(c, font, size, x, y, color, text, (int)strlen(text));
|
||||
}
|
||||
@@ -531,6 +584,7 @@ int c4fTextWrapped(C4fCanvas *c, int font, float size, float x, float y, float m
|
||||
float lineHeight, int maxLines, C4fColor color, const char *text)
|
||||
{
|
||||
int lines = 0;
|
||||
text = c4fShown(text);
|
||||
while (*text && lines < maxLines) {
|
||||
while (*text == ' ') text++;
|
||||
if (!*text) break;
|
||||
@@ -542,9 +596,51 @@ int c4fTextWrapped(C4fCanvas *c, int font, float size, float x, float y, float m
|
||||
if (!text[end]) break;
|
||||
end++;
|
||||
}
|
||||
if (c) c4fTextN(c, font, size, x, y + lines * lineHeight, color, text, fit);
|
||||
float at = c4fTextIsRTL() ? x + maxWidth - c4fTextWidthN(font, size, text, fit) : x;
|
||||
if (c) c4fTextN(c, font, size, at, y + lines * lineHeight, color, text, fit);
|
||||
lines++;
|
||||
text += fit;
|
||||
}
|
||||
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,6 +4,10 @@
|
||||
* Blending reads the destination, so never draw straight into the
|
||||
* write-combined framebuffer. */
|
||||
#include <stdint.h>
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
#include <stddef.h>
|
||||
|
||||
typedef struct { uint32_t *px; int w, h; } C4fCanvas;
|
||||
typedef struct { uint8_t r, g, b, a; } C4fColor;
|
||||
@@ -12,6 +16,17 @@ typedef struct { uint8_t r, g, b, a; } C4fColor;
|
||||
enum { C4F_FONT_LIGHT, C4F_FONT_REGULAR, C4F_FONT_COUNT };
|
||||
|
||||
int c4fDrawInit(void);
|
||||
/* Drawing-thread locale direction; shaping still works for Arabic user names
|
||||
* while the interface is English. ASCII technical values retain their order. */
|
||||
void c4fSetTextDirection(int rtl);
|
||||
int c4fTextIsRTL(void);
|
||||
/* Drawing thread only. Bytes remain owned by the caller and must outlive
|
||||
* drawing. A change clears Unicode caches and invalidates cached UI layers. */
|
||||
int c4fTextSetArabicFonts(const unsigned char *light, size_t lightSize,
|
||||
const unsigned char *regular, size_t regularSize);
|
||||
/* Prefer the installed PS4 SST Arabic faces. Returns 0 on success; -1 retains
|
||||
* the redistributable bundled font. Retry after leaving the sandbox if needed. */
|
||||
int c4fTextLoadSystemFonts(void);
|
||||
|
||||
/* Signed distance to a rounded box: centre, half size, corner radius. */
|
||||
float c4fRoundBoxDistance(float px, float py, float cx, float cy, float hx, float hy, float r);
|
||||
@@ -62,12 +77,30 @@ void c4fFillPath(C4fCanvas *c, const C4fPath *p, C4fPlacement at, C4fColor color
|
||||
/* The mask blurred and spread: a soft halo or drop shadow under a shape. */
|
||||
void c4fShadowPath(C4fCanvas *c, const C4fPath *p, C4fPlacement at, float dx, float dy, int radius, C4fColor color);
|
||||
|
||||
/* Text on a baseline. Sizes are CSS font-size in pixels. ASCII only. */
|
||||
/* Text on a baseline. Sizes are CSS font-size in pixels. UTF-8 Arabic uses
|
||||
* Unicode bidi layout and contextual shaping; Latin text keeps the Roboto
|
||||
* path. UI entry points resolve exact message keys before shaping. */
|
||||
float c4fText(C4fCanvas *c, int font, float size, float x, float y, C4fColor color, const char *text);
|
||||
float c4fTextN(C4fCanvas *c, int font, float size, float x, float y, C4fColor color, const char *text, int length);
|
||||
float c4fTextWidth(int font, float size, const char *text);
|
||||
/* User data uses the same renderer without looking it up as interface text. */
|
||||
float c4fTextRaw(C4fCanvas *c, int font, float size, float x, float y, C4fColor color, const char *text);
|
||||
float c4fTextWidthRaw(int font, float size, const char *text);
|
||||
/* Word-wrapped text; returns the number of lines (drawn only if c is set). */
|
||||
int c4fTextWrapped(C4fCanvas *c, int font, float size, float x, float y, float maxWidth,
|
||||
float lineHeight, int maxLines, C4fColor color, const char *text);
|
||||
|
||||
/* What to show in place of a text, or NULL to show it as it is. NULL turns
|
||||
* translation off. Each change bumps the generation, so cached drawings of
|
||||
* words know to redraw. */
|
||||
void c4fSetTextTranslation(const char *(*translate)(const char *text));
|
||||
unsigned c4fTextTranslationGeneration(void);
|
||||
|
||||
/* 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);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1,533 @@
|
||||
/* Offline interface catalogs. Source message keys are looked up exactly,
|
||||
* before values are inserted, so user data is never translated. */
|
||||
#include "language.h"
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include <sys/stat.h>
|
||||
#include "draw.h"
|
||||
#include "screen.h"
|
||||
|
||||
/* libSceSystemService, declared here so this file needs no SDK header. */
|
||||
int sceSystemServiceParamGetInt(int id, int *value);
|
||||
#define C4F_SYSTEM_PARAM_LANG 1
|
||||
#define C4F_SYSTEM_LANG_PORTUGUESE 7
|
||||
#define C4F_SYSTEM_LANG_PORTUGUESE_BR 17
|
||||
#define C4F_SYSTEM_LANG_ARABIC 21
|
||||
|
||||
#define C4F_LANGUAGE_DIR "/data/control4free"
|
||||
#define C4F_LANGUAGE_FILE C4F_LANGUAGE_DIR "/launcher-language"
|
||||
|
||||
static const char *const c4fLanguageNames[C4F_LANGUAGE_COUNT] = { "auto", "en", "pt-BR", "ar" };
|
||||
static int c4fActiveLanguage = C4F_LANGUAGE_ENGLISH;
|
||||
|
||||
int c4fLanguageLoad(void)
|
||||
{
|
||||
char word[16] = {0};
|
||||
FILE *f = fopen(C4F_LANGUAGE_FILE, "r");
|
||||
if (!f) return C4F_LANGUAGE_AUTO;
|
||||
if (!fgets(word, sizeof(word), f)) word[0] = 0;
|
||||
fclose(f);
|
||||
word[strcspn(word, "\r\n ")] = 0;
|
||||
for (int i = 0; i < C4F_LANGUAGE_COUNT; i++)
|
||||
if (!strcmp(word, c4fLanguageNames[i])) return i;
|
||||
return C4F_LANGUAGE_AUTO;
|
||||
}
|
||||
|
||||
void c4fLanguageSave(int choice)
|
||||
{
|
||||
if (choice < 0 || choice >= C4F_LANGUAGE_COUNT) return;
|
||||
(void)mkdir(C4F_LANGUAGE_DIR, 0777);
|
||||
FILE *f = fopen(C4F_LANGUAGE_FILE, "w");
|
||||
if (!f) { printf("[c4f-launcher] could not save the language\n"); return; }
|
||||
fprintf(f, "%s\n", c4fLanguageNames[choice]);
|
||||
fclose(f);
|
||||
}
|
||||
|
||||
static int c4fSystemLanguage(void)
|
||||
{
|
||||
int language = -1;
|
||||
if (sceSystemServiceParamGetInt(C4F_SYSTEM_PARAM_LANG, &language) < 0)
|
||||
return C4F_LANGUAGE_ENGLISH;
|
||||
if (language == C4F_SYSTEM_LANG_ARABIC) return C4F_LANGUAGE_ARABIC;
|
||||
if (language == C4F_SYSTEM_LANG_PORTUGUESE || language == C4F_SYSTEM_LANG_PORTUGUESE_BR)
|
||||
return C4F_LANGUAGE_PORTUGUESE;
|
||||
return C4F_LANGUAGE_ENGLISH;
|
||||
}
|
||||
|
||||
void c4fLanguageUse(int choice)
|
||||
{
|
||||
static int used = -1;
|
||||
int selected = choice == C4F_LANGUAGE_AUTO ? c4fSystemLanguage() : choice;
|
||||
if (selected <= C4F_LANGUAGE_AUTO || selected >= C4F_LANGUAGE_COUNT)
|
||||
selected = C4F_LANGUAGE_ENGLISH;
|
||||
if (selected == used) return;
|
||||
used = selected;
|
||||
c4fActiveLanguage = selected;
|
||||
c4fSetTextTranslation(selected == C4F_LANGUAGE_PORTUGUESE ? c4fPortugueseText :
|
||||
selected == C4F_LANGUAGE_ARABIC ? c4fArabicText : NULL);
|
||||
c4fSetTextDirection(selected == C4F_LANGUAGE_ARABIC);
|
||||
}
|
||||
|
||||
/* ---- the translations ---- */
|
||||
|
||||
/* Format conversions remain in the same order; callers format translated keys. */
|
||||
typedef struct { const char *en, *pt; } C4fPhrase;
|
||||
|
||||
static const C4fPhrase c4fPortuguese[] = {
|
||||
/* Main screen */
|
||||
{ "Not responding", "Sem resposta" },
|
||||
{ "Check Control4Free", "Verifique o Control4Free" },
|
||||
{ "Something answers on port 4264 but not the way this app expects. If an older Control4Free is running, stop it on its controller page or restart the PS4.",
|
||||
"Algo responde na porta 4264, mas não do jeito que este app espera. Se um Control4Free mais antigo estiver rodando, pare-o pela página do controle ou reinicie o PS4." },
|
||||
{ "App error", "Erro no app" },
|
||||
{ "Reopen the app", "Reabra o app" },
|
||||
{ "The app could not start properly. Close it with the PS button and open it again. Control4Free itself is not affected.",
|
||||
"O app não conseguiu iniciar direito. Feche-o com o botão PS e abra de novo. O Control4Free em si não foi afetado." },
|
||||
{ "Working", "Trabalhando" },
|
||||
{ "Please wait", "Aguarde" },
|
||||
{ "Checking", "Verificando" },
|
||||
{ "1 controller connected", "1 controle conectado" },
|
||||
{ "%d controllers connected", "%d controles conectados" },
|
||||
{ "Running", "Rodando" },
|
||||
{ "Control4Free is running", "O Control4Free está rodando" },
|
||||
{ "Open the address or scan the code on your phone or PC. Pick a controller there, then choose its user on the PS4.",
|
||||
"Abra o endereço ou escaneie o código no seu celular ou PC. Escolha um controle lá e depois o usuário dele no PS4." },
|
||||
{ "Stopped", "Parado" },
|
||||
{ "Start Control4Free", "Iniciar o Control4Free" },
|
||||
{ "It starts with GoldHEN after each restart. To start now, enable GoldHEN's PayLoader Server and press Cross.",
|
||||
"Ele inicia com o GoldHEN após cada reinício. Para iniciar agora, ative o PayLoader Server do GoldHEN e aperte X." },
|
||||
{ "Not running", "Não está rodando" },
|
||||
{ "Set up Control4Free", "Configurar o Control4Free" },
|
||||
{ "Press Cross to set up auto-start. To start Control4Free now, enable GoldHEN's PayLoader Server.",
|
||||
"Aperte X para configurar o início automático. Para iniciar o Control4Free agora, ative o PayLoader Server do GoldHEN." },
|
||||
{ "Open on your phone or PC", "Abra no seu celular ou PC" },
|
||||
{ "Connect the PS4 to your home network", "Conecte o PS4 à sua rede doméstica" },
|
||||
{ "Use the same network as the PS4. Touch, a keyboard and gamepads connected to the phone or PC all work.",
|
||||
"Use a mesma rede do PS4. Toque, teclado e gamepads conectados ao celular ou PC funcionam." },
|
||||
{ "Working...", "Trabalhando..." },
|
||||
{ "Scan with your phone's camera", "Escaneie com a câmera do celular" },
|
||||
{ "The code appears once it runs", "O código aparece quando ele estiver rodando" },
|
||||
{ "Installed %s; running %s", "Instalado %s; rodando %s" },
|
||||
{ "Press Square, then Cross to stop, and Cross again to start this app's version. All players disconnect.",
|
||||
"Aperte Quadrado e depois X para parar, e X de novo para iniciar a versão deste app. Todos os jogadores serão desconectados." },
|
||||
{ "Starts by itself with GoldHEN", "Inicia sozinho com o GoldHEN" },
|
||||
{ "GoldHEN starts Control4Free each time it loads. Closing this app leaves your controllers running. Turn off other controller plugins in your games.",
|
||||
"O GoldHEN inicia o Control4Free sempre que carrega. Fechar este app mantém os controles ativos. Desative outros plugins de controle." },
|
||||
{ "Auto-start uses an older Control4Free", "O início automático usa um Control4Free mais antigo" },
|
||||
{ "Press Triangle to update auto-start. GoldHEN uses this version after the next restart.",
|
||||
"Aperte Triângulo para atualizar o início automático. O GoldHEN usa esta versão após o próximo reinício." },
|
||||
{ "Auto-start is off", "Início automático desligado" },
|
||||
{ "Press Triangle to turn auto-start on. GoldHEN then starts Control4Free each time it loads.",
|
||||
"Aperte Triângulo para ligar o início automático. Aí o GoldHEN inicia o Control4Free toda vez que carrega." },
|
||||
{ "Auto-start status unknown", "Estado do início automático desconhecido" },
|
||||
{ "Checking with GoldHEN...", "Verificando com o GoldHEN..." },
|
||||
{ "TV remote: OK opens actions / arrows select / Back returns", "Controle da TV: OK abre as ações / setas escolhem / Voltar retorna" },
|
||||
{ "L1 Language: automatic", "L1 Idioma: automático" },
|
||||
{ "L1 Language: English", "L1 Idioma: English" },
|
||||
{ "L1 Language: Portuguese (Brazil)", "L1 Idioma: português (Brasil)" },
|
||||
{ "Start", "Iniciar" },
|
||||
{ "Set up", "Configurar" },
|
||||
{ "Turn off auto-start", "Desligar início automático" },
|
||||
{ "Update auto-start", "Atualizar início automático" },
|
||||
{ "Turn on auto-start", "Ligar início automático" },
|
||||
{ "Stop", "Parar" },
|
||||
{ "Test controllers", "Testar controles" },
|
||||
{ "Close", "Fechar" },
|
||||
|
||||
/* Actions and stopping */
|
||||
{ "Actions", "Ações" },
|
||||
{ "Working... You can return to the screen.", "Trabalhando... Você pode voltar à tela." },
|
||||
{ "Use arrows or the D-pad to choose an action.", "Use as setas ou o direcional para escolher uma ação." },
|
||||
{ "Select", "Selecionar" },
|
||||
{ "Back", "Voltar" },
|
||||
{ "TV remote: OK select / Back return", "Controle da TV: OK seleciona / Voltar retorna" },
|
||||
{ "Stop Control4Free?", "Parar o Control4Free?" },
|
||||
{ "Every controller disconnects. You can start Control4Free again from this screen.",
|
||||
"Todos os controles serão desconectados. Você pode iniciar o Control4Free de novo nesta tela." },
|
||||
{ "Cancel", "Cancelar" },
|
||||
{ "Arrows / D-pad: select OK / Cross: confirm Back / Circle: cancel",
|
||||
"Setas / direcional: escolher OK / X: confirmar Voltar / Círculo: cancelar" },
|
||||
{ "Resume", "Continuar" },
|
||||
{ "Set up and start", "Configurar e iniciar" },
|
||||
{ "Turn auto-start off", "Desligar o início automático" },
|
||||
{ "Turn auto-start on", "Ligar o início automático" },
|
||||
{ "Stop Control4Free", "Parar o Control4Free" },
|
||||
{ "Close app", "Fechar o app" },
|
||||
{ "Capture kernel log", "Capturar o log do kernel" },
|
||||
|
||||
/* What the app last did */
|
||||
{ "Checking Control4Free...", "Verificando o Control4Free..." },
|
||||
{ "Could not initialize launcher. Close with PS and retry.", "Não foi possível iniciar o app. Feche com o botão PS e tente de novo." },
|
||||
{ "Could not initialize input. Close with PS and retry.", "Não foi possível iniciar a entrada. Feche com o botão PS e tente de novo." },
|
||||
{ "Setting up Control4Free. Please wait...", "Configurando o Control4Free. Aguarde..." },
|
||||
{ "Starting Control4Free. Please wait...", "Iniciando o Control4Free. Aguarde..." },
|
||||
{ "Stopping and releasing controllers...", "Parando e liberando os controles..." },
|
||||
{ "Turning auto-start off...", "Desligando o início automático..." },
|
||||
{ "Turning auto-start on...", "Ligando o início automático..." },
|
||||
{ "Set up and running. GoldHEN will also start it after each restart.",
|
||||
"Configurado e rodando. O GoldHEN também vai iniciá-lo após cada reinício." },
|
||||
{ "Ready. Open the address on your phone or PC.", "Pronto. Abra o endereço no seu celular ou PC." },
|
||||
{ "Not running.", "Não está rodando." },
|
||||
{ "The app cannot reach Control4Free: %s", "O app não consegue alcançar o Control4Free: %s" },
|
||||
{ "No answer the app understands: %s", "Nenhuma resposta que o app entenda: %s" },
|
||||
{ "Already running. Open the address on your phone or PC.", "Já está rodando. Abra o endereço no seu celular ou PC." },
|
||||
{ "The app cannot check whether Control4Free is running (%s).", "O app não consegue verificar se o Control4Free está rodando (%s)." },
|
||||
{ "Control4Free is not responding (%s). Wait a moment after waking. If it stays stuck, restart the PS4.",
|
||||
"O Control4Free não está respondendo (%s). Aguarde um pouco depois de sair do modo de repouso. Se continuar travado, reinicie o PS4." },
|
||||
{ "The payload is missing. Reinstall the Control4Free package.", "O payload está faltando. Reinstale o pacote do Control4Free." },
|
||||
{ "The bundled payload is damaged. Reinstall the Control4Free package.", "O payload incluído está danificado. Reinstale o pacote do Control4Free." },
|
||||
{ "PayLoader did not answer (errno %d). Enable PayLoader Server in GoldHEN > Server Settings, then retry Start.",
|
||||
"O PayLoader não respondeu (errno %d). Ative o PayLoader Server em GoldHEN > Server Settings e tente Iniciar de novo." },
|
||||
{ "The transfer to PayLoader broke off. Retry Start.", "A transferência para o PayLoader foi interrompida. Tente Iniciar de novo." },
|
||||
{ "Sent, but Control4Free did not start (%s). Retry Start.", "Enviado, mas o Control4Free não iniciou (%s). Tente Iniciar de novo." },
|
||||
{ "Stopped. Ready to start again.", "Parado. Pronto para iniciar de novo." },
|
||||
{ "The app cannot reach Control4Free (%s). Stop it on its controller page.",
|
||||
"O app não consegue alcançar o Control4Free (%s). Pare-o pela página do controle." },
|
||||
{ "This copy does not answer the app (%s). Stop it on its controller page.",
|
||||
"Esta cópia não responde ao app (%s). Pare-a pela página do controle." },
|
||||
{ "Stop was not confirmed (%s). Check the controller page.", "A parada não foi confirmada (%s). Confira a página do controle." },
|
||||
{ "Still stopping. Wait a moment, then try again.", "Ainda parando. Aguarde um pouco e tente de novo." },
|
||||
{ "the status reply is not from Control4Free", "a resposta de status não é do Control4Free" },
|
||||
{ "Auto-start is on. %s", "Início automático ligado. %s" },
|
||||
{ "the bundled payload could not be read", "não foi possível ler o payload incluído" },
|
||||
{ "GoldHEN did not let the app check (errno %d)", "O GoldHEN não deixou o app verificar (errno %d)" },
|
||||
{ "Could not inspect AutoRun (errno %d)", "Não foi possível inspecionar o AutoRun (errno %d)" },
|
||||
{ "Enabled, but the payload is missing; choose Update auto-start in Actions or press Triangle",
|
||||
"Ativado, mas o payload está faltando; escolha Atualizar o início automático em Ações ou aperte Triângulo" },
|
||||
{ "Enabled, but the payload cannot be inspected (errno %d)", "Ativado, mas o payload não pode ser inspecionado (errno %d)" },
|
||||
{ "The bundled payload could not be read. Reinstall the Control4Free package.",
|
||||
"Não foi possível ler o payload incluído. Reinstale o pacote do Control4Free." },
|
||||
{ "GoldHEN did not let the app set up auto-start (errno %d).", "O GoldHEN não deixou o app configurar o início automático (errno %d)." },
|
||||
{ "Could not copy Control4Free to /data/payloads (errno %d).", "Não foi possível copiar o Control4Free para /data/payloads (errno %d)." },
|
||||
{ "Could not add Control4Free to GoldHEN's AutoRun list (errno %d).",
|
||||
"Não foi possível adicionar o Control4Free à lista de AutoRun do GoldHEN (errno %d)." },
|
||||
{ "Auto-start is on. GoldHEN starts Control4Free each time it loads.",
|
||||
"Início automático ligado. O GoldHEN inicia o Control4Free toda vez que carrega." },
|
||||
{ "GoldHEN did not let the app change auto-start (errno %d).", "O GoldHEN não deixou o app mudar o início automático (errno %d)." },
|
||||
{ "Could not change GoldHEN's AutoRun list (errno %d).", "Não foi possível mudar a lista de AutoRun do GoldHEN (errno %d)." },
|
||||
{ "Auto-start is off. Start Control4Free here with Cross, or from GoldHEN's Payloader LaunchPad.",
|
||||
"Início automático desligado. Inicie o Control4Free aqui com X, ou pelo Payloader LaunchPad do GoldHEN." },
|
||||
|
||||
/* Controller test */
|
||||
{ "Controller test", "Teste de controle" },
|
||||
{ "See the controller input received by this PS4. Select a user, then press buttons or move the sticks. PS and Share go to the PS4 itself and aren't shown here.",
|
||||
"Veja a entrada de controle que este PS4 recebe. Escolha um usuário e aperte botões ou mova os analógicos. PS e Share vão para o próprio PS4 e não aparecem aqui." },
|
||||
{ "Sticks", "Analógicos" },
|
||||
{ "Left", "Esquerdo" },
|
||||
{ "Right", "Direito" },
|
||||
{ "Triggers", "Gatilhos" },
|
||||
{ "Motion", "Movimento" },
|
||||
{ "Acceleration (G)", "Aceleração (G)" },
|
||||
{ "Gyro (rad/s)", "Giroscópio (rad/s)" },
|
||||
{ "Orientation", "Orientação" },
|
||||
{ "Light bar", "Barra de luz" },
|
||||
{ "Hold L3 + R3: menu TV remote: OK menu / Back leave", "Segure L3 + R3: menu Controle da TV: OK menu / Voltar sai" },
|
||||
{ "Waiting for the PS4's signed-in users...", "Aguardando os usuários conectados no PS4..." },
|
||||
{ "No touch", "Sem toque" },
|
||||
{ "Set by this test", "Definida por este teste" },
|
||||
{ "As the PS4 sets it", "Como o PS4 define" },
|
||||
{ "Controller page RTT", "RTT da página do controle" },
|
||||
{ "No controller page for this user", "Nenhuma página de controle para este usuário" },
|
||||
{ "C%d measuring...", "C%d medindo..." },
|
||||
{ "User %d", "Usuário %d" },
|
||||
{ "The PS4 refused that (0x%08x).", "O PS4 recusou isso (0x%08x)." },
|
||||
{ "No other user is signed in.", "Nenhum outro usuário está conectado." },
|
||||
{ "This user has no controller to test.", "Este usuário não tem controle para testar." },
|
||||
{ "Large motor: rumbling for a second.", "Motor grande: vibrando por um segundo." },
|
||||
{ "Small motor: rumbling for a second.", "Motor pequeno: vibrando por um segundo." },
|
||||
{ "Light bar reset.", "Barra de luz restaurada." },
|
||||
{ "Orientation reset.", "Orientação restaurada." },
|
||||
{ "Light bar set. It goes back to normal when you leave.", "Barra de luz definida. Ela volta ao normal quando você sair." },
|
||||
{ "This user's controller is unavailable.", "O controle deste usuário está indisponível." },
|
||||
{ "Switch user", "Trocar usuário" },
|
||||
{ "Rumble the large motor", "Vibrar o motor grande" },
|
||||
{ "Rumble the small motor", "Vibrar o motor pequeno" },
|
||||
{ "Choose the light-bar color", "Escolher a cor da barra de luz" },
|
||||
{ "Reset the light bar", "Restaurar a barra de luz" },
|
||||
{ "Reset orientation", "Restaurar orientação" },
|
||||
{ "Back to the launcher", "Voltar ao app" },
|
||||
{ "Return to live input", "Voltar à entrada ao vivo" },
|
||||
{ "Choose another player", "Escolher outro jogador" },
|
||||
{ "Recenter motion", "Recentralizar o movimento" },
|
||||
{ "Choose any RGB color", "Escolher qualquer cor RGB" },
|
||||
{ "One-second vibration", "Vibração de um segundo" },
|
||||
{ "Restore default color", "Restaurar a cor padrão" },
|
||||
{ "No other signed-in user", "Nenhum outro usuário conectado" },
|
||||
{ "Controller unavailable", "Controle indisponível" },
|
||||
{ "Large motor", "Motor grande" },
|
||||
{ "Small motor", "Motor pequeno" },
|
||||
{ "Reset light", "Restaurar luz" },
|
||||
{ "Leave test", "Sair do teste" },
|
||||
{ "Controller tools", "Ferramentas do controle" },
|
||||
{ "Mix red, green and blue. Changes preview on the selected controller.",
|
||||
"Misture vermelho, verde e azul. As mudanças aparecem no controle selecionado." },
|
||||
{ "Test motion, vibration and the light bar.", "Teste o movimento, a vibração e a barra de luz." },
|
||||
{ "D-pad or arrows to move", "Direcional ou setas para mover" },
|
||||
{ "Fast adjustment", "Ajuste rápido" },
|
||||
{ "Fine adjustment", "Ajuste fino" },
|
||||
{ "Done", "Concluído" },
|
||||
{ "Up/Down: channel / Left/Right: adjust / OK: step size / Back: done",
|
||||
"Cima/Baixo: canal / Esquerda/Direita: ajustar / OK: passo / Voltar: concluir" },
|
||||
{ "No one signed in", "Ninguém conectado" },
|
||||
{ "Controller connected", "Controle conectado" },
|
||||
{ "No controller connected", "Nenhum controle conectado" },
|
||||
{ "Signed out", "Desconectado" },
|
||||
{ "Cannot read (0x%08x)", "Não foi possível ler (0x%08x)" },
|
||||
{ "Opening controller...", "Abrindo o controle..." },
|
||||
{ "Waiting for a signed-in user", "Aguardando um usuário conectado" },
|
||||
{ "SELECTED USER", "USUÁRIO SELECIONADO" },
|
||||
{ "CONTROLLER", "CONTROLE" },
|
||||
{ "TEST OUTPUT", "SAÍDA DO TESTE" },
|
||||
{ "Light bar set by this test", "Barra de luz definida por este teste" },
|
||||
{ "Light bar: PS4 default", "Barra de luz: padrão do PS4" },
|
||||
{ "Large motor running", "Motor grande ligado" },
|
||||
{ "Small motor running", "Motor pequeno ligado" },
|
||||
{ "The light bar returns to its PS4 color when you leave the tester.", "A barra de luz volta à cor do PS4 quando você sai do teste." },
|
||||
{ "Choose a tool, or resume testing to see live input.", "Escolha uma ferramenta, ou continue o teste para ver a entrada ao vivo." },
|
||||
{ "Sign in another PS4 user to switch players here.", "Conecte outro usuário no PS4 para trocar de jogador aqui." },
|
||||
{ "Connect this user's controller to use this tool.", "Conecte o controle deste usuário para usar esta ferramenta." },
|
||||
{ "Red", "Vermelho" },
|
||||
{ "Green", "Verde" },
|
||||
{ "Blue", "Azul" },
|
||||
{ "COLOR PREVIEW", "PRÉVIA DA COR" },
|
||||
{ "Cross / OK switches fine and fast", "X / OK alterna entre fino e rápido" },
|
||||
{ "Step: %d", "Passo: %d" },
|
||||
{ "Reading this user's controller", "Lendo o controle deste usuário" },
|
||||
{ "The PS4 would not read it (0x%08x)", "O PS4 não conseguiu ler (0x%08x)" },
|
||||
{ "No one is signed in", "Ninguém está conectado" },
|
||||
{ "Opening this user's controller...", "Abrindo o controle deste usuário..." },
|
||||
{ "Rumbling: large motor", "Vibrando: motor grande" },
|
||||
{ "Rumbling: small motor", "Vibrando: motor pequeno" },
|
||||
{ "Hold L3 + R3 for two seconds for the menu: change user, rumble, light bar, leave.",
|
||||
"Segure L3 + R3 por dois segundos para o menu: trocar usuário, vibração, barra de luz, sair." },
|
||||
};
|
||||
|
||||
/* Exact interface keys only. Values are formatted after lookup by the caller. */
|
||||
static const C4fPhrase c4fArabic[] = {
|
||||
{ "Touchpad", "لوحة اللمس" },
|
||||
{ "Not responding", "لا يستجيب" },
|
||||
{ "Check Control4Free", "تحقق من Control4Free" },
|
||||
{ "Something answers on port 4264 but not the way this app expects. If an older Control4Free is running, stop it on its controller page or restart the PS4.", "توجد استجابة على المنفذ 4264 لا يتعرف عليها التطبيق. إذا كان إصدار أقدم من Control4Free يعمل، فأوقفه من صفحة التحكم أو أعد تشغيل PS4." },
|
||||
{ "App error", "خطأ في التطبيق" },
|
||||
{ "Reopen the app", "أعد فتح التطبيق" },
|
||||
{ "The app could not start properly. Close it with the PS button and open it again. Control4Free itself is not affected.", "تعذر بدء التطبيق. أغلقه باستخدام زر PS ثم افتحه مجددًا. لا يؤثر ذلك على خدمة Control4Free." },
|
||||
{ "Working", "جارٍ التنفيذ" },
|
||||
{ "Please wait", "يرجى الانتظار" },
|
||||
{ "Checking", "جارٍ التحقق" },
|
||||
{ "1 controller connected", "وحدة تحكم واحدة متصلة" },
|
||||
{ "%d controllers connected", "وحدات تحكم متصلة: %d" },
|
||||
{ "Running", "يعمل" },
|
||||
{ "Control4Free is running", "Control4Free يعمل" },
|
||||
{ "Open the address or scan the code on your phone or PC. Pick a controller there, then choose its user on the PS4.", "افتح العنوان أو امسح الرمز بهاتفك أو حاسوبك. اختر وحدة تحكم، ثم اختر مستخدمها على PS4." },
|
||||
{ "Stopped", "متوقف" },
|
||||
{ "Start Control4Free", "تشغيل Control4Free" },
|
||||
{ "It starts with GoldHEN after each restart. To start now, enable GoldHEN's PayLoader Server and press Cross.", "يبدأ مع GoldHEN بعد كل إعادة تشغيل. للبدء الآن، فعّل PayLoader Server في GoldHEN واضغط ×." },
|
||||
{ "Not running", "لا يعمل" },
|
||||
{ "Set up Control4Free", "إعداد Control4Free" },
|
||||
{ "Press Cross to set up auto-start. To start Control4Free now, enable GoldHEN's PayLoader Server.", "اضغط × لإعداد التشغيل التلقائي. لتشغيل Control4Free الآن، فعّل PayLoader Server في GoldHEN." },
|
||||
{ "Open on your phone or PC", "افتحه على هاتفك أو حاسوبك" },
|
||||
{ "Connect the PS4 to your home network", "صِل PS4 بشبكتك المنزلية" },
|
||||
{ "Use the same network as the PS4. Touch, a keyboard and gamepads connected to the phone or PC all work.", "استخدم شبكة PS4 نفسها. يمكنك اللعب باللمس أو بلوحة مفاتيح أو بوحدة تحكم متصلة بهاتفك أو حاسوبك." },
|
||||
{ "Working...", "جارٍ التنفيذ..." },
|
||||
{ "Scan with your phone's camera", "امسح الرمز بكاميرا هاتفك" },
|
||||
{ "The code appears once it runs", "يظهر الرمز عند تشغيل الخدمة" },
|
||||
{ "Installed %s; running %s", "المثبّت %s؛ قيد التشغيل %s" },
|
||||
{ "Press Square, then Cross to stop, and Cross again to start this app's version. All players disconnect.", "اضغط المربع ثم × للإيقاف، ثم × مجددًا لتشغيل إصدار هذا التطبيق. سينقطع اتصال جميع اللاعبين." },
|
||||
{ "Starts by itself with GoldHEN", "يبدأ تلقائيًا مع GoldHEN" },
|
||||
{ "GoldHEN starts Control4Free each time it loads. Closing this app leaves your controllers running. Turn off other controller plugins in your games.", "يبدأ GoldHEN خدمة Control4Free عند تحميله. تبقى وحدات التحكم متصلة بعد إغلاق التطبيق. عطّل إضافات التحكم الأخرى في ألعابك." },
|
||||
{ "Auto-start uses an older Control4Free", "التشغيل التلقائي يستخدم إصدارًا أقدم" },
|
||||
{ "Press Triangle to update auto-start. GoldHEN uses this version after the next restart.", "اضغط المثلث لتحديث التشغيل التلقائي. سيستخدم GoldHEN هذا الإصدار بعد إعادة التشغيل التالية." },
|
||||
{ "Auto-start is off", "التشغيل التلقائي معطّل" },
|
||||
{ "Press Triangle to turn auto-start on. GoldHEN then starts Control4Free each time it loads.", "اضغط المثلث لتفعيل التشغيل التلقائي. سيبدأ GoldHEN خدمة Control4Free عند تحميله." },
|
||||
{ "Auto-start status unknown", "حالة التشغيل التلقائي غير معروفة" },
|
||||
{ "Checking with GoldHEN...", "جارٍ التحقق من GoldHEN..." },
|
||||
{ "TV remote: OK opens actions / arrows select / Back returns", "ريموت التلفاز: OK للإجراءات / الأسهم للاختيار / رجوع للعودة" },
|
||||
{ "L1 Language: automatic", "L1 اللغة: تلقائي" },
|
||||
{ "L1 Language: English", "L1 اللغة: English" },
|
||||
{ "L1 Language: Portuguese (Brazil)", "L1 اللغة: البرتغالية (البرازيل)" },
|
||||
{ "Start", "تشغيل" },
|
||||
{ "Set up", "إعداد" },
|
||||
{ "Turn off auto-start", "تعطيل التشغيل التلقائي" },
|
||||
{ "Update auto-start", "تحديث التشغيل التلقائي" },
|
||||
{ "Turn on auto-start", "تفعيل التشغيل التلقائي" },
|
||||
{ "Stop", "إيقاف" },
|
||||
{ "Test controllers", "اختبار وحدات التحكم" },
|
||||
{ "Close", "إغلاق" },
|
||||
{ "Actions", "الإجراءات" },
|
||||
{ "Working... You can return to the screen.", "جارٍ التنفيذ... يمكنك العودة إلى الشاشة." },
|
||||
{ "Use arrows or the D-pad to choose an action.", "استخدم الأسهم أو أزرار الاتجاهات لاختيار إجراء." },
|
||||
{ "Select", "اختيار" },
|
||||
{ "Back", "رجوع" },
|
||||
{ "TV remote: OK select / Back return", "ريموت التلفاز: OK للاختيار / رجوع للعودة" },
|
||||
{ "Stop Control4Free?", "إيقاف Control4Free؟" },
|
||||
{ "Every controller disconnects. You can start Control4Free again from this screen.", "سينقطع اتصال جميع وحدات التحكم. يمكنك تشغيل Control4Free مجددًا من هذه الشاشة." },
|
||||
{ "Cancel", "إلغاء" },
|
||||
{ "Arrows / D-pad: select OK / Cross: confirm Back / Circle: cancel", "الأسهم / الاتجاهات: اختيار OK / ×: تأكيد رجوع / الدائرة: إلغاء" },
|
||||
{ "Resume", "متابعة" },
|
||||
{ "Set up and start", "إعداد وتشغيل" },
|
||||
{ "Turn auto-start off", "تعطيل التشغيل التلقائي" },
|
||||
{ "Turn auto-start on", "تفعيل التشغيل التلقائي" },
|
||||
{ "Stop Control4Free", "إيقاف Control4Free" },
|
||||
{ "Close app", "إغلاق التطبيق" },
|
||||
{ "Capture kernel log", "تسجيل سجل النواة" },
|
||||
{ "Checking Control4Free...", "جارٍ التحقق من Control4Free..." },
|
||||
{ "Could not initialize launcher. Close with PS and retry.", "تعذر بدء التطبيق. أغلقه بزر PS وحاول مجددًا." },
|
||||
{ "Could not initialize input. Close with PS and retry.", "تعذر بدء الإدخال. أغلق التطبيق بزر PS وحاول مجددًا." },
|
||||
{ "Setting up Control4Free. Please wait...", "جارٍ إعداد Control4Free. يرجى الانتظار..." },
|
||||
{ "Starting Control4Free. Please wait...", "جارٍ تشغيل Control4Free. يرجى الانتظار..." },
|
||||
{ "Stopping and releasing controllers...", "جارٍ الإيقاف وفصل وحدات التحكم..." },
|
||||
{ "Turning auto-start off...", "جارٍ تعطيل التشغيل التلقائي..." },
|
||||
{ "Turning auto-start on...", "جارٍ تفعيل التشغيل التلقائي..." },
|
||||
{ "Set up and running. GoldHEN will also start it after each restart.", "اكتمل الإعداد والخدمة تعمل. سيشغلها GoldHEN أيضًا بعد كل إعادة تشغيل." },
|
||||
{ "Ready. Open the address on your phone or PC.", "جاهز. افتح العنوان على هاتفك أو حاسوبك." },
|
||||
{ "Not running.", "لا يعمل." },
|
||||
{ "The app cannot reach Control4Free: %s", "يتعذر اتصال التطبيق بخدمة Control4Free: %s" },
|
||||
{ "No answer the app understands: %s", "لم يتعرف التطبيق على الاستجابة: %s" },
|
||||
{ "Already running. Open the address on your phone or PC.", "الخدمة تعمل بالفعل. افتح العنوان على هاتفك أو حاسوبك." },
|
||||
{ "The app cannot check whether Control4Free is running (%s).", "تعذر على التطبيق التحقق من تشغيل Control4Free (%s)." },
|
||||
{ "Control4Free is not responding (%s). Wait a moment after waking. If it stays stuck, restart the PS4.", "Control4Free لا يستجيب (%s). انتظر قليلًا بعد الخروج من وضع السكون. إذا استمرت المشكلة، فأعد تشغيل PS4." },
|
||||
{ "The payload is missing. Reinstall the Control4Free package.", "ملف الخدمة مفقود. أعد تثبيت حزمة Control4Free." },
|
||||
{ "The bundled payload is damaged. Reinstall the Control4Free package.", "ملف الخدمة المرفق تالف. أعد تثبيت حزمة Control4Free." },
|
||||
{ "PayLoader did not answer (errno %d). Enable PayLoader Server in GoldHEN > Server Settings, then retry Start.", "لم يستجب PayLoader (errno %d). فعّل PayLoader Server من GoldHEN > Server Settings، ثم حاول التشغيل مجددًا." },
|
||||
{ "The transfer to PayLoader broke off. Retry Start.", "انقطع الإرسال إلى PayLoader. حاول التشغيل مجددًا." },
|
||||
{ "Sent, but Control4Free did not start (%s). Retry Start.", "اكتمل الإرسال، لكن Control4Free لم يبدأ (%s). حاول التشغيل مجددًا." },
|
||||
{ "Stopped. Ready to start again.", "توقفت الخدمة. جاهزة للتشغيل مجددًا." },
|
||||
{ "The app cannot reach Control4Free (%s). Stop it on its controller page.", "يتعذر اتصال التطبيق بخدمة Control4Free (%s). أوقفها من صفحة التحكم." },
|
||||
{ "This copy does not answer the app (%s). Stop it on its controller page.", "هذه النسخة لا تستجيب للتطبيق (%s). أوقفها من صفحة التحكم." },
|
||||
{ "Stop was not confirmed (%s). Check the controller page.", "لم يتأكد الإيقاف (%s). تحقق من صفحة التحكم." },
|
||||
{ "Still stopping. Wait a moment, then try again.", "لا تزال الخدمة تتوقف. انتظر قليلًا وحاول مجددًا." },
|
||||
{ "the status reply is not from Control4Free", "استجابة الحالة ليست من Control4Free" },
|
||||
{ "Auto-start is on. %s", "التشغيل التلقائي مفعّل. %s" },
|
||||
{ "the bundled payload could not be read", "تعذرت قراءة ملف الخدمة المرفق" },
|
||||
{ "GoldHEN did not let the app check (errno %d)", "لم يسمح GoldHEN للتطبيق بالتحقق (errno %d)" },
|
||||
{ "Could not inspect AutoRun (errno %d)", "تعذر فحص AutoRun (errno %d)" },
|
||||
{ "Enabled, but the payload is missing; choose Update auto-start in Actions or press Triangle", "مفعّل، لكن ملف الخدمة مفقود؛ اختر تحديث التشغيل التلقائي من الإجراءات أو اضغط المثلث" },
|
||||
{ "Enabled, but the payload cannot be inspected (errno %d)", "مفعّل، لكن تعذر فحص ملف الخدمة (errno %d)" },
|
||||
{ "The bundled payload could not be read. Reinstall the Control4Free package.", "تعذرت قراءة ملف الخدمة المرفق. أعد تثبيت حزمة Control4Free." },
|
||||
{ "GoldHEN did not let the app set up auto-start (errno %d).", "لم يسمح GoldHEN بإعداد التشغيل التلقائي (errno %d)." },
|
||||
{ "Could not copy Control4Free to /data/payloads (errno %d).", "تعذر نسخ Control4Free إلى /data/payloads (errno %d)." },
|
||||
{ "Could not add Control4Free to GoldHEN's AutoRun list (errno %d).", "تعذرت إضافة Control4Free إلى قائمة AutoRun في GoldHEN (errno %d)." },
|
||||
{ "Auto-start is on. GoldHEN starts Control4Free each time it loads.", "التشغيل التلقائي مفعّل. يبدأ GoldHEN خدمة Control4Free عند تحميله." },
|
||||
{ "GoldHEN did not let the app change auto-start (errno %d).", "لم يسمح GoldHEN بتغيير التشغيل التلقائي (errno %d)." },
|
||||
{ "Could not change GoldHEN's AutoRun list (errno %d).", "تعذر تعديل قائمة AutoRun في GoldHEN (errno %d)." },
|
||||
{ "Auto-start is off. Start Control4Free here with Cross, or from GoldHEN's Payloader LaunchPad.", "التشغيل التلقائي معطّل. شغّل Control4Free هنا بزر × أو من Payloader LaunchPad في GoldHEN." },
|
||||
{ "Controller test", "اختبار وحدة التحكم" },
|
||||
{ "See the controller input received by this PS4. Select a user, then press buttons or move the sticks. PS and Share go to the PS4 itself and aren't shown here.", "شاهد الإدخال الذي يتلقاه PS4. اختر مستخدمًا ثم اضغط الأزرار أو حرّك العصوين. يذهب زرا PS وShare مباشرة إلى PS4 ولا يظهران هنا." },
|
||||
{ "Sticks", "العصوان" },
|
||||
{ "Left", "اليسرى" },
|
||||
{ "Right", "اليمنى" },
|
||||
{ "Triggers", "الزنادان" },
|
||||
{ "Motion", "الحركة" },
|
||||
{ "Acceleration (G)", "التسارع (G)" },
|
||||
{ "Gyro (rad/s)", "الجيروسكوب (rad/s)" },
|
||||
{ "Orientation", "الاتجاه" },
|
||||
{ "Light bar", "شريط الإضاءة" },
|
||||
{ "Hold L3 + R3: menu TV remote: OK menu / Back leave", "اضغط مطولًا L3 + R3: القائمة ريموت التلفاز: OK للقائمة / رجوع للخروج" },
|
||||
{ "Waiting for the PS4's signed-in users...", "بانتظار المستخدمين المسجّلين على PS4..." },
|
||||
{ "No touch", "لا يوجد لمس" },
|
||||
{ "Set by this test", "مضبوط من هذا الاختبار" },
|
||||
{ "As the PS4 sets it", "حسب إعداد PS4" },
|
||||
{ "Controller page RTT", "زمن اتصال صفحة التحكم" },
|
||||
{ "No controller page for this user", "لا توجد صفحة تحكم لهذا المستخدم" },
|
||||
{ "C%d measuring...", "C%d جارٍ القياس..." },
|
||||
{ "User %d", "المستخدم %d" },
|
||||
{ "The PS4 refused that (0x%08x).", "رفض PS4 هذا الإجراء (0x%08x)." },
|
||||
{ "No other user is signed in.", "لا يوجد مستخدم آخر مسجّل." },
|
||||
{ "This user has no controller to test.", "لا توجد وحدة تحكم لهذا المستخدم لاختبارها." },
|
||||
{ "Large motor: rumbling for a second.", "المحرك الكبير: اهتزاز لمدة ثانية." },
|
||||
{ "Small motor: rumbling for a second.", "المحرك الصغير: اهتزاز لمدة ثانية." },
|
||||
{ "Light bar reset.", "أُعيد ضبط شريط الإضاءة." },
|
||||
{ "Orientation reset.", "أُعيد ضبط الاتجاه." },
|
||||
{ "Light bar set. It goes back to normal when you leave.", "ضُبط شريط الإضاءة. سيعود إلى لونه المعتاد عند الخروج." },
|
||||
{ "This user's controller is unavailable.", "وحدة تحكم هذا المستخدم غير متاحة." },
|
||||
{ "Switch user", "تبديل المستخدم" },
|
||||
{ "Rumble the large motor", "تشغيل اهتزاز المحرك الكبير" },
|
||||
{ "Rumble the small motor", "تشغيل اهتزاز المحرك الصغير" },
|
||||
{ "Choose the light-bar color", "اختيار لون شريط الإضاءة" },
|
||||
{ "Reset the light bar", "إعادة ضبط شريط الإضاءة" },
|
||||
{ "Reset orientation", "إعادة ضبط الاتجاه" },
|
||||
{ "Back to the launcher", "العودة إلى المشغّل" },
|
||||
{ "Return to live input", "العودة إلى الإدخال المباشر" },
|
||||
{ "Choose another player", "اختيار لاعب آخر" },
|
||||
{ "Recenter motion", "إعادة توسيط الحركة" },
|
||||
{ "Choose any RGB color", "اختيار أي لون RGB" },
|
||||
{ "One-second vibration", "اهتزاز لمدة ثانية" },
|
||||
{ "Restore default color", "استعادة اللون الافتراضي" },
|
||||
{ "No other signed-in user", "لا يوجد مستخدم آخر مسجّل" },
|
||||
{ "Controller unavailable", "وحدة التحكم غير متاحة" },
|
||||
{ "Large motor", "المحرك الكبير" },
|
||||
{ "Small motor", "المحرك الصغير" },
|
||||
{ "Reset light", "إعادة ضبط اللون" },
|
||||
{ "Leave test", "مغادرة الاختبار" },
|
||||
{ "Controller tools", "أدوات وحدة التحكم" },
|
||||
{ "Mix red, green and blue. Changes preview on the selected controller.", "امزج الأحمر والأخضر والأزرق. يظهر اللون على وحدة التحكم المختارة." },
|
||||
{ "Test motion, vibration and the light bar.", "اختبر الحركة والاهتزاز وشريط الإضاءة." },
|
||||
{ "D-pad or arrows to move", "استخدم الاتجاهات أو الأسهم للتنقل" },
|
||||
{ "Fast adjustment", "تعديل سريع" },
|
||||
{ "Fine adjustment", "تعديل دقيق" },
|
||||
{ "Done", "تم" },
|
||||
{ "Up/Down: channel / Left/Right: adjust / OK: step size / Back: done", "أعلى/أسفل: القناة / يمين/يسار: تعديل / OK: مقدار التغيير / رجوع: إنهاء" },
|
||||
{ "No one signed in", "لا يوجد مستخدم مسجّل" },
|
||||
{ "Controller connected", "وحدة التحكم متصلة" },
|
||||
{ "No controller connected", "لا توجد وحدة تحكم متصلة" },
|
||||
{ "Signed out", "سُجّل الخروج" },
|
||||
{ "Cannot read (0x%08x)", "تعذرت القراءة (0x%08x)" },
|
||||
{ "Opening controller...", "جارٍ فتح وحدة التحكم..." },
|
||||
{ "Waiting for a signed-in user", "بانتظار مستخدم مسجّل" },
|
||||
{ "SELECTED USER", "المستخدم المختار" },
|
||||
{ "CONTROLLER", "وحدة التحكم" },
|
||||
{ "TEST OUTPUT", "خرج الاختبار" },
|
||||
{ "Light bar set by this test", "شريط الإضاءة مضبوط من الاختبار" },
|
||||
{ "Light bar: PS4 default", "شريط الإضاءة: إعداد PS4 الافتراضي" },
|
||||
{ "Large motor running", "المحرك الكبير يعمل" },
|
||||
{ "Small motor running", "المحرك الصغير يعمل" },
|
||||
{ "The light bar returns to its PS4 color when you leave the tester.", "يعود شريط الإضاءة إلى لون PS4 عند مغادرة الاختبار." },
|
||||
{ "Choose a tool, or resume testing to see live input.", "اختر أداة أو تابع الاختبار لمشاهدة الإدخال المباشر." },
|
||||
{ "Sign in another PS4 user to switch players here.", "سجّل دخول مستخدم آخر على PS4 للتبديل بين اللاعبين هنا." },
|
||||
{ "Connect this user's controller to use this tool.", "صِل وحدة تحكم هذا المستخدم لاستخدام الأداة." },
|
||||
{ "Red", "أحمر" },
|
||||
{ "Green", "أخضر" },
|
||||
{ "Blue", "أزرق" },
|
||||
{ "COLOR PREVIEW", "معاينة اللون" },
|
||||
{ "Cross / OK switches fine and fast", "× / OK للتبديل بين التعديل الدقيق والسريع" },
|
||||
{ "Step: %d", "مقدار التغيير: %d" },
|
||||
{ "Reading this user's controller", "جارٍ قراءة وحدة تحكم هذا المستخدم" },
|
||||
{ "The PS4 would not read it (0x%08x)", "لم يتمكن PS4 من قراءتها (0x%08x)" },
|
||||
{ "No one is signed in", "لا يوجد مستخدم مسجّل" },
|
||||
{ "Opening this user's controller...", "جارٍ فتح وحدة تحكم هذا المستخدم..." },
|
||||
{ "Rumbling: large motor", "اهتزاز: المحرك الكبير" },
|
||||
{ "Rumbling: small motor", "اهتزاز: المحرك الصغير" },
|
||||
{ "Hold L3 + R3 for two seconds for the menu: change user, rumble, light bar, leave.", "اضغط مطولًا L3 + R3 لمدة ثانيتين لفتح القائمة: المستخدم والاهتزاز والإضاءة والخروج." },
|
||||
{ "L1 Language: Arabic", "L1 اللغة: العربية" },
|
||||
};
|
||||
|
||||
static const char *c4fFind(const C4fPhrase *phrases, size_t count, const char *key)
|
||||
{
|
||||
if (!key) return NULL;
|
||||
for (size_t i = 0; i < count; ++i)
|
||||
if (!strcmp(phrases[i].en, key)) return phrases[i].pt;
|
||||
return NULL;
|
||||
}
|
||||
|
||||
const char *c4fPortugueseText(const char *key)
|
||||
{
|
||||
if (key && !strcmp(key, "L1 Language: Arabic")) return "L1 Idioma: العربية";
|
||||
return c4fFind(c4fPortuguese, sizeof(c4fPortuguese) / sizeof(c4fPortuguese[0]), key);
|
||||
}
|
||||
|
||||
const char *c4fArabicText(const char *key)
|
||||
{
|
||||
return c4fFind(c4fArabic, sizeof(c4fArabic) / sizeof(c4fArabic[0]), key);
|
||||
}
|
||||
|
||||
const char *c4fTr(const char *key)
|
||||
{
|
||||
const char *translated = c4fActiveLanguage == C4F_LANGUAGE_PORTUGUESE ? c4fPortugueseText(key) :
|
||||
c4fActiveLanguage == C4F_LANGUAGE_ARABIC ? c4fArabicText(key) : NULL;
|
||||
return translated ? translated : key;
|
||||
}
|
||||
|
||||
int c4fLanguageIsArabic(void)
|
||||
{
|
||||
return c4fActiveLanguage == C4F_LANGUAGE_ARABIC;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
#ifndef C4F_LAUNCHER_LANGUAGE_H
|
||||
#define C4F_LAUNCHER_LANGUAGE_H
|
||||
/* Offline interface catalogs. Translate a source message key before formatting
|
||||
* values into it. Saved language choices require the app to leave its sandbox. */
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/* The saved choice; C4F_LANGUAGE_AUTO when there is none. */
|
||||
int c4fLanguageLoad(void);
|
||||
void c4fLanguageSave(int choice);
|
||||
/* Shows every text in the language chosen. Automatic follows the PS4's system
|
||||
* language. */
|
||||
void c4fLanguageUse(int choice);
|
||||
/* Exact source-key lookups, returning NULL when a catalog has no entry. */
|
||||
const char *c4fPortugueseText(const char *key);
|
||||
const char *c4fArabicText(const char *key);
|
||||
/* Active-language lookup; an unknown key is returned unchanged. */
|
||||
const char *c4fTr(const char *key);
|
||||
int c4fLanguageIsArabic(void);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
@@ -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,10 @@
|
||||
#include <orbis/UserService.h>
|
||||
#include <orbis/VideoOut.h>
|
||||
#include "autorun.h"
|
||||
#include "draw.h"
|
||||
#include "input.h"
|
||||
#include "language.h"
|
||||
#include "navigation.h"
|
||||
#include "sandbox.h"
|
||||
#include "screen.h"
|
||||
#include "service.h"
|
||||
@@ -28,19 +33,29 @@ 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)
|
||||
static int c4fSetUp(char *message, size_t size, C4fMessage *text)
|
||||
{
|
||||
int result = c4fAutorunEnable(message, size);
|
||||
*text = *c4fAutorunMessage();
|
||||
if (result) return result;
|
||||
char started[160];
|
||||
size_t payloadSize;
|
||||
const unsigned char *payload = c4fAutorunBundled(&payloadSize);
|
||||
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.");
|
||||
*text = *c4fLauncherMessage();
|
||||
if (result == 0) c4fMessageWrite(text, 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);
|
||||
text->prefix = "Auto-start is on. %s";
|
||||
}
|
||||
return result == -2 ? -2 : 0;
|
||||
}
|
||||
|
||||
@@ -49,6 +64,7 @@ static void *c4fWorker(void *)
|
||||
uint64_t nextCheck = 0;
|
||||
int first = 1, autorun = C4F_AUTORUN_UNKNOWN;
|
||||
char autorunNote[96] = {0}, shownProblem[128] = {0};
|
||||
C4fMessage autorunText = {};
|
||||
for (;;) {
|
||||
pthread_mutex_lock(&c4fMutex);
|
||||
int command = c4fCommand, quit = c4fQuit;
|
||||
@@ -57,6 +73,7 @@ static void *c4fWorker(void *)
|
||||
if (quit) return NULL;
|
||||
if (command || c4fLauncherTimeMs() >= nextCheck) {
|
||||
char message[160] = {0}, address[64] = {0};
|
||||
C4fMessage messageText = {};
|
||||
[[maybe_unused]] int result = 0; /* read by the diagnostic build */
|
||||
/* The console's own address: shown for the phone or PC, and the
|
||||
* app's second way to reach Control4Free from its sandbox. */
|
||||
@@ -76,33 +93,55 @@ static void *c4fWorker(void *)
|
||||
size_t payloadSize;
|
||||
const unsigned char *payload = c4fAutorunBundled(&payloadSize);
|
||||
result = c4fLauncherStart(payload, payloadSize, message, sizeof(message));
|
||||
messageText = *c4fLauncherMessage();
|
||||
}
|
||||
if (command == C4F_DO_STOP) result = c4fLauncherStop(message, sizeof(message));
|
||||
if (command == C4F_DO_SET_UP) result = c4fSetUp(message, sizeof(message));
|
||||
if (command == C4F_DO_STOP) {
|
||||
result = c4fLauncherStop(message, sizeof(message));
|
||||
messageText = *c4fLauncherMessage();
|
||||
}
|
||||
if (command == C4F_DO_SET_UP) result = c4fSetUp(message, sizeof(message), &messageText);
|
||||
#ifdef C4F_DIAG
|
||||
if (command == C4F_DO_START || command == C4F_DO_SET_UP) c4fDiagAfterStart(result, message);
|
||||
#endif
|
||||
if (command == C4F_DO_AUTORUN_ON) result = c4fAutorunEnable(message, sizeof(message));
|
||||
if (command == C4F_DO_AUTORUN_OFF) result = c4fAutorunDisable(message, sizeof(message));
|
||||
if (first || command >= C4F_DO_SET_UP) autorun = c4fAutorunCheck(autorunNote, sizeof(autorunNote));
|
||||
if (command == C4F_DO_AUTORUN_ON) {
|
||||
result = c4fAutorunEnable(message, sizeof(message));
|
||||
messageText = *c4fAutorunMessage();
|
||||
}
|
||||
if (command == C4F_DO_AUTORUN_OFF) {
|
||||
result = c4fAutorunDisable(message, sizeof(message));
|
||||
messageText = *c4fAutorunMessage();
|
||||
}
|
||||
if (first || command >= C4F_DO_SET_UP) {
|
||||
autorun = c4fAutorunCheck(autorunNote, sizeof(autorunNote));
|
||||
autorunText = *c4fAutorunMessage();
|
||||
}
|
||||
C4fServiceStatus status;
|
||||
uint64_t probeAt = c4fLauncherTimeMs();
|
||||
int running = c4fLauncherProbe(&status);
|
||||
const char *problem = c4fLauncherProblem();
|
||||
pthread_mutex_lock(&c4fMutex);
|
||||
if (command) {
|
||||
snprintf(c4fScreen.message, sizeof(c4fScreen.message), "%s", message);
|
||||
c4fScreen.messageText = messageText;
|
||||
} else if (!c4fScreen.locked &&
|
||||
(first || running != c4fScreen.running || (running < 0 && strcmp(problem, shownProblem)))) {
|
||||
if (running == 1) snprintf(c4fScreen.message, sizeof(c4fScreen.message), "Ready. Open the address on your phone or PC.");
|
||||
else if (running == 0) snprintf(c4fScreen.message, sizeof(c4fScreen.message), "Not running.");
|
||||
else if (!c4fLauncherReached()) snprintf(c4fScreen.message, sizeof(c4fScreen.message), "The app cannot reach Control4Free: %s", problem);
|
||||
else snprintf(c4fScreen.message, sizeof(c4fScreen.message), "No answer the app understands: %s", problem);
|
||||
if (running == 1) c4fMessageWrite(&c4fScreen.messageText, c4fScreen.message, sizeof(c4fScreen.message), "Ready. Open the address on your phone or PC.");
|
||||
else if (running == 0) c4fMessageWrite(&c4fScreen.messageText, c4fScreen.message, sizeof(c4fScreen.message), "Not running.");
|
||||
else if (!c4fLauncherReached()) c4fMessageWrite(&c4fScreen.messageText, c4fScreen.message, sizeof(c4fScreen.message), "The app cannot reach Control4Free: %s", problem);
|
||||
else c4fMessageWrite(&c4fScreen.messageText, c4fScreen.message, sizeof(c4fScreen.message), "No answer the app understands: %s", problem);
|
||||
if (c4fScreen.messageText.argType == C4F_MESSAGE_STRING)
|
||||
c4fScreen.messageText.valueIsKey = c4fLauncherProblemIsKey();
|
||||
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);
|
||||
c4fScreen.autorunText = autorunText;
|
||||
snprintf(c4fScreen.address, sizeof(c4fScreen.address), "%s", address);
|
||||
/* A queued click while a periodic probe was in flight remains busy. */
|
||||
if (!c4fCommand) c4fScreen.busy = 0;
|
||||
@@ -110,7 +149,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 +204,427 @@ 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, ¶m);
|
||||
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;
|
||||
tester.rtl = c4fScreen.rtl;
|
||||
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;
|
||||
c4fMessageWrite(&c4fScreen.messageText, c4fScreen.message, sizeof(c4fScreen.message),
|
||||
setUp ? "Setting up Control4Free. Please wait..." : "Starting Control4Free. Please wait...");
|
||||
} else if (action == C4F_ACTION_STOP) {
|
||||
c4fCommand = C4F_DO_STOP; c4fScreen.busy = 1;
|
||||
c4fMessageWrite(&c4fScreen.messageText, 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;
|
||||
c4fMessageWrite(&c4fScreen.messageText, c4fScreen.message, sizeof(c4fScreen.message),
|
||||
off ? "Turning auto-start off..." : "Turning auto-start on...");
|
||||
} else if (action == C4F_ACTION_LANGUAGE) {
|
||||
c4fScreen.language = (c4fScreen.language + 1) % C4F_LANGUAGE_COUNT;
|
||||
}
|
||||
#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,90 +643,110 @@ int main(void)
|
||||
OrbisUserServiceInitializeParams params = {};
|
||||
params.priority = ORBIS_KERNEL_PRIO_FIFO_LOWEST;
|
||||
sceUserServiceInitialize(¶ms);
|
||||
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...");
|
||||
c4fMessageWrite(&c4fScreen.messageText, c4fScreen.message, sizeof(c4fScreen.message), "Checking Control4Free...");
|
||||
/* Read before leaving the sandbox: /app0 is only visible from inside it. */
|
||||
if (c4fAutorunLoadBundled(C4F_BUNDLED_PAYLOAD)) printf("[c4f-launcher] bundled payload unreadable\n");
|
||||
/* The sandbox refuses connections to the console itself (EACCES), so the
|
||||
* app could reach neither Control4Free nor PayLoader from inside it. */
|
||||
int sandbox = c4fSandboxLeave(), sandboxError = errno;
|
||||
if (sandbox) printf("[c4f-launcher] could not leave the sandbox, errno %d\n", sandboxError);
|
||||
if (!sandbox && c4fTextLoadSystemFonts())
|
||||
printf("[c4f-launcher] system Arabic fonts unavailable; using bundled fallback\n");
|
||||
/* The saved language lives in /data, only visible outside the sandbox. */
|
||||
int language = c4fScreen.language = c4fLanguageLoad();
|
||||
c4fLanguageUse(language);
|
||||
c4fScreen.rtl = c4fLanguageIsArabic();
|
||||
#ifdef C4F_DIAG
|
||||
c4fDiagStart(sandbox, sandboxError);
|
||||
for (int i = 0; i < C4F_DIAG_PAGES; i++) c4fScreen.diagScroll[i] = i ? C4F_DIAG_END : 0;
|
||||
#endif
|
||||
pthread_t worker;
|
||||
int workerStarted = pthread_create(&worker, NULL, c4fWorker, NULL) == 0;
|
||||
if (!workerStarted || pad < 0) {
|
||||
if (!workerStarted) {
|
||||
pthread_mutex_lock(&c4fMutex);
|
||||
c4fScreen.locked = 1;
|
||||
snprintf(c4fScreen.message, sizeof(c4fScreen.message), "Could not initialize launcher. Close with PS and retry.");
|
||||
c4fMessageWrite(&c4fScreen.messageText, 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;
|
||||
c4fMessageWrite(&c4fScreen.messageText, 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;
|
||||
/* L1 changed the language: the screen redraws as the state differs. */
|
||||
if (snapshot.language != language) {
|
||||
language = snapshot.language;
|
||||
c4fLanguageUse(language);
|
||||
pthread_mutex_lock(&c4fMutex);
|
||||
c4fScreen.rtl = c4fLanguageIsArabic();
|
||||
pthread_mutex_unlock(&c4fMutex);
|
||||
c4fLanguageSave(language);
|
||||
}
|
||||
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 +758,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);
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
#include "message.h"
|
||||
#include "language.h"
|
||||
|
||||
void c4fMessageRender(const C4fMessage *message, char *out, size_t size)
|
||||
{
|
||||
if (!out || !size) return;
|
||||
out[0] = 0;
|
||||
if (!message || !message->key) return;
|
||||
const char *key = c4fTr(message->key);
|
||||
char body[C4F_MESSAGE_RENDER_SIZE];
|
||||
switch (message->argType) {
|
||||
case C4F_MESSAGE_INT:
|
||||
snprintf(body, sizeof(body), key, message->intValue);
|
||||
break;
|
||||
case C4F_MESSAGE_UINT:
|
||||
snprintf(body, sizeof(body), key, message->uintValue);
|
||||
break;
|
||||
case C4F_MESSAGE_STRING: {
|
||||
const char *value = message->valueIsKey ? c4fTr(message->value) : message->value;
|
||||
char isolated[sizeof(message->value) * 3 + 8];
|
||||
if (c4fLanguageIsArabic() && !message->valueIsKey) {
|
||||
snprintf(isolated, sizeof(isolated), "\xE2\x81\xA8%s\xE2\x81\xA9", value);
|
||||
value = isolated;
|
||||
}
|
||||
snprintf(body, sizeof(body), key, value);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
snprintf(body, sizeof(body), "%s", key);
|
||||
break;
|
||||
}
|
||||
if (message->prefix) snprintf(out, size, c4fTr(message->prefix), body);
|
||||
else snprintf(out, size, "%s", body);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
#ifndef C4F_LAUNCHER_MESSAGE_H
|
||||
#define C4F_LAUNCHER_MESSAGE_H
|
||||
/* Worker messages retain their source key and one typed argument. Their English
|
||||
* copy remains useful in logs; the renderer translates the key on every draw. */
|
||||
#include <stdarg.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include <stddef.h>
|
||||
|
||||
enum { C4F_MESSAGE_NONE, C4F_MESSAGE_INT, C4F_MESSAGE_UINT, C4F_MESSAGE_STRING };
|
||||
enum { C4F_MESSAGE_RENDER_SIZE = 1024 };
|
||||
typedef struct {
|
||||
const char *key, *prefix;
|
||||
int argType, intValue, valueIsKey;
|
||||
unsigned uintValue;
|
||||
char value[256];
|
||||
} C4fMessage;
|
||||
|
||||
/* This deliberately handles the launcher's existing single-argument templates,
|
||||
* rather than trying to recover a message key from already formatted text. */
|
||||
static inline void c4fMessageWrite(C4fMessage *message, char *english, size_t size,
|
||||
const char *key, ...)
|
||||
{
|
||||
memset(message, 0, sizeof(*message));
|
||||
message->key = key;
|
||||
va_list args, copy;
|
||||
va_start(args, key);
|
||||
va_copy(copy, args);
|
||||
if (english && size) vsnprintf(english, size, key, args);
|
||||
va_end(args);
|
||||
const char *conversion = strchr(key, '%');
|
||||
if (conversion) {
|
||||
++conversion;
|
||||
while (*conversion && strchr("0123456789.-+ #", *conversion)) ++conversion;
|
||||
if (*conversion == 's') {
|
||||
message->argType = C4F_MESSAGE_STRING;
|
||||
const char *value = va_arg(copy, const char *);
|
||||
snprintf(message->value, sizeof(message->value), "%s", value ? value : "");
|
||||
} else if (*conversion == 'd') {
|
||||
message->argType = C4F_MESSAGE_INT;
|
||||
message->intValue = va_arg(copy, int);
|
||||
} else if (*conversion == 'x' || *conversion == 'X' || *conversion == 'u') {
|
||||
message->argType = C4F_MESSAGE_UINT;
|
||||
message->uintValue = va_arg(copy, unsigned);
|
||||
}
|
||||
}
|
||||
va_end(copy);
|
||||
}
|
||||
|
||||
static inline void c4fMessageClear(C4fMessage *message)
|
||||
{
|
||||
memset(message, 0, sizeof(*message));
|
||||
}
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
void c4fMessageRender(const C4fMessage *message, char *out, size_t size);
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
@@ -0,0 +1,193 @@
|
||||
#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 (screen->rtl && (pressed & (C4F_INPUT_BUTTON_LEFT | C4F_INPUT_BUTTON_RIGHT))) direction = -direction;
|
||||
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;
|
||||
}
|
||||
/* L1 changes the language. The diagnostic build pages with it. */
|
||||
if (!diagnostic && (standardPressed & C4F_INPUT_BUTTON_L1)) {
|
||||
c4fConsume(standardUnhandled, remoteUnhandled);
|
||||
return C4F_ACTION_LANGUAGE;
|
||||
}
|
||||
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;
|
||||
}
|
||||
@@ -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
|
||||
@@ -8,11 +8,11 @@
|
||||
#include <string.h>
|
||||
#include "autorun.h"
|
||||
#include "draw.h"
|
||||
#include "language.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,26 +26,15 @@
|
||||
#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, *c4fRtlBackdrop;
|
||||
|
||||
static uint32_t *c4fBackdrop;
|
||||
/* Align interface text in its allotted box; technical fields use c4fTextRaw. */
|
||||
static void c4fUiText(C4fCanvas *c, int font, float size, float x, float y,
|
||||
float width, C4fColor color, const char *text)
|
||||
{
|
||||
if (c4fTextIsRTL()) x += width - c4fTextWidth(font, size, text);
|
||||
c4fText(c, font, size, x, y, color, text);
|
||||
}
|
||||
|
||||
static float c4fClamp(float v) { return v < 0 ? 0 : v > 1 ? 1 : v; }
|
||||
|
||||
@@ -171,7 +160,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 +214,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 +258,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 +268,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 +298,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 +316,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 +340,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 +368,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);
|
||||
}
|
||||
@@ -436,35 +426,126 @@ static C4fLook c4fLookFor(const C4fLauncherScreen *s, char *meta, size_t metaSiz
|
||||
look = (C4fLook){ "Working", "Please wait", s->message, "Checking", C4F_WARN, C4F_ACCENT, 0 };
|
||||
} else if (s->running == 1) {
|
||||
if (s->controllers == 1) snprintf(meta, metaSize, "1 controller connected");
|
||||
else snprintf(meta, metaSize, "%d controllers connected", s->controllers);
|
||||
else snprintf(meta, metaSize, c4fTr("%d controllers connected"), s->controllers);
|
||||
look = (C4fLook){ "Running", "Control4Free is running",
|
||||
"Open the address or scan the code on your phone or PC. Pick a controller there, then "
|
||||
"choose its user on the PS4.",
|
||||
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) {
|
||||
c4fUiText(&c, C4F_FONT_LIGHT, 48, dx + 48, dy + 92, dw - 96, 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 + (c4fTextIsRTL() ? 1 - i : 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");
|
||||
}
|
||||
c4fUiText(&c, C4F_FONT_LIGHT, 23, dx + 48, dy + dh - 48, dw - 96, C4F_MUTED,
|
||||
"Arrows / D-pad: select OK / Cross: confirm Back / Circle: cancel");
|
||||
return;
|
||||
}
|
||||
|
||||
c4fUiText(&c, C4F_FONT_LIGHT, 48, dx + 48, dy + 84, dw - 96, C4F_TEXT, "Actions");
|
||||
c4fUiText(&c, C4F_FONT_LIGHT, 24, dx + 48, dy + 125, dw - 96, 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);
|
||||
c4fUiText(&c, C4F_FONT_REGULAR, 30, rx + 24, ry + 42, rw - 48,
|
||||
focused ? C4F_TEXT : C4F_MUTED, c4fActionLabel(s, actions[i]));
|
||||
if (focused) {
|
||||
float cx = c4fTextIsRTL() ? rx + 30 : rx + rw - 30, cy = ry + 32;
|
||||
float sign = c4fTextIsRTL() ? -1 : 1;
|
||||
c4fSegment(&c, cx - 5 * sign, cy - 9, cx + 4 * sign, cy, 3, C4F_TEXT);
|
||||
c4fSegment(&c, cx + 4 * sign, cy, cx - 5 * sign, cy + 9, 3, C4F_TEXT);
|
||||
}
|
||||
}
|
||||
static const C4fHint hints[2] = { { C4F_KEY_CROSS, "Select" }, { C4F_KEY_CIRCLE, "Back" } };
|
||||
c4fUiText(&c, C4F_FONT_LIGHT, 23, dx + 48, dy + dh - 32, dw - 96, 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 };
|
||||
const float left = C4F_LEFT, right = C4F_RIGHT, top = C4F_ROW_TOP, height = C4F_ROW_HEIGHT;
|
||||
const float qrWidth = C4F_QR_WIDTH, mainWidth = C4F_MAIN_WIDTH, pad = 16 * C4F_S;
|
||||
char meta[48], count[8];
|
||||
const float mainX = c4fTextIsRTL() ? right - mainWidth : left;
|
||||
char meta[256], count[8], message[C4F_MESSAGE_RENDER_SIZE], autorun[C4F_MESSAGE_RENDER_SIZE];
|
||||
c4fMessageRender(&s->messageText, message, sizeof(message));
|
||||
c4fMessageRender(&s->autorunText, autorun, sizeof(autorun));
|
||||
C4fLook look = c4fLookFor(s, meta, sizeof(meta));
|
||||
if (s->busy && s->messageText.key) look.subtitle = message;
|
||||
int live = s->running == 1 && !s->locked;
|
||||
|
||||
if (c4fBackdrop) memcpy(pixels, c4fBackdrop, (size_t)c.w * (size_t)c.h * sizeof(uint32_t));
|
||||
if (c4fTextIsRTL() && !c4fRtlBackdrop) {
|
||||
c4fRtlBackdrop = malloc((size_t)c.w * c.h * sizeof(uint32_t));
|
||||
if (c4fRtlBackdrop) {
|
||||
C4fCanvas background = { c4fRtlBackdrop, c.w, c.h };
|
||||
c4fPaintBackdrop(&background, C4F_S);
|
||||
c4fBlurUnder(&background, mainX, top, mainWidth, height, 6 * C4F_S, 6 * C4F_S);
|
||||
c4fBlurUnder(&background, left, top, qrWidth, height, 6 * C4F_S, 6 * C4F_S);
|
||||
c4fBlurUnder(&background, left, C4F_PANEL_TOP, right - left, C4F_PANEL_HEIGHT, 6 * C4F_S, 10 * C4F_S);
|
||||
#ifndef C4F_DIAG
|
||||
c4fDrawSolidarityBadge(&background, C4F_SCREEN_WIDTH - 64, 40);
|
||||
#endif
|
||||
}
|
||||
}
|
||||
const uint32_t *background = c4fTextIsRTL() && c4fRtlBackdrop ? c4fRtlBackdrop : c4fBackdrop;
|
||||
if (background) memcpy(pixels, background, (size_t)c.w * (size_t)c.h * sizeof(uint32_t));
|
||||
else memset(pixels, 0, (size_t)c.w * (size_t)c.h * sizeof(uint32_t));
|
||||
|
||||
/* .topbar: the app mark, the name and the state. */
|
||||
@@ -475,25 +556,27 @@ void c4fDrawLauncher(uint32_t *pixels, const C4fLauncherScreen *s)
|
||||
c4fText(&c, C4F_FONT_LIGHT, 13 * C4F_S, x + 16 * C4F_S, 62, C4F_MUTED, look.pill);
|
||||
|
||||
/* .screen-title and .screen-sub */
|
||||
c4fText(&c, C4F_FONT_LIGHT, 40 * C4F_S, left, 196, C4F_TEXT, look.title);
|
||||
c4fTextWrapped(&c, C4F_FONT_LIGHT, 26, left, 250, 1500, 39, 2, C4F_MUTED, look.subtitle);
|
||||
c4fUiText(&c, C4F_FONT_LIGHT, 40 * C4F_S, left, 196, right - left, C4F_TEXT, look.title);
|
||||
c4fTextWrapped(&c, C4F_FONT_LIGHT, 26, left, 250, right - left, 39, 2, C4F_MUTED, look.subtitle);
|
||||
|
||||
/* Where to connect, as a controller tile. */
|
||||
c4fTile(&c, left, top, mainWidth, height, look.bar);
|
||||
c4fTile(&c, mainX, top, mainWidth, height, look.bar);
|
||||
if (s->running == 1) snprintf(count, sizeof(count), "%d", s->controllers);
|
||||
else snprintf(count, sizeof(count), "-");
|
||||
c4fRingBadge(&c, left + pad + 26 * C4F_S, top + pad + 26 * C4F_S + 4, look.bar, count);
|
||||
float textX = left + pad + 52 * C4F_S + 22;
|
||||
c4fText(&c, C4F_FONT_REGULAR, 30, textX, top + 66, C4F_TEXT, "Open on your phone or PC");
|
||||
c4fText(&c, C4F_FONT_LIGHT, 23, textX, top + 102, C4F_MUTED, look.meta);
|
||||
c4fRingBadge(&c, c4fTextIsRTL() ? mainX + mainWidth - pad - 26 * C4F_S : mainX + pad + 26 * C4F_S,
|
||||
top + pad + 26 * C4F_S + 4, look.bar, count);
|
||||
float textX = mainX + pad + (c4fTextIsRTL() ? 0 : 52 * C4F_S + 22);
|
||||
float textW = mainWidth - 2 * pad - 52 * C4F_S - 22;
|
||||
c4fUiText(&c, C4F_FONT_REGULAR, 30, textX, top + 66, textW, C4F_TEXT, "Open on your phone or PC");
|
||||
c4fUiText(&c, C4F_FONT_LIGHT, 23, textX, top + 102, textW, C4F_MUTED, look.meta);
|
||||
|
||||
float fieldX = left + pad, fieldY = top + 150, fieldWidth = mainWidth - 2 * pad, fieldHeight = 88;
|
||||
float fieldX = mainX + pad, fieldY = top + 150, fieldWidth = mainWidth - 2 * pad, fieldHeight = 88;
|
||||
c4fFillRoundRect(&c, fieldX, fieldY, fieldWidth, fieldHeight, 4 * C4F_S, C4F_RGBA(0, 0, 0, .3f));
|
||||
c4fInsetRoundRect(&c, fieldX, fieldY, fieldWidth, fieldHeight, 4 * C4F_S, 1 * C4F_S, C4F_LINE);
|
||||
if (s->address[0])
|
||||
c4fText(&c, C4F_FONT_REGULAR, 40, fieldX + 28, fieldY + fieldHeight / 2 + 14, live ? C4F_TEXT : C4F_MUTED, s->address);
|
||||
c4fTextRaw(&c, C4F_FONT_REGULAR, 40, fieldX + 28, fieldY + fieldHeight / 2 + 14, live ? C4F_TEXT : C4F_MUTED, s->address);
|
||||
else
|
||||
c4fText(&c, C4F_FONT_LIGHT, 28, fieldX + 28, fieldY + fieldHeight / 2 + 10, C4F_MUTED,
|
||||
c4fUiText(&c, C4F_FONT_LIGHT, 28, fieldX + 28, fieldY + fieldHeight / 2 + 10, fieldWidth - 56, C4F_MUTED,
|
||||
"Connect the PS4 to your home network");
|
||||
c4fTextWrapped(&c, C4F_FONT_LIGHT, 24, fieldX, fieldY + fieldHeight + 44, fieldWidth, 36, 2, C4F_MUTED,
|
||||
"Use the same network as the PS4. Touch, a keyboard and gamepads connected to the phone "
|
||||
@@ -502,12 +585,12 @@ void c4fDrawLauncher(uint32_t *pixels, const C4fLauncherScreen *s)
|
||||
/* The last result along the bottom of the tile. */
|
||||
float lineY = top + height - 104;
|
||||
c4fFillRoundRect(&c, fieldX, lineY, fieldWidth, 1 * C4F_S, 0, C4F_LINE_SOFT);
|
||||
c4fDot(&c, fieldX + 4 * C4F_S, lineY + 44, look.dot, look.glow);
|
||||
c4fTextWrapped(&c, C4F_FONT_LIGHT, 24, fieldX + 24, lineY + 52, fieldWidth - 24, 34, 2, C4F_TEXT,
|
||||
s->busy ? "Working..." : s->message);
|
||||
c4fDot(&c, c4fTextIsRTL() ? fieldX + fieldWidth - 4 * C4F_S : fieldX + 4 * C4F_S, lineY + 44, look.dot, look.glow);
|
||||
c4fTextWrapped(&c, C4F_FONT_LIGHT, 24, fieldX + (c4fTextIsRTL() ? 0 : 24), lineY + 52, fieldWidth - 24, 34, 2, C4F_TEXT,
|
||||
s->busy ? "Working..." : s->messageText.key ? message : s->message);
|
||||
|
||||
/* The QR code tile. */
|
||||
float qrX = C4F_QR_LEFT, card = 340, cardX = qrX + (qrWidth - card) / 2, cardY = top + 34;
|
||||
float qrX = c4fTextIsRTL() ? left : C4F_QR_LEFT, card = 340, cardX = qrX + (qrWidth - card) / 2, cardY = top + 34;
|
||||
c4fTile(&c, qrX, top, qrWidth, height, look.bar);
|
||||
if (live && s->address[0]) {
|
||||
c4fQrCard(&c, cardX, cardY, card, s->address);
|
||||
@@ -524,35 +607,51 @@ 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[256];
|
||||
int mismatch = live && s->runningVersion[0] && strcmp(s->runningVersion, C4F_LAUNCHER_VERSION);
|
||||
if (mismatch) {
|
||||
char installed[64], running[64];
|
||||
snprintf(installed, sizeof(installed), c4fTextIsRTL() ? "\xE2\x81\xA8%s\xE2\x81\xA9" : "%s", C4F_LAUNCHER_VERSION);
|
||||
snprintf(running, sizeof(running), c4fTextIsRTL() ? "\xE2\x81\xA8%s\xE2\x81\xA9" : "%s", s->runningVersion);
|
||||
snprintf(versions, sizeof(versions), c4fTr("Installed %s; running %s"), installed, running);
|
||||
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...";
|
||||
panelText = s->autorunText.key ? autorun : s->autorunNote[0] ? s->autorunNote : "Checking with GoldHEN...";
|
||||
panelDot = s->autorunNote[0] ? C4F_BAD : C4F_WARN;
|
||||
}
|
||||
float panelY = C4F_PANEL_TOP;
|
||||
c4fPanel(&c, left, panelY, right - left, C4F_PANEL_HEIGHT, C4F_PANEL, C4F_LINE_SOFT);
|
||||
c4fDot(&c, left + 20 * C4F_S + 4 * C4F_S, panelY + 43, panelDot, s->autorun == C4F_AUTORUN_ON);
|
||||
c4fText(&c, C4F_FONT_LIGHT, 19 * C4F_S, left + 20 * C4F_S + 24, panelY + 52, C4F_TEXT, panelTitle);
|
||||
c4fUiText(&c, C4F_FONT_LIGHT, 19 * C4F_S, left + 20 * C4F_S + 24, panelY + 52,
|
||||
right - left - 40 * C4F_S - 24, C4F_TEXT, panelTitle);
|
||||
c4fTextWrapped(&c, C4F_FONT_LIGHT, 23, left + 20 * C4F_S, panelY + 94, right - left - 40 * C4F_S, 34, 1, C4F_MUTED,
|
||||
panelText);
|
||||
|
||||
/* .home-foot and the button guide. */
|
||||
c4fText(&c, C4F_FONT_LIGHT, 20, left, 1030, C4F_FAINT, "Control4Free " C4F_LAUNCHER_VERSION);
|
||||
if (!s->confirmStop && !s->actionMenu)
|
||||
c4fUiText(&c, C4F_FONT_LIGHT, 23, left, 973, right - left, C4F_MUTED, "TV remote: OK opens actions / arrows select / Back returns");
|
||||
float footX = left + c4fText(&c, C4F_FONT_LIGHT, 20, left, 1030, C4F_FAINT, "Control4Free " C4F_LAUNCHER_VERSION);
|
||||
static const char *const languages[C4F_LANGUAGE_COUNT] = {
|
||||
"L1 Language: automatic", "L1 Language: English", "L1 Language: Portuguese (Brazil)", "L1 Language: Arabic" };
|
||||
if (!s->confirmStop && !s->actionMenu && s->language >= 0 && s->language < C4F_LANGUAGE_COUNT)
|
||||
c4fText(&c, C4F_FONT_LIGHT, 20, footX + 36, 1030, C4F_FAINT, languages[s->language]);
|
||||
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 +666,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 +690,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;
|
||||
}
|
||||
@@ -1,16 +1,44 @@
|
||||
#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,
|
||||
C4F_ACTION_LANGUAGE
|
||||
} C4fLauncherAction;
|
||||
/* The app's language, cycled with L1 (language.h). */
|
||||
enum { C4F_LANGUAGE_AUTO, C4F_LANGUAGE_ENGLISH, C4F_LANGUAGE_PORTUGUESE,
|
||||
C4F_LANGUAGE_ARABIC, C4F_LANGUAGE_COUNT };
|
||||
/* 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];
|
||||
int language, rtl; /* C4F_LANGUAGE_*; resolved direction for input sampling. */
|
||||
char address[64], message[160], autorunNote[96], runningVersion[32], inputNote[160];
|
||||
C4fMessage messageText, autorunText;
|
||||
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 +52,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
|
||||
|
||||
@@ -34,7 +34,8 @@
|
||||
|
||||
static char c4fHost[16];
|
||||
static char c4fProblem[128];
|
||||
static int c4fReached;
|
||||
static int c4fReached, c4fProblemIsKey;
|
||||
static C4fMessage c4fServiceMessage;
|
||||
|
||||
uint64_t c4fLauncherTimeMs(void)
|
||||
{
|
||||
@@ -53,6 +54,13 @@ void c4fLauncherSetHost(const char *ipv4)
|
||||
}
|
||||
|
||||
const char *c4fLauncherProblem(void) { return c4fProblem; }
|
||||
int c4fLauncherProblemIsKey(void) { return c4fProblemIsKey; }
|
||||
const C4fMessage *c4fLauncherMessage(void)
|
||||
{
|
||||
if (c4fServiceMessage.argType == C4F_MESSAGE_STRING)
|
||||
c4fServiceMessage.valueIsKey = c4fProblemIsKey;
|
||||
return &c4fServiceMessage;
|
||||
}
|
||||
|
||||
static void c4fNote(const char *address, const char *what)
|
||||
{
|
||||
@@ -168,7 +176,7 @@ static int c4fHttp(const char *method, const char *path, char *body, size_t capa
|
||||
{
|
||||
const char *targets[2];
|
||||
int count = c4fTargets(targets), refused = 0, connected = 0;
|
||||
c4fProblem[0] = 0;
|
||||
c4fProblem[0] = 0; c4fProblemIsKey = 0;
|
||||
for (int i = 0; i < count; i++) {
|
||||
int fd = c4fConnect(targets[i], C4F_SERVICE_PORT);
|
||||
if (fd < 0) {
|
||||
@@ -192,17 +200,36 @@ 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");
|
||||
c4fProblemIsKey = 1;
|
||||
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,10 +245,29 @@ 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;
|
||||
c4fProblem[0] = 0;
|
||||
for (int p = 0; p < C4F_SERVICE_PADS; p++) if (latencyFields[p] != 7) status->latencyAvailable = 0;
|
||||
c4fProblem[0] = 0; c4fProblemIsKey = 0;
|
||||
return 1;
|
||||
}
|
||||
|
||||
@@ -229,29 +275,29 @@ int c4fLauncherStart(const unsigned char *payload, size_t payloadSize, char *mes
|
||||
{
|
||||
C4fServiceStatus status;
|
||||
int probe = c4fLauncherProbe(&status);
|
||||
if (probe == 1) { snprintf(message, size, "Already running. Open the address on your phone or PC."); return 0; }
|
||||
if (probe != 0 && !c4fReached) { snprintf(message, size, "The app cannot check whether Control4Free is running (%s).", c4fProblem); return -1; }
|
||||
if (probe != 0) { snprintf(message, size, "Control4Free is not responding (%s). Wait a moment after waking. If it stays stuck, restart the PS4.", c4fProblem); return -1; }
|
||||
if (!payload) { snprintf(message, size, "The payload is missing. Reinstall the Control4Free package."); return -1; }
|
||||
if (probe == 1) { c4fMessageWrite(&c4fServiceMessage, message, size, "Already running. Open the address on your phone or PC."); return 0; }
|
||||
if (probe != 0 && !c4fReached) { c4fMessageWrite(&c4fServiceMessage, message, size, "The app cannot check whether Control4Free is running (%s).", c4fProblem); return -1; }
|
||||
if (probe != 0) { c4fMessageWrite(&c4fServiceMessage, message, size, "Control4Free is not responding (%s). Wait a moment after waking. If it stays stuck, restart the PS4.", c4fProblem); return -1; }
|
||||
if (!payload) { c4fMessageWrite(&c4fServiceMessage, message, size, "The payload is missing. Reinstall the Control4Free package."); return -1; }
|
||||
if (payloadSize < 64 || memcmp(payload, "\177ELF", 4) || payload[4] != 2 || payload[5] != 1) {
|
||||
snprintf(message, size, "The bundled payload is damaged. Reinstall the Control4Free package."); return -1;
|
||||
c4fMessageWrite(&c4fServiceMessage, message, size, "The bundled payload is damaged. Reinstall the Control4Free package."); return -1;
|
||||
}
|
||||
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) { c4fMessageWrite(&c4fServiceMessage, 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) { c4fMessageWrite(&c4fServiceMessage, message, size, "The transfer to PayLoader broke off. Retry Start."); return -2; }
|
||||
uint64_t deadline;
|
||||
deadline = c4fLauncherTimeMs() + 20000;
|
||||
while (c4fLauncherTimeMs() < deadline) {
|
||||
if (c4fLauncherProbe(&status) == 1 && !status.stopping) {
|
||||
snprintf(message, size, "Ready. Open the address on your phone or PC."); return 0;
|
||||
c4fMessageWrite(&c4fServiceMessage, message, size, "Ready. Open the address on your phone or PC."); return 0;
|
||||
}
|
||||
usleep(250000);
|
||||
}
|
||||
snprintf(message, size, "Sent, but Control4Free did not start (%s). Press Cross to try again.", c4fProblem);
|
||||
c4fMessageWrite(&c4fServiceMessage, 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,16 +307,16 @@ 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 != 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; }
|
||||
if (probe == 0) { c4fMessageWrite(&c4fServiceMessage, message, size, "Stopped. Ready to start again."); return 0; }
|
||||
if (probe != 1 && !c4fReached) { c4fMessageWrite(&c4fServiceMessage, message, size, "The app cannot reach Control4Free (%s). Stop it on its controller page.", c4fProblem); return -1; }
|
||||
if (probe != 1) { c4fMessageWrite(&c4fServiceMessage, message, size, "This copy does not answer the app (%s). Stop it on its controller page.", c4fProblem); return -1; }
|
||||
char reply[256];
|
||||
int ret = c4fHttp("POST", "/api/stop", reply, sizeof(reply));
|
||||
if (ret != 1) { snprintf(message, size, "Stop was not confirmed (%s). Check the controller page.", c4fProblem); return -1; }
|
||||
if (ret != 1) { c4fMessageWrite(&c4fServiceMessage, 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) { c4fMessageWrite(&c4fServiceMessage, message, size, "Stopped. Ready to start again."); return 0; }
|
||||
usleep(100000);
|
||||
}
|
||||
snprintf(message, size, "Still stopping. Wait a moment, then try again."); return -1;
|
||||
c4fMessageWrite(&c4fServiceMessage, message, size, "Still stopping. Wait a moment, then try again."); return -1;
|
||||
}
|
||||
@@ -2,18 +2,33 @@
|
||||
#define C4F_LAUNCHER_SERVICE_H
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
#include "message.h"
|
||||
#ifdef __cplusplus
|
||||
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. */
|
||||
void c4fLauncherSetHost(const char *ipv4);
|
||||
/* Why the last probe could not confirm the service, for the screen. */
|
||||
const char *c4fLauncherProblem(void);
|
||||
int c4fLauncherProblemIsKey(void);
|
||||
const C4fMessage *c4fLauncherMessage(void);
|
||||
/* DOWN only when nothing accepted a connection and something refused one.
|
||||
* Ambiguous answers must not allow starting another copy in the shared
|
||||
* payload host. */
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,566 @@
|
||||
#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;
|
||||
/* Preserve UTF-8 names. Only replace ASCII control characters. */
|
||||
for (char *p = r->name; *p; p++) if ((unsigned char)*p < 32 || (unsigned char)*p == 127) *p = '?';
|
||||
r->nameGenerated = !r->name[0];
|
||||
if (r->nameGenerated) 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; c4fMessageClear(&t->noteText);
|
||||
}
|
||||
|
||||
static void c4fTesterResult(C4fTester *t, int result, const char *done)
|
||||
{
|
||||
if (result) c4fMessageWrite(&t->noteText, t->note, sizeof(t->note), "The PS4 refused that (0x%08x).", (unsigned)result);
|
||||
else c4fMessageWrite(&t->noteText, t->note, sizeof(t->note), done);
|
||||
}
|
||||
|
||||
static void c4fTesterAct(C4fTester *t, int item, uint64_t now)
|
||||
{
|
||||
C4fTesterView view;
|
||||
c4fTesterView(t, &view);
|
||||
if (!c4fTesterItemAvailable(&view, item)) {
|
||||
c4fMessageWrite(&t->noteText, t->note, sizeof(t->note), 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) {
|
||||
c4fMessageWrite(&t->noteText, 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; c4fMessageClear(&t->noteText);
|
||||
} 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;
|
||||
int rtl = t->rtl;
|
||||
c4fTesterInit(t, &ops);
|
||||
t->rtl = rtl;
|
||||
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 + (t->rtl ? 1 : C4F_TESTER_MENU_COLUMNS - 1)) % C4F_TESTER_MENU_COLUMNS;
|
||||
} else if (direction & C4F_INPUT_BUTTON_RIGHT) {
|
||||
column = (column + (t->rtl ? C4F_TESTER_MENU_COLUMNS - 1 : 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;
|
||||
c4fMessageWrite(&t->noteText, 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; c4fMessageClear(&t->noteText);
|
||||
} 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;
|
||||
c4fMessageWrite(&t->noteText, 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].nameGenerated = r->nameGenerated;
|
||||
v->user[s].state = r->state;
|
||||
v->user[s].error = r->error;
|
||||
}
|
||||
snprintf(v->note, sizeof(v->note), "%s", t->note);
|
||||
v->noteText = t->noteText;
|
||||
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 "";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
#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>
|
||||
#include "message.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 72 /* Room for a complete 16-character UTF-8 username. */
|
||||
#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, nameGenerated;
|
||||
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, rtl;
|
||||
int colorMenu, colorChannel, colorFine;
|
||||
unsigned menuOpeners;
|
||||
uint64_t usersAt, rumbleUntil;
|
||||
C4fTesterReader readers[C4F_TESTER_USERS];
|
||||
char note[96];
|
||||
C4fMessage noteText;
|
||||
} 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, nameGenerated; 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];
|
||||
C4fMessage noteText;
|
||||
} 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
|
||||
@@ -0,0 +1,266 @@
|
||||
/* Unicode layout: the Unicode bidi algorithm supplies visual runs; HarfBuzz
|
||||
* shapes each run; the existing stb rasterizer draws the resulting glyph IDs.
|
||||
* All caches are bounded and belong exclusively to the drawing thread. */
|
||||
#include "text.h"
|
||||
#include "stb_truetype.h"
|
||||
#include <hb.h>
|
||||
#include <hb-ot.h>
|
||||
#include <SheenBidi/SheenBidi.h>
|
||||
#include <math.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <stdio.h>
|
||||
|
||||
extern const unsigned char c4fFontArabicLight[], c4fFontArabicRegular[];
|
||||
extern const size_t c4fFontArabicLightSize, c4fFontArabicRegularSize;
|
||||
enum { C4F_LAYOUTS = 32, C4F_LAYOUT_BYTES = 2048, C4F_LAYOUT_GLYPHS = 1024,
|
||||
C4F_RASTERS = 512, C4F_RASTER_BYTES = 4 * 1024 * 1024 };
|
||||
typedef struct { unsigned id; float x, y; } C4fPositionedGlyph;
|
||||
typedef struct {
|
||||
char text[C4F_LAYOUT_BYTES];
|
||||
int length, font, pixels, rtl, count, ready;
|
||||
unsigned used;
|
||||
float width;
|
||||
C4fPositionedGlyph glyphs[C4F_LAYOUT_GLYPHS];
|
||||
} C4fTextLayout;
|
||||
typedef struct {
|
||||
int font, pixels, w, h, x, y, ready;
|
||||
unsigned glyph, used;
|
||||
unsigned char *bitmap;
|
||||
} C4fGlyphRaster;
|
||||
static hb_font_t *c4fArabicHB[C4F_FONT_COUNT];
|
||||
static stbtt_fontinfo c4fArabicSTB[C4F_FONT_COUNT];
|
||||
static C4fTextLayout c4fLayouts[C4F_LAYOUTS];
|
||||
static C4fGlyphRaster c4fRasters[C4F_RASTERS];
|
||||
static size_t c4fRasterBytes;
|
||||
static unsigned c4fTextTick;
|
||||
static int c4fRTL;
|
||||
static uint8_t c4fGlyphGamma[256];
|
||||
static const unsigned char *c4fArabicData[C4F_FONT_COUNT];
|
||||
static size_t c4fArabicSizes[C4F_FONT_COUNT];
|
||||
static int c4fSystemFontsLoaded;
|
||||
|
||||
void c4fSetTextDirection(int rtl) { c4fRTL = !!rtl; }
|
||||
int c4fTextIsRTL(void) { return c4fRTL; }
|
||||
|
||||
static unsigned c4fBE16(const unsigned char *p) { return (unsigned)p[0] << 8 | p[1]; }
|
||||
static uint32_t c4fBE32(const unsigned char *p) { return (uint32_t)p[0] << 24 | (uint32_t)p[1] << 16 | (uint32_t)p[2] << 8 | p[3]; }
|
||||
|
||||
static int c4fValidFont(const unsigned char *data, size_t size)
|
||||
{
|
||||
if (!data || size < 12 || size > 1024 * 1024 ||
|
||||
(memcmp(data, "OTTO", 4) && c4fBE32(data) != 0x00010000)) return 0;
|
||||
unsigned tables = c4fBE16(data + 4);
|
||||
if (!tables || (size_t)tables > (size - 12) / 16) return 0;
|
||||
for (unsigned i = 0; i < tables; i++) {
|
||||
const unsigned char *entry = data + 12 + 16 * i;
|
||||
uint32_t offset = c4fBE32(entry + 8), length = c4fBE32(entry + 12);
|
||||
if (offset > size || length > size - offset) return 0;
|
||||
}
|
||||
stbtt_fontinfo info;
|
||||
return stbtt_InitFont(&info, data, 0) && stbtt_FindGlyphIndex(&info, 0x0628) != 0;
|
||||
}
|
||||
|
||||
int c4fTextSetArabicFonts(const unsigned char *light, size_t lightSize,
|
||||
const unsigned char *regular, size_t regularSize)
|
||||
{
|
||||
if (!c4fValidFont(light, lightSize) || !c4fValidFont(regular, regularSize)) return -1;
|
||||
for (int i = 0; i < C4F_FONT_COUNT; i++) {
|
||||
if (c4fArabicHB[i]) hb_font_destroy(c4fArabicHB[i]);
|
||||
c4fArabicHB[i] = NULL;
|
||||
}
|
||||
for (int i = 0; i < C4F_RASTERS; i++) free(c4fRasters[i].bitmap);
|
||||
memset(c4fRasters, 0, sizeof(c4fRasters)); c4fRasterBytes = 0;
|
||||
memset(c4fLayouts, 0, sizeof(c4fLayouts));
|
||||
c4fArabicData[0] = light; c4fArabicData[1] = regular;
|
||||
c4fArabicSizes[0] = lightSize; c4fArabicSizes[1] = regularSize;
|
||||
int result = c4fShapedTextInit();
|
||||
c4fTextFontChanged();
|
||||
return result;
|
||||
}
|
||||
|
||||
static unsigned char *c4fReadFont(const char *path, size_t *size)
|
||||
{
|
||||
FILE *file = fopen(path, "rb");
|
||||
if (!file) return NULL;
|
||||
unsigned char *bytes = NULL;
|
||||
if (!fseek(file, 0, SEEK_END)) {
|
||||
long length = ftell(file);
|
||||
if (length >= 12 && length <= 1024 * 1024 && !fseek(file, 0, SEEK_SET)) {
|
||||
bytes = malloc((size_t)length);
|
||||
if (bytes && fread(bytes, 1, (size_t)length, file) == (size_t)length && c4fValidFont(bytes, (size_t)length)) *size = (size_t)length;
|
||||
else { free(bytes); bytes = NULL; }
|
||||
}
|
||||
}
|
||||
fclose(file);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
int c4fTextLoadSystemFonts(void)
|
||||
{
|
||||
if (c4fSystemFontsLoaded) return 0;
|
||||
static const char *const lightPaths[] = { "/preinst/common/font/SSTArabic-Light.otf", "/system/common/font2/SSTArabic-Light.otf" };
|
||||
static const char *const regularPaths[] = { "/preinst/common/font/SSTArabic-Roman.otf", "/system/common/font2/SSTArabic-Roman.otf" };
|
||||
unsigned char *bytes[C4F_FONT_COUNT] = { NULL, NULL };
|
||||
size_t sizes[C4F_FONT_COUNT] = { 0, 0 };
|
||||
for (unsigned i = 0; i < 2 && !bytes[0]; i++) bytes[0] = c4fReadFont(lightPaths[i], &sizes[0]);
|
||||
for (unsigned i = 0; i < 2 && !bytes[1]; i++) bytes[1] = c4fReadFont(regularPaths[i], &sizes[1]);
|
||||
if (bytes[0] && bytes[1] && !c4fTextSetArabicFonts(bytes[0], sizes[0], bytes[1], sizes[1])) {
|
||||
/* Retain these bounded, read-only files for the process lifetime. */
|
||||
c4fSystemFontsLoaded = 1;
|
||||
return 0;
|
||||
}
|
||||
free(bytes[0]); free(bytes[1]);
|
||||
return -1;
|
||||
}
|
||||
|
||||
int c4fShapedTextInit(void)
|
||||
{
|
||||
const unsigned char *data[] = { c4fArabicData[0] ? c4fArabicData[0] : c4fFontArabicLight,
|
||||
c4fArabicData[1] ? c4fArabicData[1] : c4fFontArabicRegular };
|
||||
const size_t sizes[] = { c4fArabicData[0] ? c4fArabicSizes[0] : c4fFontArabicLightSize,
|
||||
c4fArabicData[1] ? c4fArabicSizes[1] : c4fFontArabicRegularSize };
|
||||
for (int i = 0; i < C4F_FONT_COUNT; i++) {
|
||||
if (c4fArabicHB[i]) continue;
|
||||
if (!stbtt_InitFont(&c4fArabicSTB[i], data[i], 0)) return -1;
|
||||
hb_blob_t *blob = hb_blob_create((const char *)data[i], (unsigned)sizes[i], HB_MEMORY_MODE_READONLY, NULL, NULL);
|
||||
hb_face_t *face = hb_face_create(blob, 0);
|
||||
c4fArabicHB[i] = hb_font_create(face);
|
||||
hb_ot_font_set_funcs(c4fArabicHB[i]);
|
||||
unsigned em = hb_face_get_upem(face);
|
||||
hb_font_set_scale(c4fArabicHB[i], (int)em, (int)em);
|
||||
hb_face_destroy(face);
|
||||
hb_blob_destroy(blob);
|
||||
}
|
||||
for (int i = 0; i < 256; i++) c4fGlyphGamma[i] = (uint8_t)(powf(i / 255.0f, .8f) * 255.0f + .5f);
|
||||
return 0;
|
||||
}
|
||||
|
||||
int c4fTextNeedsShaping(const char *text, int length)
|
||||
{
|
||||
/* ASCII and Latin-1 keep the original fast Roboto path. All other Unicode,
|
||||
* including direction isolates in translated templates, uses this path. */
|
||||
for (int i = 0; i < length && text[i]; i++)
|
||||
if ((unsigned char)text[i] >= 0xc4) return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static C4fTextLayout *c4fShape(int font, float size, const char *text, int length)
|
||||
{
|
||||
if (!text || length < 0 || font < 0 || font >= C4F_FONT_COUNT || !c4fArabicHB[font]) return NULL;
|
||||
int pixels = (int)(size + .5f);
|
||||
if (pixels <= 0 || pixels > 256) return NULL;
|
||||
if (length >= C4F_LAYOUT_BYTES) {
|
||||
length = C4F_LAYOUT_BYTES - 1;
|
||||
while (length && ((unsigned char)text[length] & 0xc0) == 0x80) length--;
|
||||
}
|
||||
C4fTextLayout *out = &c4fLayouts[0];
|
||||
for (int i = 0; i < C4F_LAYOUTS; i++) {
|
||||
C4fTextLayout *p = &c4fLayouts[i];
|
||||
if (p->ready && p->font == font && p->pixels == pixels && p->rtl == c4fRTL &&
|
||||
p->length == length && !memcmp(p->text, text, (size_t)length)) {
|
||||
p->used = ++c4fTextTick;
|
||||
return p;
|
||||
}
|
||||
if (!p->ready || (out->ready && p->used < out->used)) out = p;
|
||||
}
|
||||
memset(out, 0, sizeof(*out));
|
||||
memcpy(out->text, text, (size_t)length);
|
||||
out->length = length; out->font = font; out->pixels = pixels; out->rtl = c4fRTL;
|
||||
out->used = ++c4fTextTick;
|
||||
SBCodepointSequence sequence = { SBStringEncodingUTF8, out->text, (SBUInteger)length };
|
||||
SBAlgorithmRef algorithm = SBAlgorithmCreate(&sequence);
|
||||
if (!algorithm) return NULL;
|
||||
SBParagraphRef paragraph = SBAlgorithmCreateParagraph(algorithm, 0, (SBUInteger)length,
|
||||
c4fRTL ? SBLevelDefaultRTL : SBLevelDefaultLTR);
|
||||
SBLineRef line = paragraph ? SBParagraphCreateLine(paragraph, 0, SBParagraphGetLength(paragraph)) : NULL;
|
||||
if (line) {
|
||||
const SBRun *runs = SBLineGetRunsPtr(line);
|
||||
const float scale = stbtt_ScaleForMappingEmToPixels(&c4fArabicSTB[font], (float)pixels);
|
||||
for (SBUInteger i = 0; i < SBLineGetRunCount(line); i++) {
|
||||
hb_buffer_t *buffer = hb_buffer_create();
|
||||
hb_buffer_add_utf8(buffer, out->text, length, (unsigned)runs[i].offset, (int)runs[i].length);
|
||||
hb_buffer_guess_segment_properties(buffer);
|
||||
hb_buffer_set_direction(buffer, (runs[i].level & 1) ? HB_DIRECTION_RTL : HB_DIRECTION_LTR);
|
||||
hb_shape(c4fArabicHB[font], buffer, NULL, 0);
|
||||
unsigned count;
|
||||
const hb_glyph_info_t *glyphs = hb_buffer_get_glyph_infos(buffer, &count);
|
||||
const hb_glyph_position_t *positions = hb_buffer_get_glyph_positions(buffer, NULL);
|
||||
for (unsigned j = 0; j < count && out->count < C4F_LAYOUT_GLYPHS; j++) {
|
||||
C4fPositionedGlyph *g = &out->glyphs[out->count++];
|
||||
g->id = glyphs[j].codepoint;
|
||||
g->x = out->width + positions[j].x_offset * scale;
|
||||
g->y = -positions[j].y_offset * scale;
|
||||
out->width += positions[j].x_advance * scale;
|
||||
}
|
||||
hb_buffer_destroy(buffer);
|
||||
}
|
||||
SBLineRelease(line);
|
||||
}
|
||||
if (paragraph) SBParagraphRelease(paragraph);
|
||||
SBAlgorithmRelease(algorithm);
|
||||
out->ready = line != NULL;
|
||||
return out->ready ? out : NULL;
|
||||
}
|
||||
|
||||
static void c4fFreeRaster(C4fGlyphRaster *r)
|
||||
{
|
||||
if (r->bitmap) c4fRasterBytes -= (size_t)r->w * (size_t)r->h;
|
||||
free(r->bitmap);
|
||||
memset(r, 0, sizeof(*r));
|
||||
}
|
||||
|
||||
static C4fGlyphRaster *c4fRaster(int font, int pixels, unsigned glyph)
|
||||
{
|
||||
C4fGlyphRaster *out = &c4fRasters[0];
|
||||
for (int i = 0; i < C4F_RASTERS; i++) {
|
||||
C4fGlyphRaster *r = &c4fRasters[i];
|
||||
if (r->ready && r->font == font && r->pixels == pixels && r->glyph == glyph) {
|
||||
r->used = ++c4fTextTick;
|
||||
return r;
|
||||
}
|
||||
if (!r->ready || (out->ready && r->used < out->used)) out = r;
|
||||
}
|
||||
c4fFreeRaster(out);
|
||||
const stbtt_fontinfo *face = &c4fArabicSTB[font];
|
||||
float scale = stbtt_ScaleForMappingEmToPixels(face, (float)pixels);
|
||||
int x1, y1;
|
||||
stbtt_GetGlyphBitmapBox(face, (int)glyph, scale, scale, &out->x, &out->y, &x1, &y1);
|
||||
out->w = x1 - out->x; out->h = y1 - out->y;
|
||||
size_t bytes = (size_t)out->w * (size_t)out->h;
|
||||
if (bytes > C4F_RASTER_BYTES) return NULL;
|
||||
while (c4fRasterBytes + bytes > C4F_RASTER_BYTES) {
|
||||
C4fGlyphRaster *oldest = NULL;
|
||||
for (int i = 0; i < C4F_RASTERS; i++)
|
||||
if (&c4fRasters[i] != out && c4fRasters[i].bitmap && (!oldest || c4fRasters[i].used < oldest->used)) oldest = &c4fRasters[i];
|
||||
if (!oldest) return NULL;
|
||||
c4fFreeRaster(oldest);
|
||||
}
|
||||
if (bytes) {
|
||||
out->bitmap = malloc(bytes);
|
||||
if (!out->bitmap) return NULL;
|
||||
stbtt_MakeGlyphBitmap(face, out->bitmap, out->w, out->h, out->w, scale, scale, (int)glyph);
|
||||
c4fRasterBytes += bytes;
|
||||
}
|
||||
out->font = font; out->pixels = pixels; out->glyph = glyph;
|
||||
out->used = ++c4fTextTick; out->ready = 1;
|
||||
return out;
|
||||
}
|
||||
|
||||
float c4fShapedText(C4fCanvas *c, int font, float size, float x, float y,
|
||||
C4fColor color, const char *text, int length)
|
||||
{
|
||||
C4fTextLayout *layout = c4fShape(font, size, text, length);
|
||||
if (!layout) return 0;
|
||||
if (c) for (int i = 0; i < layout->count; i++) {
|
||||
const C4fPositionedGlyph *g = &layout->glyphs[i];
|
||||
C4fGlyphRaster *r = c4fRaster(font, layout->pixels, g->id);
|
||||
if (!r || !r->bitmap) continue;
|
||||
int left = (int)floorf(x + g->x + .5f) + r->x, top = (int)floorf(y + g->y + .5f) + r->y;
|
||||
for (int row = 0; row < r->h; row++) for (int col = 0; col < r->w; col++) {
|
||||
unsigned value = r->bitmap[row * r->w + col];
|
||||
if (value) c4fBlend(c, left + col, top + row, color, c4fGlyphGamma[value] / 255.0f);
|
||||
}
|
||||
}
|
||||
return layout->width;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
#ifndef C4F_LAUNCHER_TEXT_H
|
||||
#define C4F_LAUNCHER_TEXT_H
|
||||
#include "draw.h"
|
||||
/* Internal Unicode layout path. Called only by the drawing thread. */
|
||||
int c4fShapedTextInit(void);
|
||||
int c4fTextNeedsShaping(const char *text, int length);
|
||||
float c4fShapedText(C4fCanvas *c, int font, float size, float x, float y,
|
||||
C4fColor color, const char *text, int length);
|
||||
void c4fTextFontChanged(void);
|
||||
#endif
|
||||
@@ -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,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);
|
||||
|
||||
@@ -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")) \
|
||||
@@ -24,6 +28,45 @@
|
||||
#define C4F_NET_LOG(c, fmt, ...) do { } while (0)
|
||||
#endif
|
||||
|
||||
/* System fonts stay on the console. Only these two read-only assets are served;
|
||||
* no request path is ever used as a filesystem path. Retain them across transport
|
||||
* reopens so font reads never enter the controller input loop. */
|
||||
#ifndef C4F_SYSTEM_FONT_PRIMARY
|
||||
#define C4F_SYSTEM_FONT_PRIMARY "/preinst/common/font/"
|
||||
#endif
|
||||
#ifndef C4F_SYSTEM_FONT_FALLBACK
|
||||
#define C4F_SYSTEM_FONT_FALLBACK "/system/common/font2/"
|
||||
#endif
|
||||
typedef struct { unsigned char *data; size_t size; } C4fSystemFont;
|
||||
static C4fSystemFont c4fSystemFonts[2];
|
||||
|
||||
static void c4fReadSystemFonts(void)
|
||||
{
|
||||
static const char *const names[2] = { "SSTArabic-Light.otf", "SSTArabic-Roman.otf" };
|
||||
static const char *const directories[2] = { C4F_SYSTEM_FONT_PRIMARY, C4F_SYSTEM_FONT_FALLBACK };
|
||||
for (int weight = 0; weight < 2; weight++) {
|
||||
if (c4fSystemFonts[weight].data) continue;
|
||||
for (int directory = 0; directory < 2; directory++) {
|
||||
char path[256];
|
||||
snprintf(path, sizeof(path), "%s%s", directories[directory], names[weight]);
|
||||
FILE *file = fopen(path, "rb");
|
||||
if (!file) continue;
|
||||
long length = -1;
|
||||
if (!fseek(file, 0, SEEK_END)) length = ftell(file);
|
||||
if (length < 128 || length > 1024 * 1024 || fseek(file, 0, SEEK_SET)) {
|
||||
fclose(file); continue;
|
||||
}
|
||||
unsigned char *data = malloc((size_t)length);
|
||||
int valid = data && fread(data, 1, (size_t)length, file) == (size_t)length;
|
||||
fclose(file);
|
||||
if (valid) valid = !memcmp(data, "OTTO", 4) || !memcmp(data, "\0\1\0\0", 4);
|
||||
if (!valid) { free(data); continue; }
|
||||
c4fSystemFonts[weight] = (C4fSystemFont){ data, (size_t)length };
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Which interface would carry traffic out, without sending anything: a connected
|
||||
* UDP socket picks the route, and getsockname then names the local end. */
|
||||
void c4fNetLocalAddress(char *out, size_t size)
|
||||
@@ -51,6 +94,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 +138,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 +153,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 +164,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 +178,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 +210,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 +276,21 @@ 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 fontRequest = -1, fontPreflight = 0;
|
||||
static const char *const fontRoutes[2] = { "/fonts/arabic-light.otf", "/fonts/arabic-regular.otf" };
|
||||
for (int weight = 0; weight < 2; weight++) {
|
||||
char route[80];
|
||||
snprintf(route, sizeof(route), "GET %s HTTP/1.1\r\n", fontRoutes[weight]);
|
||||
if (!strncmp(request, route, strlen(route))) fontRequest = weight;
|
||||
snprintf(route, sizeof(route), "OPTIONS %s HTTP/1.1\r\n", fontRoutes[weight]);
|
||||
if (!strncmp(request, route, strlen(route))) { fontRequest = weight; fontPreflight = 1; }
|
||||
}
|
||||
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 +300,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 && fontRequest < 0 && !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 +321,32 @@ static void c4fHttp(C4fNet *net, C4fNetClient *c)
|
||||
c4fHttpError(c, 403, "Open the PS4 IP address"); return;
|
||||
}
|
||||
}
|
||||
if (statusRequest || stopRequest) {
|
||||
if (fontRequest >= 0) {
|
||||
char expected[160];
|
||||
snprintf(expected, sizeof(expected), "http://%s", host);
|
||||
if (upgrade[0] || (origin[0] && strcmp(origin, "null") && strcmp(origin, expected))) {
|
||||
c4fHttpError(c, 403, "Origin not allowed"); return;
|
||||
}
|
||||
c->savedOrigin = !strcmp(origin, "null");
|
||||
if (fontPreflight) {
|
||||
if (!c->savedOrigin) { c4fHttpError(c, 403, "Origin not allowed"); return; }
|
||||
static const char reply[] = "HTTP/1.1 204 No Content\r\nAccess-Control-Allow-Origin: null\r\n"
|
||||
"Access-Control-Allow-Methods: GET\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";
|
||||
c4fQueue(c, reply, sizeof(reply) - 1); c->closing = 2;
|
||||
return;
|
||||
}
|
||||
C4fSystemFont *font = &c4fSystemFonts[fontRequest];
|
||||
if (!font->data) { c4fHttpError(c, 404, "System font unavailable"); return; }
|
||||
char reply[384];
|
||||
int n = snprintf(reply, sizeof(reply), "HTTP/1.1 200 OK\r\nContent-Type: font/otf\r\nContent-Length: %zu\r\n"
|
||||
"Cache-Control: private, max-age=86400\r\nX-Content-Type-Options: nosniff\r\n%sConnection: close\r\n\r\n",
|
||||
font->size, c->savedOrigin ? "Access-Control-Allow-Origin: null\r\n" : "");
|
||||
c4fQueue(c, reply, (size_t)n);
|
||||
c->body = font->data; c->bodySize = font->size; c->rxUsed = 0;
|
||||
} else 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 +361,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 +381,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 +402,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'; font-src 'self' data:; 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 +429,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 +451,21 @@ 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)
|
||||
{
|
||||
c4fReadSystemFonts();
|
||||
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 +480,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 +506,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 +536,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 +561,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 +582,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 +621,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;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -0,0 +1,547 @@
|
||||
// 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 checkControllerLayout = require('./layout_browser_test.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 checkControllerLayout(browser);
|
||||
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; });
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/* Real diagnostic storage code; no console or kernel-log reader is contacted. */
|
||||
#define C4F_DIAG_DIR "/tmp/c4f-diag-host-test"
|
||||
#define C4F_DIAG 1
|
||||
#include "../launcher/diag.c"
|
||||
#include <assert.h>
|
||||
|
||||
static int fault, calls;
|
||||
ssize_t __real_write(int fd, const void *buf, size_t n);
|
||||
int __real_fsync(int fd);
|
||||
ssize_t __wrap_write(int fd, const void *buf, size_t n)
|
||||
{
|
||||
if (fault == 1) { errno = ENOSPC; return -1; }
|
||||
if (fault == 2) return 0;
|
||||
if (fault == 3 && calls++ == 0) { errno = EINTR; return -1; }
|
||||
return __real_write(fd, buf, n > 3 ? 3 : n);
|
||||
}
|
||||
int __wrap_fsync(int fd) { if (fault == 4) { errno = EIO; return -1; } return __real_fsync(fd); }
|
||||
int sceKernelGetSystemSwVersion(OrbisKernelSwVersion *version)
|
||||
{ snprintf(version->VersionString, sizeof(version->VersionString), "host test"); return 0; }
|
||||
long c4fGoldHenCommand(unsigned long command, void *data) { (void)command; (void)data; return 0; }
|
||||
const unsigned char *c4fAutorunBundled(size_t *size) { *size = 0; return NULL; }
|
||||
uint64_t c4fLauncherTimeMs(void) { return 1000; }
|
||||
|
||||
int main(void)
|
||||
{
|
||||
assert(access(C4F_DIAG_DIR, F_OK) != 0);
|
||||
c4fDiagStart(0, 0); c4fDiagStop();
|
||||
struct stat info; assert(!stat(C4F_DIAG_DIR, &info) && S_ISDIR(info.st_mode));
|
||||
fault = 3; calls = 0;
|
||||
assert(!c4fWriteAll(C4F_DIAG_REPORT, "complete write", 0));
|
||||
char *text = c4fReadTail(C4F_DIAG_REPORT, 100, NULL); assert(text && !strcmp(text, "complete write")); free(text);
|
||||
for (fault = 1; fault <= 4; fault++) {
|
||||
if (fault == 3) continue;
|
||||
assert(c4fWriteAll(C4F_DIAG_REPORT, "failure", 0) == -1);
|
||||
c4fSaveReport(); assert(strstr(c4fReportNote, "Internal save failed"));
|
||||
assert(!strstr(c4fReportNote, "Saved /data"));
|
||||
}
|
||||
fault = 0; c4fSaveReport(); assert(strstr(c4fReportNote, "Saved /data"));
|
||||
assert(!c4fWriteAll(C4F_DIAG_LOG ".1", "older segment\n", 0));
|
||||
assert(!c4fWriteAll(C4F_DIAG_LOG, "latest segment\n", 0));
|
||||
text = c4fCurrentLogPage(); assert(strstr(text, "older segment") && strstr(text, "latest segment")); free(text);
|
||||
unlink(C4F_DIAG_KLOG); unlink(C4F_DIAG_REPORT); unlink(C4F_DIAG_LOG); unlink(C4F_DIAG_LOG ".1"); rmdir(C4F_DIAG_DIR);
|
||||
puts("PASS independent diagnostic directory, EINTR/short writes, ENOSPC/zero/fsync failures and truthful report results");
|
||||
}
|
||||
@@ -0,0 +1,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; });
|
||||
@@ -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>
|
||||
@@ -0,0 +1,214 @@
|
||||
/* Local browser test double. This file is never loaded by client/index.html.
|
||||
* Inject this script and call fakePads.loadProfiles(researched JSON profiles).
|
||||
* Example: fakePads.press(fakePads.connect(profileName), 'Green fret').
|
||||
* API contract: https://w3c.github.io/gamepad/#dom-navigator-getgamepads
|
||||
* Slots retain null holes; button pressure/touch and timestamps update on polls.
|
||||
* These snapshots emulate the documented profiles, not hardware compatibility.
|
||||
*/
|
||||
(() => {
|
||||
'use strict';
|
||||
// Chromium Gamepads::kItemsLengthCap and Windows XInput both cap at four.
|
||||
// https://chromium.googlesource.com/chromium/src/+/main/device/gamepad/public/cpp/gamepads.h
|
||||
const profiles = new Map(), devices = Array(4).fill(null), feedback = [];
|
||||
const copy = value => JSON.parse(JSON.stringify(value));
|
||||
const finite = value => {
|
||||
if (!Number.isFinite(value)) throw new TypeError('Input must be a finite number');
|
||||
return value;
|
||||
};
|
||||
const clamp = (value, min, max) => Math.max(min, Math.min(max, finite(value)));
|
||||
const valueOf = entry => entry.value;
|
||||
const evidence = (entry, label) => {
|
||||
if (!entry || !['confirmed', 'inferred'].includes(entry.status) || !entry.sources?.length ||
|
||||
entry.sources.some(source => !/^https?:\/\//.test(source.url) || !source.says) ||
|
||||
entry.status === 'inferred' && !entry.reason) throw new Error('Missing evidence: ' + label);
|
||||
};
|
||||
function validate(profile) {
|
||||
if (!profile?.name || !profile.label || !profile.environment || !profile.controls) throw new Error('Invalid profile');
|
||||
for (const key of ['id', 'mapping', 'buttons', 'axesRest', 'vibration']) evidence(profile.gamepad?.[key], profile.name + '.' + key);
|
||||
const pad = profile.gamepad, count = valueOf(pad.buttons), axes = valueOf(pad.axesRest);
|
||||
if (typeof valueOf(pad.id) !== 'string' || !['', 'standard'].includes(valueOf(pad.mapping)) ||
|
||||
!Number.isInteger(count) || count < 1 || !Array.isArray(axes) || axes.some(v => !Number.isFinite(v))) throw new Error('Invalid layout: ' + profile.name);
|
||||
for (const [name, control] of Object.entries(profile.controls)) {
|
||||
evidence(control.evidence, profile.name + '.' + name);
|
||||
if (control.kind === 'button' && (!Number.isInteger(control.index) || control.index < 0 || control.index >= count)) throw new Error('Button out of range: ' + name);
|
||||
if (['axis', 'hat'].includes(control.kind) && (!Number.isInteger(control.index) || control.index < 0 || control.index >= axes.length)) throw new Error('Axis out of range: ' + name);
|
||||
if (control.kind === 'stick' && [control.x, control.y].some(index => !Number.isInteger(index) || index < 0 || index >= axes.length)) throw new Error('Stick out of range: ' + name);
|
||||
if (control.kind === 'alias' && !profile.controls[control.target]) throw new Error('Unknown alias target: ' + name);
|
||||
if (!['button', 'axis', 'stick', 'hat', 'alias', 'unreported'].includes(control.kind)) throw new Error('Unknown control kind: ' + name);
|
||||
if (control.kind === 'hat' && (!Number.isFinite(control.neutral) || !control.directions)) throw new Error('Invalid hat: ' + name);
|
||||
}
|
||||
for (const [name, spec] of Object.entries(profile.switches || {})) evidence(spec.evidence, profile.name + '.' + name);
|
||||
return copy(profile);
|
||||
}
|
||||
function device(index) {
|
||||
const found = devices[index];
|
||||
if (!found) throw new Error('No connected fake gamepad at index ' + index);
|
||||
return found;
|
||||
}
|
||||
function controlFor(pad, name, seen = new Set()) {
|
||||
const control = pad.profile.controls[name];
|
||||
if (!control) throw new Error('Unknown control: ' + name);
|
||||
if (seen.has(name)) throw new Error('Circular control alias: ' + name);
|
||||
seen.add(name);
|
||||
if (control.kind === 'alias') return controlFor(pad, control.target, seen);
|
||||
if (control.kind === 'unreported') throw new Error(name + ' is not independently exposed: ' + (control.reason || pad.profile.notes?.join(' ') || 'see the profile evidence'));
|
||||
return control;
|
||||
}
|
||||
function snapshot(pad, connected = true) {
|
||||
const spec = pad.profile.gamepad;
|
||||
pad.sample = { connected, timestamp: pad.timestamp, buttons: [...pad.buttons], axes: Object.freeze([...pad.axes]) };
|
||||
if (!pad.public) {
|
||||
const buttons = Object.freeze(pad.buttons.map((_, index) => {
|
||||
const control = Object.values(pad.profile.controls).find(c => c.kind === 'button' && c.index === index);
|
||||
const threshold = control?.pressedThreshold ?? .5;
|
||||
return Object.freeze({
|
||||
get pressed() { const value = pad.sample.buttons[index]; return control?.pressedInclusive ? value >= threshold : value > threshold; },
|
||||
get touched() {
|
||||
const value = pad.sample.buttons[index];
|
||||
if (control?.touchedPolicy === 'pressed') return control.pressedInclusive ? value >= threshold : value > threshold;
|
||||
return value > 0;
|
||||
},
|
||||
get value() { return pad.sample.buttons[index]; }, [Symbol.toStringTag]: 'GamepadButton'
|
||||
});
|
||||
}));
|
||||
const result = {
|
||||
id: valueOf(spec.id), index: pad.index, mapping: valueOf(spec.mapping),
|
||||
get connected() { return pad.sample.connected; }, get timestamp() { return pad.sample.timestamp; },
|
||||
buttons, get axes() { return pad.sample.axes; }, [Symbol.toStringTag]: 'Gamepad'
|
||||
};
|
||||
if (valueOf(spec.vibration)) result.vibrationActuator = pad.actuator;
|
||||
else if (spec.vibration.presence === 'null') result.vibrationActuator = null;
|
||||
pad.public = Object.freeze(result);
|
||||
}
|
||||
return pad.public;
|
||||
}
|
||||
function fire(type, pad, connected) {
|
||||
// GamepadEvent's constructor rejects JS test doubles as native Gamepads.
|
||||
// The page sees the same event name and read-only gamepad property.
|
||||
const event = new Event(type);
|
||||
Object.defineProperty(event, 'gamepad', { value: snapshot(pad, connected), enumerable: true });
|
||||
window.dispatchEvent(event);
|
||||
}
|
||||
function connectAt(name, index) {
|
||||
const profile = profiles.get(name);
|
||||
if (!profile) throw new Error('Unknown profile: ' + name);
|
||||
const pad = { profile, index, timestamp: performance.now(), dirty: false, contributions: new Map(), buttons: Array(valueOf(profile.gamepad.buttons)).fill(0),
|
||||
axes: [...valueOf(profile.gamepad.axesRest)], switches: {} };
|
||||
const vibration = valueOf(profile.gamepad.vibration);
|
||||
if (vibration) {
|
||||
pad.actuator = Object.freeze({ type: vibration.type, effects: Object.freeze([...vibration.effects]),
|
||||
playEffect(type, parameters = {}) {
|
||||
if (!['dual-rumble', 'trigger-rumble'].includes(type)) throw new TypeError('Unknown haptic effect');
|
||||
if (!vibration.effects.includes(type)) return Promise.resolve('not-supported');
|
||||
// Blink playEffect parameter ranges; defaults are zero.
|
||||
// https://chromium.googlesource.com/chromium/src/+/main/third_party/blink/renderer/modules/gamepad/gamepad_haptic_actuator.cc
|
||||
if (['duration', 'startDelay'].some(key => parameters[key] !== undefined && (!Number.isFinite(parameters[key]) || parameters[key] < 0)) ||
|
||||
['strongMagnitude', 'weakMagnitude', 'leftTrigger', 'rightTrigger'].some(key => parameters[key] !== undefined &&
|
||||
(!Number.isFinite(parameters[key]) || parameters[key] < 0 || parameters[key] > 1))) return Promise.resolve('invalid-parameter');
|
||||
feedback.push({ index, type, parameters: copy(parameters) });
|
||||
// Capture stub: no motor timing, preemption, or physical validation.
|
||||
return Promise.resolve('complete');
|
||||
},
|
||||
reset() { feedback.push({ index, type: 'reset' }); return Promise.resolve('complete'); },
|
||||
[Symbol.toStringTag]: 'GamepadHapticActuator' });
|
||||
}
|
||||
devices[index] = pad;
|
||||
fire('gamepadconnected', pad, true);
|
||||
return index;
|
||||
}
|
||||
function set(index, name, value) {
|
||||
const pad = device(index), control = controlFor(pad, name);
|
||||
if (control.kind === 'button') {
|
||||
const pressure = clamp(value, 0, 1);
|
||||
if (!control.analog && ![0, 1].includes(pressure)) throw new TypeError(name + ' is a digital button');
|
||||
pad.contributions.set(name, { index: control.index, value: control.steps ? Math.round(pressure * control.steps) / control.steps : pressure });
|
||||
const next = Math.max(0, ...[...pad.contributions.values()].filter(c => c.index === control.index).map(c => c.value));
|
||||
pad.dirty ||= pad.buttons[control.index] !== next; pad.buttons[control.index] = next;
|
||||
}
|
||||
else if (control.kind === 'axis') axisValue(pad, control.index, clamp(value, control.min ?? -1, control.max ?? 1));
|
||||
else throw new Error('Use tilt or hat for ' + name);
|
||||
}
|
||||
function axisValue(pad, index, value) { pad.dirty ||= pad.axes[index] !== value; pad.axes[index] = value; }
|
||||
function release(index, name) {
|
||||
const pad = device(index), control = controlFor(pad, name);
|
||||
if (control.kind === 'button') set(index, name, 0);
|
||||
else if (control.kind === 'axis') axisValue(pad, control.index, control.rest ?? valueOf(pad.profile.gamepad.axesRest)[control.index]);
|
||||
else if (control.kind === 'stick') {
|
||||
axisValue(pad, control.x, valueOf(pad.profile.gamepad.axesRest)[control.x]);
|
||||
axisValue(pad, control.y, valueOf(pad.profile.gamepad.axesRest)[control.y]);
|
||||
} else if (control.kind === 'hat') axisValue(pad, control.index, control.neutral);
|
||||
}
|
||||
function directionKey(direction) { return direction.toLowerCase().replace(/\s+/g, '-'); }
|
||||
function hat(index, direction, name) {
|
||||
const pad = device(index);
|
||||
name ||= Object.keys(pad.profile.controls).find(key => pad.profile.controls[key].kind === 'hat') || 'D-pad';
|
||||
const control = pad.profile.controls[name] ? controlFor(pad, name) : null, key = directionKey(direction);
|
||||
if (control?.kind === 'hat') {
|
||||
if (key === 'none') axisValue(pad, control.index, control.neutral);
|
||||
else if (Object.hasOwn(control.directions, key)) axisValue(pad, control.index, control.directions[key]);
|
||||
else throw new Error('Unknown hat direction: ' + direction);
|
||||
return;
|
||||
}
|
||||
// A browser-standard D-pad is four buttons, including diagonals.
|
||||
const names = ['up', 'right', 'down', 'left'];
|
||||
if (!['none', ...names, 'up-right', 'down-right', 'down-left', 'up-left'].includes(key)) throw new Error('Unknown D-pad direction');
|
||||
for (const part of names) {
|
||||
const keyName = Object.keys(pad.profile.controls).find(n => n.toLowerCase() === 'd-pad ' + part);
|
||||
if (!keyName) throw new Error('No D-pad control in this profile');
|
||||
set(index, keyName, key.split('-').includes(part) ? 1 : 0);
|
||||
}
|
||||
}
|
||||
const api = {
|
||||
loadProfiles(list) {
|
||||
if (!Array.isArray(list)) list = [list];
|
||||
const checked = list.map(validate);
|
||||
for (const profile of checked) profiles.set(profile.name, profile);
|
||||
},
|
||||
profiles: () => [...profiles.values()].map(copy),
|
||||
describe: index => copy(device(index).profile),
|
||||
connect(name) { const index = devices.findIndex(pad => !pad); if (index < 0) throw new Error('All four fake gamepad slots are occupied'); return connectAt(name, index); },
|
||||
disconnect(index) { const pad = device(index); devices[index] = null; pad.timestamp = performance.now(); fire('gamepaddisconnected', pad, false); },
|
||||
press(index, name) {
|
||||
const pad = device(index), control = controlFor(pad, name);
|
||||
if (control.kind === 'button') set(index, name, 1);
|
||||
else if (control.kind === 'axis') set(index, name, control.full ?? (control.rest > 0 ? -1 : 1));
|
||||
else throw new Error('Use tilt or hat for ' + name);
|
||||
},
|
||||
release, set,
|
||||
tilt(index, name, x, y) {
|
||||
const pad = device(index), control = controlFor(pad, name);
|
||||
if (control.kind !== 'stick') throw new Error(name + ' is not a stick');
|
||||
axisValue(pad, control.x, clamp(x, -1, 1)); axisValue(pad, control.y, clamp(y, -1, 1));
|
||||
},
|
||||
hat,
|
||||
switch(index, name, value) {
|
||||
const pad = device(index), spec = pad.profile.switches?.[name];
|
||||
if (!spec) throw new Error('Unknown switch: ' + name);
|
||||
if (spec.kind === 'unreported') throw new Error(name + ': ' + (spec.reason || 'browser effect not found; see evidence'));
|
||||
if (spec.kind === 'profile') {
|
||||
const target = spec.values[value];
|
||||
if (!target || !profiles.has(target)) throw new Error('No researched profile for mode ' + value);
|
||||
api.disconnect(index); return connectAt(target, index);
|
||||
}
|
||||
if (spec.kind !== 'state' || spec.effect !== 'none' || !spec.values.includes(value)) throw new Error('Unsupported switch value');
|
||||
pad.switches[name] = value;
|
||||
return index;
|
||||
},
|
||||
switchState: index => copy(device(index).switches),
|
||||
feedback: () => copy(feedback),
|
||||
reset(index) {
|
||||
const pad = device(index);
|
||||
pad.dirty ||= pad.buttons.some(v => v !== 0) || pad.axes.some((v, i) => v !== valueOf(pad.profile.gamepad.axesRest)[i]);
|
||||
pad.contributions.clear();
|
||||
pad.buttons.fill(0); pad.axes = [...valueOf(pad.profile.gamepad.axesRest)];
|
||||
}
|
||||
};
|
||||
// New snapshots expose the latest input only when sampled, like getGamepads.
|
||||
Object.defineProperty(navigator, 'getGamepads', { configurable: true, value: () => devices.map(pad => {
|
||||
if (!pad) return null;
|
||||
if (pad.dirty || valueOf(pad.profile.gamepad.timestampPolicy || { value: 'poll' }) === 'poll') pad.timestamp = Math.max(performance.now(), pad.timestamp + .001);
|
||||
pad.dirty = false;
|
||||
return snapshot(pad);
|
||||
}) });
|
||||
Object.defineProperty(window, 'fakePads', { value: Object.freeze(api), configurable: true });
|
||||
})();
|
||||
@@ -0,0 +1,545 @@
|
||||
{
|
||||
"name": "crkd-les-paul-chrome-win-dongle-mode1-pre3",
|
||||
"label": "CRKD Gibson Les Paul — PC Mode 1 (earlier firmware, inferred)",
|
||||
"gamepad": {
|
||||
"id": {
|
||||
"value": "Xbox 360 Controller (XInput STANDARD GAMEPAD)",
|
||||
"status": "inferred",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://github.com/sanjay900/xinputemu/pull/1",
|
||||
"says": "Patch author tested two CRKD guitars; VID/PID match a first-party 360 gamepad (045e:028e in patch); whammy is Z/LT, converted to signed right-stick range for old PC guitar games."
|
||||
},
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
|
||||
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
|
||||
}
|
||||
],
|
||||
"reason": "The hardware-tested original Mode 1 report uses the first-party 360 VID/PID and YARG identifies it as an XInput gamepad. Applying Chromium's subtype-1 name is a source-derived candidate, not a raw CRKD browser capture."
|
||||
},
|
||||
"mapping": {
|
||||
"value": "standard",
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
|
||||
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
|
||||
}
|
||||
]
|
||||
},
|
||||
"buttons": {
|
||||
"value": 17,
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
|
||||
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
|
||||
}
|
||||
]
|
||||
},
|
||||
"axesRest": {
|
||||
"value": [
|
||||
1.52587890625E-05,
|
||||
-1.52587890625E-05,
|
||||
1.52587890625E-05,
|
||||
-1.52587890625E-05
|
||||
],
|
||||
"status": "inferred",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
|
||||
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
|
||||
},
|
||||
{
|
||||
"url": "https://cdn.shopify.com/s/files/1/0679/3392/9791/files/Guitar_User_Guide_Multi_V1.4_08Jan26.pdf?v=1767902895",
|
||||
"says": "CK25GM PC connections include USB cable and included USB wireless receiver; PC slider and mode dial select game preset; the hardware includes both sticks, View/Capture/Menu, CTRL, Home and D-pad."
|
||||
}
|
||||
],
|
||||
"reason": "Nominal neutral signed XInput stick words are assumed to be zero. Chromium normalizes signed zero to +0.0000152587890625 and negates Y; physical CRKD neutral raw words were not captured."
|
||||
},
|
||||
"timestampPolicy": {
|
||||
"value": "poll",
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
|
||||
"says": "GetXInputPadData assigns CurrentTimeInMicroseconds to pad.timestamp after every successful XInput state poll, even if the input values did not change."
|
||||
}
|
||||
]
|
||||
},
|
||||
"vibration": {
|
||||
"presence": "object",
|
||||
"value": {
|
||||
"type": "dual-rumble",
|
||||
"effects": [
|
||||
"dual-rumble"
|
||||
]
|
||||
},
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
|
||||
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"controls": {
|
||||
"Green fret": {
|
||||
"kind": "button",
|
||||
"index": 0,
|
||||
"pressedThreshold": 0,
|
||||
"evidence": {
|
||||
"status": "inferred",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://github.com/YARC-Official/YARG/blob/f5bfbe6996b4af35722d4204a93c6cc5c8f52a45/Assets/Script/Input/Bindings/Defaults/BindingCollection.Gamepad.cs",
|
||||
"says": "CrkdGuitar_Mode1 maps Green/Red/Yellow/Blue/Orange to A/B/Y/X/LB, strum up/down to D-pad, star power to Select, whammy to leftTrigger; no dedicated tilt axis. Menu includes left-stick and D-pad."
|
||||
},
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
|
||||
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
|
||||
}
|
||||
],
|
||||
"reason": "YARG's CRKD Mode 1 Green fret binding is translated through Chromium's XInput button order. Factory button aliases are inferred, not a browser dump."
|
||||
}
|
||||
},
|
||||
"Red fret": {
|
||||
"kind": "button",
|
||||
"index": 1,
|
||||
"pressedThreshold": 0,
|
||||
"evidence": {
|
||||
"status": "inferred",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://github.com/YARC-Official/YARG/blob/f5bfbe6996b4af35722d4204a93c6cc5c8f52a45/Assets/Script/Input/Bindings/Defaults/BindingCollection.Gamepad.cs",
|
||||
"says": "CrkdGuitar_Mode1 maps Green/Red/Yellow/Blue/Orange to A/B/Y/X/LB, strum up/down to D-pad, star power to Select, whammy to leftTrigger; no dedicated tilt axis. Menu includes left-stick and D-pad."
|
||||
},
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
|
||||
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
|
||||
}
|
||||
],
|
||||
"reason": "YARG's CRKD Mode 1 Red fret binding is translated through Chromium's XInput button order. Factory button aliases are inferred, not a browser dump."
|
||||
}
|
||||
},
|
||||
"Yellow fret": {
|
||||
"kind": "button",
|
||||
"index": 3,
|
||||
"pressedThreshold": 0,
|
||||
"evidence": {
|
||||
"status": "inferred",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://github.com/YARC-Official/YARG/blob/f5bfbe6996b4af35722d4204a93c6cc5c8f52a45/Assets/Script/Input/Bindings/Defaults/BindingCollection.Gamepad.cs",
|
||||
"says": "CrkdGuitar_Mode1 maps Green/Red/Yellow/Blue/Orange to A/B/Y/X/LB, strum up/down to D-pad, star power to Select, whammy to leftTrigger; no dedicated tilt axis. Menu includes left-stick and D-pad."
|
||||
},
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
|
||||
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
|
||||
}
|
||||
],
|
||||
"reason": "YARG's CRKD Mode 1 Yellow fret binding is translated through Chromium's XInput button order. Factory button aliases are inferred, not a browser dump."
|
||||
}
|
||||
},
|
||||
"Blue fret": {
|
||||
"kind": "button",
|
||||
"index": 2,
|
||||
"pressedThreshold": 0,
|
||||
"evidence": {
|
||||
"status": "inferred",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://github.com/YARC-Official/YARG/blob/f5bfbe6996b4af35722d4204a93c6cc5c8f52a45/Assets/Script/Input/Bindings/Defaults/BindingCollection.Gamepad.cs",
|
||||
"says": "CrkdGuitar_Mode1 maps Green/Red/Yellow/Blue/Orange to A/B/Y/X/LB, strum up/down to D-pad, star power to Select, whammy to leftTrigger; no dedicated tilt axis. Menu includes left-stick and D-pad."
|
||||
},
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
|
||||
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
|
||||
}
|
||||
],
|
||||
"reason": "YARG's CRKD Mode 1 Blue fret binding is translated through Chromium's XInput button order. Factory button aliases are inferred, not a browser dump."
|
||||
}
|
||||
},
|
||||
"Orange fret": {
|
||||
"kind": "button",
|
||||
"index": 4,
|
||||
"pressedThreshold": 0,
|
||||
"evidence": {
|
||||
"status": "inferred",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://github.com/YARC-Official/YARG/blob/f5bfbe6996b4af35722d4204a93c6cc5c8f52a45/Assets/Script/Input/Bindings/Defaults/BindingCollection.Gamepad.cs",
|
||||
"says": "CrkdGuitar_Mode1 maps Green/Red/Yellow/Blue/Orange to A/B/Y/X/LB, strum up/down to D-pad, star power to Select, whammy to leftTrigger; no dedicated tilt axis. Menu includes left-stick and D-pad."
|
||||
},
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
|
||||
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
|
||||
}
|
||||
],
|
||||
"reason": "YARG's CRKD Mode 1 Orange fret binding is translated through Chromium's XInput button order. Factory button aliases are inferred, not a browser dump."
|
||||
}
|
||||
},
|
||||
"Strum up": {
|
||||
"kind": "button",
|
||||
"index": 12,
|
||||
"pressedThreshold": 0,
|
||||
"evidence": {
|
||||
"status": "inferred",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://github.com/YARC-Official/YARG/blob/f5bfbe6996b4af35722d4204a93c6cc5c8f52a45/Assets/Script/Input/Bindings/Defaults/BindingCollection.Gamepad.cs",
|
||||
"says": "CrkdGuitar_Mode1 maps Green/Red/Yellow/Blue/Orange to A/B/Y/X/LB, strum up/down to D-pad, star power to Select, whammy to leftTrigger; no dedicated tilt axis. Menu includes left-stick and D-pad."
|
||||
},
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
|
||||
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
|
||||
}
|
||||
],
|
||||
"reason": "YARG's CRKD Mode 1 Strum up binding is translated through Chromium's XInput button order. Factory button aliases are inferred, not a browser dump."
|
||||
}
|
||||
},
|
||||
"Strum down": {
|
||||
"kind": "button",
|
||||
"index": 13,
|
||||
"pressedThreshold": 0,
|
||||
"evidence": {
|
||||
"status": "inferred",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://github.com/YARC-Official/YARG/blob/f5bfbe6996b4af35722d4204a93c6cc5c8f52a45/Assets/Script/Input/Bindings/Defaults/BindingCollection.Gamepad.cs",
|
||||
"says": "CrkdGuitar_Mode1 maps Green/Red/Yellow/Blue/Orange to A/B/Y/X/LB, strum up/down to D-pad, star power to Select, whammy to leftTrigger; no dedicated tilt axis. Menu includes left-stick and D-pad."
|
||||
},
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
|
||||
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
|
||||
}
|
||||
],
|
||||
"reason": "YARG's CRKD Mode 1 Strum down binding is translated through Chromium's XInput button order. Factory button aliases are inferred, not a browser dump."
|
||||
}
|
||||
},
|
||||
"CTRL": {
|
||||
"kind": "button",
|
||||
"index": 8,
|
||||
"pressedThreshold": 0,
|
||||
"evidence": {
|
||||
"status": "inferred",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://github.com/YARC-Official/YARG/blob/f5bfbe6996b4af35722d4204a93c6cc5c8f52a45/Assets/Script/Input/Bindings/Defaults/BindingCollection.Gamepad.cs",
|
||||
"says": "CrkdGuitar_Mode1 maps Green/Red/Yellow/Blue/Orange to A/B/Y/X/LB, strum up/down to D-pad, star power to Select, whammy to leftTrigger; no dedicated tilt axis. Menu includes left-stick and D-pad."
|
||||
},
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
|
||||
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
|
||||
}
|
||||
],
|
||||
"reason": "YARG's CRKD Mode 1 star-power/select binding is translated through Chromium's XInput button order. Factory button aliases are inferred, not a browser dump."
|
||||
}
|
||||
},
|
||||
"View": {
|
||||
"kind": "button",
|
||||
"index": 8,
|
||||
"pressedThreshold": 0,
|
||||
"evidence": {
|
||||
"status": "inferred",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://github.com/YARC-Official/YARG/blob/f5bfbe6996b4af35722d4204a93c6cc5c8f52a45/Assets/Script/Input/Bindings/Defaults/BindingCollection.Gamepad.cs",
|
||||
"says": "CrkdGuitar_Mode1 maps Green/Red/Yellow/Blue/Orange to A/B/Y/X/LB, strum up/down to D-pad, star power to Select, whammy to leftTrigger; no dedicated tilt axis. Menu includes left-stick and D-pad."
|
||||
},
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
|
||||
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
|
||||
}
|
||||
],
|
||||
"reason": "YARG's CRKD Mode 1 View binding is translated through Chromium's XInput button order. Factory button aliases are inferred, not a browser dump."
|
||||
}
|
||||
},
|
||||
"Menu": {
|
||||
"kind": "button",
|
||||
"index": 9,
|
||||
"pressedThreshold": 0,
|
||||
"evidence": {
|
||||
"status": "inferred",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://github.com/YARC-Official/YARG/blob/f5bfbe6996b4af35722d4204a93c6cc5c8f52a45/Assets/Script/Input/Bindings/Defaults/BindingCollection.Gamepad.cs",
|
||||
"says": "CrkdGuitar_Mode1 maps Green/Red/Yellow/Blue/Orange to A/B/Y/X/LB, strum up/down to D-pad, star power to Select, whammy to leftTrigger; no dedicated tilt axis. Menu includes left-stick and D-pad."
|
||||
},
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
|
||||
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
|
||||
}
|
||||
],
|
||||
"reason": "YARG's CRKD Mode 1 Menu binding is translated through Chromium's XInput button order. Factory button aliases are inferred, not a browser dump."
|
||||
}
|
||||
},
|
||||
"D-pad up": {
|
||||
"kind": "button",
|
||||
"index": 12,
|
||||
"pressedThreshold": 0,
|
||||
"evidence": {
|
||||
"status": "inferred",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://github.com/YARC-Official/YARG/blob/f5bfbe6996b4af35722d4204a93c6cc5c8f52a45/Assets/Script/Input/Bindings/Defaults/BindingCollection.Gamepad.cs",
|
||||
"says": "CrkdGuitar_Mode1 maps Green/Red/Yellow/Blue/Orange to A/B/Y/X/LB, strum up/down to D-pad, star power to Select, whammy to leftTrigger; no dedicated tilt axis. Menu includes left-stick and D-pad."
|
||||
},
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
|
||||
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
|
||||
}
|
||||
],
|
||||
"reason": "YARG's CRKD Mode 1 D-pad up binding is translated through Chromium's XInput button order. Factory button aliases are inferred, not a browser dump."
|
||||
}
|
||||
},
|
||||
"D-pad down": {
|
||||
"kind": "button",
|
||||
"index": 13,
|
||||
"pressedThreshold": 0,
|
||||
"evidence": {
|
||||
"status": "inferred",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://github.com/YARC-Official/YARG/blob/f5bfbe6996b4af35722d4204a93c6cc5c8f52a45/Assets/Script/Input/Bindings/Defaults/BindingCollection.Gamepad.cs",
|
||||
"says": "CrkdGuitar_Mode1 maps Green/Red/Yellow/Blue/Orange to A/B/Y/X/LB, strum up/down to D-pad, star power to Select, whammy to leftTrigger; no dedicated tilt axis. Menu includes left-stick and D-pad."
|
||||
},
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
|
||||
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
|
||||
}
|
||||
],
|
||||
"reason": "YARG's CRKD Mode 1 D-pad down binding is translated through Chromium's XInput button order. Factory button aliases are inferred, not a browser dump."
|
||||
}
|
||||
},
|
||||
"D-pad left": {
|
||||
"kind": "button",
|
||||
"index": 14,
|
||||
"pressedThreshold": 0,
|
||||
"evidence": {
|
||||
"status": "inferred",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://github.com/YARC-Official/YARG/blob/f5bfbe6996b4af35722d4204a93c6cc5c8f52a45/Assets/Script/Input/Bindings/Defaults/BindingCollection.Gamepad.cs",
|
||||
"says": "CrkdGuitar_Mode1 maps Green/Red/Yellow/Blue/Orange to A/B/Y/X/LB, strum up/down to D-pad, star power to Select, whammy to leftTrigger; no dedicated tilt axis. Menu includes left-stick and D-pad."
|
||||
},
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
|
||||
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
|
||||
}
|
||||
],
|
||||
"reason": "YARG's CRKD Mode 1 D-pad left binding is translated through Chromium's XInput button order. Factory button aliases are inferred, not a browser dump."
|
||||
}
|
||||
},
|
||||
"D-pad right": {
|
||||
"kind": "button",
|
||||
"index": 15,
|
||||
"pressedThreshold": 0,
|
||||
"evidence": {
|
||||
"status": "inferred",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://github.com/YARC-Official/YARG/blob/f5bfbe6996b4af35722d4204a93c6cc5c8f52a45/Assets/Script/Input/Bindings/Defaults/BindingCollection.Gamepad.cs",
|
||||
"says": "CrkdGuitar_Mode1 maps Green/Red/Yellow/Blue/Orange to A/B/Y/X/LB, strum up/down to D-pad, star power to Select, whammy to leftTrigger; no dedicated tilt axis. Menu includes left-stick and D-pad."
|
||||
},
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
|
||||
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
|
||||
}
|
||||
],
|
||||
"reason": "YARG's CRKD Mode 1 D-pad right binding is translated through Chromium's XInput button order. Factory button aliases are inferred, not a browser dump."
|
||||
}
|
||||
},
|
||||
"Whammy": {
|
||||
"kind": "button",
|
||||
"index": 6,
|
||||
"steps": 255,
|
||||
"pressedThreshold": 0.11764705882352941,
|
||||
"pressedInclusive": true,
|
||||
"evidence": {
|
||||
"status": "inferred",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://github.com/YARC-Official/YARG/blob/f5bfbe6996b4af35722d4204a93c6cc5c8f52a45/Assets/Script/Input/Bindings/Defaults/BindingCollection.Gamepad.cs",
|
||||
"says": "CrkdGuitar_Mode1 maps Green/Red/Yellow/Blue/Orange to A/B/Y/X/LB, strum up/down to D-pad, star power to Select, whammy to leftTrigger; no dedicated tilt axis. Menu includes left-stick and D-pad."
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/sanjay900/xinputemu/pull/1",
|
||||
"says": "Patch author tested two CRKD guitars; VID/PID match a first-party 360 gamepad (045e:028e in patch); whammy is Z/LT, converted to signed right-stick range for old PC guitar games."
|
||||
},
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
|
||||
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
|
||||
}
|
||||
],
|
||||
"reason": "YARG binds CRKD Mode 1 whammy to LT; Chromium exposes LT as analog button 6, divided by 255, pressed inclusively at byte 30. No -1 whammy axis is claimed."
|
||||
},
|
||||
"analog": true
|
||||
},
|
||||
"Left stick": {
|
||||
"kind": "stick",
|
||||
"x": 0,
|
||||
"y": 1,
|
||||
"evidence": {
|
||||
"status": "inferred",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://github.com/YARC-Official/YARG/blob/f5bfbe6996b4af35722d4204a93c6cc5c8f52a45/Assets/Script/Input/Bindings/Defaults/BindingCollection.Gamepad.cs",
|
||||
"says": "CrkdGuitar_Mode1 maps Green/Red/Yellow/Blue/Orange to A/B/Y/X/LB, strum up/down to D-pad, star power to Select, whammy to leftTrigger; no dedicated tilt axis. Menu includes left-stick and D-pad."
|
||||
},
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
|
||||
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
|
||||
}
|
||||
],
|
||||
"reason": "YARG's CRKD Mode 1 menu accepts the left stick; Chromium's XInput axes 0/1 carry LX/inverted LY."
|
||||
}
|
||||
},
|
||||
"Right stick": {
|
||||
"kind": "stick",
|
||||
"x": 2,
|
||||
"y": 3,
|
||||
"evidence": {
|
||||
"status": "inferred",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://cdn.shopify.com/s/files/1/0679/3392/9791/files/Guitar_User_Guide_Multi_V1.4_08Jan26.pdf?v=1767902895",
|
||||
"says": "CK25GM PC connections include USB cable and included USB wireless receiver; PC slider and mode dial select game preset; the hardware includes both sticks, View/Capture/Menu, CTRL, Home and D-pad."
|
||||
},
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
|
||||
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
|
||||
}
|
||||
],
|
||||
"reason": "Manufacturer identifies a right analog stick. Standard XInput axes 2/3 carry RX/inverted RY; its factory operation in this earlier Mode 1 preset is inferred."
|
||||
}
|
||||
},
|
||||
"L3": {
|
||||
"kind": "button",
|
||||
"index": 10,
|
||||
"pressedThreshold": 0,
|
||||
"evidence": {
|
||||
"status": "inferred",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://cdn.shopify.com/s/files/1/0679/3392/9791/files/Guitar_User_Guide_Multi_V1.4_08Jan26.pdf?v=1767902895",
|
||||
"says": "CK25GM PC connections include USB cable and included USB wireless receiver; PC slider and mode dial select game preset; the hardware includes both sticks, View/Capture/Menu, CTRL, Home and D-pad."
|
||||
},
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
|
||||
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
|
||||
}
|
||||
],
|
||||
"reason": "Manufacturer names this physical control; its normal XInput button correspondence is inferred from the standard browser path, not observed on hardware."
|
||||
}
|
||||
},
|
||||
"R3": {
|
||||
"kind": "button",
|
||||
"index": 11,
|
||||
"pressedThreshold": 0,
|
||||
"evidence": {
|
||||
"status": "inferred",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://cdn.shopify.com/s/files/1/0679/3392/9791/files/Guitar_User_Guide_Multi_V1.4_08Jan26.pdf?v=1767902895",
|
||||
"says": "CK25GM PC connections include USB cable and included USB wireless receiver; PC slider and mode dial select game preset; the hardware includes both sticks, View/Capture/Menu, CTRL, Home and D-pad."
|
||||
},
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
|
||||
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
|
||||
}
|
||||
],
|
||||
"reason": "Manufacturer names this physical control; its normal XInput button correspondence is inferred from the standard browser path, not observed on hardware."
|
||||
}
|
||||
},
|
||||
"CRKD Home": {
|
||||
"kind": "button",
|
||||
"index": 16,
|
||||
"pressedThreshold": 0,
|
||||
"evidence": {
|
||||
"status": "inferred",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://cdn.shopify.com/s/files/1/0679/3392/9791/files/Guitar_User_Guide_Multi_V1.4_08Jan26.pdf?v=1767902895",
|
||||
"says": "CK25GM PC connections include USB cable and included USB wireless receiver; PC slider and mode dial select game preset; the hardware includes both sticks, View/Capture/Menu, CTRL, Home and D-pad."
|
||||
},
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
|
||||
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
|
||||
}
|
||||
],
|
||||
"reason": "Manufacturer names this physical control; its normal XInput button correspondence is inferred from the standard browser path, not observed on hardware."
|
||||
}
|
||||
},
|
||||
"Capture": {
|
||||
"kind": "unreported",
|
||||
"reason": "No independent XInput capture bit exists; the factory action or alias was not found.",
|
||||
"evidence": {
|
||||
"status": "inferred",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://cdn.shopify.com/s/files/1/0679/3392/9791/files/Guitar_User_Guide_Multi_V1.4_08Jan26.pdf?v=1767902895",
|
||||
"says": "CK25GM PC connections include USB cable and included USB wireless receiver; PC slider and mode dial select game preset; the hardware includes both sticks, View/Capture/Menu, CTRL, Home and D-pad."
|
||||
},
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
|
||||
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
|
||||
}
|
||||
],
|
||||
"reason": "The manual confirms Capture exists physically but its PC report is not established; no browser index is invented."
|
||||
}
|
||||
}
|
||||
},
|
||||
"switches": {
|
||||
"Mode dial": {
|
||||
"kind": "unreported",
|
||||
"reason": "Only the earlier PC Mode 1 mapping is sourced. Factory presets, firmware 3 and legacy Mode 8 change reports and need separately researched profiles.",
|
||||
"evidence": {
|
||||
"value": null,
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://cdn.shopify.com/s/files/1/0679/3392/9791/files/Guitar_User_Guide_Multi_V1.4_08Jan26.pdf?v=1767902895",
|
||||
"says": "CK25GM PC connections include USB cable and included USB wireless receiver; PC slider and mode dial select game preset; the hardware includes both sticks, View/Capture/Menu, CTRL, Home and D-pad."
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/YARC-Official/YARG/blob/f5bfbe6996b4af35722d4204a93c6cc5c8f52a45/Assets/Script/Input/Bindings/Defaults/BindingCollection.Gamepad.cs",
|
||||
"says": "CrkdGuitar_Mode1 maps Green/Red/Yellow/Blue/Orange to A/B/Y/X/LB, strum up/down to D-pad, star power to Select, whammy to leftTrigger; no dedicated tilt axis. Menu includes left-stick and D-pad."
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"Platform slider": {
|
||||
"kind": "unreported",
|
||||
"reason": "PC Mode 1 only. NS, AN and P3 identifiers and raw reports have not been established.",
|
||||
"evidence": {
|
||||
"value": null,
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://cdn.shopify.com/s/files/1/0679/3392/9791/files/Guitar_User_Guide_Multi_V1.4_08Jan26.pdf?v=1767902895",
|
||||
"says": "CK25GM PC connections include USB cable and included USB wireless receiver; PC slider and mode dial select game preset; the hardware includes both sticks, View/Capture/Menu, CTRL, Home and D-pad."
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"notes": [
|
||||
"Local source-derived emulation, not a hardware/browser capture. This profile models the earlier CRKD Mode 1 preset identified by YARG as CrkdGuitar_Mode1, before firmware 3 changed star power to D-pad left.",
|
||||
"Use the Multi-platform Gibson Les Paul CK25GM; Nitro Deck is a separate Switch controller product.",
|
||||
"Stock Windows XInputGetStateEx availability is assumed for button 16. Without it Chromium exposes 16 buttons; that separate variant is omitted.",
|
||||
"All unpressed buttons, including whammy and unused RT, are modeled at zero; those physical resting bytes are inferred. The tested conversion patch supports whammy LT with rest zero, but no raw browser rest dump was located.",
|
||||
"Whammy is analog button 6 (0..1), not an axis. D-pad and strum share standard button indices; there is no numeric hat axis or independent tilt axis in this preset.",
|
||||
"Browser indices: 0 A/Green; 1 B/Red; 2 X/Blue; 3 Y/Yellow; 4 LB/Orange; 5 RB (physical alias unconfirmed); 6 LT/Whammy; 7 RT (physical alias unconfirmed); 8 View/Select/CTRL inferred; 9 Menu/Start; 10 L3; 11 R3; 12 D-pad/Strum up; 13 D-pad/Strum down; 14 D-pad left; 15 D-pad right; 16 Home/Guide. Axes 0/1 Left stick; 2/3 Right stick, nominal rest inferred.",
|
||||
"The dual-rumble object is Chromium's unconditional XInput API exposure. It does not confirm browser-controlled physical vibration or equate to the guitar's local strum haptics.",
|
||||
"Chromium XInput id hides VID/PID. Hardware-tested old Mode 1 uses 045e:028e; newer native CRKD IDs from Linux/SDL are not transplanted into this earlier profile.",
|
||||
"Factory names whose report is unknown are omitted or explicitly unreported. Capture, firmware/current platform modes and remapped presets need real captures."
|
||||
],
|
||||
"environment": {
|
||||
"browser": "Chrome / Edge (Chromium XInput path)",
|
||||
"os": "Windows 10 / 11",
|
||||
"connection": "2.4 GHz dongle",
|
||||
"mode": "PC slider; Mode 1; earlier firmware preset"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,545 @@
|
||||
{
|
||||
"name": "crkd-les-paul-chrome-win-usb-mode1-pre3",
|
||||
"label": "CRKD Gibson Les Paul — PC Mode 1 (earlier firmware, inferred)",
|
||||
"gamepad": {
|
||||
"id": {
|
||||
"value": "Xbox 360 Controller (XInput STANDARD GAMEPAD)",
|
||||
"status": "inferred",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://github.com/sanjay900/xinputemu/pull/1",
|
||||
"says": "Patch author tested two CRKD guitars; VID/PID match a first-party 360 gamepad (045e:028e in patch); whammy is Z/LT, converted to signed right-stick range for old PC guitar games."
|
||||
},
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
|
||||
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
|
||||
}
|
||||
],
|
||||
"reason": "The hardware-tested original Mode 1 report uses the first-party 360 VID/PID and YARG identifies it as an XInput gamepad. Applying Chromium's subtype-1 name is a source-derived candidate, not a raw CRKD browser capture."
|
||||
},
|
||||
"mapping": {
|
||||
"value": "standard",
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
|
||||
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
|
||||
}
|
||||
]
|
||||
},
|
||||
"buttons": {
|
||||
"value": 17,
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
|
||||
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
|
||||
}
|
||||
]
|
||||
},
|
||||
"axesRest": {
|
||||
"value": [
|
||||
1.52587890625E-05,
|
||||
-1.52587890625E-05,
|
||||
1.52587890625E-05,
|
||||
-1.52587890625E-05
|
||||
],
|
||||
"status": "inferred",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
|
||||
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
|
||||
},
|
||||
{
|
||||
"url": "https://cdn.shopify.com/s/files/1/0679/3392/9791/files/Guitar_User_Guide_Multi_V1.4_08Jan26.pdf?v=1767902895",
|
||||
"says": "CK25GM PC connections include USB cable and included USB wireless receiver; PC slider and mode dial select game preset; the hardware includes both sticks, View/Capture/Menu, CTRL, Home and D-pad."
|
||||
}
|
||||
],
|
||||
"reason": "Nominal neutral signed XInput stick words are assumed to be zero. Chromium normalizes signed zero to +0.0000152587890625 and negates Y; physical CRKD neutral raw words were not captured."
|
||||
},
|
||||
"timestampPolicy": {
|
||||
"value": "poll",
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
|
||||
"says": "GetXInputPadData assigns CurrentTimeInMicroseconds to pad.timestamp after every successful XInput state poll, even if the input values did not change."
|
||||
}
|
||||
]
|
||||
},
|
||||
"vibration": {
|
||||
"presence": "object",
|
||||
"value": {
|
||||
"type": "dual-rumble",
|
||||
"effects": [
|
||||
"dual-rumble"
|
||||
]
|
||||
},
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
|
||||
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"controls": {
|
||||
"Green fret": {
|
||||
"kind": "button",
|
||||
"index": 0,
|
||||
"pressedThreshold": 0,
|
||||
"evidence": {
|
||||
"status": "inferred",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://github.com/YARC-Official/YARG/blob/f5bfbe6996b4af35722d4204a93c6cc5c8f52a45/Assets/Script/Input/Bindings/Defaults/BindingCollection.Gamepad.cs",
|
||||
"says": "CrkdGuitar_Mode1 maps Green/Red/Yellow/Blue/Orange to A/B/Y/X/LB, strum up/down to D-pad, star power to Select, whammy to leftTrigger; no dedicated tilt axis. Menu includes left-stick and D-pad."
|
||||
},
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
|
||||
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
|
||||
}
|
||||
],
|
||||
"reason": "YARG's CRKD Mode 1 Green fret binding is translated through Chromium's XInput button order. Factory button aliases are inferred, not a browser dump."
|
||||
}
|
||||
},
|
||||
"Red fret": {
|
||||
"kind": "button",
|
||||
"index": 1,
|
||||
"pressedThreshold": 0,
|
||||
"evidence": {
|
||||
"status": "inferred",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://github.com/YARC-Official/YARG/blob/f5bfbe6996b4af35722d4204a93c6cc5c8f52a45/Assets/Script/Input/Bindings/Defaults/BindingCollection.Gamepad.cs",
|
||||
"says": "CrkdGuitar_Mode1 maps Green/Red/Yellow/Blue/Orange to A/B/Y/X/LB, strum up/down to D-pad, star power to Select, whammy to leftTrigger; no dedicated tilt axis. Menu includes left-stick and D-pad."
|
||||
},
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
|
||||
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
|
||||
}
|
||||
],
|
||||
"reason": "YARG's CRKD Mode 1 Red fret binding is translated through Chromium's XInput button order. Factory button aliases are inferred, not a browser dump."
|
||||
}
|
||||
},
|
||||
"Yellow fret": {
|
||||
"kind": "button",
|
||||
"index": 3,
|
||||
"pressedThreshold": 0,
|
||||
"evidence": {
|
||||
"status": "inferred",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://github.com/YARC-Official/YARG/blob/f5bfbe6996b4af35722d4204a93c6cc5c8f52a45/Assets/Script/Input/Bindings/Defaults/BindingCollection.Gamepad.cs",
|
||||
"says": "CrkdGuitar_Mode1 maps Green/Red/Yellow/Blue/Orange to A/B/Y/X/LB, strum up/down to D-pad, star power to Select, whammy to leftTrigger; no dedicated tilt axis. Menu includes left-stick and D-pad."
|
||||
},
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
|
||||
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
|
||||
}
|
||||
],
|
||||
"reason": "YARG's CRKD Mode 1 Yellow fret binding is translated through Chromium's XInput button order. Factory button aliases are inferred, not a browser dump."
|
||||
}
|
||||
},
|
||||
"Blue fret": {
|
||||
"kind": "button",
|
||||
"index": 2,
|
||||
"pressedThreshold": 0,
|
||||
"evidence": {
|
||||
"status": "inferred",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://github.com/YARC-Official/YARG/blob/f5bfbe6996b4af35722d4204a93c6cc5c8f52a45/Assets/Script/Input/Bindings/Defaults/BindingCollection.Gamepad.cs",
|
||||
"says": "CrkdGuitar_Mode1 maps Green/Red/Yellow/Blue/Orange to A/B/Y/X/LB, strum up/down to D-pad, star power to Select, whammy to leftTrigger; no dedicated tilt axis. Menu includes left-stick and D-pad."
|
||||
},
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
|
||||
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
|
||||
}
|
||||
],
|
||||
"reason": "YARG's CRKD Mode 1 Blue fret binding is translated through Chromium's XInput button order. Factory button aliases are inferred, not a browser dump."
|
||||
}
|
||||
},
|
||||
"Orange fret": {
|
||||
"kind": "button",
|
||||
"index": 4,
|
||||
"pressedThreshold": 0,
|
||||
"evidence": {
|
||||
"status": "inferred",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://github.com/YARC-Official/YARG/blob/f5bfbe6996b4af35722d4204a93c6cc5c8f52a45/Assets/Script/Input/Bindings/Defaults/BindingCollection.Gamepad.cs",
|
||||
"says": "CrkdGuitar_Mode1 maps Green/Red/Yellow/Blue/Orange to A/B/Y/X/LB, strum up/down to D-pad, star power to Select, whammy to leftTrigger; no dedicated tilt axis. Menu includes left-stick and D-pad."
|
||||
},
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
|
||||
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
|
||||
}
|
||||
],
|
||||
"reason": "YARG's CRKD Mode 1 Orange fret binding is translated through Chromium's XInput button order. Factory button aliases are inferred, not a browser dump."
|
||||
}
|
||||
},
|
||||
"Strum up": {
|
||||
"kind": "button",
|
||||
"index": 12,
|
||||
"pressedThreshold": 0,
|
||||
"evidence": {
|
||||
"status": "inferred",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://github.com/YARC-Official/YARG/blob/f5bfbe6996b4af35722d4204a93c6cc5c8f52a45/Assets/Script/Input/Bindings/Defaults/BindingCollection.Gamepad.cs",
|
||||
"says": "CrkdGuitar_Mode1 maps Green/Red/Yellow/Blue/Orange to A/B/Y/X/LB, strum up/down to D-pad, star power to Select, whammy to leftTrigger; no dedicated tilt axis. Menu includes left-stick and D-pad."
|
||||
},
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
|
||||
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
|
||||
}
|
||||
],
|
||||
"reason": "YARG's CRKD Mode 1 Strum up binding is translated through Chromium's XInput button order. Factory button aliases are inferred, not a browser dump."
|
||||
}
|
||||
},
|
||||
"Strum down": {
|
||||
"kind": "button",
|
||||
"index": 13,
|
||||
"pressedThreshold": 0,
|
||||
"evidence": {
|
||||
"status": "inferred",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://github.com/YARC-Official/YARG/blob/f5bfbe6996b4af35722d4204a93c6cc5c8f52a45/Assets/Script/Input/Bindings/Defaults/BindingCollection.Gamepad.cs",
|
||||
"says": "CrkdGuitar_Mode1 maps Green/Red/Yellow/Blue/Orange to A/B/Y/X/LB, strum up/down to D-pad, star power to Select, whammy to leftTrigger; no dedicated tilt axis. Menu includes left-stick and D-pad."
|
||||
},
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
|
||||
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
|
||||
}
|
||||
],
|
||||
"reason": "YARG's CRKD Mode 1 Strum down binding is translated through Chromium's XInput button order. Factory button aliases are inferred, not a browser dump."
|
||||
}
|
||||
},
|
||||
"CTRL": {
|
||||
"kind": "button",
|
||||
"index": 8,
|
||||
"pressedThreshold": 0,
|
||||
"evidence": {
|
||||
"status": "inferred",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://github.com/YARC-Official/YARG/blob/f5bfbe6996b4af35722d4204a93c6cc5c8f52a45/Assets/Script/Input/Bindings/Defaults/BindingCollection.Gamepad.cs",
|
||||
"says": "CrkdGuitar_Mode1 maps Green/Red/Yellow/Blue/Orange to A/B/Y/X/LB, strum up/down to D-pad, star power to Select, whammy to leftTrigger; no dedicated tilt axis. Menu includes left-stick and D-pad."
|
||||
},
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
|
||||
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
|
||||
}
|
||||
],
|
||||
"reason": "YARG's CRKD Mode 1 star-power/select binding is translated through Chromium's XInput button order. Factory button aliases are inferred, not a browser dump."
|
||||
}
|
||||
},
|
||||
"View": {
|
||||
"kind": "button",
|
||||
"index": 8,
|
||||
"pressedThreshold": 0,
|
||||
"evidence": {
|
||||
"status": "inferred",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://github.com/YARC-Official/YARG/blob/f5bfbe6996b4af35722d4204a93c6cc5c8f52a45/Assets/Script/Input/Bindings/Defaults/BindingCollection.Gamepad.cs",
|
||||
"says": "CrkdGuitar_Mode1 maps Green/Red/Yellow/Blue/Orange to A/B/Y/X/LB, strum up/down to D-pad, star power to Select, whammy to leftTrigger; no dedicated tilt axis. Menu includes left-stick and D-pad."
|
||||
},
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
|
||||
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
|
||||
}
|
||||
],
|
||||
"reason": "YARG's CRKD Mode 1 View binding is translated through Chromium's XInput button order. Factory button aliases are inferred, not a browser dump."
|
||||
}
|
||||
},
|
||||
"Menu": {
|
||||
"kind": "button",
|
||||
"index": 9,
|
||||
"pressedThreshold": 0,
|
||||
"evidence": {
|
||||
"status": "inferred",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://github.com/YARC-Official/YARG/blob/f5bfbe6996b4af35722d4204a93c6cc5c8f52a45/Assets/Script/Input/Bindings/Defaults/BindingCollection.Gamepad.cs",
|
||||
"says": "CrkdGuitar_Mode1 maps Green/Red/Yellow/Blue/Orange to A/B/Y/X/LB, strum up/down to D-pad, star power to Select, whammy to leftTrigger; no dedicated tilt axis. Menu includes left-stick and D-pad."
|
||||
},
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
|
||||
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
|
||||
}
|
||||
],
|
||||
"reason": "YARG's CRKD Mode 1 Menu binding is translated through Chromium's XInput button order. Factory button aliases are inferred, not a browser dump."
|
||||
}
|
||||
},
|
||||
"D-pad up": {
|
||||
"kind": "button",
|
||||
"index": 12,
|
||||
"pressedThreshold": 0,
|
||||
"evidence": {
|
||||
"status": "inferred",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://github.com/YARC-Official/YARG/blob/f5bfbe6996b4af35722d4204a93c6cc5c8f52a45/Assets/Script/Input/Bindings/Defaults/BindingCollection.Gamepad.cs",
|
||||
"says": "CrkdGuitar_Mode1 maps Green/Red/Yellow/Blue/Orange to A/B/Y/X/LB, strum up/down to D-pad, star power to Select, whammy to leftTrigger; no dedicated tilt axis. Menu includes left-stick and D-pad."
|
||||
},
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
|
||||
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
|
||||
}
|
||||
],
|
||||
"reason": "YARG's CRKD Mode 1 D-pad up binding is translated through Chromium's XInput button order. Factory button aliases are inferred, not a browser dump."
|
||||
}
|
||||
},
|
||||
"D-pad down": {
|
||||
"kind": "button",
|
||||
"index": 13,
|
||||
"pressedThreshold": 0,
|
||||
"evidence": {
|
||||
"status": "inferred",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://github.com/YARC-Official/YARG/blob/f5bfbe6996b4af35722d4204a93c6cc5c8f52a45/Assets/Script/Input/Bindings/Defaults/BindingCollection.Gamepad.cs",
|
||||
"says": "CrkdGuitar_Mode1 maps Green/Red/Yellow/Blue/Orange to A/B/Y/X/LB, strum up/down to D-pad, star power to Select, whammy to leftTrigger; no dedicated tilt axis. Menu includes left-stick and D-pad."
|
||||
},
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
|
||||
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
|
||||
}
|
||||
],
|
||||
"reason": "YARG's CRKD Mode 1 D-pad down binding is translated through Chromium's XInput button order. Factory button aliases are inferred, not a browser dump."
|
||||
}
|
||||
},
|
||||
"D-pad left": {
|
||||
"kind": "button",
|
||||
"index": 14,
|
||||
"pressedThreshold": 0,
|
||||
"evidence": {
|
||||
"status": "inferred",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://github.com/YARC-Official/YARG/blob/f5bfbe6996b4af35722d4204a93c6cc5c8f52a45/Assets/Script/Input/Bindings/Defaults/BindingCollection.Gamepad.cs",
|
||||
"says": "CrkdGuitar_Mode1 maps Green/Red/Yellow/Blue/Orange to A/B/Y/X/LB, strum up/down to D-pad, star power to Select, whammy to leftTrigger; no dedicated tilt axis. Menu includes left-stick and D-pad."
|
||||
},
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
|
||||
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
|
||||
}
|
||||
],
|
||||
"reason": "YARG's CRKD Mode 1 D-pad left binding is translated through Chromium's XInput button order. Factory button aliases are inferred, not a browser dump."
|
||||
}
|
||||
},
|
||||
"D-pad right": {
|
||||
"kind": "button",
|
||||
"index": 15,
|
||||
"pressedThreshold": 0,
|
||||
"evidence": {
|
||||
"status": "inferred",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://github.com/YARC-Official/YARG/blob/f5bfbe6996b4af35722d4204a93c6cc5c8f52a45/Assets/Script/Input/Bindings/Defaults/BindingCollection.Gamepad.cs",
|
||||
"says": "CrkdGuitar_Mode1 maps Green/Red/Yellow/Blue/Orange to A/B/Y/X/LB, strum up/down to D-pad, star power to Select, whammy to leftTrigger; no dedicated tilt axis. Menu includes left-stick and D-pad."
|
||||
},
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
|
||||
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
|
||||
}
|
||||
],
|
||||
"reason": "YARG's CRKD Mode 1 D-pad right binding is translated through Chromium's XInput button order. Factory button aliases are inferred, not a browser dump."
|
||||
}
|
||||
},
|
||||
"Whammy": {
|
||||
"kind": "button",
|
||||
"index": 6,
|
||||
"steps": 255,
|
||||
"pressedThreshold": 0.11764705882352941,
|
||||
"pressedInclusive": true,
|
||||
"evidence": {
|
||||
"status": "inferred",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://github.com/YARC-Official/YARG/blob/f5bfbe6996b4af35722d4204a93c6cc5c8f52a45/Assets/Script/Input/Bindings/Defaults/BindingCollection.Gamepad.cs",
|
||||
"says": "CrkdGuitar_Mode1 maps Green/Red/Yellow/Blue/Orange to A/B/Y/X/LB, strum up/down to D-pad, star power to Select, whammy to leftTrigger; no dedicated tilt axis. Menu includes left-stick and D-pad."
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/sanjay900/xinputemu/pull/1",
|
||||
"says": "Patch author tested two CRKD guitars; VID/PID match a first-party 360 gamepad (045e:028e in patch); whammy is Z/LT, converted to signed right-stick range for old PC guitar games."
|
||||
},
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
|
||||
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
|
||||
}
|
||||
],
|
||||
"reason": "YARG binds CRKD Mode 1 whammy to LT; Chromium exposes LT as analog button 6, divided by 255, pressed inclusively at byte 30. No -1 whammy axis is claimed."
|
||||
},
|
||||
"analog": true
|
||||
},
|
||||
"Left stick": {
|
||||
"kind": "stick",
|
||||
"x": 0,
|
||||
"y": 1,
|
||||
"evidence": {
|
||||
"status": "inferred",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://github.com/YARC-Official/YARG/blob/f5bfbe6996b4af35722d4204a93c6cc5c8f52a45/Assets/Script/Input/Bindings/Defaults/BindingCollection.Gamepad.cs",
|
||||
"says": "CrkdGuitar_Mode1 maps Green/Red/Yellow/Blue/Orange to A/B/Y/X/LB, strum up/down to D-pad, star power to Select, whammy to leftTrigger; no dedicated tilt axis. Menu includes left-stick and D-pad."
|
||||
},
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
|
||||
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
|
||||
}
|
||||
],
|
||||
"reason": "YARG's CRKD Mode 1 menu accepts the left stick; Chromium's XInput axes 0/1 carry LX/inverted LY."
|
||||
}
|
||||
},
|
||||
"Right stick": {
|
||||
"kind": "stick",
|
||||
"x": 2,
|
||||
"y": 3,
|
||||
"evidence": {
|
||||
"status": "inferred",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://cdn.shopify.com/s/files/1/0679/3392/9791/files/Guitar_User_Guide_Multi_V1.4_08Jan26.pdf?v=1767902895",
|
||||
"says": "CK25GM PC connections include USB cable and included USB wireless receiver; PC slider and mode dial select game preset; the hardware includes both sticks, View/Capture/Menu, CTRL, Home and D-pad."
|
||||
},
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
|
||||
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
|
||||
}
|
||||
],
|
||||
"reason": "Manufacturer identifies a right analog stick. Standard XInput axes 2/3 carry RX/inverted RY; its factory operation in this earlier Mode 1 preset is inferred."
|
||||
}
|
||||
},
|
||||
"L3": {
|
||||
"kind": "button",
|
||||
"index": 10,
|
||||
"pressedThreshold": 0,
|
||||
"evidence": {
|
||||
"status": "inferred",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://cdn.shopify.com/s/files/1/0679/3392/9791/files/Guitar_User_Guide_Multi_V1.4_08Jan26.pdf?v=1767902895",
|
||||
"says": "CK25GM PC connections include USB cable and included USB wireless receiver; PC slider and mode dial select game preset; the hardware includes both sticks, View/Capture/Menu, CTRL, Home and D-pad."
|
||||
},
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
|
||||
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
|
||||
}
|
||||
],
|
||||
"reason": "Manufacturer names this physical control; its normal XInput button correspondence is inferred from the standard browser path, not observed on hardware."
|
||||
}
|
||||
},
|
||||
"R3": {
|
||||
"kind": "button",
|
||||
"index": 11,
|
||||
"pressedThreshold": 0,
|
||||
"evidence": {
|
||||
"status": "inferred",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://cdn.shopify.com/s/files/1/0679/3392/9791/files/Guitar_User_Guide_Multi_V1.4_08Jan26.pdf?v=1767902895",
|
||||
"says": "CK25GM PC connections include USB cable and included USB wireless receiver; PC slider and mode dial select game preset; the hardware includes both sticks, View/Capture/Menu, CTRL, Home and D-pad."
|
||||
},
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
|
||||
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
|
||||
}
|
||||
],
|
||||
"reason": "Manufacturer names this physical control; its normal XInput button correspondence is inferred from the standard browser path, not observed on hardware."
|
||||
}
|
||||
},
|
||||
"CRKD Home": {
|
||||
"kind": "button",
|
||||
"index": 16,
|
||||
"pressedThreshold": 0,
|
||||
"evidence": {
|
||||
"status": "inferred",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://cdn.shopify.com/s/files/1/0679/3392/9791/files/Guitar_User_Guide_Multi_V1.4_08Jan26.pdf?v=1767902895",
|
||||
"says": "CK25GM PC connections include USB cable and included USB wireless receiver; PC slider and mode dial select game preset; the hardware includes both sticks, View/Capture/Menu, CTRL, Home and D-pad."
|
||||
},
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
|
||||
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
|
||||
}
|
||||
],
|
||||
"reason": "Manufacturer names this physical control; its normal XInput button correspondence is inferred from the standard browser path, not observed on hardware."
|
||||
}
|
||||
},
|
||||
"Capture": {
|
||||
"kind": "unreported",
|
||||
"reason": "No independent XInput capture bit exists; the factory action or alias was not found.",
|
||||
"evidence": {
|
||||
"status": "inferred",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://cdn.shopify.com/s/files/1/0679/3392/9791/files/Guitar_User_Guide_Multi_V1.4_08Jan26.pdf?v=1767902895",
|
||||
"says": "CK25GM PC connections include USB cable and included USB wireless receiver; PC slider and mode dial select game preset; the hardware includes both sticks, View/Capture/Menu, CTRL, Home and D-pad."
|
||||
},
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/xinput_data_fetcher_win.cc",
|
||||
"says": "XInput fetcher uses STANDARD GAMEPAD for subtype 1; 17 buttons when XInputGetStateEx exists; four axes LX, inverted LY, RX, inverted RY; triggers are analog buttons; exposes dual-rumble unconditionally."
|
||||
}
|
||||
],
|
||||
"reason": "The manual confirms Capture exists physically but its PC report is not established; no browser index is invented."
|
||||
}
|
||||
}
|
||||
},
|
||||
"switches": {
|
||||
"Mode dial": {
|
||||
"kind": "unreported",
|
||||
"reason": "Only the earlier PC Mode 1 mapping is sourced. Factory presets, firmware 3 and legacy Mode 8 change reports and need separately researched profiles.",
|
||||
"evidence": {
|
||||
"value": null,
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://cdn.shopify.com/s/files/1/0679/3392/9791/files/Guitar_User_Guide_Multi_V1.4_08Jan26.pdf?v=1767902895",
|
||||
"says": "CK25GM PC connections include USB cable and included USB wireless receiver; PC slider and mode dial select game preset; the hardware includes both sticks, View/Capture/Menu, CTRL, Home and D-pad."
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/YARC-Official/YARG/blob/f5bfbe6996b4af35722d4204a93c6cc5c8f52a45/Assets/Script/Input/Bindings/Defaults/BindingCollection.Gamepad.cs",
|
||||
"says": "CrkdGuitar_Mode1 maps Green/Red/Yellow/Blue/Orange to A/B/Y/X/LB, strum up/down to D-pad, star power to Select, whammy to leftTrigger; no dedicated tilt axis. Menu includes left-stick and D-pad."
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"Platform slider": {
|
||||
"kind": "unreported",
|
||||
"reason": "PC Mode 1 only. NS, AN and P3 identifiers and raw reports have not been established.",
|
||||
"evidence": {
|
||||
"value": null,
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://cdn.shopify.com/s/files/1/0679/3392/9791/files/Guitar_User_Guide_Multi_V1.4_08Jan26.pdf?v=1767902895",
|
||||
"says": "CK25GM PC connections include USB cable and included USB wireless receiver; PC slider and mode dial select game preset; the hardware includes both sticks, View/Capture/Menu, CTRL, Home and D-pad."
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"notes": [
|
||||
"Local source-derived emulation, not a hardware/browser capture. This profile models the earlier CRKD Mode 1 preset identified by YARG as CrkdGuitar_Mode1, before firmware 3 changed star power to D-pad left.",
|
||||
"Use the Multi-platform Gibson Les Paul CK25GM; Nitro Deck is a separate Switch controller product.",
|
||||
"Stock Windows XInputGetStateEx availability is assumed for button 16. Without it Chromium exposes 16 buttons; that separate variant is omitted.",
|
||||
"All unpressed buttons, including whammy and unused RT, are modeled at zero; those physical resting bytes are inferred. The tested conversion patch supports whammy LT with rest zero, but no raw browser rest dump was located.",
|
||||
"Whammy is analog button 6 (0..1), not an axis. D-pad and strum share standard button indices; there is no numeric hat axis or independent tilt axis in this preset.",
|
||||
"Browser indices: 0 A/Green; 1 B/Red; 2 X/Blue; 3 Y/Yellow; 4 LB/Orange; 5 RB (physical alias unconfirmed); 6 LT/Whammy; 7 RT (physical alias unconfirmed); 8 View/Select/CTRL inferred; 9 Menu/Start; 10 L3; 11 R3; 12 D-pad/Strum up; 13 D-pad/Strum down; 14 D-pad left; 15 D-pad right; 16 Home/Guide. Axes 0/1 Left stick; 2/3 Right stick, nominal rest inferred.",
|
||||
"The dual-rumble object is Chromium's unconditional XInput API exposure. It does not confirm browser-controlled physical vibration or equate to the guitar's local strum haptics.",
|
||||
"Chromium XInput id hides VID/PID. Hardware-tested old Mode 1 uses 045e:028e; newer native CRKD IDs from Linux/SDL are not transplanted into this earlier profile.",
|
||||
"Factory names whose report is unknown are omitted or explicitly unreported. Capture, firmware/current platform modes and remapped presets need real captures."
|
||||
],
|
||||
"environment": {
|
||||
"browser": "Chrome / Edge (Chromium XInput path)",
|
||||
"os": "Windows 10 / 11",
|
||||
"connection": "USB",
|
||||
"mode": "PC slider; Mode 1; earlier firmware preset"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,566 @@
|
||||
{
|
||||
"name": "elite2-chromium-win-adapter-wgi-conditional",
|
||||
"label": "Xbox Elite Series 2 — Xbox Wireless Adapter / Chromium WGI (conditional paddles)",
|
||||
"environment": {
|
||||
"browser": "Chrome / Edge (Chromium WGI backend)",
|
||||
"os": "Windows 10 RS1+ / Windows 11",
|
||||
"connection": "Xbox Wireless Adapter",
|
||||
"mode": "Default hardware profile; four WGI paddle labels assumed",
|
||||
"backend": "Windows.Gaming.Input",
|
||||
"status": "inferred",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://www.xbox.com/en-US/accessories/controllers/elite-wireless-controller-series-2",
|
||||
"says": "Windows 10/11 supports USB, Bluetooth and Xbox Wireless Adapter."
|
||||
}
|
||||
],
|
||||
"reason": "Browser source is confirmed; controller-specific WGI paddle labels were not captured."
|
||||
},
|
||||
"hardwareIds": {
|
||||
"value": {
|
||||
"vendor": null,
|
||||
"product": null
|
||||
},
|
||||
"status": "inferred",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://www.xbox.com/en-US/accessories/controllers/elite-wireless-controller-series-2",
|
||||
"says": "Xbox Wireless Adapter is a supported connection."
|
||||
}
|
||||
],
|
||||
"reason": "No RawGameController VID/PID dump for adapter was found; adapter USB PID is not asserted to be the gamepad PID."
|
||||
},
|
||||
"gamepad": {
|
||||
"timestampPolicy": {
|
||||
"value": "poll",
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
|
||||
"says": "GetGamepadData updates timestamp on each successful WGI reading."
|
||||
}
|
||||
]
|
||||
},
|
||||
"id": {
|
||||
"value": "Xbox 360 Controller (XInput STANDARD GAMEPAD)",
|
||||
"status": "inferred",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
|
||||
"says": "Known XInput devices retain this literal ID."
|
||||
},
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/gamepad_id_list.cc",
|
||||
"says": "045e:0b00 has XInput type XboxOne."
|
||||
}
|
||||
],
|
||||
"reason": "Assumes the adapter exposes the physical Elite via a known-XInput product ID, as expected for GIP. No adapter browser dump was found."
|
||||
},
|
||||
"mapping": {
|
||||
"value": "standard",
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
|
||||
"says": "WGI fetcher always sets standard mapping."
|
||||
}
|
||||
]
|
||||
},
|
||||
"buttons": {
|
||||
"value": 21,
|
||||
"status": "inferred",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
|
||||
"says": "Length is standard count (17) plus number of non-None paddle labels."
|
||||
},
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/gamepad_standard_mappings.h",
|
||||
"says": "Standard button count is 17."
|
||||
},
|
||||
{
|
||||
"url": "https://www.xbox.com/en-US/accessories/controllers/elite-wireless-controller-series-2",
|
||||
"says": "Controller has four paddles."
|
||||
}
|
||||
],
|
||||
"reason": "Conditional 17+4 branch. Four physical paddles do not prove four WGI labels; this is not an exact Elite hardware clone."
|
||||
},
|
||||
"axesRest": {
|
||||
"value": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"status": "inferred",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
|
||||
"says": "Four axes contain the two sticks."
|
||||
},
|
||||
{
|
||||
"url": "https://learn.microsoft.com/en-us/windows/uwp/gaming/gamepad-and-vibration",
|
||||
"says": "Centered sticks approximately report zero and can drift."
|
||||
}
|
||||
],
|
||||
"reason": "Ideal zero-center fixture; an actual resting sample varies and was not captured. Triggers are buttons 6/7, not extra axes."
|
||||
},
|
||||
"vibration": {
|
||||
"value": {
|
||||
"type": "dual-rumble",
|
||||
"effects": [
|
||||
"trigger-rumble",
|
||||
"dual-rumble"
|
||||
]
|
||||
},
|
||||
"status": "inferred",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
|
||||
"says": "WGI actuator is non-null, with internal trigger rumble for supported IDs."
|
||||
},
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/gamepad_id_list.cc",
|
||||
"says": "Elite Series 2 USB/Bluetooth IDs are in trigger-rumble support set."
|
||||
},
|
||||
{
|
||||
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/third_party/blink/renderer/modules/gamepad/gamepad_haptic_actuator.cc",
|
||||
"says": "Trigger rumble publishes type dual-rumble and both supported effects."
|
||||
}
|
||||
],
|
||||
"reason": "Conditional on the adapter exposing a recognized Elite controller ID in Chromium's trigger-rumble set. The adapter's own USB PID is not used as a controller PID; an exact adapter browser dump was not found.",
|
||||
"presence": "object"
|
||||
}
|
||||
},
|
||||
"controls": {
|
||||
"A": {
|
||||
"kind": "button",
|
||||
"index": 0,
|
||||
"evidence": {
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
|
||||
"says": "Browser backend sends A to standard button 0."
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"B": {
|
||||
"kind": "button",
|
||||
"index": 1,
|
||||
"evidence": {
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
|
||||
"says": "Browser backend sends B to standard button 1."
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"X": {
|
||||
"kind": "button",
|
||||
"index": 2,
|
||||
"evidence": {
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
|
||||
"says": "Browser backend sends X to standard button 2."
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"Y": {
|
||||
"kind": "button",
|
||||
"index": 3,
|
||||
"evidence": {
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
|
||||
"says": "Browser backend sends Y to standard button 3."
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"Left bumper": {
|
||||
"kind": "button",
|
||||
"index": 4,
|
||||
"evidence": {
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
|
||||
"says": "Browser backend sends Left bumper to standard button 4."
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"Right bumper": {
|
||||
"kind": "button",
|
||||
"index": 5,
|
||||
"evidence": {
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
|
||||
"says": "Browser backend sends Right bumper to standard button 5."
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"Left trigger": {
|
||||
"kind": "button",
|
||||
"index": 6,
|
||||
"evidence": {
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
|
||||
"says": "Trigger 6 copies WGI 0..1 reading; pressed above 30/255."
|
||||
},
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/public/cpp/gamepad.h",
|
||||
"says": "Chromium default pressed threshold is 30/255."
|
||||
}
|
||||
]
|
||||
},
|
||||
"analog": true,
|
||||
"pressedThreshold": 0.11764705882352941,
|
||||
"pressedInclusive": false
|
||||
},
|
||||
"Right trigger": {
|
||||
"kind": "button",
|
||||
"index": 7,
|
||||
"evidence": {
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
|
||||
"says": "Trigger 7 copies WGI 0..1 reading; pressed above 30/255."
|
||||
},
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/public/cpp/gamepad.h",
|
||||
"says": "Chromium default pressed threshold is 30/255."
|
||||
}
|
||||
]
|
||||
},
|
||||
"analog": true,
|
||||
"pressedThreshold": 0.11764705882352941,
|
||||
"pressedInclusive": false
|
||||
},
|
||||
"View": {
|
||||
"kind": "button",
|
||||
"index": 8,
|
||||
"evidence": {
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
|
||||
"says": "Browser backend sends View to standard button 8."
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"Menu": {
|
||||
"kind": "button",
|
||||
"index": 9,
|
||||
"evidence": {
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
|
||||
"says": "Browser backend sends Menu to standard button 9."
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"Left stick click": {
|
||||
"kind": "button",
|
||||
"index": 10,
|
||||
"evidence": {
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
|
||||
"says": "Browser backend sends Left stick click to standard button 10."
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"Right stick click": {
|
||||
"kind": "button",
|
||||
"index": 11,
|
||||
"evidence": {
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
|
||||
"says": "Browser backend sends Right stick click to standard button 11."
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"D-pad up": {
|
||||
"kind": "button",
|
||||
"index": 12,
|
||||
"evidence": {
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
|
||||
"says": "Browser backend sends D-pad up to standard button 12."
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"D-pad down": {
|
||||
"kind": "button",
|
||||
"index": 13,
|
||||
"evidence": {
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
|
||||
"says": "Browser backend sends D-pad down to standard button 13."
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"D-pad left": {
|
||||
"kind": "button",
|
||||
"index": 14,
|
||||
"evidence": {
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
|
||||
"says": "Browser backend sends D-pad left to standard button 14."
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"D-pad right": {
|
||||
"kind": "button",
|
||||
"index": 15,
|
||||
"evidence": {
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
|
||||
"says": "Browser backend sends D-pad right to standard button 15."
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"Xbox": {
|
||||
"kind": "button",
|
||||
"index": 16,
|
||||
"evidence": {
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
|
||||
"says": "Browser backend sends Xbox to standard button 16."
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"Left stick": {
|
||||
"kind": "stick",
|
||||
"x": 0,
|
||||
"y": 1,
|
||||
"evidence": {
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
|
||||
"says": "Axes 0/1 are left X/Y; Y is inverted for down-positive."
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"Right stick": {
|
||||
"kind": "stick",
|
||||
"x": 2,
|
||||
"y": 3,
|
||||
"evidence": {
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
|
||||
"says": "Axes 2/3 are right X/Y; Y is inverted for down-positive."
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"P1": {
|
||||
"kind": "button",
|
||||
"index": 17,
|
||||
"evidence": {
|
||||
"status": "inferred",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
|
||||
"says": "Paddle1 is read at button 17 when exposed by WGI."
|
||||
},
|
||||
{
|
||||
"url": "https://www.xbox.com/en-US/accessories/controllers/elite-wireless-controller-series-2",
|
||||
"says": "Controller has four removable paddles."
|
||||
}
|
||||
],
|
||||
"reason": "Conditional: physical Elite Series 2 paddle labels/readings from IGamepad2 were not found; this exercises the browser branch, not a verified hardware report."
|
||||
}
|
||||
},
|
||||
"P2": {
|
||||
"kind": "button",
|
||||
"index": 18,
|
||||
"evidence": {
|
||||
"status": "inferred",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
|
||||
"says": "Paddle2 is read at button 18 when exposed by WGI."
|
||||
},
|
||||
{
|
||||
"url": "https://www.xbox.com/en-US/accessories/controllers/elite-wireless-controller-series-2",
|
||||
"says": "Controller has four removable paddles."
|
||||
}
|
||||
],
|
||||
"reason": "Conditional: physical Elite Series 2 paddle labels/readings from IGamepad2 were not found; this exercises the browser branch, not a verified hardware report."
|
||||
}
|
||||
},
|
||||
"P3": {
|
||||
"kind": "button",
|
||||
"index": 19,
|
||||
"evidence": {
|
||||
"status": "inferred",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
|
||||
"says": "Paddle3 is read at button 19 when exposed by WGI."
|
||||
},
|
||||
{
|
||||
"url": "https://www.xbox.com/en-US/accessories/controllers/elite-wireless-controller-series-2",
|
||||
"says": "Controller has four removable paddles."
|
||||
}
|
||||
],
|
||||
"reason": "Conditional: physical Elite Series 2 paddle labels/readings from IGamepad2 were not found; this exercises the browser branch, not a verified hardware report."
|
||||
}
|
||||
},
|
||||
"P4": {
|
||||
"kind": "button",
|
||||
"index": 20,
|
||||
"evidence": {
|
||||
"status": "inferred",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
|
||||
"says": "Paddle4 is read at button 20 when exposed by WGI."
|
||||
},
|
||||
{
|
||||
"url": "https://www.xbox.com/en-US/accessories/controllers/elite-wireless-controller-series-2",
|
||||
"says": "Controller has four removable paddles."
|
||||
}
|
||||
],
|
||||
"reason": "Conditional: physical Elite Series 2 paddle labels/readings from IGamepad2 were not found; this exercises the browser branch, not a verified hardware report."
|
||||
}
|
||||
},
|
||||
"Profile button": {
|
||||
"kind": "unreported",
|
||||
"evidence": {
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
|
||||
"says": "Backend maps standard controls, not an Elite profile selector."
|
||||
},
|
||||
{
|
||||
"url": "https://news.xbox.com/en-us/2019/06/09/meet-the-xbox-elite-wireless-controller-series-2-over-30-new-ways-to-play-like-a-pro/",
|
||||
"says": "Dedicated button switches default and three saved custom profiles."
|
||||
}
|
||||
]
|
||||
},
|
||||
"reason": "Stored profiles are programmable and their contents are unknown. The selector has no distinct button index here."
|
||||
}
|
||||
},
|
||||
"switches": {
|
||||
"Left trigger lock": {
|
||||
"kind": "state",
|
||||
"values": [
|
||||
"full",
|
||||
"medium",
|
||||
"short"
|
||||
],
|
||||
"effect": "none",
|
||||
"evidence": {
|
||||
"status": "inferred",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://news.xbox.com/en-us/2019/11/07/xbox-elite-wireless-controller-series-2-available-now/",
|
||||
"says": "Elite Series 2 has three levels of trigger depth."
|
||||
},
|
||||
{
|
||||
"url": "https://news.xbox.com/en-us/2019/06/09/meet-the-xbox-elite-wireless-controller-series-2-over-30-new-ways-to-play-like-a-pro/",
|
||||
"says": "Controller automatically recognizes trigger-lock adjustments."
|
||||
},
|
||||
{
|
||||
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
|
||||
"says": "Browser exposes normalized trigger values, with no trigger-lock state field."
|
||||
}
|
||||
],
|
||||
"reason": "This switch records physical travel only. set() supplies the browser's normalized value, so no guessed travel-to-value curve is applied."
|
||||
}
|
||||
},
|
||||
"Right trigger lock": {
|
||||
"kind": "state",
|
||||
"values": [
|
||||
"full",
|
||||
"medium",
|
||||
"short"
|
||||
],
|
||||
"effect": "none",
|
||||
"evidence": {
|
||||
"status": "inferred",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://news.xbox.com/en-us/2019/11/07/xbox-elite-wireless-controller-series-2-available-now/",
|
||||
"says": "Elite Series 2 has three levels of trigger depth."
|
||||
},
|
||||
{
|
||||
"url": "https://news.xbox.com/en-us/2019/06/09/meet-the-xbox-elite-wireless-controller-series-2-over-30-new-ways-to-play-like-a-pro/",
|
||||
"says": "Controller automatically recognizes trigger-lock adjustments."
|
||||
},
|
||||
{
|
||||
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
|
||||
"says": "Browser exposes normalized trigger values, with no trigger-lock state field."
|
||||
}
|
||||
],
|
||||
"reason": "This switch records physical travel only. set() supplies the browser's normalized value, so no guessed travel-to-value curve is applied."
|
||||
}
|
||||
},
|
||||
"Profile button": {
|
||||
"kind": "unreported",
|
||||
"evidence": {
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://news.xbox.com/en-us/2019/06/09/meet-the-xbox-elite-wireless-controller-series-2-over-30-new-ways-to-play-like-a-pro/",
|
||||
"says": "Dedicated button selects default or one of three programmable profiles."
|
||||
}
|
||||
]
|
||||
},
|
||||
"reason": "The stored remapping slots and firmware are not known. No invented paddle aliases or profile-button signal is emitted."
|
||||
}
|
||||
},
|
||||
"notes": [
|
||||
"Source-derived conditional browser fixture; no real-device Gamepad API dump was found. Never treat passing tests as hardware confirmation.",
|
||||
"Buttons 0–16 and axes 0–3 follow the standard layout; P1–P4 conditionally occupy 17–20. No hat axis: D-pad is buttons 12–15.",
|
||||
"All released button values are 0, pressed/touched false. Trigger value 0 is fully released and 1 fully pulled.",
|
||||
"The Xbox/Guide button is redirected to the lowest-index WGI gamepad using XInput; tests connect this profile first.",
|
||||
"Hardware trigger stops change travel; exact analog transfer curves and stored profile contents remain unknown.",
|
||||
"Chromium source default enables WGI; Windows GameInput fetcher is feature-gated and disabled by default in the inspected source. Edge-specific overrides were not found."
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,563 @@
|
||||
{
|
||||
"name": "elite2-chromium-win-usb-wgi-conditional",
|
||||
"label": "Xbox Elite Series 2 — USB / Chromium WGI (conditional paddles)",
|
||||
"environment": {
|
||||
"browser": "Chrome / Edge (Chromium WGI backend)",
|
||||
"os": "Windows 10 RS1+ / Windows 11",
|
||||
"connection": "USB",
|
||||
"mode": "Default hardware profile; four WGI paddle labels assumed",
|
||||
"backend": "Windows.Gaming.Input",
|
||||
"status": "inferred",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://www.xbox.com/en-US/accessories/controllers/elite-wireless-controller-series-2",
|
||||
"says": "Windows 10/11 supports USB, Bluetooth and Xbox Wireless Adapter."
|
||||
}
|
||||
],
|
||||
"reason": "Browser source is confirmed; controller-specific WGI paddle labels were not captured."
|
||||
},
|
||||
"hardwareIds": {
|
||||
"value": {
|
||||
"vendor": "045e",
|
||||
"product": "0b00"
|
||||
},
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/gamepad_id_list.cc",
|
||||
"says": "045e:0b00 is classified as Xbox One XInput; trigger-rumble list identifies Elite Series 2 USB."
|
||||
}
|
||||
]
|
||||
},
|
||||
"gamepad": {
|
||||
"timestampPolicy": {
|
||||
"value": "poll",
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
|
||||
"says": "GetGamepadData updates timestamp on each successful WGI reading."
|
||||
}
|
||||
]
|
||||
},
|
||||
"id": {
|
||||
"value": "Xbox 360 Controller (XInput STANDARD GAMEPAD)",
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
|
||||
"says": "Known XInput devices retain this literal ID."
|
||||
},
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/gamepad_id_list.cc",
|
||||
"says": "045e:0b00 has XInput type XboxOne."
|
||||
}
|
||||
]
|
||||
},
|
||||
"mapping": {
|
||||
"value": "standard",
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
|
||||
"says": "WGI fetcher always sets standard mapping."
|
||||
}
|
||||
]
|
||||
},
|
||||
"buttons": {
|
||||
"value": 21,
|
||||
"status": "inferred",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
|
||||
"says": "Length is standard count (17) plus number of non-None paddle labels."
|
||||
},
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/gamepad_standard_mappings.h",
|
||||
"says": "Standard button count is 17."
|
||||
},
|
||||
{
|
||||
"url": "https://www.xbox.com/en-US/accessories/controllers/elite-wireless-controller-series-2",
|
||||
"says": "Controller has four paddles."
|
||||
}
|
||||
],
|
||||
"reason": "Conditional 17+4 branch. Four physical paddles do not prove four WGI labels; this is not an exact Elite hardware clone."
|
||||
},
|
||||
"axesRest": {
|
||||
"value": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"status": "inferred",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
|
||||
"says": "Four axes contain the two sticks."
|
||||
},
|
||||
{
|
||||
"url": "https://learn.microsoft.com/en-us/windows/uwp/gaming/gamepad-and-vibration",
|
||||
"says": "Centered sticks approximately report zero and can drift."
|
||||
}
|
||||
],
|
||||
"reason": "Ideal zero-center fixture; an actual resting sample varies and was not captured. Triggers are buttons 6/7, not extra axes."
|
||||
},
|
||||
"vibration": {
|
||||
"value": {
|
||||
"type": "dual-rumble",
|
||||
"effects": [
|
||||
"trigger-rumble",
|
||||
"dual-rumble"
|
||||
]
|
||||
},
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
|
||||
"says": "WGI actuator is non-null, with internal trigger rumble for supported IDs."
|
||||
},
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/gamepad_id_list.cc",
|
||||
"says": "Elite Series 2 USB/Bluetooth IDs are in trigger-rumble support set."
|
||||
},
|
||||
{
|
||||
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/third_party/blink/renderer/modules/gamepad/gamepad_haptic_actuator.cc",
|
||||
"says": "Trigger rumble publishes type dual-rumble and both supported effects."
|
||||
}
|
||||
],
|
||||
"presence": "object"
|
||||
}
|
||||
},
|
||||
"controls": {
|
||||
"A": {
|
||||
"kind": "button",
|
||||
"index": 0,
|
||||
"evidence": {
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
|
||||
"says": "Browser backend sends A to standard button 0."
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"B": {
|
||||
"kind": "button",
|
||||
"index": 1,
|
||||
"evidence": {
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
|
||||
"says": "Browser backend sends B to standard button 1."
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"X": {
|
||||
"kind": "button",
|
||||
"index": 2,
|
||||
"evidence": {
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
|
||||
"says": "Browser backend sends X to standard button 2."
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"Y": {
|
||||
"kind": "button",
|
||||
"index": 3,
|
||||
"evidence": {
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
|
||||
"says": "Browser backend sends Y to standard button 3."
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"Left bumper": {
|
||||
"kind": "button",
|
||||
"index": 4,
|
||||
"evidence": {
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
|
||||
"says": "Browser backend sends Left bumper to standard button 4."
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"Right bumper": {
|
||||
"kind": "button",
|
||||
"index": 5,
|
||||
"evidence": {
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
|
||||
"says": "Browser backend sends Right bumper to standard button 5."
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"Left trigger": {
|
||||
"kind": "button",
|
||||
"index": 6,
|
||||
"evidence": {
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
|
||||
"says": "Trigger 6 copies WGI 0..1 reading; pressed above 30/255."
|
||||
},
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/public/cpp/gamepad.h",
|
||||
"says": "Chromium default pressed threshold is 30/255."
|
||||
}
|
||||
]
|
||||
},
|
||||
"analog": true,
|
||||
"pressedThreshold": 0.11764705882352941,
|
||||
"pressedInclusive": false
|
||||
},
|
||||
"Right trigger": {
|
||||
"kind": "button",
|
||||
"index": 7,
|
||||
"evidence": {
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
|
||||
"says": "Trigger 7 copies WGI 0..1 reading; pressed above 30/255."
|
||||
},
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/chromium/chromium/main/device/gamepad/public/cpp/gamepad.h",
|
||||
"says": "Chromium default pressed threshold is 30/255."
|
||||
}
|
||||
]
|
||||
},
|
||||
"analog": true,
|
||||
"pressedThreshold": 0.11764705882352941,
|
||||
"pressedInclusive": false
|
||||
},
|
||||
"View": {
|
||||
"kind": "button",
|
||||
"index": 8,
|
||||
"evidence": {
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
|
||||
"says": "Browser backend sends View to standard button 8."
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"Menu": {
|
||||
"kind": "button",
|
||||
"index": 9,
|
||||
"evidence": {
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
|
||||
"says": "Browser backend sends Menu to standard button 9."
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"Left stick click": {
|
||||
"kind": "button",
|
||||
"index": 10,
|
||||
"evidence": {
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
|
||||
"says": "Browser backend sends Left stick click to standard button 10."
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"Right stick click": {
|
||||
"kind": "button",
|
||||
"index": 11,
|
||||
"evidence": {
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
|
||||
"says": "Browser backend sends Right stick click to standard button 11."
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"D-pad up": {
|
||||
"kind": "button",
|
||||
"index": 12,
|
||||
"evidence": {
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
|
||||
"says": "Browser backend sends D-pad up to standard button 12."
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"D-pad down": {
|
||||
"kind": "button",
|
||||
"index": 13,
|
||||
"evidence": {
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
|
||||
"says": "Browser backend sends D-pad down to standard button 13."
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"D-pad left": {
|
||||
"kind": "button",
|
||||
"index": 14,
|
||||
"evidence": {
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
|
||||
"says": "Browser backend sends D-pad left to standard button 14."
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"D-pad right": {
|
||||
"kind": "button",
|
||||
"index": 15,
|
||||
"evidence": {
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
|
||||
"says": "Browser backend sends D-pad right to standard button 15."
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"Xbox": {
|
||||
"kind": "button",
|
||||
"index": 16,
|
||||
"evidence": {
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
|
||||
"says": "Browser backend sends Xbox to standard button 16."
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"Left stick": {
|
||||
"kind": "stick",
|
||||
"x": 0,
|
||||
"y": 1,
|
||||
"evidence": {
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
|
||||
"says": "Axes 0/1 are left X/Y; Y is inverted for down-positive."
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"Right stick": {
|
||||
"kind": "stick",
|
||||
"x": 2,
|
||||
"y": 3,
|
||||
"evidence": {
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
|
||||
"says": "Axes 2/3 are right X/Y; Y is inverted for down-positive."
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"P1": {
|
||||
"kind": "button",
|
||||
"index": 17,
|
||||
"evidence": {
|
||||
"status": "inferred",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
|
||||
"says": "Paddle1 is read at button 17 when exposed by WGI."
|
||||
},
|
||||
{
|
||||
"url": "https://www.xbox.com/en-US/accessories/controllers/elite-wireless-controller-series-2",
|
||||
"says": "Controller has four removable paddles."
|
||||
}
|
||||
],
|
||||
"reason": "Conditional: physical Elite Series 2 paddle labels/readings from IGamepad2 were not found; this exercises the browser branch, not a verified hardware report."
|
||||
}
|
||||
},
|
||||
"P2": {
|
||||
"kind": "button",
|
||||
"index": 18,
|
||||
"evidence": {
|
||||
"status": "inferred",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
|
||||
"says": "Paddle2 is read at button 18 when exposed by WGI."
|
||||
},
|
||||
{
|
||||
"url": "https://www.xbox.com/en-US/accessories/controllers/elite-wireless-controller-series-2",
|
||||
"says": "Controller has four removable paddles."
|
||||
}
|
||||
],
|
||||
"reason": "Conditional: physical Elite Series 2 paddle labels/readings from IGamepad2 were not found; this exercises the browser branch, not a verified hardware report."
|
||||
}
|
||||
},
|
||||
"P3": {
|
||||
"kind": "button",
|
||||
"index": 19,
|
||||
"evidence": {
|
||||
"status": "inferred",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
|
||||
"says": "Paddle3 is read at button 19 when exposed by WGI."
|
||||
},
|
||||
{
|
||||
"url": "https://www.xbox.com/en-US/accessories/controllers/elite-wireless-controller-series-2",
|
||||
"says": "Controller has four removable paddles."
|
||||
}
|
||||
],
|
||||
"reason": "Conditional: physical Elite Series 2 paddle labels/readings from IGamepad2 were not found; this exercises the browser branch, not a verified hardware report."
|
||||
}
|
||||
},
|
||||
"P4": {
|
||||
"kind": "button",
|
||||
"index": 20,
|
||||
"evidence": {
|
||||
"status": "inferred",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
|
||||
"says": "Paddle4 is read at button 20 when exposed by WGI."
|
||||
},
|
||||
{
|
||||
"url": "https://www.xbox.com/en-US/accessories/controllers/elite-wireless-controller-series-2",
|
||||
"says": "Controller has four removable paddles."
|
||||
}
|
||||
],
|
||||
"reason": "Conditional: physical Elite Series 2 paddle labels/readings from IGamepad2 were not found; this exercises the browser branch, not a verified hardware report."
|
||||
}
|
||||
},
|
||||
"Profile button": {
|
||||
"kind": "unreported",
|
||||
"evidence": {
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
|
||||
"says": "Backend maps standard controls, not an Elite profile selector."
|
||||
},
|
||||
{
|
||||
"url": "https://news.xbox.com/en-us/2019/06/09/meet-the-xbox-elite-wireless-controller-series-2-over-30-new-ways-to-play-like-a-pro/",
|
||||
"says": "Dedicated button switches default and three saved custom profiles."
|
||||
}
|
||||
]
|
||||
},
|
||||
"reason": "Stored profiles are programmable and their contents are unknown. The selector has no distinct button index here."
|
||||
}
|
||||
},
|
||||
"switches": {
|
||||
"Left trigger lock": {
|
||||
"kind": "state",
|
||||
"values": [
|
||||
"full",
|
||||
"medium",
|
||||
"short"
|
||||
],
|
||||
"effect": "none",
|
||||
"evidence": {
|
||||
"status": "inferred",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://news.xbox.com/en-us/2019/11/07/xbox-elite-wireless-controller-series-2-available-now/",
|
||||
"says": "Elite Series 2 has three levels of trigger depth."
|
||||
},
|
||||
{
|
||||
"url": "https://news.xbox.com/en-us/2019/06/09/meet-the-xbox-elite-wireless-controller-series-2-over-30-new-ways-to-play-like-a-pro/",
|
||||
"says": "Controller automatically recognizes trigger-lock adjustments."
|
||||
},
|
||||
{
|
||||
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
|
||||
"says": "Browser exposes normalized trigger values, with no trigger-lock state field."
|
||||
}
|
||||
],
|
||||
"reason": "This switch records physical travel only. set() supplies the browser's normalized value, so no guessed travel-to-value curve is applied."
|
||||
}
|
||||
},
|
||||
"Right trigger lock": {
|
||||
"kind": "state",
|
||||
"values": [
|
||||
"full",
|
||||
"medium",
|
||||
"short"
|
||||
],
|
||||
"effect": "none",
|
||||
"evidence": {
|
||||
"status": "inferred",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://news.xbox.com/en-us/2019/11/07/xbox-elite-wireless-controller-series-2-available-now/",
|
||||
"says": "Elite Series 2 has three levels of trigger depth."
|
||||
},
|
||||
{
|
||||
"url": "https://news.xbox.com/en-us/2019/06/09/meet-the-xbox-elite-wireless-controller-series-2-over-30-new-ways-to-play-like-a-pro/",
|
||||
"says": "Controller automatically recognizes trigger-lock adjustments."
|
||||
},
|
||||
{
|
||||
"url": "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc",
|
||||
"says": "Browser exposes normalized trigger values, with no trigger-lock state field."
|
||||
}
|
||||
],
|
||||
"reason": "This switch records physical travel only. set() supplies the browser's normalized value, so no guessed travel-to-value curve is applied."
|
||||
}
|
||||
},
|
||||
"Profile button": {
|
||||
"kind": "unreported",
|
||||
"evidence": {
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://news.xbox.com/en-us/2019/06/09/meet-the-xbox-elite-wireless-controller-series-2-over-30-new-ways-to-play-like-a-pro/",
|
||||
"says": "Dedicated button selects default or one of three programmable profiles."
|
||||
}
|
||||
]
|
||||
},
|
||||
"reason": "The stored remapping slots and firmware are not known. No invented paddle aliases or profile-button signal is emitted."
|
||||
}
|
||||
},
|
||||
"notes": [
|
||||
"Source-derived conditional browser fixture; no real-device Gamepad API dump was found. Never treat passing tests as hardware confirmation.",
|
||||
"Buttons 0–16 and axes 0–3 follow the standard layout; P1–P4 conditionally occupy 17–20. No hat axis: D-pad is buttons 12–15.",
|
||||
"All released button values are 0, pressed/touched false. Trigger value 0 is fully released and 1 fully pulled.",
|
||||
"The Xbox/Guide button is redirected to the lowest-index WGI gamepad using XInput; tests connect this profile first.",
|
||||
"Hardware trigger stops change travel; exact analog transfer curves and stored profile contents remain unknown.",
|
||||
"Chromium source default enables WGI; Windows GameInput fetcher is feature-gated and disabled by default in the inspected source. Edge-specific overrides were not found."
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,530 @@
|
||||
{
|
||||
"name": "elite2-firefox-win-adapter-xinput",
|
||||
"label": "Xbox Elite Series 2 — Xbox Wireless Adapter / Firefox XInput",
|
||||
"environment": {
|
||||
"browser": "Firefox",
|
||||
"os": "Windows 10 / Windows 11",
|
||||
"connection": "Xbox Wireless Adapter",
|
||||
"mode": "XInput with unknown onboard paddle assignments",
|
||||
"backend": "XInput",
|
||||
"status": "inferred",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://www.xbox.com/en-US/accessories/controllers/elite-wireless-controller-series-2",
|
||||
"says": "All three connections are supported on Windows 10/11."
|
||||
},
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
|
||||
"says": "Windows XInput backend handles any successful XInputGetState device."
|
||||
}
|
||||
],
|
||||
"reason": "Conditional on Windows routing this connection through XInput. No per-connection physical browser dump was found."
|
||||
},
|
||||
"gamepad": {
|
||||
"timestampPolicy": {
|
||||
"value": "change",
|
||||
"status": "inferred",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
|
||||
"says": "The XInput poll emits events only for changed buttons/axes."
|
||||
},
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/Gamepad.cpp",
|
||||
"says": "Construction, input events and SyncState update the DOM timestamp using performance.now."
|
||||
},
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/base/nsGlobalWindowInner.cpp",
|
||||
"says": "GetGamepads returns existing objects without calling SyncState."
|
||||
},
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/base/nsGlobalWindowOuter.cpp",
|
||||
"says": "SetIsBackground invokes SyncGamepadState when returning to foreground and also some unchanged background-state calls."
|
||||
}
|
||||
],
|
||||
"reason": "Change policy models ordinary foreground polling; construction starts at performance.now. Browser background/foreground synchronization and timer precision reduction are outside this synthetic fixture."
|
||||
},
|
||||
"id": {
|
||||
"value": "xinput",
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
|
||||
"says": "ScanForXInputDevices passes literal xinput to AddGamepad, with no VID/PID."
|
||||
}
|
||||
]
|
||||
},
|
||||
"mapping": {
|
||||
"value": "standard",
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
|
||||
"says": "XInput AddGamepad specifies Standard mapping."
|
||||
}
|
||||
]
|
||||
},
|
||||
"buttons": {
|
||||
"value": 17,
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
|
||||
"says": "XInput adds kStandardGamepadButtons and only standard masks, including Guide."
|
||||
}
|
||||
]
|
||||
},
|
||||
"axesRest": {
|
||||
"value": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"status": "inferred",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
|
||||
"says": "XInput exposes four normalized stick axes."
|
||||
},
|
||||
{
|
||||
"url": "https://learn.microsoft.com/en-us/windows/uwp/gaming/gamepad-and-vibration",
|
||||
"says": "Centered sticks approximately report zero and can drift."
|
||||
}
|
||||
],
|
||||
"reason": "Ideal zero center, not a captured resting sample. Trigger release is button value 0 and no trigger axes are returned."
|
||||
},
|
||||
"vibration": {
|
||||
"value": null,
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
|
||||
"says": "Windows backend supplies zero haptics and leaves a haptics TODO."
|
||||
},
|
||||
{
|
||||
"url": "https://searchfox.org/firefox-main/source/dom/webidl/Gamepad.webidl",
|
||||
"says": "Gamepad has hapticActuators but no vibrationActuator attribute."
|
||||
}
|
||||
],
|
||||
"presence": "absent"
|
||||
}
|
||||
},
|
||||
"controls": {
|
||||
"A": {
|
||||
"kind": "button",
|
||||
"index": 0,
|
||||
"evidence": {
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
|
||||
"says": "Browser backend sends A to standard button 0."
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"B": {
|
||||
"kind": "button",
|
||||
"index": 1,
|
||||
"evidence": {
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
|
||||
"says": "Browser backend sends B to standard button 1."
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"X": {
|
||||
"kind": "button",
|
||||
"index": 2,
|
||||
"evidence": {
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
|
||||
"says": "Browser backend sends X to standard button 2."
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"Y": {
|
||||
"kind": "button",
|
||||
"index": 3,
|
||||
"evidence": {
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
|
||||
"says": "Browser backend sends Y to standard button 3."
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"Left bumper": {
|
||||
"kind": "button",
|
||||
"index": 4,
|
||||
"evidence": {
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
|
||||
"says": "Browser backend sends Left bumper to standard button 4."
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"Right bumper": {
|
||||
"kind": "button",
|
||||
"index": 5,
|
||||
"evidence": {
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
|
||||
"says": "Browser backend sends Right bumper to standard button 5."
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"Left trigger": {
|
||||
"kind": "button",
|
||||
"index": 6,
|
||||
"evidence": {
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
|
||||
"says": "Trigger 6 copies XInput byte /255; pressed at byte >=30."
|
||||
},
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/GamepadPlatformService.cpp",
|
||||
"says": "The pressed/value overload copies pressed into touched, including XInput triggers below the threshold."
|
||||
}
|
||||
]
|
||||
},
|
||||
"analog": true,
|
||||
"pressedThreshold": 0.11764705882352941,
|
||||
"pressedInclusive": true,
|
||||
"steps": 255,
|
||||
"touchedPolicy": "pressed"
|
||||
},
|
||||
"Right trigger": {
|
||||
"kind": "button",
|
||||
"index": 7,
|
||||
"evidence": {
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
|
||||
"says": "Trigger 7 copies XInput byte /255; pressed at byte >=30."
|
||||
},
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/GamepadPlatformService.cpp",
|
||||
"says": "The pressed/value overload copies pressed into touched, including XInput triggers below the threshold."
|
||||
}
|
||||
]
|
||||
},
|
||||
"analog": true,
|
||||
"pressedThreshold": 0.11764705882352941,
|
||||
"pressedInclusive": true,
|
||||
"steps": 255,
|
||||
"touchedPolicy": "pressed"
|
||||
},
|
||||
"View": {
|
||||
"kind": "button",
|
||||
"index": 8,
|
||||
"evidence": {
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
|
||||
"says": "Browser backend sends View to standard button 8."
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"Menu": {
|
||||
"kind": "button",
|
||||
"index": 9,
|
||||
"evidence": {
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
|
||||
"says": "Browser backend sends Menu to standard button 9."
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"Left stick click": {
|
||||
"kind": "button",
|
||||
"index": 10,
|
||||
"evidence": {
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
|
||||
"says": "Browser backend sends Left stick click to standard button 10."
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"Right stick click": {
|
||||
"kind": "button",
|
||||
"index": 11,
|
||||
"evidence": {
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
|
||||
"says": "Browser backend sends Right stick click to standard button 11."
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"D-pad up": {
|
||||
"kind": "button",
|
||||
"index": 12,
|
||||
"evidence": {
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
|
||||
"says": "Browser backend sends D-pad up to standard button 12."
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"D-pad down": {
|
||||
"kind": "button",
|
||||
"index": 13,
|
||||
"evidence": {
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
|
||||
"says": "Browser backend sends D-pad down to standard button 13."
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"D-pad left": {
|
||||
"kind": "button",
|
||||
"index": 14,
|
||||
"evidence": {
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
|
||||
"says": "Browser backend sends D-pad left to standard button 14."
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"D-pad right": {
|
||||
"kind": "button",
|
||||
"index": 15,
|
||||
"evidence": {
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
|
||||
"says": "Browser backend sends D-pad right to standard button 15."
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"Xbox": {
|
||||
"kind": "button",
|
||||
"index": 16,
|
||||
"evidence": {
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
|
||||
"says": "Browser backend sends Xbox to standard button 16."
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"Left stick": {
|
||||
"kind": "stick",
|
||||
"x": 0,
|
||||
"y": 1,
|
||||
"evidence": {
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
|
||||
"says": "Axes 0/1 are left X/Y; Y is inverted for down-positive."
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"Right stick": {
|
||||
"kind": "stick",
|
||||
"x": 2,
|
||||
"y": 3,
|
||||
"evidence": {
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
|
||||
"says": "Axes 2/3 are right X/Y; Y is inverted for down-positive."
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"P1": {
|
||||
"kind": "unreported",
|
||||
"evidence": {
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
|
||||
"says": "XInput backend only maps the standard 17 buttons; it has no paddle mask."
|
||||
}
|
||||
]
|
||||
},
|
||||
"reason": "No independent paddle index in this backend. An onboard assignment may repeat another control; this fixture does not guess those assignments."
|
||||
},
|
||||
"P2": {
|
||||
"kind": "unreported",
|
||||
"evidence": {
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
|
||||
"says": "XInput backend only maps the standard 17 buttons; it has no paddle mask."
|
||||
}
|
||||
]
|
||||
},
|
||||
"reason": "No independent paddle index in this backend. An onboard assignment may repeat another control; this fixture does not guess those assignments."
|
||||
},
|
||||
"P3": {
|
||||
"kind": "unreported",
|
||||
"evidence": {
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
|
||||
"says": "XInput backend only maps the standard 17 buttons; it has no paddle mask."
|
||||
}
|
||||
]
|
||||
},
|
||||
"reason": "No independent paddle index in this backend. An onboard assignment may repeat another control; this fixture does not guess those assignments."
|
||||
},
|
||||
"P4": {
|
||||
"kind": "unreported",
|
||||
"evidence": {
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
|
||||
"says": "XInput backend only maps the standard 17 buttons; it has no paddle mask."
|
||||
}
|
||||
]
|
||||
},
|
||||
"reason": "No independent paddle index in this backend. An onboard assignment may repeat another control; this fixture does not guess those assignments."
|
||||
},
|
||||
"Profile button": {
|
||||
"kind": "unreported",
|
||||
"evidence": {
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
|
||||
"says": "Backend maps standard controls, not an Elite profile selector."
|
||||
},
|
||||
{
|
||||
"url": "https://news.xbox.com/en-us/2019/06/09/meet-the-xbox-elite-wireless-controller-series-2-over-30-new-ways-to-play-like-a-pro/",
|
||||
"says": "Dedicated button switches default and three saved custom profiles."
|
||||
}
|
||||
]
|
||||
},
|
||||
"reason": "Stored profiles are programmable and their contents are unknown. The selector has no distinct button index here."
|
||||
}
|
||||
},
|
||||
"switches": {
|
||||
"Left trigger lock": {
|
||||
"kind": "state",
|
||||
"values": [
|
||||
"full",
|
||||
"medium",
|
||||
"short"
|
||||
],
|
||||
"effect": "none",
|
||||
"evidence": {
|
||||
"status": "inferred",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://news.xbox.com/en-us/2019/11/07/xbox-elite-wireless-controller-series-2-available-now/",
|
||||
"says": "Elite Series 2 has three levels of trigger depth."
|
||||
},
|
||||
{
|
||||
"url": "https://news.xbox.com/en-us/2019/06/09/meet-the-xbox-elite-wireless-controller-series-2-over-30-new-ways-to-play-like-a-pro/",
|
||||
"says": "Controller automatically recognizes trigger-lock adjustments."
|
||||
},
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
|
||||
"says": "Browser exposes normalized trigger values, with no trigger-lock state field."
|
||||
}
|
||||
],
|
||||
"reason": "This switch records physical travel only. set() supplies the browser's normalized value, so no guessed travel-to-value curve is applied."
|
||||
}
|
||||
},
|
||||
"Right trigger lock": {
|
||||
"kind": "state",
|
||||
"values": [
|
||||
"full",
|
||||
"medium",
|
||||
"short"
|
||||
],
|
||||
"effect": "none",
|
||||
"evidence": {
|
||||
"status": "inferred",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://news.xbox.com/en-us/2019/11/07/xbox-elite-wireless-controller-series-2-available-now/",
|
||||
"says": "Elite Series 2 has three levels of trigger depth."
|
||||
},
|
||||
{
|
||||
"url": "https://news.xbox.com/en-us/2019/06/09/meet-the-xbox-elite-wireless-controller-series-2-over-30-new-ways-to-play-like-a-pro/",
|
||||
"says": "Controller automatically recognizes trigger-lock adjustments."
|
||||
},
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
|
||||
"says": "Browser exposes normalized trigger values, with no trigger-lock state field."
|
||||
}
|
||||
],
|
||||
"reason": "This switch records physical travel only. set() supplies the browser's normalized value, so no guessed travel-to-value curve is applied."
|
||||
}
|
||||
},
|
||||
"Profile button": {
|
||||
"kind": "unreported",
|
||||
"evidence": {
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://news.xbox.com/en-us/2019/06/09/meet-the-xbox-elite-wireless-controller-series-2-over-30-new-ways-to-play-like-a-pro/",
|
||||
"says": "Dedicated button selects default or one of three programmable profiles."
|
||||
}
|
||||
]
|
||||
},
|
||||
"reason": "The stored remapping slots and firmware are not known. No invented paddle aliases or profile-button signal is emitted."
|
||||
}
|
||||
},
|
||||
"notes": [
|
||||
"Exact browser backend layout, with connection routing inferred; no real-device Gamepad API dump was found.",
|
||||
"Buttons 0–16 and axes 0–3 follow the standard layout. No hat axis; D-pad is buttons 12–15.",
|
||||
"All released button values are 0, pressed/touched false. Trigger values are quantized byte/255.",
|
||||
"P1–P4 cannot be bound as independent browser inputs here. Onboard remappings may repeat existing controls, but their stored assignments are deliberately unknown.",
|
||||
"Profile button and trigger-lock switches have no independent Gamepad fields. Physical travel is recorded, not transformed with invented ratios.",
|
||||
"vibrationActuator is absent; null in profile data means omit the property. Firefox hapticActuators is not implemented by this Windows backend.",
|
||||
"Firefox trigger touched mirrors pressed: byte values 1 through 29 have value greater than zero, pressed false and touched false.",
|
||||
"Timestamp starts at performance.now and changes with input during ordinary foreground polling. Firefox also updates on window synchronization; background/foreground scheduling and clock precision reduction are not reproduced."
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,530 @@
|
||||
{
|
||||
"name": "elite2-firefox-win-bt-xinput",
|
||||
"label": "Xbox Elite Series 2 — Bluetooth / Firefox XInput",
|
||||
"environment": {
|
||||
"browser": "Firefox",
|
||||
"os": "Windows 10 / Windows 11",
|
||||
"connection": "Bluetooth",
|
||||
"mode": "XInput with unknown onboard paddle assignments",
|
||||
"backend": "XInput",
|
||||
"status": "inferred",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://www.xbox.com/en-US/accessories/controllers/elite-wireless-controller-series-2",
|
||||
"says": "All three connections are supported on Windows 10/11."
|
||||
},
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
|
||||
"says": "Windows XInput backend handles any successful XInputGetState device."
|
||||
}
|
||||
],
|
||||
"reason": "Conditional on Windows routing this connection through XInput. No per-connection physical browser dump was found."
|
||||
},
|
||||
"gamepad": {
|
||||
"timestampPolicy": {
|
||||
"value": "change",
|
||||
"status": "inferred",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
|
||||
"says": "The XInput poll emits events only for changed buttons/axes."
|
||||
},
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/Gamepad.cpp",
|
||||
"says": "Construction, input events and SyncState update the DOM timestamp using performance.now."
|
||||
},
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/base/nsGlobalWindowInner.cpp",
|
||||
"says": "GetGamepads returns existing objects without calling SyncState."
|
||||
},
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/base/nsGlobalWindowOuter.cpp",
|
||||
"says": "SetIsBackground invokes SyncGamepadState when returning to foreground and also some unchanged background-state calls."
|
||||
}
|
||||
],
|
||||
"reason": "Change policy models ordinary foreground polling; construction starts at performance.now. Browser background/foreground synchronization and timer precision reduction are outside this synthetic fixture."
|
||||
},
|
||||
"id": {
|
||||
"value": "xinput",
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
|
||||
"says": "ScanForXInputDevices passes literal xinput to AddGamepad, with no VID/PID."
|
||||
}
|
||||
]
|
||||
},
|
||||
"mapping": {
|
||||
"value": "standard",
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
|
||||
"says": "XInput AddGamepad specifies Standard mapping."
|
||||
}
|
||||
]
|
||||
},
|
||||
"buttons": {
|
||||
"value": 17,
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
|
||||
"says": "XInput adds kStandardGamepadButtons and only standard masks, including Guide."
|
||||
}
|
||||
]
|
||||
},
|
||||
"axesRest": {
|
||||
"value": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"status": "inferred",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
|
||||
"says": "XInput exposes four normalized stick axes."
|
||||
},
|
||||
{
|
||||
"url": "https://learn.microsoft.com/en-us/windows/uwp/gaming/gamepad-and-vibration",
|
||||
"says": "Centered sticks approximately report zero and can drift."
|
||||
}
|
||||
],
|
||||
"reason": "Ideal zero center, not a captured resting sample. Trigger release is button value 0 and no trigger axes are returned."
|
||||
},
|
||||
"vibration": {
|
||||
"value": null,
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
|
||||
"says": "Windows backend supplies zero haptics and leaves a haptics TODO."
|
||||
},
|
||||
{
|
||||
"url": "https://searchfox.org/firefox-main/source/dom/webidl/Gamepad.webidl",
|
||||
"says": "Gamepad has hapticActuators but no vibrationActuator attribute."
|
||||
}
|
||||
],
|
||||
"presence": "absent"
|
||||
}
|
||||
},
|
||||
"controls": {
|
||||
"A": {
|
||||
"kind": "button",
|
||||
"index": 0,
|
||||
"evidence": {
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
|
||||
"says": "Browser backend sends A to standard button 0."
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"B": {
|
||||
"kind": "button",
|
||||
"index": 1,
|
||||
"evidence": {
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
|
||||
"says": "Browser backend sends B to standard button 1."
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"X": {
|
||||
"kind": "button",
|
||||
"index": 2,
|
||||
"evidence": {
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
|
||||
"says": "Browser backend sends X to standard button 2."
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"Y": {
|
||||
"kind": "button",
|
||||
"index": 3,
|
||||
"evidence": {
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
|
||||
"says": "Browser backend sends Y to standard button 3."
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"Left bumper": {
|
||||
"kind": "button",
|
||||
"index": 4,
|
||||
"evidence": {
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
|
||||
"says": "Browser backend sends Left bumper to standard button 4."
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"Right bumper": {
|
||||
"kind": "button",
|
||||
"index": 5,
|
||||
"evidence": {
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
|
||||
"says": "Browser backend sends Right bumper to standard button 5."
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"Left trigger": {
|
||||
"kind": "button",
|
||||
"index": 6,
|
||||
"evidence": {
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
|
||||
"says": "Trigger 6 copies XInput byte /255; pressed at byte >=30."
|
||||
},
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/GamepadPlatformService.cpp",
|
||||
"says": "The pressed/value overload copies pressed into touched, including XInput triggers below the threshold."
|
||||
}
|
||||
]
|
||||
},
|
||||
"analog": true,
|
||||
"pressedThreshold": 0.11764705882352941,
|
||||
"pressedInclusive": true,
|
||||
"steps": 255,
|
||||
"touchedPolicy": "pressed"
|
||||
},
|
||||
"Right trigger": {
|
||||
"kind": "button",
|
||||
"index": 7,
|
||||
"evidence": {
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
|
||||
"says": "Trigger 7 copies XInput byte /255; pressed at byte >=30."
|
||||
},
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/GamepadPlatformService.cpp",
|
||||
"says": "The pressed/value overload copies pressed into touched, including XInput triggers below the threshold."
|
||||
}
|
||||
]
|
||||
},
|
||||
"analog": true,
|
||||
"pressedThreshold": 0.11764705882352941,
|
||||
"pressedInclusive": true,
|
||||
"steps": 255,
|
||||
"touchedPolicy": "pressed"
|
||||
},
|
||||
"View": {
|
||||
"kind": "button",
|
||||
"index": 8,
|
||||
"evidence": {
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
|
||||
"says": "Browser backend sends View to standard button 8."
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"Menu": {
|
||||
"kind": "button",
|
||||
"index": 9,
|
||||
"evidence": {
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
|
||||
"says": "Browser backend sends Menu to standard button 9."
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"Left stick click": {
|
||||
"kind": "button",
|
||||
"index": 10,
|
||||
"evidence": {
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
|
||||
"says": "Browser backend sends Left stick click to standard button 10."
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"Right stick click": {
|
||||
"kind": "button",
|
||||
"index": 11,
|
||||
"evidence": {
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
|
||||
"says": "Browser backend sends Right stick click to standard button 11."
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"D-pad up": {
|
||||
"kind": "button",
|
||||
"index": 12,
|
||||
"evidence": {
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
|
||||
"says": "Browser backend sends D-pad up to standard button 12."
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"D-pad down": {
|
||||
"kind": "button",
|
||||
"index": 13,
|
||||
"evidence": {
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
|
||||
"says": "Browser backend sends D-pad down to standard button 13."
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"D-pad left": {
|
||||
"kind": "button",
|
||||
"index": 14,
|
||||
"evidence": {
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
|
||||
"says": "Browser backend sends D-pad left to standard button 14."
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"D-pad right": {
|
||||
"kind": "button",
|
||||
"index": 15,
|
||||
"evidence": {
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
|
||||
"says": "Browser backend sends D-pad right to standard button 15."
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"Xbox": {
|
||||
"kind": "button",
|
||||
"index": 16,
|
||||
"evidence": {
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
|
||||
"says": "Browser backend sends Xbox to standard button 16."
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"Left stick": {
|
||||
"kind": "stick",
|
||||
"x": 0,
|
||||
"y": 1,
|
||||
"evidence": {
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
|
||||
"says": "Axes 0/1 are left X/Y; Y is inverted for down-positive."
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"Right stick": {
|
||||
"kind": "stick",
|
||||
"x": 2,
|
||||
"y": 3,
|
||||
"evidence": {
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
|
||||
"says": "Axes 2/3 are right X/Y; Y is inverted for down-positive."
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"P1": {
|
||||
"kind": "unreported",
|
||||
"evidence": {
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
|
||||
"says": "XInput backend only maps the standard 17 buttons; it has no paddle mask."
|
||||
}
|
||||
]
|
||||
},
|
||||
"reason": "No independent paddle index in this backend. An onboard assignment may repeat another control; this fixture does not guess those assignments."
|
||||
},
|
||||
"P2": {
|
||||
"kind": "unreported",
|
||||
"evidence": {
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
|
||||
"says": "XInput backend only maps the standard 17 buttons; it has no paddle mask."
|
||||
}
|
||||
]
|
||||
},
|
||||
"reason": "No independent paddle index in this backend. An onboard assignment may repeat another control; this fixture does not guess those assignments."
|
||||
},
|
||||
"P3": {
|
||||
"kind": "unreported",
|
||||
"evidence": {
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
|
||||
"says": "XInput backend only maps the standard 17 buttons; it has no paddle mask."
|
||||
}
|
||||
]
|
||||
},
|
||||
"reason": "No independent paddle index in this backend. An onboard assignment may repeat another control; this fixture does not guess those assignments."
|
||||
},
|
||||
"P4": {
|
||||
"kind": "unreported",
|
||||
"evidence": {
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
|
||||
"says": "XInput backend only maps the standard 17 buttons; it has no paddle mask."
|
||||
}
|
||||
]
|
||||
},
|
||||
"reason": "No independent paddle index in this backend. An onboard assignment may repeat another control; this fixture does not guess those assignments."
|
||||
},
|
||||
"Profile button": {
|
||||
"kind": "unreported",
|
||||
"evidence": {
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
|
||||
"says": "Backend maps standard controls, not an Elite profile selector."
|
||||
},
|
||||
{
|
||||
"url": "https://news.xbox.com/en-us/2019/06/09/meet-the-xbox-elite-wireless-controller-series-2-over-30-new-ways-to-play-like-a-pro/",
|
||||
"says": "Dedicated button switches default and three saved custom profiles."
|
||||
}
|
||||
]
|
||||
},
|
||||
"reason": "Stored profiles are programmable and their contents are unknown. The selector has no distinct button index here."
|
||||
}
|
||||
},
|
||||
"switches": {
|
||||
"Left trigger lock": {
|
||||
"kind": "state",
|
||||
"values": [
|
||||
"full",
|
||||
"medium",
|
||||
"short"
|
||||
],
|
||||
"effect": "none",
|
||||
"evidence": {
|
||||
"status": "inferred",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://news.xbox.com/en-us/2019/11/07/xbox-elite-wireless-controller-series-2-available-now/",
|
||||
"says": "Elite Series 2 has three levels of trigger depth."
|
||||
},
|
||||
{
|
||||
"url": "https://news.xbox.com/en-us/2019/06/09/meet-the-xbox-elite-wireless-controller-series-2-over-30-new-ways-to-play-like-a-pro/",
|
||||
"says": "Controller automatically recognizes trigger-lock adjustments."
|
||||
},
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
|
||||
"says": "Browser exposes normalized trigger values, with no trigger-lock state field."
|
||||
}
|
||||
],
|
||||
"reason": "This switch records physical travel only. set() supplies the browser's normalized value, so no guessed travel-to-value curve is applied."
|
||||
}
|
||||
},
|
||||
"Right trigger lock": {
|
||||
"kind": "state",
|
||||
"values": [
|
||||
"full",
|
||||
"medium",
|
||||
"short"
|
||||
],
|
||||
"effect": "none",
|
||||
"evidence": {
|
||||
"status": "inferred",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://news.xbox.com/en-us/2019/11/07/xbox-elite-wireless-controller-series-2-available-now/",
|
||||
"says": "Elite Series 2 has three levels of trigger depth."
|
||||
},
|
||||
{
|
||||
"url": "https://news.xbox.com/en-us/2019/06/09/meet-the-xbox-elite-wireless-controller-series-2-over-30-new-ways-to-play-like-a-pro/",
|
||||
"says": "Controller automatically recognizes trigger-lock adjustments."
|
||||
},
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
|
||||
"says": "Browser exposes normalized trigger values, with no trigger-lock state field."
|
||||
}
|
||||
],
|
||||
"reason": "This switch records physical travel only. set() supplies the browser's normalized value, so no guessed travel-to-value curve is applied."
|
||||
}
|
||||
},
|
||||
"Profile button": {
|
||||
"kind": "unreported",
|
||||
"evidence": {
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://news.xbox.com/en-us/2019/06/09/meet-the-xbox-elite-wireless-controller-series-2-over-30-new-ways-to-play-like-a-pro/",
|
||||
"says": "Dedicated button selects default or one of three programmable profiles."
|
||||
}
|
||||
]
|
||||
},
|
||||
"reason": "The stored remapping slots and firmware are not known. No invented paddle aliases or profile-button signal is emitted."
|
||||
}
|
||||
},
|
||||
"notes": [
|
||||
"Exact browser backend layout, with connection routing inferred; no real-device Gamepad API dump was found.",
|
||||
"Buttons 0–16 and axes 0–3 follow the standard layout. No hat axis; D-pad is buttons 12–15.",
|
||||
"All released button values are 0, pressed/touched false. Trigger values are quantized byte/255.",
|
||||
"P1–P4 cannot be bound as independent browser inputs here. Onboard remappings may repeat existing controls, but their stored assignments are deliberately unknown.",
|
||||
"Profile button and trigger-lock switches have no independent Gamepad fields. Physical travel is recorded, not transformed with invented ratios.",
|
||||
"vibrationActuator is absent; null in profile data means omit the property. Firefox hapticActuators is not implemented by this Windows backend.",
|
||||
"Firefox trigger touched mirrors pressed: byte values 1 through 29 have value greater than zero, pressed false and touched false.",
|
||||
"Timestamp starts at performance.now and changes with input during ordinary foreground polling. Firefox also updates on window synchronization; background/foreground scheduling and clock precision reduction are not reproduced."
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,530 @@
|
||||
{
|
||||
"name": "elite2-firefox-win-usb-xinput",
|
||||
"label": "Xbox Elite Series 2 — USB / Firefox XInput",
|
||||
"environment": {
|
||||
"browser": "Firefox",
|
||||
"os": "Windows 10 / Windows 11",
|
||||
"connection": "USB",
|
||||
"mode": "XInput with unknown onboard paddle assignments",
|
||||
"backend": "XInput",
|
||||
"status": "inferred",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://www.xbox.com/en-US/accessories/controllers/elite-wireless-controller-series-2",
|
||||
"says": "All three connections are supported on Windows 10/11."
|
||||
},
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
|
||||
"says": "Windows XInput backend handles any successful XInputGetState device."
|
||||
}
|
||||
],
|
||||
"reason": "Conditional on Windows routing this connection through XInput. No per-connection physical browser dump was found."
|
||||
},
|
||||
"gamepad": {
|
||||
"timestampPolicy": {
|
||||
"value": "change",
|
||||
"status": "inferred",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
|
||||
"says": "The XInput poll emits events only for changed buttons/axes."
|
||||
},
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/Gamepad.cpp",
|
||||
"says": "Construction, input events and SyncState update the DOM timestamp using performance.now."
|
||||
},
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/base/nsGlobalWindowInner.cpp",
|
||||
"says": "GetGamepads returns existing objects without calling SyncState."
|
||||
},
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/base/nsGlobalWindowOuter.cpp",
|
||||
"says": "SetIsBackground invokes SyncGamepadState when returning to foreground and also some unchanged background-state calls."
|
||||
}
|
||||
],
|
||||
"reason": "Change policy models ordinary foreground polling; construction starts at performance.now. Browser background/foreground synchronization and timer precision reduction are outside this synthetic fixture."
|
||||
},
|
||||
"id": {
|
||||
"value": "xinput",
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
|
||||
"says": "ScanForXInputDevices passes literal xinput to AddGamepad, with no VID/PID."
|
||||
}
|
||||
]
|
||||
},
|
||||
"mapping": {
|
||||
"value": "standard",
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
|
||||
"says": "XInput AddGamepad specifies Standard mapping."
|
||||
}
|
||||
]
|
||||
},
|
||||
"buttons": {
|
||||
"value": 17,
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
|
||||
"says": "XInput adds kStandardGamepadButtons and only standard masks, including Guide."
|
||||
}
|
||||
]
|
||||
},
|
||||
"axesRest": {
|
||||
"value": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"status": "inferred",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
|
||||
"says": "XInput exposes four normalized stick axes."
|
||||
},
|
||||
{
|
||||
"url": "https://learn.microsoft.com/en-us/windows/uwp/gaming/gamepad-and-vibration",
|
||||
"says": "Centered sticks approximately report zero and can drift."
|
||||
}
|
||||
],
|
||||
"reason": "Ideal zero center, not a captured resting sample. Trigger release is button value 0 and no trigger axes are returned."
|
||||
},
|
||||
"vibration": {
|
||||
"value": null,
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
|
||||
"says": "Windows backend supplies zero haptics and leaves a haptics TODO."
|
||||
},
|
||||
{
|
||||
"url": "https://searchfox.org/firefox-main/source/dom/webidl/Gamepad.webidl",
|
||||
"says": "Gamepad has hapticActuators but no vibrationActuator attribute."
|
||||
}
|
||||
],
|
||||
"presence": "absent"
|
||||
}
|
||||
},
|
||||
"controls": {
|
||||
"A": {
|
||||
"kind": "button",
|
||||
"index": 0,
|
||||
"evidence": {
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
|
||||
"says": "Browser backend sends A to standard button 0."
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"B": {
|
||||
"kind": "button",
|
||||
"index": 1,
|
||||
"evidence": {
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
|
||||
"says": "Browser backend sends B to standard button 1."
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"X": {
|
||||
"kind": "button",
|
||||
"index": 2,
|
||||
"evidence": {
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
|
||||
"says": "Browser backend sends X to standard button 2."
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"Y": {
|
||||
"kind": "button",
|
||||
"index": 3,
|
||||
"evidence": {
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
|
||||
"says": "Browser backend sends Y to standard button 3."
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"Left bumper": {
|
||||
"kind": "button",
|
||||
"index": 4,
|
||||
"evidence": {
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
|
||||
"says": "Browser backend sends Left bumper to standard button 4."
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"Right bumper": {
|
||||
"kind": "button",
|
||||
"index": 5,
|
||||
"evidence": {
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
|
||||
"says": "Browser backend sends Right bumper to standard button 5."
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"Left trigger": {
|
||||
"kind": "button",
|
||||
"index": 6,
|
||||
"evidence": {
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
|
||||
"says": "Trigger 6 copies XInput byte /255; pressed at byte >=30."
|
||||
},
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/GamepadPlatformService.cpp",
|
||||
"says": "The pressed/value overload copies pressed into touched, including XInput triggers below the threshold."
|
||||
}
|
||||
]
|
||||
},
|
||||
"analog": true,
|
||||
"pressedThreshold": 0.11764705882352941,
|
||||
"pressedInclusive": true,
|
||||
"steps": 255,
|
||||
"touchedPolicy": "pressed"
|
||||
},
|
||||
"Right trigger": {
|
||||
"kind": "button",
|
||||
"index": 7,
|
||||
"evidence": {
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
|
||||
"says": "Trigger 7 copies XInput byte /255; pressed at byte >=30."
|
||||
},
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/GamepadPlatformService.cpp",
|
||||
"says": "The pressed/value overload copies pressed into touched, including XInput triggers below the threshold."
|
||||
}
|
||||
]
|
||||
},
|
||||
"analog": true,
|
||||
"pressedThreshold": 0.11764705882352941,
|
||||
"pressedInclusive": true,
|
||||
"steps": 255,
|
||||
"touchedPolicy": "pressed"
|
||||
},
|
||||
"View": {
|
||||
"kind": "button",
|
||||
"index": 8,
|
||||
"evidence": {
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
|
||||
"says": "Browser backend sends View to standard button 8."
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"Menu": {
|
||||
"kind": "button",
|
||||
"index": 9,
|
||||
"evidence": {
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
|
||||
"says": "Browser backend sends Menu to standard button 9."
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"Left stick click": {
|
||||
"kind": "button",
|
||||
"index": 10,
|
||||
"evidence": {
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
|
||||
"says": "Browser backend sends Left stick click to standard button 10."
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"Right stick click": {
|
||||
"kind": "button",
|
||||
"index": 11,
|
||||
"evidence": {
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
|
||||
"says": "Browser backend sends Right stick click to standard button 11."
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"D-pad up": {
|
||||
"kind": "button",
|
||||
"index": 12,
|
||||
"evidence": {
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
|
||||
"says": "Browser backend sends D-pad up to standard button 12."
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"D-pad down": {
|
||||
"kind": "button",
|
||||
"index": 13,
|
||||
"evidence": {
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
|
||||
"says": "Browser backend sends D-pad down to standard button 13."
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"D-pad left": {
|
||||
"kind": "button",
|
||||
"index": 14,
|
||||
"evidence": {
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
|
||||
"says": "Browser backend sends D-pad left to standard button 14."
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"D-pad right": {
|
||||
"kind": "button",
|
||||
"index": 15,
|
||||
"evidence": {
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
|
||||
"says": "Browser backend sends D-pad right to standard button 15."
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"Xbox": {
|
||||
"kind": "button",
|
||||
"index": 16,
|
||||
"evidence": {
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
|
||||
"says": "Browser backend sends Xbox to standard button 16."
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"Left stick": {
|
||||
"kind": "stick",
|
||||
"x": 0,
|
||||
"y": 1,
|
||||
"evidence": {
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
|
||||
"says": "Axes 0/1 are left X/Y; Y is inverted for down-positive."
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"Right stick": {
|
||||
"kind": "stick",
|
||||
"x": 2,
|
||||
"y": 3,
|
||||
"evidence": {
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
|
||||
"says": "Axes 2/3 are right X/Y; Y is inverted for down-positive."
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"P1": {
|
||||
"kind": "unreported",
|
||||
"evidence": {
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
|
||||
"says": "XInput backend only maps the standard 17 buttons; it has no paddle mask."
|
||||
}
|
||||
]
|
||||
},
|
||||
"reason": "No independent paddle index in this backend. An onboard assignment may repeat another control; this fixture does not guess those assignments."
|
||||
},
|
||||
"P2": {
|
||||
"kind": "unreported",
|
||||
"evidence": {
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
|
||||
"says": "XInput backend only maps the standard 17 buttons; it has no paddle mask."
|
||||
}
|
||||
]
|
||||
},
|
||||
"reason": "No independent paddle index in this backend. An onboard assignment may repeat another control; this fixture does not guess those assignments."
|
||||
},
|
||||
"P3": {
|
||||
"kind": "unreported",
|
||||
"evidence": {
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
|
||||
"says": "XInput backend only maps the standard 17 buttons; it has no paddle mask."
|
||||
}
|
||||
]
|
||||
},
|
||||
"reason": "No independent paddle index in this backend. An onboard assignment may repeat another control; this fixture does not guess those assignments."
|
||||
},
|
||||
"P4": {
|
||||
"kind": "unreported",
|
||||
"evidence": {
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
|
||||
"says": "XInput backend only maps the standard 17 buttons; it has no paddle mask."
|
||||
}
|
||||
]
|
||||
},
|
||||
"reason": "No independent paddle index in this backend. An onboard assignment may repeat another control; this fixture does not guess those assignments."
|
||||
},
|
||||
"Profile button": {
|
||||
"kind": "unreported",
|
||||
"evidence": {
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
|
||||
"says": "Backend maps standard controls, not an Elite profile selector."
|
||||
},
|
||||
{
|
||||
"url": "https://news.xbox.com/en-us/2019/06/09/meet-the-xbox-elite-wireless-controller-series-2-over-30-new-ways-to-play-like-a-pro/",
|
||||
"says": "Dedicated button switches default and three saved custom profiles."
|
||||
}
|
||||
]
|
||||
},
|
||||
"reason": "Stored profiles are programmable and their contents are unknown. The selector has no distinct button index here."
|
||||
}
|
||||
},
|
||||
"switches": {
|
||||
"Left trigger lock": {
|
||||
"kind": "state",
|
||||
"values": [
|
||||
"full",
|
||||
"medium",
|
||||
"short"
|
||||
],
|
||||
"effect": "none",
|
||||
"evidence": {
|
||||
"status": "inferred",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://news.xbox.com/en-us/2019/11/07/xbox-elite-wireless-controller-series-2-available-now/",
|
||||
"says": "Elite Series 2 has three levels of trigger depth."
|
||||
},
|
||||
{
|
||||
"url": "https://news.xbox.com/en-us/2019/06/09/meet-the-xbox-elite-wireless-controller-series-2-over-30-new-ways-to-play-like-a-pro/",
|
||||
"says": "Controller automatically recognizes trigger-lock adjustments."
|
||||
},
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
|
||||
"says": "Browser exposes normalized trigger values, with no trigger-lock state field."
|
||||
}
|
||||
],
|
||||
"reason": "This switch records physical travel only. set() supplies the browser's normalized value, so no guessed travel-to-value curve is applied."
|
||||
}
|
||||
},
|
||||
"Right trigger lock": {
|
||||
"kind": "state",
|
||||
"values": [
|
||||
"full",
|
||||
"medium",
|
||||
"short"
|
||||
],
|
||||
"effect": "none",
|
||||
"evidence": {
|
||||
"status": "inferred",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://news.xbox.com/en-us/2019/11/07/xbox-elite-wireless-controller-series-2-available-now/",
|
||||
"says": "Elite Series 2 has three levels of trigger depth."
|
||||
},
|
||||
{
|
||||
"url": "https://news.xbox.com/en-us/2019/06/09/meet-the-xbox-elite-wireless-controller-series-2-over-30-new-ways-to-play-like-a-pro/",
|
||||
"says": "Controller automatically recognizes trigger-lock adjustments."
|
||||
},
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/gamepad/windows/WindowsGamepad.cpp",
|
||||
"says": "Browser exposes normalized trigger values, with no trigger-lock state field."
|
||||
}
|
||||
],
|
||||
"reason": "This switch records physical travel only. set() supplies the browser's normalized value, so no guessed travel-to-value curve is applied."
|
||||
}
|
||||
},
|
||||
"Profile button": {
|
||||
"kind": "unreported",
|
||||
"evidence": {
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://news.xbox.com/en-us/2019/06/09/meet-the-xbox-elite-wireless-controller-series-2-over-30-new-ways-to-play-like-a-pro/",
|
||||
"says": "Dedicated button selects default or one of three programmable profiles."
|
||||
}
|
||||
]
|
||||
},
|
||||
"reason": "The stored remapping slots and firmware are not known. No invented paddle aliases or profile-button signal is emitted."
|
||||
}
|
||||
},
|
||||
"notes": [
|
||||
"Exact browser backend layout, with connection routing inferred; no real-device Gamepad API dump was found.",
|
||||
"Buttons 0–16 and axes 0–3 follow the standard layout. No hat axis; D-pad is buttons 12–15.",
|
||||
"All released button values are 0, pressed/touched false. Trigger values are quantized byte/255.",
|
||||
"P1–P4 cannot be bound as independent browser inputs here. Onboard remappings may repeat existing controls, but their stored assignments are deliberately unknown.",
|
||||
"Profile button and trigger-lock switches have no independent Gamepad fields. Physical travel is recorded, not transformed with invented ratios.",
|
||||
"vibrationActuator is absent; null in profile data means omit the property. Firefox hapticActuators is not implemented by this Windows backend.",
|
||||
"Firefox trigger touched mirrors pressed: byte values 1 through 29 have value greater than zero, pressed false and touched false.",
|
||||
"Timestamp starts at performance.now and changes with input during ordinary foreground polling. Firefox also updates on window synchronization; background/foreground scheduling and clock precision reduction are not reproduced."
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"profiles": [
|
||||
"crkd-les-paul-chrome-win-dongle-mode1-pre3.json",
|
||||
"crkd-les-paul-chrome-win-usb-mode1-pre3.json",
|
||||
"elite2-chromium-win-adapter-wgi-conditional.json",
|
||||
"elite2-chromium-win-usb-wgi-conditional.json",
|
||||
"elite2-firefox-win-adapter-xinput.json",
|
||||
"elite2-firefox-win-bt-xinput.json",
|
||||
"elite2-firefox-win-usb-xinput.json",
|
||||
"wheel-g920-chrome-win-usb.json"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,575 @@
|
||||
{
|
||||
"name": "wheel-g920-chrome-win-usb",
|
||||
"label": "Logitech G920 — Chrome Windows USB, pedals unplugged (inferred)",
|
||||
"environment": {
|
||||
"browser": "Chrome",
|
||||
"os": "Windows",
|
||||
"connection": "USB",
|
||||
"mode": "Native HID, separate pedals; driver version unknown"
|
||||
},
|
||||
"environmentEvidence": {
|
||||
"status": "inferred",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://www.reddit.com/r/Stadia/comments/fdc7sh/",
|
||||
"says": "A G920 owner pasted a Chrome Gamepad object on 2020-03-04: this exact ID, empty mapping, 18 buttons, 10 axes with the saved resting vector, and vibrationActuator null. The report does not specify the OS, driver version, or whether pedals were disconnected."
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/chromium/chromium/blob/main/device/gamepad/raw_input_gamepad_device_win.cc",
|
||||
"says": "Windows RawInput assigns a button index from HID button usage minus one, and an axis index from HID Generic Desktop usage minus 0x30. It normalizes to [-1,+1], preserving an out-of-range hat null state."
|
||||
},
|
||||
{
|
||||
"url": "https://www.logitech.com/assets/54557/2/g920-driving-forcetm-racing-wheel.pdf",
|
||||
"says": "The G920 manual identifies A/B/X/Y, directional pad, left/right bumpers and stick buttons, View/Menu, Xbox, USB, and separate pedal/shifter connectors; its numbered callouts are physical labels, not browser indices."
|
||||
}
|
||||
],
|
||||
"reason": "Chrome is explicit in the dump; Windows is inferred from its sparse 10-axis HID usage layout. USB is the wheel's physical host connection. This historical report does not prove current Edge/Chrome behavior under every Logitech driver."
|
||||
},
|
||||
"gamepad": {
|
||||
"id": {
|
||||
"value": "G920 Driving Force Racing Wheel for Xbox One (Vendor: 046d Product: c262)",
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://www.reddit.com/r/Stadia/comments/fdc7sh/",
|
||||
"says": "A G920 owner pasted a Chrome Gamepad object on 2020-03-04: this exact ID, empty mapping, 18 buttons, 10 axes with the saved resting vector, and vibrationActuator null. The report does not specify the OS, driver version, or whether pedals were disconnected."
|
||||
}
|
||||
]
|
||||
},
|
||||
"mapping": {
|
||||
"value": "",
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://www.reddit.com/r/Stadia/comments/fdc7sh/",
|
||||
"says": "A G920 owner pasted a Chrome Gamepad object on 2020-03-04: this exact ID, empty mapping, 18 buttons, 10 axes with the saved resting vector, and vibrationActuator null. The report does not specify the OS, driver version, or whether pedals were disconnected."
|
||||
}
|
||||
]
|
||||
},
|
||||
"buttons": {
|
||||
"value": 18,
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://www.reddit.com/r/Stadia/comments/fdc7sh/",
|
||||
"says": "A G920 owner pasted a Chrome Gamepad object on 2020-03-04: this exact ID, empty mapping, 18 buttons, 10 axes with the saved resting vector, and vibrationActuator null. The report does not specify the OS, driver version, or whether pedals were disconnected."
|
||||
},
|
||||
{
|
||||
"url": "https://forum.scssoft.com/viewtopic.php?p=1313821",
|
||||
"says": "A real 046d:c262 G920 HID dump has 18 button usages, X usage 0x30 (0..65535), Y 0x31/Z 0x32/Rz 0x35 (0..255), and hat 0x39 (0..7). The dump is from macOS native HID, not Safari."
|
||||
}
|
||||
]
|
||||
},
|
||||
"axesRest": {
|
||||
"value": [
|
||||
-0.0013580322265625,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1.2857143878936768
|
||||
],
|
||||
"status": "inferred",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://www.reddit.com/r/Stadia/comments/fdc7sh/",
|
||||
"says": "A G920 owner pasted a Chrome Gamepad object on 2020-03-04: this exact ID, empty mapping, 18 buttons, 10 axes with the saved resting vector, and vibrationActuator null. The report does not specify the OS, driver version, or whether pedals were disconnected."
|
||||
},
|
||||
{
|
||||
"url": "https://spenibus.net/b/p/F/PC-steering-wheel-viewer-prototype-in-html-javascript",
|
||||
"says": "The author's 2018 G920 Firefox/Chrome browser prototype reads steering axis 0, throttle 1, brake 2, and clutch 3 or 5; its pedal conversion treats +1 as released and -1 as pressed."
|
||||
},
|
||||
{
|
||||
"url": "https://forum.scssoft.com/viewtopic.php?p=1313821",
|
||||
"says": "A real 046d:c262 G920 HID dump has 18 button usages, X usage 0x30 (0..65535), Y 0x31/Z 0x32/Rz 0x35 (0..255), and hat 0x39 (0..7). The dump is from macOS native HID, not Safari."
|
||||
}
|
||||
],
|
||||
"reason": "The complete vector is copied exactly from the real Chrome snapshot, including zeros at HID axis gaps and steering sensor offset. The snapshot never says pedals were unplugged; using its released-pedal +1 values for unplugged pedals is explicitly inferred."
|
||||
},
|
||||
"vibration": {
|
||||
"value": null,
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://www.reddit.com/r/Stadia/comments/fdc7sh/",
|
||||
"says": "A G920 owner pasted a Chrome Gamepad object on 2020-03-04: this exact ID, empty mapping, 18 buttons, 10 axes with the saved resting vector, and vibrationActuator null. The report does not specify the OS, driver version, or whether pedals were disconnected."
|
||||
}
|
||||
],
|
||||
"presence": "null"
|
||||
}
|
||||
},
|
||||
"controls": {
|
||||
"A": {
|
||||
"kind": "button",
|
||||
"index": 0,
|
||||
"evidence": {
|
||||
"status": "inferred",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://steamcommunity.com/sharedfiles/filedetails/?id=2489644810",
|
||||
"says": "The author's G920 Windows DirectInput setup lists raw indices 0 A, 1 B, 2 X, 3 Y, 4 right paddle, 5 left paddle, 6 Menu, 7 View, 8 RSB, and 9 LSB. The guide warns the G HUB product name differs."
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/chromium/chromium/blob/main/device/gamepad/raw_input_gamepad_device_win.cc",
|
||||
"says": "Windows RawInput assigns a button index from HID button usage minus one, and an axis index from HID Generic Desktop usage minus 0x30. It normalizes to [-1,+1], preserving an out-of-range hat null state."
|
||||
}
|
||||
],
|
||||
"reason": "The G920 owner's native Windows indices are projected to Chrome's unremapped HID button order; no Chrome dump of this individual button was found."
|
||||
}
|
||||
},
|
||||
"B": {
|
||||
"kind": "button",
|
||||
"index": 1,
|
||||
"evidence": {
|
||||
"status": "inferred",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://steamcommunity.com/sharedfiles/filedetails/?id=2489644810",
|
||||
"says": "The author's G920 Windows DirectInput setup lists raw indices 0 A, 1 B, 2 X, 3 Y, 4 right paddle, 5 left paddle, 6 Menu, 7 View, 8 RSB, and 9 LSB. The guide warns the G HUB product name differs."
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/chromium/chromium/blob/main/device/gamepad/raw_input_gamepad_device_win.cc",
|
||||
"says": "Windows RawInput assigns a button index from HID button usage minus one, and an axis index from HID Generic Desktop usage minus 0x30. It normalizes to [-1,+1], preserving an out-of-range hat null state."
|
||||
}
|
||||
],
|
||||
"reason": "The G920 owner's native Windows indices are projected to Chrome's unremapped HID button order; no Chrome dump of this individual button was found."
|
||||
}
|
||||
},
|
||||
"X": {
|
||||
"kind": "button",
|
||||
"index": 2,
|
||||
"evidence": {
|
||||
"status": "inferred",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://steamcommunity.com/sharedfiles/filedetails/?id=2489644810",
|
||||
"says": "The author's G920 Windows DirectInput setup lists raw indices 0 A, 1 B, 2 X, 3 Y, 4 right paddle, 5 left paddle, 6 Menu, 7 View, 8 RSB, and 9 LSB. The guide warns the G HUB product name differs."
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/chromium/chromium/blob/main/device/gamepad/raw_input_gamepad_device_win.cc",
|
||||
"says": "Windows RawInput assigns a button index from HID button usage minus one, and an axis index from HID Generic Desktop usage minus 0x30. It normalizes to [-1,+1], preserving an out-of-range hat null state."
|
||||
}
|
||||
],
|
||||
"reason": "The G920 owner's native Windows indices are projected to Chrome's unremapped HID button order; no Chrome dump of this individual button was found."
|
||||
}
|
||||
},
|
||||
"Y": {
|
||||
"kind": "button",
|
||||
"index": 3,
|
||||
"evidence": {
|
||||
"status": "inferred",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://steamcommunity.com/sharedfiles/filedetails/?id=2489644810",
|
||||
"says": "The author's G920 Windows DirectInput setup lists raw indices 0 A, 1 B, 2 X, 3 Y, 4 right paddle, 5 left paddle, 6 Menu, 7 View, 8 RSB, and 9 LSB. The guide warns the G HUB product name differs."
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/chromium/chromium/blob/main/device/gamepad/raw_input_gamepad_device_win.cc",
|
||||
"says": "Windows RawInput assigns a button index from HID button usage minus one, and an axis index from HID Generic Desktop usage minus 0x30. It normalizes to [-1,+1], preserving an out-of-range hat null state."
|
||||
}
|
||||
],
|
||||
"reason": "The G920 owner's native Windows indices are projected to Chrome's unremapped HID button order; no Chrome dump of this individual button was found."
|
||||
}
|
||||
},
|
||||
"Right paddle shifter": {
|
||||
"kind": "button",
|
||||
"index": 4,
|
||||
"evidence": {
|
||||
"status": "inferred",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://steamcommunity.com/sharedfiles/filedetails/?id=2489644810",
|
||||
"says": "The author's G920 Windows DirectInput setup lists raw indices 0 A, 1 B, 2 X, 3 Y, 4 right paddle, 5 left paddle, 6 Menu, 7 View, 8 RSB, and 9 LSB. The guide warns the G HUB product name differs."
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/chromium/chromium/blob/main/device/gamepad/raw_input_gamepad_device_win.cc",
|
||||
"says": "Windows RawInput assigns a button index from HID button usage minus one, and an axis index from HID Generic Desktop usage minus 0x30. It normalizes to [-1,+1], preserving an out-of-range hat null state."
|
||||
}
|
||||
],
|
||||
"reason": "The G920 owner's native Windows indices are projected to Chrome's unremapped HID button order; no Chrome dump of this individual button was found."
|
||||
}
|
||||
},
|
||||
"Left paddle shifter": {
|
||||
"kind": "button",
|
||||
"index": 5,
|
||||
"evidence": {
|
||||
"status": "inferred",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://steamcommunity.com/sharedfiles/filedetails/?id=2489644810",
|
||||
"says": "The author's G920 Windows DirectInput setup lists raw indices 0 A, 1 B, 2 X, 3 Y, 4 right paddle, 5 left paddle, 6 Menu, 7 View, 8 RSB, and 9 LSB. The guide warns the G HUB product name differs."
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/chromium/chromium/blob/main/device/gamepad/raw_input_gamepad_device_win.cc",
|
||||
"says": "Windows RawInput assigns a button index from HID button usage minus one, and an axis index from HID Generic Desktop usage minus 0x30. It normalizes to [-1,+1], preserving an out-of-range hat null state."
|
||||
}
|
||||
],
|
||||
"reason": "The G920 owner's native Windows indices are projected to Chrome's unremapped HID button order; no Chrome dump of this individual button was found."
|
||||
}
|
||||
},
|
||||
"Menu": {
|
||||
"kind": "button",
|
||||
"index": 6,
|
||||
"evidence": {
|
||||
"status": "inferred",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://steamcommunity.com/sharedfiles/filedetails/?id=2489644810",
|
||||
"says": "The author's G920 Windows DirectInput setup lists raw indices 0 A, 1 B, 2 X, 3 Y, 4 right paddle, 5 left paddle, 6 Menu, 7 View, 8 RSB, and 9 LSB. The guide warns the G HUB product name differs."
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/chromium/chromium/blob/main/device/gamepad/raw_input_gamepad_device_win.cc",
|
||||
"says": "Windows RawInput assigns a button index from HID button usage minus one, and an axis index from HID Generic Desktop usage minus 0x30. It normalizes to [-1,+1], preserving an out-of-range hat null state."
|
||||
}
|
||||
],
|
||||
"reason": "The G920 owner's native Windows indices are projected to Chrome's unremapped HID button order; no Chrome dump of this individual button was found."
|
||||
}
|
||||
},
|
||||
"View": {
|
||||
"kind": "button",
|
||||
"index": 7,
|
||||
"evidence": {
|
||||
"status": "inferred",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://steamcommunity.com/sharedfiles/filedetails/?id=2489644810",
|
||||
"says": "The author's G920 Windows DirectInput setup lists raw indices 0 A, 1 B, 2 X, 3 Y, 4 right paddle, 5 left paddle, 6 Menu, 7 View, 8 RSB, and 9 LSB. The guide warns the G HUB product name differs."
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/chromium/chromium/blob/main/device/gamepad/raw_input_gamepad_device_win.cc",
|
||||
"says": "Windows RawInput assigns a button index from HID button usage minus one, and an axis index from HID Generic Desktop usage minus 0x30. It normalizes to [-1,+1], preserving an out-of-range hat null state."
|
||||
}
|
||||
],
|
||||
"reason": "The G920 owner's native Windows indices are projected to Chrome's unremapped HID button order; no Chrome dump of this individual button was found."
|
||||
}
|
||||
},
|
||||
"RSB": {
|
||||
"kind": "button",
|
||||
"index": 8,
|
||||
"evidence": {
|
||||
"status": "inferred",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://steamcommunity.com/sharedfiles/filedetails/?id=2489644810",
|
||||
"says": "The author's G920 Windows DirectInput setup lists raw indices 0 A, 1 B, 2 X, 3 Y, 4 right paddle, 5 left paddle, 6 Menu, 7 View, 8 RSB, and 9 LSB. The guide warns the G HUB product name differs."
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/chromium/chromium/blob/main/device/gamepad/raw_input_gamepad_device_win.cc",
|
||||
"says": "Windows RawInput assigns a button index from HID button usage minus one, and an axis index from HID Generic Desktop usage minus 0x30. It normalizes to [-1,+1], preserving an out-of-range hat null state."
|
||||
}
|
||||
],
|
||||
"reason": "The G920 owner's native Windows indices are projected to Chrome's unremapped HID button order; no Chrome dump of this individual button was found."
|
||||
}
|
||||
},
|
||||
"LSB": {
|
||||
"kind": "button",
|
||||
"index": 9,
|
||||
"evidence": {
|
||||
"status": "inferred",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://steamcommunity.com/sharedfiles/filedetails/?id=2489644810",
|
||||
"says": "The author's G920 Windows DirectInput setup lists raw indices 0 A, 1 B, 2 X, 3 Y, 4 right paddle, 5 left paddle, 6 Menu, 7 View, 8 RSB, and 9 LSB. The guide warns the G HUB product name differs."
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/chromium/chromium/blob/main/device/gamepad/raw_input_gamepad_device_win.cc",
|
||||
"says": "Windows RawInput assigns a button index from HID button usage minus one, and an axis index from HID Generic Desktop usage minus 0x30. It normalizes to [-1,+1], preserving an out-of-range hat null state."
|
||||
}
|
||||
],
|
||||
"reason": "The G920 owner's native Windows indices are projected to Chrome's unremapped HID button order; no Chrome dump of this individual button was found."
|
||||
}
|
||||
},
|
||||
"Xbox": {
|
||||
"kind": "button",
|
||||
"index": 10,
|
||||
"evidence": {
|
||||
"status": "inferred",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://www.logitech.com/assets/54557/2/g920-driving-forcetm-racing-wheel.pdf",
|
||||
"says": "The G920 manual identifies A/B/X/Y, directional pad, left/right bumpers and stick buttons, View/Menu, Xbox, USB, and separate pedal/shifter connectors; its numbered callouts are physical labels, not browser indices."
|
||||
},
|
||||
{
|
||||
"url": "https://www.reddit.com/r/LogitechG/comments/lzmpf3/",
|
||||
"says": "A G920 and G HUB owner reports Windows joy.cpl buttons 1-11 on the wheel, button 12 for reverse, and buttons 13-18 for forward gears; pushing the lever down and moving it bottom right selects reverse."
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/chromium/chromium/blob/main/device/gamepad/raw_input_gamepad_device_win.cc",
|
||||
"says": "Windows RawInput assigns a button index from HID button usage minus one, and an axis index from HID Generic Desktop usage minus 0x30. It normalizes to [-1,+1], preserving an out-of-range hat null state."
|
||||
}
|
||||
],
|
||||
"reason": "The manual identifies Xbox as the remaining wheel button; the owner's Windows report puts wheel buttons at one-based 1..11. Assigning Xbox to zero-based 10 is inferred."
|
||||
}
|
||||
},
|
||||
"Reverse gear": {
|
||||
"kind": "button",
|
||||
"index": 11,
|
||||
"evidence": {
|
||||
"status": "inferred",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://www.reddit.com/r/LogitechG/comments/lzmpf3/",
|
||||
"says": "A G920 and G HUB owner reports Windows joy.cpl buttons 1-11 on the wheel, button 12 for reverse, and buttons 13-18 for forward gears; pushing the lever down and moving it bottom right selects reverse."
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/chromium/chromium/blob/main/device/gamepad/raw_input_gamepad_device_win.cc",
|
||||
"says": "Windows RawInput assigns a button index from HID button usage minus one, and an axis index from HID Generic Desktop usage minus 0x30. It normalizes to [-1,+1], preserving an out-of-range hat null state."
|
||||
}
|
||||
],
|
||||
"reason": "One-based Windows button 12 is projected to unremapped browser index 11. LGS documentation uses a conflicting 19-button order; this is only a G HUB-style candidate."
|
||||
}
|
||||
},
|
||||
"Gear 1": {
|
||||
"kind": "button",
|
||||
"index": 12,
|
||||
"evidence": {
|
||||
"status": "inferred",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://www.reddit.com/r/LogitechG/comments/lzmpf3/",
|
||||
"says": "A G920 and G HUB owner reports Windows joy.cpl buttons 1-11 on the wheel, button 12 for reverse, and buttons 13-18 for forward gears; pushing the lever down and moving it bottom right selects reverse."
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/chromium/chromium/blob/main/device/gamepad/raw_input_gamepad_device_win.cc",
|
||||
"says": "Windows RawInput assigns a button index from HID button usage minus one, and an axis index from HID Generic Desktop usage minus 0x30. It normalizes to [-1,+1], preserving an out-of-range hat null state."
|
||||
}
|
||||
],
|
||||
"reason": "The owner's one-based Windows forward-gear buttons 13..18 are projected to browser indices 12..17. The optional shifter is assumed disconnected by default."
|
||||
}
|
||||
},
|
||||
"Gear 2": {
|
||||
"kind": "button",
|
||||
"index": 13,
|
||||
"evidence": {
|
||||
"status": "inferred",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://www.reddit.com/r/LogitechG/comments/lzmpf3/",
|
||||
"says": "A G920 and G HUB owner reports Windows joy.cpl buttons 1-11 on the wheel, button 12 for reverse, and buttons 13-18 for forward gears; pushing the lever down and moving it bottom right selects reverse."
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/chromium/chromium/blob/main/device/gamepad/raw_input_gamepad_device_win.cc",
|
||||
"says": "Windows RawInput assigns a button index from HID button usage minus one, and an axis index from HID Generic Desktop usage minus 0x30. It normalizes to [-1,+1], preserving an out-of-range hat null state."
|
||||
}
|
||||
],
|
||||
"reason": "The owner's one-based Windows forward-gear buttons 13..18 are projected to browser indices 12..17. The optional shifter is assumed disconnected by default."
|
||||
}
|
||||
},
|
||||
"Gear 3": {
|
||||
"kind": "button",
|
||||
"index": 14,
|
||||
"evidence": {
|
||||
"status": "inferred",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://www.reddit.com/r/LogitechG/comments/lzmpf3/",
|
||||
"says": "A G920 and G HUB owner reports Windows joy.cpl buttons 1-11 on the wheel, button 12 for reverse, and buttons 13-18 for forward gears; pushing the lever down and moving it bottom right selects reverse."
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/chromium/chromium/blob/main/device/gamepad/raw_input_gamepad_device_win.cc",
|
||||
"says": "Windows RawInput assigns a button index from HID button usage minus one, and an axis index from HID Generic Desktop usage minus 0x30. It normalizes to [-1,+1], preserving an out-of-range hat null state."
|
||||
}
|
||||
],
|
||||
"reason": "The owner's one-based Windows forward-gear buttons 13..18 are projected to browser indices 12..17. The optional shifter is assumed disconnected by default."
|
||||
}
|
||||
},
|
||||
"Gear 4": {
|
||||
"kind": "button",
|
||||
"index": 15,
|
||||
"evidence": {
|
||||
"status": "inferred",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://www.reddit.com/r/LogitechG/comments/lzmpf3/",
|
||||
"says": "A G920 and G HUB owner reports Windows joy.cpl buttons 1-11 on the wheel, button 12 for reverse, and buttons 13-18 for forward gears; pushing the lever down and moving it bottom right selects reverse."
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/chromium/chromium/blob/main/device/gamepad/raw_input_gamepad_device_win.cc",
|
||||
"says": "Windows RawInput assigns a button index from HID button usage minus one, and an axis index from HID Generic Desktop usage minus 0x30. It normalizes to [-1,+1], preserving an out-of-range hat null state."
|
||||
}
|
||||
],
|
||||
"reason": "The owner's one-based Windows forward-gear buttons 13..18 are projected to browser indices 12..17. The optional shifter is assumed disconnected by default."
|
||||
}
|
||||
},
|
||||
"Gear 5": {
|
||||
"kind": "button",
|
||||
"index": 16,
|
||||
"evidence": {
|
||||
"status": "inferred",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://www.reddit.com/r/LogitechG/comments/lzmpf3/",
|
||||
"says": "A G920 and G HUB owner reports Windows joy.cpl buttons 1-11 on the wheel, button 12 for reverse, and buttons 13-18 for forward gears; pushing the lever down and moving it bottom right selects reverse."
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/chromium/chromium/blob/main/device/gamepad/raw_input_gamepad_device_win.cc",
|
||||
"says": "Windows RawInput assigns a button index from HID button usage minus one, and an axis index from HID Generic Desktop usage minus 0x30. It normalizes to [-1,+1], preserving an out-of-range hat null state."
|
||||
}
|
||||
],
|
||||
"reason": "The owner's one-based Windows forward-gear buttons 13..18 are projected to browser indices 12..17. The optional shifter is assumed disconnected by default."
|
||||
}
|
||||
},
|
||||
"Gear 6": {
|
||||
"kind": "button",
|
||||
"index": 17,
|
||||
"evidence": {
|
||||
"status": "inferred",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://www.reddit.com/r/LogitechG/comments/lzmpf3/",
|
||||
"says": "A G920 and G HUB owner reports Windows joy.cpl buttons 1-11 on the wheel, button 12 for reverse, and buttons 13-18 for forward gears; pushing the lever down and moving it bottom right selects reverse."
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/chromium/chromium/blob/main/device/gamepad/raw_input_gamepad_device_win.cc",
|
||||
"says": "Windows RawInput assigns a button index from HID button usage minus one, and an axis index from HID Generic Desktop usage minus 0x30. It normalizes to [-1,+1], preserving an out-of-range hat null state."
|
||||
}
|
||||
],
|
||||
"reason": "The owner's one-based Windows forward-gear buttons 13..18 are projected to browser indices 12..17. The optional shifter is assumed disconnected by default."
|
||||
}
|
||||
},
|
||||
"Wheel": {
|
||||
"kind": "axis",
|
||||
"index": 0,
|
||||
"rest": -0.0013580322265625,
|
||||
"min": -1,
|
||||
"max": 1,
|
||||
"evidence": {
|
||||
"status": "confirmed",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://www.reddit.com/r/Stadia/comments/fdc7sh/",
|
||||
"says": "A G920 owner pasted a Chrome Gamepad object on 2020-03-04: this exact ID, empty mapping, 18 buttons, 10 axes with the saved resting vector, and vibrationActuator null. The report does not specify the OS, driver version, or whether pedals were disconnected."
|
||||
},
|
||||
{
|
||||
"url": "https://spenibus.net/b/p/F/PC-steering-wheel-viewer-prototype-in-html-javascript",
|
||||
"says": "The author's 2018 G920 Firefox/Chrome browser prototype reads steering axis 0, throttle 1, brake 2, and clutch 3 or 5; its pedal conversion treats +1 as released and -1 as pressed."
|
||||
}
|
||||
],
|
||||
"reason": "The dump preserves a small observed steering offset; the author's browser code identifies axis 0 as steering. Positive values turn right by the browser axis convention."
|
||||
}
|
||||
},
|
||||
"Accelerator pedal": {
|
||||
"kind": "axis",
|
||||
"index": 1,
|
||||
"rest": 1,
|
||||
"min": -1,
|
||||
"max": 1,
|
||||
"evidence": {
|
||||
"status": "inferred",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://www.reddit.com/r/Stadia/comments/fdc7sh/",
|
||||
"says": "A G920 owner pasted a Chrome Gamepad object on 2020-03-04: this exact ID, empty mapping, 18 buttons, 10 axes with the saved resting vector, and vibrationActuator null. The report does not specify the OS, driver version, or whether pedals were disconnected."
|
||||
},
|
||||
{
|
||||
"url": "https://spenibus.net/b/p/F/PC-steering-wheel-viewer-prototype-in-html-javascript",
|
||||
"says": "The author's 2018 G920 Firefox/Chrome browser prototype reads steering axis 0, throttle 1, brake 2, and clutch 3 or 5; its pedal conversion treats +1 as released and -1 as pressed."
|
||||
},
|
||||
{
|
||||
"url": "https://forum.scssoft.com/viewtopic.php?p=1313821",
|
||||
"says": "A real 046d:c262 G920 HID dump has 18 button usages, X usage 0x30 (0..65535), Y 0x31/Z 0x32/Rz 0x35 (0..255), and hat 0x39 (0..7). The dump is from macOS native HID, not Safari."
|
||||
}
|
||||
],
|
||||
"reason": "The real Chrome snapshot has +1 here and the owner's browser prototype supplies the pedal identity/range. Retaining +1 with the pedal connector unplugged is a scenario assumption, not an observed disconnected-pedal capture."
|
||||
}
|
||||
},
|
||||
"Brake pedal": {
|
||||
"kind": "axis",
|
||||
"index": 2,
|
||||
"rest": 1,
|
||||
"min": -1,
|
||||
"max": 1,
|
||||
"evidence": {
|
||||
"status": "inferred",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://www.reddit.com/r/Stadia/comments/fdc7sh/",
|
||||
"says": "A G920 owner pasted a Chrome Gamepad object on 2020-03-04: this exact ID, empty mapping, 18 buttons, 10 axes with the saved resting vector, and vibrationActuator null. The report does not specify the OS, driver version, or whether pedals were disconnected."
|
||||
},
|
||||
{
|
||||
"url": "https://spenibus.net/b/p/F/PC-steering-wheel-viewer-prototype-in-html-javascript",
|
||||
"says": "The author's 2018 G920 Firefox/Chrome browser prototype reads steering axis 0, throttle 1, brake 2, and clutch 3 or 5; its pedal conversion treats +1 as released and -1 as pressed."
|
||||
},
|
||||
{
|
||||
"url": "https://forum.scssoft.com/viewtopic.php?p=1313821",
|
||||
"says": "A real 046d:c262 G920 HID dump has 18 button usages, X usage 0x30 (0..65535), Y 0x31/Z 0x32/Rz 0x35 (0..255), and hat 0x39 (0..7). The dump is from macOS native HID, not Safari."
|
||||
}
|
||||
],
|
||||
"reason": "The real Chrome snapshot has +1 here and the owner's browser prototype supplies the pedal identity/range. Retaining +1 with the pedal connector unplugged is a scenario assumption, not an observed disconnected-pedal capture."
|
||||
}
|
||||
},
|
||||
"Clutch pedal": {
|
||||
"kind": "axis",
|
||||
"index": 5,
|
||||
"rest": 1,
|
||||
"min": -1,
|
||||
"max": 1,
|
||||
"evidence": {
|
||||
"status": "inferred",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://www.reddit.com/r/Stadia/comments/fdc7sh/",
|
||||
"says": "A G920 owner pasted a Chrome Gamepad object on 2020-03-04: this exact ID, empty mapping, 18 buttons, 10 axes with the saved resting vector, and vibrationActuator null. The report does not specify the OS, driver version, or whether pedals were disconnected."
|
||||
},
|
||||
{
|
||||
"url": "https://spenibus.net/b/p/F/PC-steering-wheel-viewer-prototype-in-html-javascript",
|
||||
"says": "The author's 2018 G920 Firefox/Chrome browser prototype reads steering axis 0, throttle 1, brake 2, and clutch 3 or 5; its pedal conversion treats +1 as released and -1 as pressed."
|
||||
},
|
||||
{
|
||||
"url": "https://forum.scssoft.com/viewtopic.php?p=1313821",
|
||||
"says": "A real 046d:c262 G920 HID dump has 18 button usages, X usage 0x30 (0..65535), Y 0x31/Z 0x32/Rz 0x35 (0..255), and hat 0x39 (0..7). The dump is from macOS native HID, not Safari."
|
||||
}
|
||||
],
|
||||
"reason": "The real Chrome snapshot has +1 here and the owner's browser prototype supplies the pedal identity/range. Retaining +1 with the pedal connector unplugged is a scenario assumption, not an observed disconnected-pedal capture."
|
||||
}
|
||||
},
|
||||
"D-pad": {
|
||||
"kind": "hat",
|
||||
"index": 9,
|
||||
"neutral": 1.2857143878936768,
|
||||
"directions": {
|
||||
"up": -1,
|
||||
"up-right": -0.7142857313156128,
|
||||
"right": -0.4285714030265808,
|
||||
"down-right": -0.1428571343421936,
|
||||
"down": 0.14285719394683838,
|
||||
"down-left": 0.4285714626312256,
|
||||
"left": 0.7142857313156128,
|
||||
"up-left": 1
|
||||
},
|
||||
"evidence": {
|
||||
"status": "inferred",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://www.reddit.com/r/Stadia/comments/fdc7sh/",
|
||||
"says": "A G920 owner pasted a Chrome Gamepad object on 2020-03-04: this exact ID, empty mapping, 18 buttons, 10 axes with the saved resting vector, and vibrationActuator null. The report does not specify the OS, driver version, or whether pedals were disconnected."
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/chromium/chromium/blob/main/device/gamepad/raw_input_gamepad_device_win.cc",
|
||||
"says": "Windows RawInput assigns a button index from HID button usage minus one, and an axis index from HID Generic Desktop usage minus 0x30. It normalizes to [-1,+1], preserving an out-of-range hat null state."
|
||||
},
|
||||
{
|
||||
"url": "https://forum.scssoft.com/viewtopic.php?p=1313821",
|
||||
"says": "A real 046d:c262 G920 HID dump has 18 button usages, X usage 0x30 (0..65535), Y 0x31/Z 0x32/Rz 0x35 (0..255), and hat 0x39 (0..7). The dump is from macOS native HID, not Safari."
|
||||
}
|
||||
],
|
||||
"reason": "Hat axis 9 and neutral 1.2857143878936768 are confirmed by the Chrome dump. Direction values are derived using Chromium float normalization of the G920 HID hat range 0..7, clockwise from north."
|
||||
}
|
||||
}
|
||||
},
|
||||
"switches": {},
|
||||
"notes": [
|
||||
"This profile is a historical Chrome/HID report plus named-control inferences, not a claim of hardware compatibility.",
|
||||
"Axes 3,4,6,7,8 are preserved zero-valued gaps in the reported array, not imaginary sticks.",
|
||||
"The hat neutral is 1.2857143878936768 for this dump; do not replace it with the often-seen 3.2857.",
|
||||
"Pedal axes remain in the fixed HID report descriptor when the separate pedal unit is absent. Their +1 unplugged rests are unconfirmed and intentionally inferred.",
|
||||
"Buttons rest at value 0, pressed false, touched false, using Chromium's binary raw HID button semantics; pedals are axes, not analog buttons.",
|
||||
"No user-operated platform/mode switch is documented for G920. Driver-generated XInput and combined-pedal variants are outside this profile.",
|
||||
"Optional shifter indices use a firsthand G HUB native report; Logitech's LGS shifter guide disagrees. Do not silently apply that LGS layout to this 18-button snapshot.",
|
||||
"No Firefox, Safari, Edge, current-G-HUB Chrome, or second-wheel profile is emitted without a full matching browser report."
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,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(); });
|
||||
}
|
||||
}
|
||||
@@ -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 =>
|
||||
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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; });
|
||||
@@ -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 };
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
const fs = require('node:fs');
|
||||
const vm = require('node:vm');
|
||||
const assert = require('node:assert/strict');
|
||||
const tr = (key, values) => values ? key.replace(/\{(\w+)\}/g, (_, name) => values[name]) : key;
|
||||
const page = fs.readFileSync('client/index.html', 'utf8');
|
||||
const extract = (start, end) => {
|
||||
const from = page.indexOf(start);
|
||||
@@ -15,10 +16,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(Object.assign(pingCtx, { tr }));
|
||||
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(Object.assign(routeCtx, { tr }));
|
||||
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 +52,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) {
|
||||
@@ -37,7 +63,7 @@ function padStatus(fields) {
|
||||
},
|
||||
applyStatus() {}, toast() {}, closePad() { ctx.primaryPad = -1; }, renderGamepads() {},
|
||||
};
|
||||
vm.createContext(ctx);
|
||||
vm.createContext(Object.assign(ctx, { tr }));
|
||||
vm.runInContext(sendClaimSource, ctx);
|
||||
vm.runInContext('sendClaim(true)', ctx);
|
||||
for (let i = 0; i < 5; i++) await new Promise(setImmediate);
|
||||
@@ -47,6 +73,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(Object.assign(serial, { tr })); 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,9 +120,11 @@ 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.createContext(Object.assign(pads, { tr }));
|
||||
vm.runInContext(pollSource, pads);
|
||||
vm.runInContext('pollGamepads(100)', pads);
|
||||
const src = pads.gamepadSources.get(0);
|
||||
@@ -85,7 +152,7 @@ function padStatus(fields) {
|
||||
toast(message) { ctx.toasts.push(message); },
|
||||
};
|
||||
ctx.window.matchMedia = ctx.matchMedia;
|
||||
vm.createContext(ctx);
|
||||
vm.createContext(Object.assign(ctx, { tr }));
|
||||
vm.runInContext(fsSource, ctx);
|
||||
if (!button.hidden) await button.handlers.click();
|
||||
return ctx;
|
||||
@@ -113,7 +180,7 @@ function padStatus(fields) {
|
||||
|
||||
// Which gamepads the page says it can rumble.
|
||||
const rumbleCtx = {};
|
||||
vm.createContext(rumbleCtx);
|
||||
vm.createContext(Object.assign(rumbleCtx, { tr }));
|
||||
vm.runInContext(extract(' function canRumble(gp)', ' function renderGamepads()'), rumbleCtx);
|
||||
const can = gp => vm.runInContext('canRumble(gp)', Object.assign(rumbleCtx, { gp }));
|
||||
const play = () => Promise.resolve();
|
||||
@@ -126,7 +193,7 @@ function padStatus(fields) {
|
||||
|
||||
// The Invite panel draws the console's grid as runs of dark modules.
|
||||
const qrCtx = {};
|
||||
vm.createContext(qrCtx);
|
||||
vm.createContext(Object.assign(qrCtx, { tr }));
|
||||
vm.runInContext(extract(' function qrPath(size, rows, quiet)', ' // The address this page reached'), qrCtx);
|
||||
// 5 wide: row 0 = 11011, row 1 = 00100 -> hex d8, 20.
|
||||
assert.equal(vm.runInContext("qrPath(5, ['d8', '20'], 4)", qrCtx),
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
// Real DOM regressions for translation boundaries and the narrow-screen picker.
|
||||
// No service or console: instrument the shipped page in memory and block network.
|
||||
const fs = require('node:fs');
|
||||
const assert = require('node:assert/strict');
|
||||
const { chromium } = require('playwright');
|
||||
const hook = '\nwindow.c4fTest = { ControllerMapping, mapper, profileStore, gamepadSources, renderGamepads, renderPads, renderPadLabel, status, keymap, setPrimary: value => { primaryPad = value; } };\n';
|
||||
const original = fs.readFileSync('client/index.html', 'utf8');
|
||||
const html = original.replace(' connect(address);', ' // No signaling in this offline fixture.')
|
||||
.replace('\n })();', hook + ' })();');
|
||||
assert.notEqual(html, original);
|
||||
const clean = value => value.replace(/[\u2066-\u2069]/g, '');
|
||||
const settle = page => page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
try {
|
||||
for (const locale of ['en-US', 'pt-BR', 'ar-SA']) for (const width of [320, 360, 390, 1440]) {
|
||||
const language = locale.startsWith('ar') ? 'ar' : locale.startsWith('pt') ? 'pt-BR' : 'en';
|
||||
const controller = language === 'ar' ? 'يد التحكم' : language === 'pt-BR' ? 'Controle' : 'Controller';
|
||||
const gamepad = language === 'ar' ? 'يد متصلة' : 'Gamepad';
|
||||
const context = await browser.newContext({ viewport: { width, height: 900 }, locale });
|
||||
await context.addInitScript(() => {
|
||||
window.mockPad = { index: 0, id: 'Left', mapping: 'standard', connected: true,
|
||||
buttons: Array.from({ length: 18 }, () => ({ value: 0, pressed: false })), axes: [0, 0, 0, 0] };
|
||||
Object.defineProperty(navigator, 'getGamepads', { value: () => [window.mockPad] });
|
||||
});
|
||||
const page = await context.newPage(), errors = [];
|
||||
page.on('pageerror', error => errors.push(error.message));
|
||||
await page.route('**/*', route => {
|
||||
const url = route.request().url();
|
||||
if (url === 'http://c4f.test/') return route.fulfill({ contentType: 'text/html', body: html });
|
||||
const font = url.endsWith('/fonts/arabic-light.otf') ? 'SSTArabic-Light.otf' : url.endsWith('/fonts/arabic-regular.otf') ? 'SSTArabic-Roman.otf' : '';
|
||||
if (font && process.env.C4F_TEST_PS4_FONTS) return route.fulfill({ contentType: 'font/otf', body: fs.readFileSync(process.env.C4F_TEST_PS4_FONTS + '/' + font) });
|
||||
return route.abort();
|
||||
});
|
||||
await page.goto('http://c4f.test/');
|
||||
if (width === 1440) assert(await page.locator('.screen-sub').evaluate(e => e.getBoundingClientRect().width > 800),
|
||||
'introductory copy uses the available width instead of an English-sized character cap');
|
||||
await page.waitForFunction(() => window.c4fTest?.gamepadSources.size === 1);
|
||||
assert.equal(await page.evaluate(() => c4fTest.keymap.CROSS), 'Space', 'keyboard event codes never follow the interface language');
|
||||
if (language === 'ar') {
|
||||
// The normal CI fixture denies font endpoints: embedded offline fallback remains usable.
|
||||
// Optional local fixture uses console-supplied files without redistributing Sony bytes.
|
||||
await page.evaluate(() => c4fI18n.loadConsoleFonts('http://c4f.test/'));
|
||||
if (process.env.C4F_TEST_PS4_FONTS) assert.deepEqual(await page.evaluate(() =>
|
||||
[...document.fonts].filter(font => font.family.replaceAll(String.fromCharCode(34), '') === 'SST Arabic' && font.status === 'loaded').map(font => font.weight).sort()), ['300', '400'],
|
||||
'both console SST weights are actually loaded, rather than only available through fallback');
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
await settle(page);
|
||||
}
|
||||
assert.equal(clean(await page.locator('html').getAttribute('lang')), language);
|
||||
assert.equal(clean(await page.locator('html').getAttribute('dir')), language === 'ar' ? 'rtl' : 'ltr');
|
||||
assert.equal(clean(await page.locator('#openSettings').getAttribute('aria-label')), language === 'ar' ? 'الإعدادات' : language === 'pt-BR' ? 'Configurações' : 'Settings');
|
||||
if (language === 'ar' && (width === 390 || width === 1440)) {
|
||||
fs.mkdirSync('dev/web-preview', { recursive: true });
|
||||
await page.evaluate(() => { document.querySelector('#toast').classList.remove('show'); document.querySelector('#toast').style.visibility = 'hidden'; });
|
||||
await page.screenshot({ path: 'dev/web-preview/ar-home-' + width + '.png' });
|
||||
}
|
||||
await page.click('#openSettings');
|
||||
await page.locator('#langSelect').scrollIntoViewIfNeeded();
|
||||
await settle(page);
|
||||
const bounds = await page.locator('#langSelect').evaluate(select => {
|
||||
const row = select.closest('.row'), a = select.getBoundingClientRect(), b = row.getBoundingClientRect();
|
||||
return { left: a.left, right: a.right, rowLeft: b.left, rowRight: b.right, scroll: row.scrollWidth, width: row.clientWidth };
|
||||
});
|
||||
assert(bounds.left >= bounds.rowLeft - 1 && bounds.right <= bounds.rowRight + 1, `${width}: picker fits ${JSON.stringify(bounds)}`);
|
||||
assert(bounds.scroll <= bounds.width + 1, `${width}: language row does not overflow`);
|
||||
|
||||
if (language === 'ar' && (width === 390 || width === 1440)) {
|
||||
await page.screenshot({ path: 'dev/web-preview/ar-settings-' + width + '.png' });
|
||||
for (const section of ['keyboard', 'gamepads', 'experimental']) {
|
||||
await page.click('[data-page="' + section + '"]');
|
||||
await page.screenshot({ path: 'dev/web-preview/ar-' + section + '-' + width + '.png' });
|
||||
}
|
||||
}
|
||||
// Runtime data has no localization observer, including after later mutations.
|
||||
await page.evaluate(() => {
|
||||
const box = document.createElement('div');
|
||||
box.id = 'rawFixture'; box.translate = false;
|
||||
box.innerHTML = '<span title="Left" aria-label="Settings">Left</span>';
|
||||
document.body.appendChild(box);
|
||||
});
|
||||
await settle(page);
|
||||
assert.deepEqual(await page.locator('#rawFixture span').evaluate(e => [e.textContent, e.title, e.getAttribute('aria-label')]), ['Left', 'Left', 'Settings']);
|
||||
await page.locator('#rawFixture span').evaluate(e => { e.textContent = 'Right'; e.title = 'Settings'; });
|
||||
await settle(page);
|
||||
assert.deepEqual(await page.locator('#rawFixture span').evaluate(e => [e.textContent, e.title]), ['Right', 'Settings']);
|
||||
|
||||
for (const name of ['Left', 'Esquerda', 'Left. Right.', '<b>Settings</b>', 'أحمد DS4']) {
|
||||
await page.evaluate(name => {
|
||||
const t = c4fTest, src = t.gamepadSources.get(0);
|
||||
mockPad.id = name; src.signature.id = name;
|
||||
const profile = t.ControllerMapping.browserDefault(src.signature);
|
||||
profile.id = 'custom'; profile.name = name;
|
||||
t.profileStore.profiles = [profile]; src.profileId = profile.id;
|
||||
Object.assign(t.status[0], { known: true, user: name, state: 'ready', open: true, connected: true });
|
||||
t.renderPads(); t.renderGamepads(); t.setPrimary(0); t.renderPadLabel();
|
||||
t.mapper.open(0);
|
||||
}, name);
|
||||
await settle(page);
|
||||
if (language === 'ar' && width === 1440 && name === 'أحمد DS4') await page.screenshot({path:'dev/web-preview/ar-mapping-desktop.png'});
|
||||
assert.equal(clean(await page.locator('.tile-name').first().textContent()), name);
|
||||
assert.equal(clean(await page.locator('#gamepadList .gp-name').first().textContent()), name);
|
||||
assert.equal(clean(await page.locator('#gamepadList .gp-profile').first().textContent()), name);
|
||||
assert.equal(clean(await page.locator('#mapProfile option[value="custom"]').textContent()), name);
|
||||
assert.equal(clean(await page.locator('#mapSource option[value="0"]').textContent()), gamepad + ' 1 · ' + name);
|
||||
assert.equal(clean(await page.locator('#gamepadList [data-focus="pad-0"]').getAttribute('title')), controller + ' 1 · ' + name);
|
||||
assert(clean(await page.locator('#padLabel').textContent()).startsWith(controller + ' 1 · ' + name));
|
||||
await page.evaluate(name => {
|
||||
c4fTest.mapper.draft.name = name;
|
||||
document.querySelector('#mapName').value = name;
|
||||
c4fTest.mapper.save(true);
|
||||
}, name);
|
||||
await settle(page);
|
||||
assert.equal(clean(await page.locator('#mapStatus').textContent()), language === 'ar' ? 'حُفظ باسم ' + name + '.' : language === 'pt-BR' ? 'Salvo como ' + name + '.' : 'Saved as ' + name + '.');
|
||||
await page.evaluate(() => c4fTest.mapper.remove());
|
||||
await settle(page);
|
||||
assert.equal(clean(await page.locator('#mapStatus').textContent()), language === 'ar' ? 'اضغط «حذف» مجددًا لحذف ' + name + '.' : language === 'pt-BR' ? 'Aperte Excluir de novo para excluir ' + name + '.' : 'Press Delete again to delete ' + name + '.');
|
||||
await page.evaluate(() => { c4fTest.mapper.backArmed = 0; c4fTest.mapper.deleteArmed = 0; c4fTest.mapper.close(); });
|
||||
}
|
||||
const mapping = await page.evaluate(() => {
|
||||
const m = c4fTest.mapper;
|
||||
m.open(0);
|
||||
m.draft = c4fTest.ControllerMapping.browserDefault(c4fTest.gamepadSources.get(0).signature);
|
||||
m.draft.bindings.find(binding => binding.output === 'lx').invert = true;
|
||||
m.target = 'LS'; m.render();
|
||||
const reversed = document.querySelector('#mapPanel .map-row-label').textContent;
|
||||
m.draft.calibration[1] = { type: 'pedal', released: -1, full: 1 };
|
||||
m.tab = 'inputs'; m.render();
|
||||
const pedal = document.querySelector('#mapInputs [data-input="axis:1"] .in-name').textContent;
|
||||
m.openInput({kind: 'button', index: 0});
|
||||
const output = document.querySelector('#mapPanel .map-section h4').textContent;
|
||||
const type = m.draft.calibration[1].type;
|
||||
m.close();
|
||||
return { reversed, pedal, output, type };
|
||||
});
|
||||
assert(mapping.reversed.includes(language === 'ar' ? 'معكوس' : language === 'pt-BR' ? 'invertido' : 'reversed'));
|
||||
assert(mapping.pedal.includes(language === 'ar' ? 'دواسة' : 'pedal'));
|
||||
assert.equal(mapping.output, language === 'ar' ? '×' : language === 'pt-BR' ? 'X' : 'Cross');
|
||||
assert.equal(mapping.type, 'pedal', 'calibration machine values are not translated');
|
||||
// Closed and open drawers must each use the proper RTL side and transform.
|
||||
const drawer = page.locator('#optionsMenu');
|
||||
await drawer.evaluate(element => { element.style.transition = 'none'; });
|
||||
const closed = await drawer.boundingBox();
|
||||
if (language === 'ar') assert(closed.x + closed.width <= 1, 'RTL drawer is hidden beyond the left edge');
|
||||
else assert(closed.x >= width - 1, 'LTR drawer is hidden beyond the right edge');
|
||||
await page.locator('#padMenuBtn').evaluate(button => button.click());
|
||||
const open = await drawer.boundingBox();
|
||||
assert(open.x >= -1 && open.x + open.width <= width + 1, 'open drawer fits the viewport');
|
||||
assert(Math.abs(open.x - (language === 'ar' ? 0 : width - open.width)) < 1, 'drawer follows the interface direction');
|
||||
await page.locator('#closeOptions').evaluate(button => button.click());
|
||||
assert.equal(await page.locator('#mapPad .pad-area').evaluate(e => getComputedStyle(e).direction), 'ltr');
|
||||
assert.deepEqual(errors, [], `${width}: page errors`);
|
||||
if (width === 1440) {
|
||||
for (const choice of ['ar', 'pt-BR', 'en', 'auto']) {
|
||||
await page.locator('#langSelect').evaluate((select, value) => { select.value = value; select.dispatchEvent(new Event('change')); }, choice);
|
||||
const expected = choice === 'auto' ? language : choice;
|
||||
await page.waitForFunction(expected => document.documentElement.lang === expected, expected);
|
||||
assert.equal(await page.evaluate(() => JSON.parse(localStorage.getItem('c4f.lang'))), choice);
|
||||
}
|
||||
}
|
||||
await context.close();
|
||||
console.log(`PASS ${language} ${width}px: raw names, options, tooltips, messages, explicit boundaries and language picker`);
|
||||
}
|
||||
} finally { await browser.close(); }
|
||||
})().catch(error => { console.error(error); process.exitCode = 1; });
|
||||
@@ -0,0 +1,77 @@
|
||||
// Exercise the shipped offline message catalogue and language preference logic.
|
||||
const fs = require('node:fs'), vm = require('node:vm'), assert = require('node:assert/strict');
|
||||
const page = fs.readFileSync('client/index.html', 'utf8');
|
||||
const from = page.indexOf(' // Interface language.');
|
||||
const end = page.indexOf('\n }());', from) + '\n }());'.length;
|
||||
assert(from > 0 && end > from);
|
||||
const source = page.slice(from,end);
|
||||
assert(!source.includes('MutationObserver'), 'interface localization must not inspect arbitrary runtime data');
|
||||
function load({languages = ['en-US'], saved = null} = {}) {
|
||||
const ctx = { navigator: {languages, language: languages[0]},
|
||||
localStorage: {getItem: () => saved, setItem() {}},
|
||||
document: { documentElement: {}, querySelectorAll: () => [], getElementById: () => null } };
|
||||
ctx.window = ctx;
|
||||
vm.createContext(ctx); vm.runInContext(source,ctx);
|
||||
return ctx;
|
||||
}
|
||||
for (const [languages,saved,expected] of [
|
||||
[['en-US'],null,'en'], [['pt-PT'],null,'pt-BR'], [['ar-SA'],null,'ar'],
|
||||
[['en-GB','ar'],null,'en'], [['ar'],'"en"','en'], [['en'],'"pt-BR"','pt-BR'],
|
||||
[['en'],'"ar"','ar'], [['ar'],'"auto"','ar'], [['ar'],'"unsupported"','ar'],
|
||||
[['ar'],'invalid json','ar']
|
||||
]) assert.equal(load({languages,saved}).c4fLang,expected);
|
||||
const en=load(), pt=load({languages:['pt-BR']}), ar=load({languages:['ar']});
|
||||
assert.equal(en.document.documentElement.dir,'ltr');
|
||||
assert.equal(ar.document.documentElement.dir,'rtl');
|
||||
assert.equal(pt.c4fI18n.t('Settings'),'Configurações');
|
||||
assert.equal(ar.c4fI18n.t('Settings'),'الإعدادات');
|
||||
assert.equal(ar.c4fI18n.t('Controller {number}',{number:2}),'يد التحكم \u20682\u2069');
|
||||
for (const ctx of [en,pt,ar]) {
|
||||
const {t,messages}=ctx.c4fI18n;
|
||||
for (const name of ['Left','Esquerda','Left. Right.','Settings','أحمد DS4','<b>Settings</b>']) {
|
||||
assert(t('Saved as {name}.',{name}).includes(name), 'inserted names stay unchanged');
|
||||
assert.equal(t('Unknown service error 173'),'Unknown service error 173');
|
||||
}
|
||||
for (const key of Object.keys(messages['pt-BR'])) {
|
||||
assert(messages.ar[key], 'missing Arabic: '+key);
|
||||
for (const locale of ['pt-BR','ar']) assert.deepEqual(
|
||||
(messages[locale][key].match(/\{\w+\}/g)||[]).sort(),
|
||||
(key.match(/\{\w+\}/g)||[]).sort(), 'template values: '+key);
|
||||
}
|
||||
}
|
||||
for (const match of page.slice(0,from).matchAll(/data-i18n(?:-(?:aria-label|title|placeholder))?="([^"]+)"/g))
|
||||
assert(ar.c4fI18n.messages.ar[match[1].replace(/"/g,'"').replace(/&/g,'&')], 'markup key: '+match[1]);
|
||||
assert(!/[\u0660-\u0669\u06f0-\u06f9]/.test(JSON.stringify(ar.c4fI18n.messages.ar)), 'Arabic uses 0–9');
|
||||
assert.equal((page.match(/data:font\/ttf;base64,/g)||[]).length,2, 'both Arabic weights work offline');
|
||||
console.log('PASS offline English/Portuguese/Arabic catalogues, template boundaries, preferences, RTL and embedded fonts');
|
||||
|
||||
// A partial SST failure must retry only the missing weight, retaining the loaded one.
|
||||
(async () => {
|
||||
const ctx = load({ languages: ['ar'] }), faces = new Set(), created = [], attempts = {};
|
||||
ctx.URL = URL;
|
||||
ctx.document.fonts = { add: face => faces.add(face), delete: face => faces.delete(face) };
|
||||
ctx.FontFace = class {
|
||||
constructor(family, source, options) {
|
||||
Object.assign(this, options, { family, source, status: 'unloaded' }); created.push(this);
|
||||
}
|
||||
load() {
|
||||
attempts[this.weight] = (attempts[this.weight] || 0) + 1;
|
||||
if (this.weight === '400' && attempts[this.weight] === 1) return Promise.reject(Error('temporary font failure'));
|
||||
this.status = 'loaded'; return Promise.resolve(this);
|
||||
}
|
||||
};
|
||||
await ctx.c4fI18n.loadConsoleFonts('http://c4f.test/');
|
||||
assert.equal(faces.size, 1);
|
||||
const retained = [...faces][0];
|
||||
await ctx.c4fI18n.loadConsoleFonts('http://c4f.test/');
|
||||
assert.equal(faces.size, 2);
|
||||
assert(faces.has(retained), 'the successfully loaded weight remains during retry');
|
||||
assert.deepEqual(attempts, { 300: 1, 400: 2 });
|
||||
await ctx.c4fI18n.loadConsoleFonts('http://c4f.test/');
|
||||
assert.equal(created.length, 3, 'complete font sets are not requested again');
|
||||
await ctx.c4fI18n.loadConsoleFonts('http://second-c4f.test/');
|
||||
assert.equal(faces.size, 2, 'changing consoles replaces both old faces without growth');
|
||||
assert(!faces.has(retained));
|
||||
assert([...faces].every(face => face.source.includes('http://second-c4f.test/fonts/')));
|
||||
console.log('PASS missing SST weight retries, loaded weight retention and bounded font replacement');
|
||||
})().catch(error => { console.error(error); process.exitCode = 1; });
|
||||
@@ -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
|
||||
@@ -0,0 +1,7 @@
|
||||
/* Host render fixtures start in English unless they explicitly choose a locale. */
|
||||
int sceSystemServiceParamGetInt(int id, int *value)
|
||||
{
|
||||
(void)id;
|
||||
*value = 1;
|
||||
return 0;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,594 @@
|
||||
/* 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");
|
||||
}
|
||||
|
||||
static void languageButton(void)
|
||||
{
|
||||
C4fLauncherScreen screen = stopped();
|
||||
uint32_t standardUnhandled, remoteUnhandled;
|
||||
assert(c4fNavigate(&screen, C4F_INPUT_BUTTON_L1, 0, 0, &standardUnhandled, &remoteUnhandled) == C4F_ACTION_LANGUAGE);
|
||||
assert(!standardUnhandled && !remoteUnhandled);
|
||||
/* Even while the app is busy: it changes nothing on the console. */
|
||||
screen.busy = 1;
|
||||
assert(navigate(&screen, C4F_INPUT_BUTTON_L1, 0, 0) == C4F_ACTION_LANGUAGE);
|
||||
screen.busy = 0;
|
||||
/* Open menus and the stop confirmation keep L1 to themselves. */
|
||||
navigate(&screen, 0, C4F_INPUT_BUTTON_CROSS, 0);
|
||||
assert(screen.actionMenu && navigate(&screen, C4F_INPUT_BUTTON_L1, 0, 0) == C4F_ACTION_NONE);
|
||||
screen = stopped();
|
||||
screen.running = 1;
|
||||
navigate(&screen, C4F_INPUT_BUTTON_SQUARE, 0, 0);
|
||||
assert(screen.confirmStop && navigate(&screen, C4F_INPUT_BUTTON_L1, 0, 0) == C4F_ACTION_NONE && screen.confirmStop);
|
||||
/* The diagnostic build pages with L1, so it is left unhandled there. */
|
||||
screen = stopped();
|
||||
assert(c4fNavigate(&screen, C4F_INPUT_BUTTON_L1, 0, 1, &standardUnhandled, &remoteUnhandled) == C4F_ACTION_NONE);
|
||||
assert(standardUnhandled == C4F_INPUT_BUTTON_L1);
|
||||
puts("PASS L1 changes the language on the main screen only, and still pages the diagnostic build");
|
||||
}
|
||||
|
||||
int main(void)
|
||||
{
|
||||
remoteKeyNormalization();
|
||||
remoteOnly();
|
||||
sourceEdges();
|
||||
remoteOpenRetry();
|
||||
readerRecovery();
|
||||
unavailableUserAndBorrowedHandle();
|
||||
menuActions();
|
||||
extendedRemoteNavigation(0);
|
||||
extendedRemoteNavigation(1);
|
||||
heldRemoteConfirmation();
|
||||
stopConfirmation();
|
||||
unavailableActions();
|
||||
diagnosticNavigation();
|
||||
languageButton();
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/* Reuse the real tester fixtures; compare fresh and language-switched frames. */
|
||||
#include "draw.h"
|
||||
#define main c4fOriginalRendererMain
|
||||
#include "tester_render_test.c"
|
||||
#undef main
|
||||
#include "language.h"
|
||||
#include <assert.h>
|
||||
|
||||
int sceSystemServiceParamGetInt(int id, int *value)
|
||||
{
|
||||
(void)id;
|
||||
*value = 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int c4fRawNameSeen;
|
||||
static const char *c4fNameGuard(const char *text)
|
||||
{
|
||||
if (!strcmp(text, "RawProfileNameMarker")) c4fRawNameSeen++;
|
||||
return c4fPortugueseText(text);
|
||||
}
|
||||
|
||||
static void c4fMetadataFixture(C4fLauncherScreen *screen, int which)
|
||||
{
|
||||
c4fFixture(screen, c4fCaseId("menu"));
|
||||
if (which < 2) {
|
||||
/* A real username may equal the English fallback. Only the generated
|
||||
* flag distinguishes whether that text is translated. */
|
||||
snprintf(screen->tester.user[0].name, sizeof(screen->tester.user[0].name), "User 1");
|
||||
screen->tester.user[0].nameGenerated = which;
|
||||
} else {
|
||||
/* The English diagnostic is identical, while explicit argument
|
||||
* provenance controls whether this value is an interface key. */
|
||||
c4fMessageWrite(&screen->tester.noteText, screen->tester.note,
|
||||
sizeof(screen->tester.note), "The app cannot reach Control4Free: %s", "Left");
|
||||
screen->tester.noteText.valueIsKey = which % 2;
|
||||
}
|
||||
}
|
||||
|
||||
int main(int argc, char **argv)
|
||||
{
|
||||
assert(!c4fScreenInit());
|
||||
uint32_t *pixels = calloc((size_t)C4F_SCREEN_WIDTH * C4F_SCREEN_HEIGHT, sizeof(*pixels));
|
||||
assert(pixels);
|
||||
C4fLauncherScreen screen;
|
||||
if (argc == 2 && !strcmp(argv[1], "names")) {
|
||||
c4fSetTextTranslation(c4fNameGuard);
|
||||
for (int i = 0; i < 3; i++) {
|
||||
c4fFixture(&screen, c4fCaseId(i == 0 ? "idle" : i == 1 ? "menu" : "color-red"));
|
||||
snprintf(screen.tester.user[0].name, sizeof(screen.tester.user[0].name), "RawProfileNameMarker");
|
||||
c4fDrawTester(pixels, &screen);
|
||||
}
|
||||
assert(!c4fRawNameSeen);
|
||||
assert(c4fTextWidthRaw(C4F_FONT_REGULAR, 26, "Left") != c4fTextWidth(C4F_FONT_REGULAR, 26, "Left"));
|
||||
puts("PASS names bypass interface translation in tabs, selected user and touchpad");
|
||||
} else if (argc == 5 && !strcmp(argv[1], "metadata")) {
|
||||
c4fLanguageUse(!strcmp(argv[2], "ar") ? C4F_LANGUAGE_ARABIC : C4F_LANGUAGE_PORTUGUESE);
|
||||
int which = atoi(argv[4]);
|
||||
assert(which >= 0 && which < 4);
|
||||
if (!strcmp(argv[3], "warm")) {
|
||||
c4fMetadataFixture(&screen, which ^ 1);
|
||||
c4fDrawTester(pixels, &screen);
|
||||
}
|
||||
c4fMetadataFixture(&screen, which);
|
||||
c4fDrawTester(pixels, &screen);
|
||||
printf("%016llx\n", (unsigned long long)c4fHash(pixels));
|
||||
} else {
|
||||
assert(argc == 4);
|
||||
const int language = !strcmp(argv[1], "pt") ? C4F_LANGUAGE_PORTUGUESE :
|
||||
!strcmp(argv[1], "ar") ? C4F_LANGUAGE_ARABIC : C4F_LANGUAGE_ENGLISH;
|
||||
if (!strcmp(argv[2], "warm")) {
|
||||
c4fLanguageUse(language == C4F_LANGUAGE_ENGLISH ? C4F_LANGUAGE_PORTUGUESE : C4F_LANGUAGE_ENGLISH);
|
||||
for (int i = 0; i < 6; i++) {
|
||||
c4fFixture(&screen, c4fCaseId("color-red"));
|
||||
screen.tester.colorChannel = i % 3;
|
||||
screen.tester.colorFine = i / 3;
|
||||
c4fDrawTester(pixels, &screen);
|
||||
}
|
||||
}
|
||||
c4fLanguageUse(language);
|
||||
c4fFixture(&screen, c4fCaseId("color-red"));
|
||||
int which = atoi(argv[3]);
|
||||
screen.tester.colorChannel = which % 3;
|
||||
screen.tester.colorFine = which / 3;
|
||||
c4fDrawTester(pixels, &screen);
|
||||
printf("%016llx\n", (unsigned long long)c4fHash(pixels));
|
||||
}
|
||||
free(pixels);
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
/* Exact offline interface catalogs, formatting, and automatic language choice.
|
||||
* The SDK's system language call and renderer hooks are replaced. */
|
||||
#include <assert.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include "../launcher/language.c"
|
||||
|
||||
static const char *(*shownWith)(const char *text);
|
||||
static unsigned changes;
|
||||
static int systemLanguage = 1, systemError;
|
||||
static int rtl;
|
||||
|
||||
void c4fSetTextDirection(int value) { rtl = value; }
|
||||
|
||||
void c4fSetTextTranslation(const char *(*translate)(const char *text))
|
||||
{
|
||||
shownWith = translate;
|
||||
changes++;
|
||||
}
|
||||
|
||||
unsigned c4fTextTranslationGeneration(void) { return changes; }
|
||||
|
||||
int sceSystemServiceParamGetInt(int id, int *value)
|
||||
{
|
||||
assert(id == C4F_SYSTEM_PARAM_LANG);
|
||||
*value = systemLanguage;
|
||||
return systemError;
|
||||
}
|
||||
|
||||
/* draw.c shows two-byte UTF-8 as Latin-1 and anything longer as '?'. */
|
||||
static int drawable(const char *text)
|
||||
{
|
||||
for (const unsigned char *s = (const unsigned char *)text; *s; s++) {
|
||||
if (*s < 0x80) { if (*s < 32 || *s == 127) return 0; continue; }
|
||||
if (s[0] != 0xc2 && s[0] != 0xc3) return 0;
|
||||
if ((s[1] & 0xc0) != 0x80 || (s[0] == 0xc2 && s[1] < 0xa0)) return 0;
|
||||
s++;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
/* The conversions in order, as one string ("%d%s" for "%d of %s"). */
|
||||
static void conversions(const char *text, char *out, size_t size)
|
||||
{
|
||||
size_t used = 0;
|
||||
for (const char *at = text; *at; at++) {
|
||||
if (*at != '%') continue;
|
||||
const char *end = at + 1;
|
||||
while (*end && strchr("0123456789.-+ #lhz", *end)) end++;
|
||||
if (*end) end++;
|
||||
size_t length = (size_t)(end - at);
|
||||
assert(used + length < size);
|
||||
memcpy(out + used, at, length);
|
||||
used += length;
|
||||
at = end - 1;
|
||||
}
|
||||
out[used] = 0;
|
||||
}
|
||||
|
||||
static void table(void)
|
||||
{
|
||||
const size_t count = sizeof(c4fPortuguese) / sizeof(c4fPortuguese[0]);
|
||||
for (size_t i = 0; i < count; i++) {
|
||||
const C4fPhrase *p = &c4fPortuguese[i];
|
||||
char en[32], pt[32];
|
||||
assert(drawable(p->pt));
|
||||
conversions(p->en, en, sizeof(en));
|
||||
conversions(p->pt, pt, sizeof(pt));
|
||||
if (strcmp(en, pt)) { fprintf(stderr, "conversions differ: %s\n", p->en); assert(0); }
|
||||
for (size_t j = 0; j < i; j++) assert(strcmp(p->en, c4fPortuguese[j].en));
|
||||
assert(c4fPortugueseText(p->en) == p->pt);
|
||||
}
|
||||
printf("PASS %zu Portuguese texts: drawable, unique, same filled-in values\n", count);
|
||||
}
|
||||
|
||||
static void formattedKeys(void)
|
||||
{
|
||||
assert(!strcmp(c4fPortugueseText("Close"), "Fechar"));
|
||||
assert(!strcmp(c4fPortugueseText("1 controller connected"), "1 controle conectado"));
|
||||
char out[512];
|
||||
snprintf(out, sizeof(out), c4fPortugueseText("%d controllers connected"), 3);
|
||||
assert(!strcmp(out, "3 controles conectados"));
|
||||
snprintf(out, sizeof(out), c4fPortugueseText("Cannot read (0x%08x)"), 0x80020001u);
|
||||
assert(!strcmp(out, "Não foi possível ler (0x80020001)"));
|
||||
/* Translate the template before inserting a literal value. A value that
|
||||
* happens to be an interface key must stay literal. */
|
||||
snprintf(out, sizeof(out), c4fPortugueseText("Sent, but Control4Free did not start (%s). Retry Start."), "Left");
|
||||
assert(!strcmp(out, "Enviado, mas o Control4Free não iniciou (Left). Tente Iniciar de novo."));
|
||||
assert(!c4fPortugueseText("3 controllers connected"));
|
||||
assert(!c4fPortugueseText("User 1"));
|
||||
assert(!c4fPortugueseText("Cannot read (0x80020001)"));
|
||||
/* Unknown keys and near misses remain untouched. */
|
||||
assert(!c4fPortugueseText("Lucas") && !c4fPortugueseText("") && !c4fPortugueseText(NULL));
|
||||
assert(!c4fPortugueseText(" controllers connected"));
|
||||
assert(!c4fPortugueseText("Close "));
|
||||
puts("PASS exact keys, formatting before value insertion, no filled-string matching");
|
||||
}
|
||||
|
||||
static void arabicTable(void)
|
||||
{
|
||||
const size_t count = sizeof(c4fArabic) / sizeof(c4fArabic[0]);
|
||||
for (size_t i = 0; i < count; ++i) {
|
||||
const C4fPhrase *p = &c4fArabic[i];
|
||||
char en[32], ar[32];
|
||||
conversions(p->en, en, sizeof(en));
|
||||
conversions(p->pt, ar, sizeof(ar));
|
||||
assert(!strcmp(en, ar));
|
||||
assert(c4fArabicText(p->en) == p->pt);
|
||||
for (size_t j = 0; j < i; ++j) assert(strcmp(p->en, c4fArabic[j].en));
|
||||
for (const unsigned char *s = (const unsigned char *)p->pt; *s; ++s)
|
||||
assert(!(s[0] == 0xd9 && s[1] >= 0xa0 && s[1] <= 0xa9)); /* keep digits 0-9 */
|
||||
}
|
||||
assert(count >= sizeof(c4fPortuguese) / sizeof(c4fPortuguese[0]));
|
||||
assert(!c4fArabicText("User 1") && !c4fArabicText(NULL));
|
||||
puts("PASS Arabic catalog: unique keys, matching format parameters, digits 0-9");
|
||||
}
|
||||
|
||||
static void choice(void)
|
||||
{
|
||||
systemLanguage = 1; /* English (United States) */
|
||||
c4fLanguageUse(C4F_LANGUAGE_AUTO);
|
||||
assert(!shownWith);
|
||||
unsigned before = changes;
|
||||
c4fLanguageUse(C4F_LANGUAGE_ENGLISH);
|
||||
assert(!shownWith && changes == before);
|
||||
c4fLanguageUse(C4F_LANGUAGE_PORTUGUESE);
|
||||
assert(shownWith == c4fPortugueseText && !rtl);
|
||||
assert(!strcmp(c4fTr("Left"), "Esquerdo"));
|
||||
assert(!strcmp(c4fTr("Unknown service error"), "Unknown service error"));
|
||||
c4fLanguageUse(C4F_LANGUAGE_ARABIC);
|
||||
assert(shownWith == c4fArabicText && rtl && c4fLanguageIsArabic());
|
||||
assert(!strcmp(c4fTr("Red"), "أحمر"));
|
||||
systemLanguage = 21;
|
||||
c4fLanguageUse(C4F_LANGUAGE_ENGLISH);
|
||||
assert(!shownWith && !rtl && !c4fLanguageIsArabic());
|
||||
c4fLanguageUse(C4F_LANGUAGE_AUTO);
|
||||
assert(shownWith == c4fArabicText && rtl);
|
||||
c4fLanguageUse(C4F_LANGUAGE_ENGLISH);
|
||||
assert(!shownWith);
|
||||
for (int language = 7; language <= 17; language += 10) { /* Portuguese, then Portuguese (Brazil) */
|
||||
systemLanguage = language;
|
||||
c4fLanguageUse(C4F_LANGUAGE_AUTO);
|
||||
assert(shownWith == c4fPortugueseText);
|
||||
c4fLanguageUse(C4F_LANGUAGE_ENGLISH);
|
||||
}
|
||||
systemLanguage = 17; systemError = -1;
|
||||
c4fLanguageUse(C4F_LANGUAGE_AUTO);
|
||||
assert(!shownWith);
|
||||
puts("PASS automatic follows PS4 Portuguese/Arabic; explicit choices override it");
|
||||
}
|
||||
|
||||
static void workerMessages(void)
|
||||
{
|
||||
char english[160], out[C4F_MESSAGE_RENDER_SIZE];
|
||||
C4fMessage message;
|
||||
c4fMessageWrite(&message, english, sizeof(english),
|
||||
"Sent, but Control4Free did not start (%s). Retry Start.", "Left");
|
||||
c4fLanguageUse(C4F_LANGUAGE_PORTUGUESE);
|
||||
c4fMessageRender(&message, out, sizeof(out));
|
||||
assert(strstr(out, "(Left)") && !strstr(out, "Esquerdo"));
|
||||
c4fLanguageUse(C4F_LANGUAGE_ARABIC);
|
||||
c4fMessageRender(&message, out, sizeof(out));
|
||||
assert(strstr(out, "\xE2\x81\xA8Left\xE2\x81\xA9"));
|
||||
assert(!strcmp(english, "Sent, but Control4Free did not start (Left). Retry Start."));
|
||||
c4fMessageWrite(&message, english, sizeof(english), "The PS4 refused that (0x%08x).", 0x80020001u);
|
||||
c4fMessageRender(&message, out, sizeof(out));
|
||||
assert(strstr(out, "0x80020001"));
|
||||
c4fLanguageUse(C4F_LANGUAGE_ENGLISH);
|
||||
c4fMessageRender(&message, out, sizeof(out));
|
||||
assert(!strcmp(out, english));
|
||||
puts("PASS worker messages: language switching, literal values, Arabic isolation, English diagnostics");
|
||||
}
|
||||
|
||||
int main(void)
|
||||
{
|
||||
table();
|
||||
formattedKeys();
|
||||
arabicTable();
|
||||
choice();
|
||||
workerMessages();
|
||||
return 0;
|
||||
}
|
||||