Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f18f4a80c0 | ||
|
|
b5ea114581 | ||
|
|
d555484826 | ||
|
|
b572cd9133 | ||
|
|
73437fe5c6 | ||
|
|
d1b67cd8d6 | ||
|
|
3d27495dcd | ||
|
|
5b1dbd0062 | ||
|
|
e3ab3925b9 | ||
|
|
b3a725c56a | ||
|
|
c4ff621d7e | ||
|
|
b7976e8b1f | ||
|
|
dba861bb7f | ||
|
|
6a0e6925e5 | ||
|
|
aa45b9b123 | ||
|
|
9890560867 | ||
|
|
7e503478c4 |
No files matched your search
@@ -37,18 +37,31 @@ jobs:
|
||||
- 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'
|
||||
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
|
||||
@@ -72,4 +85,9 @@ jobs:
|
||||
version=$(cat VERSION)
|
||||
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,38 @@
|
||||
# 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
|
||||
|
||||
|
||||
@@ -45,16 +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.
|
||||
|
||||
`tests/fake_gamepads_test.cjs` runs researched gamepad emulations against the controller page in the Playwright Docker image, without console access or hardware compatibility claims.
|
||||
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.
|
||||
@@ -80,8 +100,8 @@ Build without new warnings; the app is built with `-Werror`.
|
||||
|
||||
1. Update `VERSION` and the changelog's release date and notes. Review mapping, compatibility and
|
||||
diagnostic documentation; distinguish hardware reports from emulated-input tests.
|
||||
2. Run all five Docker host suites and the browser/editor and emulated-gamepad checks. Build normal and
|
||||
diagnostic packages without new warnings. Inspect launcher previews and browser layouts.
|
||||
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.
|
||||
|
||||
@@ -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 $< $@
|
||||
|
||||
|
||||
@@ -46,14 +46,45 @@ sees a virtual DualShock 4, which provides:
|
||||
- **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. 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
|
||||
|
||||
@@ -73,6 +104,10 @@ 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.
|
||||
@@ -81,7 +116,9 @@ New to GoldHEN packages? The **[installation guide](docs/installation.md)** walk
|
||||
- **[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)**.
|
||||
@@ -91,7 +128,8 @@ New to GoldHEN packages? The **[installation guide](docs/installation.md)** walk
|
||||
- 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, on the same network as the PS4.
|
||||
- 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
|
||||
@@ -106,9 +144,11 @@ New to GoldHEN packages? The **[installation guide](docs/installation.md)** walk
|
||||
- **Browser input limits apply.** Hidden buttons and lost input identity cannot be recovered by mapping.
|
||||
With several Xbox controllers, some Chrome/Edge Windows backends combine their Xbox buttons; see the
|
||||
[GameInput workaround](docs/troubleshooting.md#gamepads).
|
||||
- **Six pages at a time.** Up to six phones or computers can have the page open at once.
|
||||
- **No motion controls.** Browsers only give phone motion sensors to secure (`https`) pages, and the
|
||||
console's page is plain `http`.
|
||||
- **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
|
||||
@@ -116,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
|
||||
|
||||
@@ -126,6 +166,12 @@ Found a bug? Install the diagnostic package from the latest release first, then
|
||||
report. If Control4Free is useful to you, you can help keep it going on
|
||||
[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
|
||||
|
||||
Control4Free's virtual-device code is ported from [SplashDown](https://github.com/seregonwar/SplashDown) by
|
||||
|
||||
@@ -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,13 +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.
|
||||
|
||||
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.
|
||||
|
||||
@@ -36,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 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.
|
||||
|
||||
@@ -63,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
|
||||
@@ -36,8 +48,28 @@
|
||||
by Tokyoship, licensed under CC 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.
|
||||
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,8 +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).
|
||||
# Docker's official Ubuntu mirror avoids Docker Hub's shared pull quota.
|
||||
FROM public.ecr.aws/docker/library/ubuntu:24.04@sha256:534baea6a22c03a63003dbc8dbe78fe34bc0d7e595d9a9dc9834884ff530eb55
|
||||
# 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,5 +1,5 @@
|
||||
# The official release contains the same OpenOrbis files as the former pinned
|
||||
# toolchain image. Fetch it directly so this build needs no Docker Hub pulls.
|
||||
# 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
|
||||
@@ -12,8 +12,8 @@ RUN curl -fsSL --retry 3 -o /tmp/openorbis.zip \
|
||||
&& tar -xzf /tmp/openorbis/toolchain-llvm-18.tar.gz -C /opt \
|
||||
&& test -f /opt/OpenOrbis/PS4Toolchain/lib/crt1.o \
|
||||
&& rm -rf /tmp/openorbis /tmp/openorbis.zip
|
||||
# Docker's official Ubuntu mirror, pinned to the same image as Docker Hub.
|
||||
FROM public.ecr.aws/docker/library/ubuntu:20.04@sha256:8feb4d8ca5354def3d8fce243717141ce31e2c428701f6682bd2fafe15388214 AS pkgcompat
|
||||
# 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
|
||||
|
||||
@@ -5,6 +5,11 @@ the four virtual DualShock 4 controllers. Wheel and guitar model compatibility r
|
||||
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.
|
||||
|
||||
@@ -19,7 +19,7 @@ after a restart without the server; see [Installation](installation.md).
|
||||
|
||||
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, Stop and Close when those actions are available.
|
||||
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**
|
||||
@@ -29,6 +29,17 @@ Existing DualShock 4 shortcuts work outside the menu. Its D-pad can also open an
|
||||
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
|
||||
|
||||
@@ -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]
|
||||
@@ -60,7 +189,7 @@ status clears the user and ownership. A failed login-list read leaves controller
|
||||
user is first observed in the list before absence counts as logout. The browser clears affected gamepad
|
||||
selections and returns to its home screen, requiring an explicit choice to reconnect.
|
||||
|
||||
None of this blocks. Creating a controller is a small state machine the main loop advances: drain the log's
|
||||
Creating a controller is a small state machine the main loop advances: drain the log's
|
||||
backlog, write a marker and wait for it to come back (proof that the reader really delivers), call
|
||||
`AddDevice`, wait for the line. The other players keep playing and keep being answered meanwhile.
|
||||
|
||||
@@ -77,34 +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 connection closes, its buttons are released at once. With no new input for 3 seconds the controller
|
||||
goes neutral, and after 15 it's removed. A page that reconnects forgets which controllers it had; picking the same one again
|
||||
within those 15 seconds gets it back, still signed in.
|
||||
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 everything driving one controller is combined: buttons are pressed if any source presses them,
|
||||
triggers take the strongest pull, and sticks take the input pushed furthest. See
|
||||
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).
|
||||
|
||||
The service sends each page a WebSocket ping every 5 seconds and closes a connection that hasn't answered
|
||||
within 10, so a phone that left the network doesn't hold a connection for ever. At most six pages are
|
||||
connected at once, which keeps room for loading the page itself.
|
||||
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.
|
||||
|
||||
@@ -128,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: 504 KiB After Width: | Height: | Size: 642 KiB |
|
After Width: | Height: | Size: 640 KiB |
|
Before Width: | Height: | Size: 1.2 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 |
|
Before Width: | Height: | Size: 561 KiB After Width: | Height: | Size: 672 KiB |
|
Before Width: | Height: | Size: 495 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,7 +6,8 @@ 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
|
||||
|
||||
@@ -17,12 +18,12 @@ Control4Free comes in two pieces:
|
||||
- **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, on the same network as the PS4.
|
||||
- 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
|
||||
|
||||
Every firmware GoldHEN supports has a GoldHEN version of v2.4b18.10 or later, so updating GoldHEN is all it
|
||||
takes. **Tested** means someone confirmed it on a console; the other rows have GoldHEN support and support in
|
||||
**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 |
|
||||
@@ -83,6 +84,7 @@ screen, that's fine too: the app doesn't get in the way of Control4Free.
|
||||
| **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
|
||||
@@ -93,14 +95,17 @@ and compatibility.
|
||||
1. Install the new package over the old one. Delete the old app first if the installer refuses.
|
||||
You don't need to stop Control4Free first: deleting or replacing the app leaves the running service and
|
||||
its auto-start copy alone.
|
||||
2. Open Control4Free. It says the auto-start copy is older than the one in the app: press **Triangle** to
|
||||
update it.
|
||||
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:
|
||||
auto-start then starts the new one.
|
||||
|
||||
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
|
||||
|
||||
The service is an ordinary GoldHEN payload, so the app is optional.
|
||||
|
||||
@@ -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.
|
||||
@@ -15,6 +15,10 @@ Signing out of that user on the PS4 disconnects their virtual controllers. The p
|
||||
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 |
|
||||
@@ -23,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
|
||||
@@ -45,8 +49,16 @@ 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
|
||||
|
||||
@@ -78,6 +90,10 @@ appears under **Gamepads on this device**:
|
||||
|
||||
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.
|
||||
@@ -87,10 +103,15 @@ pages, and the console's page is plain `http`. Save the page to the device (**Sa
|
||||
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.
|
||||
**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 |
|
||||
|---|---|
|
||||
@@ -101,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
|
||||
|
||||
@@ -127,16 +148,94 @@ 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
|
||||
- **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
|
||||
|
||||
@@ -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,21 +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`.
|
||||
The control protocol remains version 2 and the launcher API remains version 1.
|
||||
Send methods on `control` unless their channel is identified below.
|
||||
|
||||
| Method | Params | Reply |
|
||||
|---|---|---|
|
||||
| `info` | `[]` | `{"version": "<version>", "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` | `[]` | `{}` |
|
||||
@@ -57,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 |
|
||||
|---|---|---|---|
|
||||
@@ -85,15 +137,47 @@ 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
|
||||
connection closes, its controllers' buttons are released at once, and they're removed 15 seconds later unless
|
||||
a connection claims them again. The page doesn't do that by itself after reconnecting: the player picks the
|
||||
controller again.
|
||||
**`m`** sends the motion sensors of one controller this connection owns. Like `i`, it is ignored for any
|
||||
other controller.
|
||||
|
||||
At most six WebSocket connections are open at once; a seventh handshake gets `503`. The service pings each
|
||||
connection every 5 seconds and closes it if the matching pong hasn't come back within 10. Browsers answer
|
||||
pings by themselves; another client has to answer them too. Other messages don't count as an answer.
|
||||
```
|
||||
[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 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.
|
||||
@@ -110,10 +194,10 @@ modules around it when you draw it.
|
||||
```json
|
||||
{
|
||||
"version": "<version>",
|
||||
"protocol": 2,
|
||||
"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}
|
||||
]
|
||||
}
|
||||
```
|
||||
@@ -137,9 +221,12 @@ PS4 user logout removes that user's virtual controllers. Their status becomes `f
|
||||
`connected` and `mine` false, `clients: 0`, and an empty `user`. Owners receive `[pad, 0, 0]` to stop rumble
|
||||
and a disconnection message. Pages clear the affected selections instead of automatically claiming them.
|
||||
|
||||
- `{"method": "s", "params": <status>}`: the status, whenever something changes and at least once a second.
|
||||
- `{"method": "v", "params": [pad, large, small]}`: rumble, 0 to 255 for each motor, sent to the controller's
|
||||
owner when it changes and when a connection takes the controller over. `[pad, 0, 0]` stops it.
|
||||
- `{"method": "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 or PS4 user logout, a request that couldn't be parsed, or an error for a request sent without an
|
||||
`id`.
|
||||
@@ -161,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": "<version>", "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.
|
||||
@@ -48,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
|
||||
@@ -65,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
|
||||
|
||||
@@ -86,43 +111,53 @@ of Bluetooth, or the other way round), can also help.
|
||||
**A gamepad button does nothing after changing its mapping.** Let go of it and press it again: a button held
|
||||
down while a gamepad starts playing waits until it's released.
|
||||
|
||||
**The Xbox button presses PS for another controller in Chrome or Edge on Windows.** The browser's
|
||||
Windows.Gaming.Input backend combines Xbox/Guide presses from several controllers and reports them on one
|
||||
gamepad. Other buttons can still belong to the correct gamepad. Check **Mapping → Gamepad Controls** on
|
||||
each device: if the wrong device's **B16** lights up, the browser has already lost the physical source.
|
||||
Control4Free cannot recover that source from the reported input. See the
|
||||
[Chromium implementation](https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc#419).
|
||||
**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, newer Chrome builds offer an experimental **Windows GameInput** backend with
|
||||
per-device Guide-button callbacks. If your browser offers it, open
|
||||
`chrome://flags/#enable-windows-gameinput-data-fetcher` (or search `edge://flags` in Edge), set it to
|
||||
**Enabled**, and relaunch. Reconnect the gamepads and repeat the **Gamepad Controls** check: each Xbox
|
||||
button should light **B16** only on its own gamepad. Confirm both PS buttons after assigning controllers.
|
||||
This resolved combined Xbox/Guide reporting in a two-controller user test on Chrome 154 and Windows 11.
|
||||
Controller models and connection modes were not recorded, and other input and haptic capabilities were
|
||||
not revalidated. The option remains experimental; input counts and haptics may change. Compare
|
||||
**Gamepad Controls**, including any extra paddles, and review saved profiles after changing the backend.
|
||||
If gamepads stop appearing, return the flag to **Default**.
|
||||
See Chromium's [backend selection](https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/gamepad_platform_data_fetcher.h)
|
||||
and [per-device Guide handling](https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/gameinput_data_fetcher.cc).
|
||||
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. The existing mapper
|
||||
can also use an independently reported button for PS.
|
||||
path requires a browser fix; a future PC app could choose its own per-device reader.
|
||||
|
||||
In older browser builds that offer **Enable Windows.Gaming.Input**, open
|
||||
`chrome://flags/#enable-windows-gaming-input-data-fetcher` (or search `edge://flags` in Edge), set it to
|
||||
**Disabled**, and relaunch the browser. Reconnect the gamepads, select their controllers again, and check
|
||||
both Xbox buttons independently. This switches those builds to per-device XInput; extra paddles, input
|
||||
counts and haptic capabilities may change, so review saved mappings. The flag is unavailable in some
|
||||
newer builds.
|
||||
|
||||
If these options are unavailable or the raw Guide input still crosses devices, try Firefox on Windows
|
||||
or map an independently reported button to **PS button**
|
||||
using **DS4 Mapping → PS button → Change**. That replaces the Xbox/Guide binding. Remapping or swapping
|
||||
controller numbers alone cannot separate the combined Xbox-button signal.
|
||||
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**.
|
||||
@@ -149,16 +184,57 @@ 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 says RUNNING, the normal version is still the one running: press **Square**, then **Cross**
|
||||
|
||||
@@ -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,6 +3,8 @@
|
||||
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
#include "c4f_conn.h"
|
||||
#include "c4f_rtc.h"
|
||||
|
||||
#define C4F_NET_CLIENTS 8
|
||||
#define C4F_NET_WEBSOCKETS 6
|
||||
@@ -14,7 +16,8 @@
|
||||
#endif
|
||||
#define C4F_NET_MESSAGE 4096
|
||||
typedef struct C4fNetClient {
|
||||
int fd, websocket, closing, fragmented;
|
||||
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;
|
||||
@@ -27,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
|
||||
@@ -82,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
|
||||
@@ -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)/input.o $(OUT)/navigation.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,12 +173,13 @@ 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) {
|
||||
snprintf(problem, size, "Could not inspect AutoRun (errno %d)", errno); return C4F_AUTORUN_UNKNOWN;
|
||||
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;
|
||||
@@ -184,10 +188,10 @@ int c4fAutorunCheck(char *problem, size_t size)
|
||||
state = installedSize == c4fBundledSize && !memcmp(installed, c4fBundled, installedSize) ? C4F_AUTORUN_ON : C4F_AUTORUN_OUTDATED;
|
||||
else if (errno == ENOENT) {
|
||||
state = C4F_AUTORUN_OUTDATED;
|
||||
snprintf(problem, size, "Enabled, but the payload is missing; choose Update auto-start in Actions or press Triangle");
|
||||
c4fMessageWrite(&c4fAutorunText, problem, size, "Enabled, but the payload is missing; choose Update auto-start in Actions or press Triangle");
|
||||
} else {
|
||||
state = C4F_AUTORUN_UNKNOWN;
|
||||
snprintf(problem, size, "Enabled, but the payload cannot be inspected (errno %d)", errno);
|
||||
c4fMessageWrite(&c4fAutorunText, problem, size, "Enabled, but the payload cannot be inspected (errno %d)", errno);
|
||||
}
|
||||
free(installed);
|
||||
}
|
||||
@@ -199,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");
|
||||
@@ -214,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;
|
||||
@@ -225,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
|
||||
}
|
||||
|
||||
@@ -617,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;
|
||||
@@ -638,5 +640,8 @@ void c4fDrawDiag(uint32_t *pixels, const C4fLauncherScreen *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;
|
||||
}
|
||||
@@ -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
|
||||
@@ -9,18 +9,23 @@ 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. */
|
||||
/* 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)
|
||||
|
||||
@@ -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
|
||||
@@ -14,7 +14,9 @@
|
||||
#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"
|
||||
@@ -31,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. %s", started);
|
||||
else {
|
||||
snprintf(message, size, "Auto-start is on. %s", started);
|
||||
text->prefix = "Auto-start is on. %s";
|
||||
}
|
||||
return result == -2 ? -2 : 0;
|
||||
}
|
||||
|
||||
@@ -52,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;
|
||||
@@ -60,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. */
|
||||
@@ -79,34 +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;
|
||||
@@ -268,6 +303,119 @@ static int c4fNativeRead(void *context, int handle, uint32_t *buttons, int *conn
|
||||
|
||||
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",
|
||||
@@ -278,6 +426,205 @@ static void c4fNativeInputNote(const C4fNativeInput *input, char *note, size_t s
|
||||
(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);
|
||||
@@ -296,19 +643,20 @@ int main(void)
|
||||
OrbisUserServiceInitializeParams params = {};
|
||||
params.priority = ORBIS_KERNEL_PRIO_FIFO_LOWEST;
|
||||
sceUserServiceInitialize(¶ms);
|
||||
C4fNativeInput nativeInput = {};
|
||||
nativeInput.handles[0] = nativeInput.handles[1] = -1;
|
||||
C4fInputOps inputOps = { &nativeInput, c4fNativeInitialUser, c4fNativeOpen, c4fNativeRead, c4fNativeClose };
|
||||
C4fInput inputs;
|
||||
c4fInputInit(&inputs, &inputOps);
|
||||
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;
|
||||
@@ -318,80 +666,87 @@ int main(void)
|
||||
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();
|
||||
uint64_t lastInputAt = c4fLauncherTimeMs();
|
||||
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) {
|
||||
uint64_t now = c4fLauncherTimeMs();
|
||||
/* A suspended app must not resume a pending confirmation or reuse a
|
||||
* held navigation button. Do not guess unreversed focus API signatures. */
|
||||
int resumed = now > lastInputAt && now - lastInputAt > 1000;
|
||||
if (resumed) c4fInputReset(&inputs, now);
|
||||
lastInputAt = now;
|
||||
C4fInputFrame input;
|
||||
c4fInputPoll(&inputs, now, &input);
|
||||
pthread_mutex_lock(&c4fMutex);
|
||||
c4fNativeInputNote(&nativeInput, c4fScreen.inputNote, sizeof(c4fScreen.inputNote));
|
||||
if (resumed) {
|
||||
c4fScreen.actionMenu = c4fScreen.confirmStop = c4fScreen.confirmMenu = 0;
|
||||
c4fScreen.actionFocus = C4F_ACTION_NONE;
|
||||
}
|
||||
uint32_t standardUnhandled = 0, remoteUnhandled = 0;
|
||||
#ifdef C4F_DIAG
|
||||
const int diagnostic = 1;
|
||||
#else
|
||||
const int diagnostic = 0;
|
||||
#endif
|
||||
C4fLauncherAction action = c4fNavigate(&c4fScreen, input.standard.pressed, input.remote.pressed,
|
||||
diagnostic, &standardUnhandled, &remoteUnhandled);
|
||||
if (action == C4F_ACTION_CLOSE) done = 1;
|
||||
else if (action == C4F_ACTION_START) {
|
||||
/* Until auto-start is on, Start 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 (action == C4F_ACTION_STOP) {
|
||||
c4fCommand = C4F_DO_STOP; c4fScreen.busy = 1;
|
||||
snprintf(c4fScreen.message, sizeof(c4fScreen.message), "Stopping and releasing controllers...");
|
||||
} else if (action == C4F_ACTION_AUTORUN) {
|
||||
int off = c4fScreen.autorun == C4F_AUTORUN_ON;
|
||||
c4fCommand = off ? C4F_DO_AUTORUN_OFF : C4F_DO_AUTORUN_ON; c4fScreen.busy = 1;
|
||||
snprintf(c4fScreen.message, sizeof(c4fScreen.message), "%s",
|
||||
off ? "Turning auto-start off..." : "Turning auto-start on...");
|
||||
}
|
||||
#ifdef C4F_DIAG
|
||||
/* Pages and scrolling work while busy too. */
|
||||
uint32_t pressed = 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;
|
||||
}
|
||||
if (action == C4F_ACTION_CAPTURE || (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;
|
||||
@@ -403,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
|
||||
c4fInputClose(&inputs);
|
||||
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
|
||||
@@ -17,6 +17,7 @@ int c4fActionAvailable(const C4fLauncherScreen *screen, C4fLauncherAction action
|
||||
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;
|
||||
}
|
||||
@@ -26,7 +27,7 @@ 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_CAPTURE, C4F_ACTION_CLOSE };
|
||||
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;
|
||||
@@ -48,6 +49,7 @@ const char *c4fActionLabel(const C4fLauncherScreen *screen, C4fLauncherAction ac
|
||||
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 "";
|
||||
}
|
||||
}
|
||||
@@ -107,6 +109,7 @@ C4fLauncherAction c4fNavigate(C4fLauncherScreen *screen, uint32_t standardPresse
|
||||
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;
|
||||
}
|
||||
@@ -130,8 +133,8 @@ C4fLauncherAction c4fNavigate(C4fLauncherScreen *screen, uint32_t standardPresse
|
||||
if (pressed & C4F_NAV_DIRECTIONS) {
|
||||
int direction = c4fDirection(pressed);
|
||||
if (direction) {
|
||||
C4fLauncherAction actions[6];
|
||||
int count = c4fActionList(screen, diagnostic, actions, 6), at = 0;
|
||||
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];
|
||||
}
|
||||
@@ -159,6 +162,18 @@ C4fLauncherAction c4fNavigate(C4fLauncherScreen *screen, uint32_t standardPresse
|
||||
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);
|
||||
|
||||
@@ -8,12 +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
|
||||
@@ -27,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; }
|
||||
|
||||
@@ -172,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);
|
||||
@@ -226,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;
|
||||
@@ -270,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);
|
||||
@@ -280,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);
|
||||
@@ -310,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));
|
||||
@@ -328,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);
|
||||
@@ -352,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++)
|
||||
@@ -383,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);
|
||||
}
|
||||
@@ -437,7 +426,7 @@ 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.",
|
||||
@@ -468,8 +457,8 @@ void c4fDrawActionOverlay(uint32_t *pixels, const C4fLauncherScreen *s)
|
||||
#ifdef C4F_DIAG
|
||||
diagnostic = 1;
|
||||
#endif
|
||||
C4fLauncherAction actions[7];
|
||||
int count = s->confirmStop ? 0 : c4fActionList(s, diagnostic, actions, 7);
|
||||
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));
|
||||
@@ -479,7 +468,7 @@ void c4fDrawActionOverlay(uint32_t *pixels, const C4fLauncherScreen *s)
|
||||
c4fBandRoundRect(&c, dx, dy, dw, dh, 6 * C4F_S, 3 * C4F_S, C4F_ACCENT);
|
||||
|
||||
if (s->confirmStop) {
|
||||
c4fText(&c, C4F_FONT_LIGHT, 48, dx + 48, dy + 92, C4F_TEXT, "Stop Control4Free?");
|
||||
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) {
|
||||
@@ -489,7 +478,7 @@ void c4fDrawActionOverlay(uint32_t *pixels, const C4fLauncherScreen *s)
|
||||
}
|
||||
const float bw = 240, gap = 24, by = dy + 235;
|
||||
for (int i = 0; i < 2; i++) {
|
||||
float bx = dx + dw - 48 - 2 * bw - gap + i * (bw + gap);
|
||||
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);
|
||||
@@ -498,13 +487,13 @@ void c4fDrawActionOverlay(uint32_t *pixels, const C4fLauncherScreen *s)
|
||||
c4fTextCentered(&c, C4F_FONT_REGULAR, 30, bx + bw / 2, by + 42,
|
||||
focused ? C4F_TEXT : C4F_MUTED, i ? "Stop" : "Cancel");
|
||||
}
|
||||
c4fText(&c, C4F_FONT_LIGHT, 23, dx + 48, dy + dh - 48, C4F_MUTED,
|
||||
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;
|
||||
}
|
||||
|
||||
c4fText(&c, C4F_FONT_LIGHT, 48, dx + 48, dy + 84, C4F_TEXT, "Actions");
|
||||
c4fText(&c, C4F_FONT_LIGHT, 24, dx + 48, dy + 125, C4F_MUTED,
|
||||
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;
|
||||
@@ -515,16 +504,17 @@ void c4fDrawActionOverlay(uint32_t *pixels, const C4fLauncherScreen *s)
|
||||
focused ? C4F_RGBA(40, 103, 205, .85f) : C4F_RGBA(255, 255, 255, .04f));
|
||||
c4fInsetRoundRect(&c, rx, ry, rw, 64, 6 * C4F_S,
|
||||
focused ? 3 : 1 * C4F_S, focused ? C4F_ACCENT : C4F_LINE_SOFT);
|
||||
c4fText(&c, C4F_FONT_REGULAR, 30, rx + 24, ry + 42,
|
||||
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 = rx + rw - 30, cy = ry + 32;
|
||||
c4fSegment(&c, cx - 5, cy - 9, cx + 4, cy, 3, C4F_TEXT);
|
||||
c4fSegment(&c, cx + 4, cy, cx - 5, cy + 9, 3, C4F_TEXT);
|
||||
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" } };
|
||||
c4fText(&c, C4F_FONT_LIGHT, 23, dx + 48, dy + dh - 32, C4F_MUTED, "TV remote: OK select / Back return");
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -533,11 +523,29 @@ 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. */
|
||||
@@ -548,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 "
|
||||
@@ -575,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);
|
||||
@@ -597,10 +607,13 @@ void c4fDrawLauncher(uint32_t *pixels, const C4fLauncherScreen *s)
|
||||
/* .panel: whether GoldHEN starts it by itself. */
|
||||
const char *panelTitle, *panelText;
|
||||
C4fColor panelDot = C4F_FAINT;
|
||||
char versions[160];
|
||||
char versions[256];
|
||||
int mismatch = live && s->runningVersion[0] && strcmp(s->runningVersion, C4F_LAUNCHER_VERSION);
|
||||
if (mismatch) {
|
||||
snprintf(versions, sizeof(versions), "Installed %s; running %s", C4F_LAUNCHER_VERSION, s->runningVersion);
|
||||
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;
|
||||
@@ -618,22 +631,27 @@ void c4fDrawLauncher(uint32_t *pixels, const C4fLauncherScreen *s)
|
||||
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. */
|
||||
if (!s->confirmStop && !s->actionMenu)
|
||||
c4fText(&c, C4F_FONT_LIGHT, 23, left, 973, C4F_MUTED, "TV remote: OK opens actions / arrows select / Back returns");
|
||||
c4fText(&c, C4F_FONT_LIGHT, 20, left, 1030, C4F_FAINT, "Control4Free " C4F_LAUNCHER_VERSION);
|
||||
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;
|
||||
@@ -648,6 +666,10 @@ 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);
|
||||
@@ -668,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,6 +1,7 @@
|
||||
#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"
|
||||
@@ -14,14 +15,30 @@ typedef enum {
|
||||
C4F_ACTION_AUTORUN,
|
||||
C4F_ACTION_STOP,
|
||||
C4F_ACTION_CLOSE,
|
||||
C4F_ACTION_CAPTURE
|
||||
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 */
|
||||
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];
|
||||
@@ -37,6 +54,8 @@ int c4fScreenInit(void);
|
||||
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). Enable PayLoader Server in GoldHEN > Server Settings, then retry Start.", 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. Retry Start."); 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). Retry Start.", 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. Ready to start again."); return 0; }
|
||||
if (probe != 1 && !c4fReached) { snprintf(message, size, "The app cannot reach Control4Free (%s). Stop it on its controller page.", c4fProblem); return -1; }
|
||||
if (probe != 1) { snprintf(message, size, "This copy does not answer the app (%s). Stop it on its controller page.", c4fProblem); return -1; }
|
||||
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. Ready to start 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];
|
||||
|
||||
@@ -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);
|
||||
@@ -250,7 +390,10 @@ static void c4fHttp(C4fNet *net, C4fNetClient *c)
|
||||
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);
|
||||
@@ -259,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) {
|
||||
@@ -299,7 +443,7 @@ static void c4fWs(C4fNet *net, C4fNetClient *c)
|
||||
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; }
|
||||
@@ -307,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);
|
||||
@@ -327,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;
|
||||
@@ -346,9 +506,15 @@ 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) {
|
||||
@@ -357,6 +523,7 @@ int c4fNetPoll(C4fNet *net, int timeoutMs)
|
||||
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);
|
||||
@@ -369,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);
|
||||
@@ -391,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);
|
||||
@@ -409,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)) {
|
||||
@@ -446,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;
|
||||
}
|
||||
@@ -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,11 +35,17 @@
|
||||
/* 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;
|
||||
@@ -48,15 +55,34 @@ typedef struct {
|
||||
C4fPadFeedback feedback;
|
||||
int feedbackKnown, rumbleSentValid, feedbackFailed;
|
||||
uint8_t rumbleSent[2];
|
||||
uint64_t feedbackAt, colorLogAt;
|
||||
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
|
||||
@@ -74,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 */
|
||||
@@ -98,11 +124,18 @@ typedef struct {
|
||||
C4fAdd add;
|
||||
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;
|
||||
@@ -143,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;
|
||||
@@ -155,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
|
||||
@@ -189,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"
|
||||
@@ -219,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
|
||||
@@ -233,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) {
|
||||
@@ -251,11 +303,11 @@ static void c4fRemove(C4fWeb *app, C4fWebPad *p)
|
||||
|
||||
static void c4fLogoutPad(C4fWeb *app, C4fWebPad *p, int index)
|
||||
{
|
||||
C4fNetClient *owner = p->owner;
|
||||
C4fConn *owner = p->owner;
|
||||
if (owner) {
|
||||
char message[64];
|
||||
snprintf(message, sizeof(message), "{\"method\":\"v\",\"params\":[%d,0,0]}", index);
|
||||
c4fNetText(owner, message);
|
||||
c4fConnInput(owner, message);
|
||||
}
|
||||
c4fLog("web controller %d disconnected after PS4 user logout\n", index + 1);
|
||||
c4fRemove(app, p);
|
||||
@@ -404,11 +456,13 @@ static void c4fPollFeedback(C4fWeb *app, uint64_t now)
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -561,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);
|
||||
}
|
||||
@@ -583,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;
|
||||
@@ -678,7 +735,7 @@ static void c4fAdvanceAdd(C4fWeb *app, uint64_t now, long klogBytes)
|
||||
}
|
||||
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));
|
||||
@@ -707,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;
|
||||
@@ -754,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)];
|
||||
@@ -791,42 +848,186 @@ 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) {
|
||||
@@ -836,8 +1037,16 @@ static void c4fWebEvent(C4fNetClient *c, int event, const char *text, size_t len
|
||||
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) c4fCancelAdd(app);
|
||||
for (int i = 0; i < C4F_MAX_PADS; i++) if (app->pads[i].owner == c) {
|
||||
@@ -848,18 +1057,28 @@ 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; }
|
||||
@@ -882,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);
|
||||
@@ -921,11 +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
|
||||
@@ -936,7 +1162,7 @@ int c4fWebRun(int klogFd)
|
||||
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");
|
||||
@@ -949,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
|
||||
@@ -975,9 +1199,22 @@ 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);
|
||||
@@ -995,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");
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
// Test instrumentation is inserted into the page in memory, never shipped.
|
||||
const { chromium } = require('playwright');
|
||||
const fs = require('node:fs'), assert = require('node:assert/strict'), { spawn } = require('node:child_process');
|
||||
const hook = '\nwindow.c4fTest = { ControllerMapping, mapper, profileStore, gamepadSources, pollGamepads, setGamepadPad, mergedState, renderGamepads, renderPads, openSettings, closeSettings, openPad, profileFor, loadProfiles, persistProfiles, clearDevice, applyStatus, status, getKeys: () => keymap };\n';
|
||||
const { 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 + ' })();');
|
||||
fs.copyFileSync('/src/build/web-host-test', '/tmp/c4f-browser-service');
|
||||
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()));
|
||||
@@ -16,33 +18,118 @@ const listening = page => page.waitForFunction(() => c4fTest.mapper.capture?.rea
|
||||
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.testClaims = [];
|
||||
window.testInputs = [];
|
||||
const socketSend = WebSocket.prototype.send;
|
||||
WebSocket.prototype.send = function(data) {
|
||||
try {
|
||||
const message = JSON.parse(data);
|
||||
if (message.method === 'claim') testClaims.push(message.params);
|
||||
if (message.method === 'u') testInputs.push(message.params);
|
||||
} catch {}
|
||||
return socketSend.call(this, data);
|
||||
};
|
||||
window.testPads = [0, 1, 2].map(index => ({ index, id: index < 2 ? 'Synthetic standard controller' : 'Synthetic wheel / pedals',
|
||||
mapping: index < 2 ? 'standard' : '', connected: true, buttons: Array.from({ length: 24 }, () => ({ value: 0, pressed: false })), axes: Array(6).fill(0) }));
|
||||
Object.defineProperty(navigator, 'getGamepads', { value: () => { if (window.failPolling) throw Error('SecurityError'); return window.testPads; } });
|
||||
});
|
||||
const page = await context.newPage(), errors = [];
|
||||
page.on('websocket', ws => ws.on('socketerror', error => console.error('WebSocket:', error)));
|
||||
page.on('pageerror', e => { errors.push(e.message); console.error('Page error:', e.stack); });
|
||||
await 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);
|
||||
@@ -99,7 +186,7 @@ const bindings = (page, output) => page.evaluate(o => c4fTest.mapper.draft.bindi
|
||||
assert.equal(await page.evaluate(other => c4fTest.mergedState(other).buttons & 0x10000, other), 0, 'PS leaked into the other virtual controller');
|
||||
await page.waitForFunction(target => document.querySelector('#psBtn').classList.contains('on') === (target === 1), target);
|
||||
await page.waitForFunction(target => !!(testInputs.findLast(p => p[0] === target)?.[1] & 0x10000), target);
|
||||
assert.equal(await page.evaluate(other => testInputs.findLast(p => p[0] === other)?.[1] & 0x10000, other), 0, 'PS went to the wrong WebSocket slot');
|
||||
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'));
|
||||
@@ -116,7 +203,7 @@ const bindings = (page, output) => page.evaluate(o => c4fTest.mapper.draft.bindi
|
||||
}
|
||||
await page.evaluate(() => { c4fTest.setGamepadPad(0, 0); c4fTest.setGamepadPad(1, 1); c4fTest.setGamepadPad(2, 0); });
|
||||
await page.click('#backBtn');
|
||||
console.log('PASS real browser: independent PS buttons on identical gamepads, reversed assignments, simultaneous holds, both release orders, display and WebSocket routing');
|
||||
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
|
||||
@@ -428,11 +515,13 @@ const bindings = (page, output) => page.evaluate(o => c4fTest.mapper.draft.bindi
|
||||
|
||||
// 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');
|
||||
|
||||
@@ -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; });
|
||||
@@ -125,12 +125,12 @@
|
||||
profiles = data; index = null;
|
||||
$('profile').textContent = '';
|
||||
for (const profile of profiles) $('profile').add(new Option(profile.label + ' — ' + profile.name, profile.name));
|
||||
const relay = location.protocol === 'http:' && location.hostname === '127.0.0.1' ? 'ws://' + location.host + '/local-service' : null;
|
||||
const 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' +
|
||||
'window.WebSocket = class LocalTestSocket extends WebSocket { constructor(address, protocols) {\n' +
|
||||
'const url = new URL(address); if (url.protocol !== "ws:" || url.hostname !== "127.0.0.1" || url.port !== "4264") throw new Error("The gamepad lab only connects to the local test service");\n' +
|
||||
'super(' + JSON.stringify(relay) + ' || address, protocols); } };';
|
||||
'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.'); };
|
||||
}
|
||||
|
||||
@@ -1,11 +1,36 @@
|
||||
// Local-only manual lab: static files plus a fixed loopback service relay.
|
||||
// Run in Docker after tests/run.py has built build/web-host-test.
|
||||
const http = require('node:http'), net = require('node:net'), fs = require('node:fs'), path = require('node:path');
|
||||
// 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',
|
||||
@@ -20,34 +45,15 @@ function createLabServer() {
|
||||
});
|
||||
const sockets = new Set();
|
||||
server.on('connection', socket => { sockets.add(socket); socket.on('close', () => sockets.delete(socket)); });
|
||||
server.on('upgrade', (request, socket, head) => {
|
||||
const expected = 'http://127.0.0.1:' + server.address().port;
|
||||
if (request.url !== '/local-service' || request.headers.origin !== expected || request.headers.upgrade?.toLowerCase() !== 'websocket') {
|
||||
socket.end('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n'); return;
|
||||
}
|
||||
// No caller-specified destination: the host stub is the only upstream.
|
||||
const upstream = net.connect(4264, '127.0.0.1'); sockets.add(upstream);
|
||||
upstream.on('close', () => { sockets.delete(upstream); socket.destroy(); });
|
||||
upstream.on('error', () => socket.destroy()); socket.on('error', () => upstream.destroy()); socket.on('close', () => upstream.destroy());
|
||||
upstream.on('connect', () => {
|
||||
const headers = ['GET /ws HTTP/1.1', 'Host: 127.0.0.1:4264', 'Origin: http://127.0.0.1:4264'];
|
||||
for (const name of ['upgrade', 'connection', 'sec-websocket-key', 'sec-websocket-version', 'sec-websocket-protocol']) {
|
||||
if (request.headers[name]) headers.push(name + ': ' + request.headers[name]);
|
||||
}
|
||||
upstream.write(headers.join('\r\n') + '\r\n\r\n');
|
||||
if (head.length) upstream.write(head);
|
||||
socket.pipe(upstream); upstream.pipe(socket);
|
||||
});
|
||||
});
|
||||
server.stop = () => { for (const socket of sockets) socket.destroy(); return new Promise(resolve => server.close(resolve)); };
|
||||
return server;
|
||||
}
|
||||
module.exports = { createLabServer };
|
||||
if (require.main === module) {
|
||||
const binary = path.join(root, 'build', 'web-host-test');
|
||||
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 {
|
||||
fs.copyFileSync(binary, '/tmp/c4f-gamepad-lab-service');
|
||||
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/'));
|
||||
|
||||
@@ -6,6 +6,7 @@ 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')
|
||||
@@ -27,7 +28,7 @@ const outputs = {
|
||||
rx: ['Right stick, left and right'], ry: ['Right stick, up and down']
|
||||
};
|
||||
const lab = createLabServer();
|
||||
fs.copyFileSync(path.join(root, 'build/web-host-test'), '/tmp/c4f-fake-gamepad-service');
|
||||
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; });
|
||||
@@ -216,6 +217,7 @@ async function runProfile(browser, profile) {
|
||||
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);
|
||||
@@ -407,10 +409,10 @@ async function runManualHarness(browser) {
|
||||
await frame.waitForFunction(() => window.c4fTest?.gamepadSources.size === 1);
|
||||
await frame.waitForFunction(() => document.querySelector('#conn').dataset.conn === 'open');
|
||||
assert.equal(await frame.evaluate(() => navigator.getGamepads()[0].id), chosen.gamepad.id.value);
|
||||
const refused = await frame.evaluate(() => {
|
||||
try { new WebSocket('wss://example.invalid/ws'); return ''; } catch (error) { return error.message; }
|
||||
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 WebSocket destinations');
|
||||
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);
|
||||
|
||||
@@ -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);
|
||||
@@ -19,6 +20,31 @@ function padStatus(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 = [];
|
||||
@@ -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);
|
||||
@@ -60,7 +86,7 @@ function padStatus(fields) {
|
||||
serialClaims.push(params); return new Promise((resolve, reject) => deferred.push({ resolve, reject }));
|
||||
}, applyStatus() {}, toast() {}, closePad() {}, renderGamepads() {},
|
||||
};
|
||||
vm.createContext(serial); vm.runInContext(sendClaimSource, serial);
|
||||
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;
|
||||
@@ -98,7 +124,7 @@ function padStatus(fields) {
|
||||
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);
|
||||
@@ -126,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;
|
||||
@@ -154,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();
|
||||
@@ -167,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,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);
|
||||
|
||||
@@ -374,6 +374,9 @@ static void heldRemoteConfirmation(void)
|
||||
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);
|
||||
@@ -432,6 +435,8 @@ static void stopConfirmation(void)
|
||||
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);
|
||||
@@ -441,6 +446,7 @@ static void stopConfirmation(void)
|
||||
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);
|
||||
@@ -544,6 +550,30 @@ static void diagnosticNavigation(void)
|
||||
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();
|
||||
@@ -559,5 +589,6 @@ int main(void)
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
/* The real sampler and visible-state comparison from main.cpp, with only native
|
||||
* APIs replaced. The display thread never polls input in these scenarios. */
|
||||
#include <assert.h>
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <pthread.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include <thread>
|
||||
#include <unistd.h>
|
||||
#include "autorun.h"
|
||||
#include "input.h"
|
||||
#include "navigation.h"
|
||||
#include "screen.h"
|
||||
#include "service.h"
|
||||
|
||||
static pthread_mutex_t c4fMutex = PTHREAD_MUTEX_INITIALIZER;
|
||||
static C4fLauncherScreen c4fScreen;
|
||||
static int c4fCommand, c4fQuit, c4fCloseRequested;
|
||||
static uint64_t c4fRenderAt, c4fInputAt;
|
||||
/* Native API context is opaque to this test; its handles are owned by sampler. */
|
||||
typedef struct { int handles[2]; } C4fNativeInput;
|
||||
static std::atomic<uint32_t> standardButtons{0}, remoteButtons{0};
|
||||
static std::atomic<int> polls{0}, closes{0}, largeMotor{0};
|
||||
static std::atomic<bool> standardOpen{false}, lightOn{false};
|
||||
static std::atomic<bool> blockRead{false}, readBlocked{false}, releaseRead{false};
|
||||
|
||||
uint64_t c4fLauncherTimeMs(void)
|
||||
{
|
||||
return (uint64_t)std::chrono::duration_cast<std::chrono::milliseconds>(
|
||||
std::chrono::steady_clock::now().time_since_epoch()).count();
|
||||
}
|
||||
|
||||
static int c4fNativeInitialUser(void *, int32_t *user) { *user = 1; return 0; }
|
||||
static int c4fNativeOpen(void *, int32_t, int port, int *owned)
|
||||
{
|
||||
*owned = 1;
|
||||
if (port != 16) standardOpen = true;
|
||||
return port == 16 ? 11 : 10;
|
||||
}
|
||||
static int c4fNativeRead(void *, int handle, uint32_t *buttons, int *connected)
|
||||
{
|
||||
polls++;
|
||||
*buttons = handle == 10 ? standardButtons.load() : remoteButtons.load();
|
||||
*connected = 1;
|
||||
return 0;
|
||||
}
|
||||
static void c4fNativeClose(void *, int handle)
|
||||
{
|
||||
if (handle == 10) {
|
||||
assert(largeMotor.load() == 0 && !lightOn.load());
|
||||
standardOpen = false;
|
||||
}
|
||||
closes++;
|
||||
}
|
||||
static int c4fTesterLoginUsers(void *, int32_t *users) { users[0] = 1; return 1; }
|
||||
static void c4fTesterUserName(void *, int32_t, char *name, int size) { snprintf(name, size, "Alex"); }
|
||||
static int c4fTesterOpenPad(void *, int32_t, int *owned) { *owned = 0; return 10; }
|
||||
static int c4fTesterRead(void *, int, C4fTesterSample *sample)
|
||||
{
|
||||
if (blockRead.exchange(false)) {
|
||||
readBlocked = true;
|
||||
while (!releaseRead) usleep(1000);
|
||||
readBlocked = false;
|
||||
}
|
||||
memset(sample, 0, sizeof(*sample));
|
||||
sample->buttons = standardButtons.load(); sample->connected = 1;
|
||||
sample->lx = sample->ly = sample->rx = sample->ry = 128;
|
||||
return 0;
|
||||
}
|
||||
static void c4fTesterClosePad(void *, int) { assert(!"borrowed handle closed"); }
|
||||
static int c4fTesterVibrate(void *, int, uint8_t large, uint8_t)
|
||||
{
|
||||
assert(standardOpen.load());
|
||||
largeMotor = large;
|
||||
return 0;
|
||||
}
|
||||
static int c4fTesterLight(void *, int, uint8_t, uint8_t, uint8_t) { assert(standardOpen.load()); lightOn = true; return 0; }
|
||||
static int c4fTesterResetLight(void *, int) { assert(standardOpen.load()); lightOn = false; return 0; }
|
||||
static int c4fTesterResetOrientation(void *, int) { return 0; }
|
||||
static int c4fTesterMotion(void *, int, int) { return 0; }
|
||||
static void c4fTesterLogState(void *, int, int, uint32_t) {}
|
||||
static void c4fNativeInputNote(const C4fNativeInput *, char *note, size_t size) { snprintf(note, size, "test input"); }
|
||||
|
||||
#include "launcher-sampling-source.inc"
|
||||
|
||||
template <typename Predicate> static void until(Predicate predicate)
|
||||
{
|
||||
uint64_t deadline = c4fLauncherTimeMs() + 1000;
|
||||
while (!predicate()) { assert(c4fLauncherTimeMs() < deadline); usleep(1000); }
|
||||
}
|
||||
|
||||
static C4fTesterView view()
|
||||
{
|
||||
pthread_mutex_lock(&c4fMutex);
|
||||
C4fTesterView result = c4fScreen.tester;
|
||||
pthread_mutex_unlock(&c4fMutex);
|
||||
return result;
|
||||
}
|
||||
|
||||
static void tap(std::atomic<uint32_t> &buttons, uint32_t mask)
|
||||
{
|
||||
buttons = mask; usleep(16000); buttons = 0; usleep(16000);
|
||||
}
|
||||
|
||||
static void noConnectionPads(C4fTesterConnection connection, int available)
|
||||
{
|
||||
assert(connection.available == available && connection.pads == 0);
|
||||
for (int p = 0; p < C4F_SERVICE_PADS; p++) assert(connection.rttMs[p] == -1);
|
||||
}
|
||||
|
||||
static void connectionView()
|
||||
{
|
||||
C4fServiceStatus status = {};
|
||||
status.latencyAvailable = 1;
|
||||
C4fTesterView tester = {};
|
||||
tester.active = 1; tester.selected = 2;
|
||||
tester.user[0].used = tester.user[2].used = 1;
|
||||
tester.user[0].state = tester.user[2].state = C4F_TESTER_READY;
|
||||
tester.user[0].id = 1; tester.user[2].id = 0x1A2B3C4D;
|
||||
snprintf(tester.user[0].name, sizeof(tester.user[0].name), "Alex");
|
||||
snprintf(tester.user[2].name, sizeof(tester.user[2].name), "Alex");
|
||||
status.latency[0] = { 1, 1, 4, 0 };
|
||||
status.latency[1] = { 1, 0x1A2B3C4D, 8, 100 };
|
||||
status.latency[2] = { 0, 0x1A2B3C4D, 0, 0 };
|
||||
status.latency[3] = { 1, 0x1A2B3C4D, 12, 200 };
|
||||
const uint64_t checkedAt = 10000;
|
||||
C4fTesterConnection connection = c4fTesterConnectionView(&status, C4F_SERVICE_RUNNING, checkedAt, checkedAt, &tester);
|
||||
assert(connection.available && connection.pads == ((1U << 1) | (1U << 3)));
|
||||
assert(connection.rttMs[0] == -1 && connection.rttMs[1] == 8 && connection.rttMs[2] == -1 && connection.rttMs[3] == 12);
|
||||
/* Equal display names and equal slot indexes never establish ownership. */
|
||||
tester.selected = 0;
|
||||
connection = c4fTesterConnectionView(&status, C4F_SERVICE_RUNNING, checkedAt, checkedAt, &tester);
|
||||
assert(connection.pads == 1 && connection.rttMs[0] == 4 && connection.rttMs[1] == -1 && connection.rttMs[3] == -1);
|
||||
tester.selected = 2;
|
||||
tester.user[2].id = 99;
|
||||
noConnectionPads(c4fTesterConnectionView(&status, C4F_SERVICE_RUNNING, checkedAt, checkedAt, &tester), 1);
|
||||
tester.user[2].id = 0x1A2B3C4D;
|
||||
printf("PASS connection view: selected user ID matches every owned virtual slot, independently of names and indexes\n");
|
||||
|
||||
/* The cached service reply also ages while the launcher renders it. */
|
||||
status.latency[1].ageMs = C4F_SERVICE_LATENCY_STALE_MS - 1000;
|
||||
status.latency[3].ageMs = C4F_SERVICE_LATENCY_STALE_MS - 999;
|
||||
connection = c4fTesterConnectionView(&status, C4F_SERVICE_RUNNING, checkedAt, checkedAt + 1000, &tester);
|
||||
assert(connection.pads == ((1U << 1) | (1U << 3)) && connection.rttMs[1] == 8 && connection.rttMs[3] == -1);
|
||||
connection = c4fTesterConnectionView(&status, C4F_SERVICE_RUNNING, checkedAt, checkedAt + 1001, &tester);
|
||||
assert(connection.pads == ((1U << 1) | (1U << 3)) && connection.rttMs[1] == -1 && connection.rttMs[3] == -1);
|
||||
status.latency[1].ageMs = status.latency[3].ageMs = 0;
|
||||
connection = c4fTesterConnectionView(&status, C4F_SERVICE_RUNNING, checkedAt, checkedAt + C4F_SERVICE_LATENCY_STALE_MS, &tester);
|
||||
assert(connection.available && connection.rttMs[1] == 8 && connection.rttMs[3] == 12);
|
||||
noConnectionPads(c4fTesterConnectionView(&status, C4F_SERVICE_RUNNING, checkedAt,
|
||||
checkedAt + C4F_SERVICE_LATENCY_STALE_MS + 1, &tester), 0);
|
||||
noConnectionPads(c4fTesterConnectionView(&status, C4F_SERVICE_RUNNING, checkedAt, checkedAt - 1, &tester), 0);
|
||||
printf("PASS connection view: each sample expires at its own age plus local cache age; stale or future cache is unavailable\n");
|
||||
|
||||
status.latency[1].rttMs = -1;
|
||||
status.latency[3].ageMs = -1;
|
||||
connection = c4fTesterConnectionView(&status, C4F_SERVICE_RUNNING, checkedAt, checkedAt, &tester);
|
||||
assert(connection.available && connection.pads == ((1U << 1) | (1U << 3)));
|
||||
for (int p = 0; p < C4F_SERVICE_PADS; p++) assert(connection.rttMs[p] == -1);
|
||||
status.latency[1].rttMs = 0;
|
||||
connection = c4fTesterConnectionView(&status, C4F_SERVICE_RUNNING, checkedAt, checkedAt, &tester);
|
||||
assert(connection.rttMs[1] == 0 && connection.rttMs[3] == -1);
|
||||
const int serviceStates[] = { C4F_SERVICE_UNKNOWN, C4F_SERVICE_DOWN };
|
||||
for (int running : serviceStates)
|
||||
noConnectionPads(c4fTesterConnectionView(&status, running, checkedAt, checkedAt, &tester), 0);
|
||||
status.latencyAvailable = 0; /* A service without telemetry support. */
|
||||
noConnectionPads(c4fTesterConnectionView(&status, C4F_SERVICE_RUNNING, checkedAt, checkedAt, &tester), 0);
|
||||
status.latencyAvailable = 1;
|
||||
printf("PASS connection view: unknown RTT stays -1, measured zero stays zero, unknown/stopped/older service has no readings\n");
|
||||
|
||||
tester.selected = -1;
|
||||
noConnectionPads(c4fTesterConnectionView(&status, C4F_SERVICE_RUNNING, checkedAt, checkedAt, &tester), 1);
|
||||
tester.selected = C4F_TESTER_USERS;
|
||||
noConnectionPads(c4fTesterConnectionView(&status, C4F_SERVICE_RUNNING, checkedAt, checkedAt, &tester), 1);
|
||||
tester.selected = 2; tester.user[2].used = 0;
|
||||
noConnectionPads(c4fTesterConnectionView(&status, C4F_SERVICE_RUNNING, checkedAt, checkedAt, &tester), 1);
|
||||
tester.user[2].used = 1;
|
||||
const int readerStates[] = { C4F_TESTER_OPENING, C4F_TESTER_NO_CONTROLLER, C4F_TESTER_FAILED, C4F_TESTER_SIGNED_OUT };
|
||||
for (int state : readerStates) {
|
||||
tester.user[2].state = state;
|
||||
noConnectionPads(c4fTesterConnectionView(&status, C4F_SERVICE_RUNNING, checkedAt, checkedAt, &tester), 1);
|
||||
}
|
||||
printf("PASS connection view: no selected ready user means no virtual-controller association\n");
|
||||
}
|
||||
|
||||
int main()
|
||||
{
|
||||
connectionView();
|
||||
c4fScreen.running = 1; c4fScreen.autorun = C4F_AUTORUN_ON;
|
||||
c4fRenderAt = c4fLauncherTimeMs();
|
||||
pthread_t worker;
|
||||
assert(!pthread_create(&worker, NULL, c4fInputWorker, NULL));
|
||||
until([] { return polls.load() >= 4; });
|
||||
tap(standardButtons, C4F_INPUT_BUTTON_OPTIONS);
|
||||
until([] { C4fTesterView v = view(); return v.active && v.user[0].state == C4F_TESTER_READY; });
|
||||
tap(remoteButtons, C4F_INPUT_BUTTON_CROSS);
|
||||
until([] { return view().menu; });
|
||||
std::thread press([] {
|
||||
usleep(20000); standardButtons = C4F_INPUT_BUTTON_DOWN;
|
||||
usleep(16000); standardButtons = 0;
|
||||
});
|
||||
int before = polls;
|
||||
usleep(100000); /* A display stall cannot swallow this complete short tap. */
|
||||
press.join();
|
||||
assert(view().focus == C4F_TESTER_RUMBLE_LARGE);
|
||||
assert(polls.load() - before >= 8);
|
||||
printf("PASS sampler: a short navigation tap survives a 100 ms display pause\n");
|
||||
|
||||
assert(view().focus == C4F_TESTER_RUMBLE_LARGE);
|
||||
tap(standardButtons, C4F_INPUT_BUTTON_CROSS);
|
||||
until([] { return largeMotor.load() == 255; });
|
||||
blockRead = true;
|
||||
until([] { return readBlocked.load(); });
|
||||
assert(!pthread_mutex_trylock(&c4fMutex));
|
||||
c4fScreen.controllers = 3;
|
||||
pthread_mutex_unlock(&c4fMutex);
|
||||
releaseRead = true;
|
||||
until([] { return !readBlocked.load(); });
|
||||
printf("PASS sampler: blocked native reads leave the display/status lock available\n");
|
||||
|
||||
// Keep the display alive while the input thread stalls. This must exercise
|
||||
// the input-gap resume branch, not the stale-display pause branch. Both
|
||||
// outputs use a handle borrowed from the ordinary launcher input reader.
|
||||
tap(standardButtons, C4F_INPUT_BUTTON_UP);
|
||||
tap(standardButtons, C4F_INPUT_BUTTON_LEFT);
|
||||
assert(view().focus == C4F_TESTER_LIGHT_COLOR);
|
||||
tap(standardButtons, C4F_INPUT_BUTTON_CROSS);
|
||||
until([] { return lightOn.load(); });
|
||||
tap(standardButtons, C4F_INPUT_BUTTON_CIRCLE);
|
||||
tap(standardButtons, C4F_INPUT_BUTTON_DOWN);
|
||||
tap(standardButtons, C4F_INPUT_BUTTON_RIGHT);
|
||||
assert(view().focus == C4F_TESTER_RUMBLE_LARGE);
|
||||
tap(standardButtons, C4F_INPUT_BUTTON_CROSS);
|
||||
until([] { return largeMotor.load() == 255; });
|
||||
releaseRead = false;
|
||||
blockRead = true;
|
||||
until([] { return readBlocked.load(); });
|
||||
int beforeResume = closes.load();
|
||||
for (int i = 0; i < 12; i++) {
|
||||
pthread_mutex_lock(&c4fMutex);
|
||||
c4fRenderAt = c4fLauncherTimeMs();
|
||||
pthread_mutex_unlock(&c4fMutex);
|
||||
usleep(100000);
|
||||
}
|
||||
pthread_mutex_lock(&c4fMutex);
|
||||
c4fRenderAt = c4fLauncherTimeMs();
|
||||
pthread_mutex_unlock(&c4fMutex);
|
||||
releaseRead = true;
|
||||
until([beforeResume] { return closes.load() >= beforeResume + 2 && !view().menu &&
|
||||
view().user[0].state == C4F_TESTER_READY; });
|
||||
assert(!largeMotor.load() && !lightOn.load());
|
||||
printf("PASS sampler: fresh-display input-gap resume quiets borrowed-handle outputs before closing their owner\n");
|
||||
|
||||
// Restore active output so the independent stale-display safety check
|
||||
// below cannot pass merely because the resume case already stopped it.
|
||||
tap(remoteButtons, C4F_INPUT_BUTTON_CROSS);
|
||||
until([] { return view().menu; });
|
||||
tap(standardButtons, C4F_INPUT_BUTTON_DOWN);
|
||||
assert(view().focus == C4F_TESTER_RUMBLE_LARGE);
|
||||
tap(standardButtons, C4F_INPUT_BUTTON_CROSS);
|
||||
until([] { return largeMotor.load() == 255; });
|
||||
int beforeDisplayPause = closes.load();
|
||||
|
||||
pthread_mutex_lock(&c4fMutex);
|
||||
c4fRenderAt = c4fLauncherTimeMs() - 1100;
|
||||
pthread_mutex_unlock(&c4fMutex);
|
||||
until([] { return largeMotor.load() == 0 && view().menu == 0; });
|
||||
assert(closes.load() >= beforeDisplayPause + 2);
|
||||
pthread_mutex_lock(&c4fMutex);
|
||||
assert(c4fScreen.controllers == 3);
|
||||
c4fRenderAt = c4fLauncherTimeMs();
|
||||
pthread_mutex_unlock(&c4fMutex);
|
||||
until([] { return view().user[0].state == C4F_TESTER_READY; });
|
||||
pthread_mutex_lock(&c4fMutex); c4fQuit = 1; pthread_mutex_unlock(&c4fMutex);
|
||||
pthread_join(worker, NULL);
|
||||
assert(largeMotor.load() == 0 && closes.load() >= 4);
|
||||
printf("PASS sampler: stalled display stops output; recovery and owned-handle cleanup are safe\n");
|
||||
|
||||
C4fLauncherScreen a = {}, b;
|
||||
a.tester.active = a.tester.menu = 1; a.tester.selected = 0;
|
||||
b = a; b.controllers = 4; b.tester.l2 = 170; b.tester.gyro[2] = 230;
|
||||
C4fLauncherScreen va = c4fVisibleScreen(&a), vb = c4fVisibleScreen(&b);
|
||||
assert(!memcmp(&va, &vb, sizeof(va)));
|
||||
b.tester.focus = 1; vb = c4fVisibleScreen(&b); assert(memcmp(&va, &vb, sizeof(va)));
|
||||
b = a; b.tester.rumble = 1; vb = c4fVisibleScreen(&b); assert(memcmp(&va, &vb, sizeof(va)));
|
||||
a.connection.available = 1; a.connection.pads = 1U << 1;
|
||||
for (int p = 0; p < C4F_SERVICE_PADS; p++) a.connection.rttMs[p] = -1;
|
||||
a.connection.rttMs[1] = 8;
|
||||
b = a; b.connection.rttMs[1] = 9;
|
||||
va = c4fVisibleScreen(&a); vb = c4fVisibleScreen(&b); assert(memcmp(&va, &vb, sizeof(va)));
|
||||
b = a; b.connection.rttMs[1] = -1;
|
||||
vb = c4fVisibleScreen(&b); assert(memcmp(&va, &vb, sizeof(va)));
|
||||
printf("PASS tester redraw key: fresh and expired connection RTT changes remain visible in tools\n");
|
||||
a.tester.colorMenu = 1;
|
||||
va = c4fVisibleScreen(&a);
|
||||
b = a; b.tester.gyro[2] = 190;
|
||||
vb = c4fVisibleScreen(&b); assert(!memcmp(&va, &vb, sizeof(va)));
|
||||
b.tester.colorChannel = 1;
|
||||
vb = c4fVisibleScreen(&b); assert(memcmp(&va, &vb, sizeof(va)));
|
||||
b = a; b.tester.colorFine = 1;
|
||||
vb = c4fVisibleScreen(&b); assert(memcmp(&va, &vb, sizeof(va)));
|
||||
b = a; b.tester.light[0] = 128;
|
||||
vb = c4fVisibleScreen(&b); assert(memcmp(&va, &vb, sizeof(va)));
|
||||
printf("PASS RGB picker redraw key: selection, precision and color visible; hidden live motion ignored\n");
|
||||
b = a;
|
||||
a.tester.menu = b.tester.menu = 0; b.tester.gyro[2] = 200;
|
||||
va = c4fVisibleScreen(&a); vb = c4fVisibleScreen(&b); assert(memcmp(&va, &vb, sizeof(va)));
|
||||
printf("PASS tester redraw key: hidden readings ignored by tools; live view and output stay visible\n");
|
||||
}
|
||||
@@ -0,0 +1,786 @@
|
||||
/* The controller test's logic with simulated users and pads. No console, no
|
||||
* screen: the real tester.c and navigation.c against fake native calls. */
|
||||
#include <assert.h>
|
||||
#include <math.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include "autorun.h"
|
||||
#include "input.h"
|
||||
#include "navigation.h"
|
||||
#include "screen.h"
|
||||
#include "tester.h"
|
||||
|
||||
#define STICKS (C4F_INPUT_BUTTON_L3 | C4F_INPUT_BUTTON_R3)
|
||||
|
||||
typedef struct {
|
||||
int32_t user;
|
||||
int handle, owned, openError, readError, connected;
|
||||
int opens, closes, reads, motionOn, lightResets, orientationResets, vibrations, lightCalls, lightError;
|
||||
int vibrating, large, small, lit;
|
||||
uint8_t rgb[3];
|
||||
C4fTesterSample sample;
|
||||
} FakePad;
|
||||
|
||||
typedef struct {
|
||||
int32_t users[4];
|
||||
int count, listError;
|
||||
FakePad pads[4];
|
||||
int stateCalls;
|
||||
const char *nameOverride;
|
||||
} Fake;
|
||||
|
||||
static FakePad *byUser(Fake *f, int32_t user)
|
||||
{
|
||||
for (int i = 0; i < 4; i++) if (f->pads[i].user == user) return &f->pads[i];
|
||||
assert(!"unknown user");
|
||||
return NULL;
|
||||
}
|
||||
|
||||
static FakePad *byHandle(Fake *f, int handle)
|
||||
{
|
||||
for (int i = 0; i < 4; i++) if (f->pads[i].handle == handle) return &f->pads[i];
|
||||
assert(!"unknown handle");
|
||||
return NULL;
|
||||
}
|
||||
|
||||
static int fakeUsers(void *context, int32_t *users)
|
||||
{
|
||||
Fake *f = context;
|
||||
if (f->listError) return f->listError;
|
||||
for (int i = 0; i < f->count; i++) users[i] = f->users[i];
|
||||
return f->count;
|
||||
}
|
||||
|
||||
static void fakeName(void *context, int32_t user, char *name, int size)
|
||||
{
|
||||
Fake *f = context;
|
||||
if (user == 1 && f->nameOverride) {
|
||||
snprintf(name, (size_t)size, "%s", f->nameOverride);
|
||||
return;
|
||||
}
|
||||
/* User 3 has no name the app can draw: the tester falls back. */
|
||||
if (user == 3) { name[0] = 0; return; }
|
||||
snprintf(name, (size_t)size, user == 1 ? "Alex" : user == 2 ? "Sam\x01" : "Kim");
|
||||
}
|
||||
|
||||
static int fakeOpen(void *context, int32_t user, int *owned)
|
||||
{
|
||||
FakePad *p = byUser(context, user);
|
||||
p->opens++;
|
||||
*owned = p->owned;
|
||||
return p->openError ? p->openError : p->handle;
|
||||
}
|
||||
|
||||
static int fakeRead(void *context, int handle, C4fTesterSample *sample)
|
||||
{
|
||||
FakePad *p = byHandle(context, handle);
|
||||
p->reads++;
|
||||
*sample = p->sample;
|
||||
sample->connected = p->connected;
|
||||
return p->readError;
|
||||
}
|
||||
|
||||
static void fakeClose(void *context, int handle)
|
||||
{
|
||||
FakePad *p = byHandle(context, handle);
|
||||
assert(p->owned);
|
||||
p->closes++;
|
||||
assert(p->closes <= p->opens);
|
||||
}
|
||||
|
||||
static int fakeVibrate(void *context, int handle, uint8_t large, uint8_t small)
|
||||
{
|
||||
FakePad *p = byHandle(context, handle);
|
||||
p->vibrations++;
|
||||
p->large = large;
|
||||
p->small = small;
|
||||
p->vibrating = large || small;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int fakeLight(void *context, int handle, uint8_t r, uint8_t g, uint8_t b)
|
||||
{
|
||||
FakePad *p = byHandle(context, handle);
|
||||
p->lightCalls++;
|
||||
if (p->lightError) return p->lightError;
|
||||
p->lit = 1;
|
||||
p->rgb[0] = r; p->rgb[1] = g; p->rgb[2] = b;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int fakeResetLight(void *context, int handle)
|
||||
{
|
||||
FakePad *p = byHandle(context, handle);
|
||||
p->lightResets++;
|
||||
p->lit = 0;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int fakeResetOrientation(void *context, int handle)
|
||||
{
|
||||
byHandle(context, handle)->orientationResets++;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int fakeMotion(void *context, int handle, int on)
|
||||
{
|
||||
byHandle(context, handle)->motionOn = on;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static void fakeState(void *context, int slot, int state, uint32_t error)
|
||||
{
|
||||
(void)slot; (void)state; (void)error;
|
||||
((Fake *)context)->stateCalls++;
|
||||
}
|
||||
|
||||
static void setup(Fake *f, C4fTester *t)
|
||||
{
|
||||
memset(f, 0, sizeof(*f));
|
||||
f->count = 2;
|
||||
f->users[0] = 1;
|
||||
f->users[1] = 2;
|
||||
for (int i = 0; i < 4; i++) {
|
||||
f->pads[i] = (FakePad){ .user = i + 1, .handle = 100 + i, .owned = 1, .connected = 1 };
|
||||
f->pads[i].sample.lx = f->pads[i].sample.ly = f->pads[i].sample.rx = f->pads[i].sample.ry = 128;
|
||||
}
|
||||
/* The launcher's own reader already holds the first user's handle. */
|
||||
f->pads[0].owned = 0;
|
||||
C4fTesterOps ops = { f, fakeUsers, fakeName, fakeOpen, fakeRead, fakeClose, fakeVibrate, fakeLight,
|
||||
fakeResetLight, fakeResetOrientation, fakeMotion, fakeState };
|
||||
c4fTesterInit(t, &ops);
|
||||
}
|
||||
|
||||
/* A frame with the given buttons held on the first user's controller. */
|
||||
static int frame(Fake *f, C4fTester *t, uint64_t now, uint32_t held, uint32_t remote)
|
||||
{
|
||||
f->pads[0].sample.buttons = held;
|
||||
return c4fTesterFrame(t, now, remote);
|
||||
}
|
||||
|
||||
static int tap(Fake *f, C4fTester *t, uint64_t *now, uint32_t button)
|
||||
{
|
||||
int active = frame(f, t, *now += 10, button, 0);
|
||||
if (active) frame(f, t, *now += 10, 0, 0);
|
||||
return active;
|
||||
}
|
||||
|
||||
static void holdTiming(void)
|
||||
{
|
||||
C4fTesterHold hold = { 0 };
|
||||
assert(!c4fTesterHoldUpdate(&hold, C4F_INPUT_BUTTON_L3, 0));
|
||||
assert(!c4fTesterHoldUpdate(&hold, STICKS, 100));
|
||||
assert(!c4fTesterHoldUpdate(&hold, STICKS | C4F_INPUT_BUTTON_CROSS, 2099));
|
||||
assert(c4fTesterHoldUpdate(&hold, STICKS, 2100));
|
||||
assert(!c4fTesterHoldUpdate(&hold, STICKS, 5000));
|
||||
assert(!c4fTesterHoldUpdate(&hold, 0, 5001));
|
||||
assert(!c4fTesterHoldUpdate(&hold, STICKS, 6000));
|
||||
assert(c4fTesterHoldUpdate(&hold, STICKS, 8000));
|
||||
puts("PASS L3+R3 must be held together for two seconds, once per hold");
|
||||
}
|
||||
|
||||
static void readsUsers(void)
|
||||
{
|
||||
Fake f;
|
||||
static C4fTester t;
|
||||
setup(&f, &t);
|
||||
c4fTesterOpen(&t, 1000);
|
||||
assert(t.active && t.selected == 0);
|
||||
f.pads[0].sample.lx = 12; f.pads[0].sample.l2 = 200; f.pads[0].sample.touches = 3;
|
||||
f.pads[0].sample.touch[1].x = 1919; f.pads[0].sample.touch[1].y = 941;
|
||||
f.pads[0].sample.accel[1] = 0.987f; f.pads[0].sample.gyro[0] = -1.234f; f.pads[0].sample.quat[3] = 0.5f;
|
||||
f.pads[0].sample.quat[0] = NAN;
|
||||
assert(frame(&f, &t, 1000, C4F_INPUT_BUTTON_CROSS, 0));
|
||||
assert(f.pads[0].opens == 1 && f.pads[1].opens == 1 && f.pads[0].motionOn && f.pads[1].motionOn);
|
||||
C4fTesterView v;
|
||||
c4fTesterView(&t, &v);
|
||||
assert(v.active && !v.menu && v.selected == 0);
|
||||
assert(v.user[0].used && v.user[1].used && !v.user[2].used);
|
||||
assert(v.user[0].id == 1 && v.user[1].id == 2 && v.user[2].id == -1 && v.user[3].id == -1);
|
||||
assert(v.user[v.selected].id == 1);
|
||||
assert(!strcmp(v.user[0].name, "Alex") && !strcmp(v.user[1].name, "Sam?"));
|
||||
assert(v.user[0].state == C4F_TESTER_READY && v.user[1].state == C4F_TESTER_READY);
|
||||
assert(v.buttons == C4F_INPUT_BUTTON_CROSS && v.lx == 12 && v.l2 == 200);
|
||||
assert(v.touches == 2 && v.touchX[1] == 1919 && v.touchY[1] == 941);
|
||||
assert(v.accel[1] == 99 && v.gyro[0] == -123 && v.quat[3] == 500 && v.quat[0] == 0);
|
||||
/* The Cross held when reading started is shown, but is not a press. */
|
||||
assert(t.readers[0].pressed == 0);
|
||||
t.selected = 1;
|
||||
c4fTesterView(&t, &v);
|
||||
assert(v.selected == 1 && v.user[v.selected].id == 2);
|
||||
t.selected = 0;
|
||||
frame(&f, &t, 1010, C4F_INPUT_BUTTON_CROSS | C4F_INPUT_BUTTON_CIRCLE, 0);
|
||||
assert(t.readers[0].pressed == C4F_INPUT_BUTTON_CIRCLE && t.active && !t.menu);
|
||||
|
||||
/* A third user with no drawable name gets a stand-in. */
|
||||
f.count = 3; f.users[2] = 3;
|
||||
frame(&f, &t, 2010, 0, 0);
|
||||
c4fTesterView(&t, &v);
|
||||
assert(!strcmp(v.user[2].name, "User 3"));
|
||||
assert(v.user[2].id == 3);
|
||||
c4fTesterClose(&t);
|
||||
assert(!t.active && f.pads[0].closes == 0 && f.pads[1].closes == 1 && f.pads[2].closes == 1);
|
||||
c4fTesterView(&t, &v);
|
||||
assert(!v.active);
|
||||
for (int s = 0; s < C4F_TESTER_USERS; s++) assert(!v.user[s].used && v.user[s].id == -1);
|
||||
|
||||
setup(&f, &t);
|
||||
f.count = 0;
|
||||
c4fTesterOpen(&t, 0);
|
||||
frame(&f, &t, 0, 0, 0);
|
||||
c4fTesterView(&t, &v);
|
||||
assert(v.active && v.selected == -1);
|
||||
for (int s = 0; s < C4F_TESTER_USERS; s++) assert(!v.user[s].used && v.user[s].id == -1);
|
||||
c4fTesterClose(&t);
|
||||
puts("PASS readers for every signed-in user, borrowed handle kept, values in display units");
|
||||
}
|
||||
|
||||
/* Opens the test, lets the opening hold go, then holds again until the menu opens. */
|
||||
static uint64_t openMenu(Fake *f, C4fTester *t)
|
||||
{
|
||||
c4fTesterOpen(t, 0);
|
||||
/* Still holding the L3+R3 that opened it: no menu, however long. */
|
||||
for (uint64_t now = 0; now <= 4000; now += 500) assert(frame(f, t, now, STICKS, 0) && !t->menu);
|
||||
frame(f, t, 4100, 0, 0);
|
||||
frame(f, t, 4200, STICKS, 0);
|
||||
frame(f, t, 6199, STICKS, 0);
|
||||
assert(!t->menu);
|
||||
frame(f, t, 6200, STICKS, 0);
|
||||
assert(t->menu && !t->armed && t->focus == C4F_TESTER_RESUME);
|
||||
return 6200;
|
||||
}
|
||||
|
||||
static void menuActions(void)
|
||||
{
|
||||
Fake f;
|
||||
static C4fTester t;
|
||||
setup(&f, &t);
|
||||
uint64_t now = openMenu(&f, &t);
|
||||
frame(&f, &t, now += 10, 0, 0);
|
||||
assert(t.armed);
|
||||
tap(&f, &t, &now, C4F_INPUT_BUTTON_DOWN);
|
||||
assert(t.focus == C4F_TESTER_RUMBLE_LARGE);
|
||||
tap(&f, &t, &now, C4F_INPUT_BUTTON_CROSS);
|
||||
assert(f.pads[0].vibrating && f.pads[0].large == 255 && f.pads[0].small == 0);
|
||||
C4fTesterView v;
|
||||
c4fTesterView(&t, &v);
|
||||
assert(v.rumble == 1 && strstr(v.note, "Large motor"));
|
||||
now = t.rumbleUntil - 1;
|
||||
frame(&f, &t, now, 0, 0);
|
||||
assert(f.pads[0].vibrating);
|
||||
frame(&f, &t, ++now, 0, 0);
|
||||
assert(!f.pads[0].vibrating && !t.rumble);
|
||||
|
||||
/* Small motor, then the light bar and orientation. */
|
||||
tap(&f, &t, &now, C4F_INPUT_BUTTON_RIGHT);
|
||||
assert(t.focus == C4F_TESTER_RUMBLE_SMALL);
|
||||
tap(&f, &t, &now, C4F_INPUT_BUTTON_CROSS);
|
||||
assert(f.pads[0].vibrating && f.pads[0].large == 0 && f.pads[0].small == 255);
|
||||
/* A move and a select arriving together only move. */
|
||||
frame(&f, &t, now += 10, C4F_INPUT_BUTTON_DOWN | C4F_INPUT_BUTTON_CROSS, 0);
|
||||
assert(t.active && t.focus == C4F_TESTER_USER && !f.pads[0].lit);
|
||||
frame(&f, &t, now += 10, 0, 0);
|
||||
tap(&f, &t, &now, C4F_INPUT_BUTTON_RIGHT);
|
||||
tap(&f, &t, &now, C4F_INPUT_BUTTON_RIGHT);
|
||||
assert(t.focus == C4F_TESTER_LIGHT_COLOR);
|
||||
tap(&f, &t, &now, C4F_INPUT_BUTTON_CROSS);
|
||||
assert(t.colorMenu && f.pads[0].lit && f.pads[0].rgb[0] == 255 && f.pads[0].rgb[1] == 255);
|
||||
c4fTesterView(&t, &v);
|
||||
assert(v.lightSet && v.light[0] == 255);
|
||||
tap(&f, &t, &now, C4F_INPUT_BUTTON_CIRCLE);
|
||||
tap(&f, &t, &now, C4F_INPUT_BUTTON_LEFT);
|
||||
assert(t.focus == C4F_TESTER_ORIENTATION);
|
||||
tap(&f, &t, &now, C4F_INPUT_BUTTON_CROSS);
|
||||
assert(f.pads[0].orientationResets == 1);
|
||||
|
||||
/* Back to the launcher leaves and cleans up. */
|
||||
tap(&f, &t, &now, C4F_INPUT_BUTTON_RIGHT);
|
||||
tap(&f, &t, &now, C4F_INPUT_BUTTON_DOWN);
|
||||
assert(t.focus == C4F_TESTER_EXIT);
|
||||
assert(!tap(&f, &t, &now, C4F_INPUT_BUTTON_CROSS));
|
||||
assert(!t.active);
|
||||
assert(f.pads[0].lightResets == 1 && !f.pads[0].lit && !f.pads[0].vibrating);
|
||||
assert(f.pads[0].closes == 0 && f.pads[1].closes == 1);
|
||||
puts("PASS menu after a fresh hold and release; rumble stops after a second; light bar reset on leaving");
|
||||
}
|
||||
|
||||
static void switchUsers(void)
|
||||
{
|
||||
Fake f;
|
||||
static C4fTester t;
|
||||
setup(&f, &t);
|
||||
uint64_t now = openMenu(&f, &t);
|
||||
frame(&f, &t, now += 10, 0, 0);
|
||||
tap(&f, &t, &now, C4F_INPUT_BUTTON_RIGHT);
|
||||
assert(t.focus == C4F_TESTER_USER);
|
||||
tap(&f, &t, &now, C4F_INPUT_BUTTON_CROSS);
|
||||
assert(t.selected == 1);
|
||||
tap(&f, &t, &now, C4F_INPUT_BUTTON_CROSS);
|
||||
assert(t.selected == 0);
|
||||
/* The second user's own controller drives the menu as well. */
|
||||
f.pads[1].sample.buttons = C4F_INPUT_BUTTON_CROSS;
|
||||
frame(&f, &t, now += 10, 0, 0);
|
||||
assert(t.selected == 1);
|
||||
f.pads[1].sample.buttons = 0;
|
||||
frame(&f, &t, now += 10, 0, 0);
|
||||
/* Rumble goes to the selected user's controller. */
|
||||
tap(&f, &t, &now, C4F_INPUT_BUTTON_DOWN);
|
||||
tap(&f, &t, &now, C4F_INPUT_BUTTON_LEFT);
|
||||
tap(&f, &t, &now, C4F_INPUT_BUTTON_CROSS);
|
||||
assert(f.pads[1].vibrating && !f.pads[0].vibrating);
|
||||
/* Back closes the menu; the test stays open. */
|
||||
tap(&f, &t, &now, C4F_INPUT_BUTTON_CIRCLE);
|
||||
assert(t.active && !t.menu);
|
||||
c4fTesterClose(&t);
|
||||
assert(!f.pads[1].vibrating && f.pads[1].closes == 1);
|
||||
puts("PASS user choice from either controller, output to the chosen user, Back resumes testing");
|
||||
}
|
||||
|
||||
static void menuOpeningSafety(void)
|
||||
{
|
||||
Fake f;
|
||||
static C4fTester t;
|
||||
setup(&f, &t);
|
||||
/* Another controller held Down before the menu opened. It must neither
|
||||
* scroll by itself nor prevent fresh input on the opening controller. */
|
||||
f.pads[1].sample.buttons = C4F_INPUT_BUTTON_DOWN;
|
||||
uint64_t now = openMenu(&f, &t);
|
||||
frame(&f, &t, now + 10, STICKS | C4F_INPUT_BUTTON_RIGHT, 0);
|
||||
assert(!t.armed && t.focus == C4F_TESTER_USER);
|
||||
frame(&f, &t, now + 20, STICKS | C4F_INPUT_BUTTON_RIGHT | C4F_INPUT_BUTTON_CROSS, 0);
|
||||
assert(t.selected == 0 && t.menu);
|
||||
/* Releasing the opener arms activation even though Cross and another
|
||||
* controller's Down remain held. The old Cross still is not a fresh click. */
|
||||
frame(&f, &t, now + 30, C4F_INPUT_BUTTON_CROSS, 0);
|
||||
assert(t.armed && t.selected == 0);
|
||||
frame(&f, &t, now + 40, C4F_INPUT_BUTTON_CROSS, 0);
|
||||
assert(t.selected == 0);
|
||||
frame(&f, &t, now + 50, 0, 0);
|
||||
frame(&f, &t, now + 60, C4F_INPUT_BUTTON_CROSS, 0);
|
||||
assert(t.selected == 1 && t.focus == C4F_TESTER_USER);
|
||||
frame(&f, &t, now + 1000, C4F_INPUT_BUTTON_CROSS, 0);
|
||||
assert(t.selected == 1 && t.focus == C4F_TESTER_USER);
|
||||
c4fTesterClose(&t);
|
||||
|
||||
setup(&f, &t);
|
||||
now = openMenu(&f, &t);
|
||||
/* No neutral frame is required between the opening gesture and a new
|
||||
* direction: release L3/R3 and press Down in the same sample. */
|
||||
frame(&f, &t, now + 10, C4F_INPUT_BUTTON_DOWN, 0);
|
||||
assert(t.armed && t.focus == C4F_TESTER_RUMBLE_LARGE);
|
||||
frame(&f, &t, now + 20, 0, 0);
|
||||
frame(&f, &t, now + 30, C4F_INPUT_BUTTON_CROSS, 0);
|
||||
assert(f.pads[0].vibrating);
|
||||
c4fTesterClose(&t);
|
||||
|
||||
setup(&f, &t);
|
||||
f.pads[1].sample.buttons = STICKS;
|
||||
now = openMenu(&f, &t);
|
||||
frame(&f, &t, now + 10, C4F_INPUT_BUTTON_RIGHT, 0);
|
||||
assert(t.armed && t.focus == C4F_TESTER_USER); /* unrelated held sticks do not block */
|
||||
frame(&f, &t, now + 20, C4F_INPUT_BUTTON_CROSS, 0);
|
||||
assert(t.selected == 1);
|
||||
c4fTesterClose(&t);
|
||||
|
||||
setup(&f, &t);
|
||||
c4fTesterOpen(&t, 0);
|
||||
frame(&f, &t, 0, 0, 0);
|
||||
frame(&f, &t, 100, STICKS, 0);
|
||||
f.pads[1].sample.buttons = STICKS;
|
||||
frame(&f, &t, 1000, 0, 0);
|
||||
frame(&f, &t, 2100, 0, 0);
|
||||
assert(!t.menu); /* a different reader cannot inherit elapsed hold time */
|
||||
frame(&f, &t, 2999, 0, 0);
|
||||
assert(!t.menu);
|
||||
frame(&f, &t, 3000, 0, 0);
|
||||
assert(t.menu && t.menuOpeners == (1u << 1));
|
||||
c4fTesterClose(&t);
|
||||
puts("PASS menu release gate belongs only to its opener; fresh navigation survives; held activation never replays");
|
||||
}
|
||||
|
||||
static void gridNavigationAndRepeat(void)
|
||||
{
|
||||
Fake f;
|
||||
static C4fTester t;
|
||||
setup(&f, &t);
|
||||
uint64_t now = openMenu(&f, &t);
|
||||
assert(C4F_TESTER_MENU_COLUMNS == 4 && C4F_TESTER_MENU_ROWS == 2);
|
||||
frame(&f, &t, now + 10, 0, 0);
|
||||
frame(&f, &t, now + 20, C4F_INPUT_BUTTON_RIGHT, 0);
|
||||
assert(t.focus == C4F_TESTER_USER);
|
||||
frame(&f, &t, now + 369, C4F_INPUT_BUTTON_RIGHT, 0);
|
||||
assert(t.focus == C4F_TESTER_USER);
|
||||
frame(&f, &t, now + 370, C4F_INPUT_BUTTON_RIGHT, 0);
|
||||
assert(t.focus == C4F_TESTER_ORIENTATION);
|
||||
frame(&f, &t, now + 469, C4F_INPUT_BUTTON_RIGHT, 0);
|
||||
assert(t.focus == C4F_TESTER_ORIENTATION);
|
||||
frame(&f, &t, now + 470, C4F_INPUT_BUTTON_RIGHT, 0);
|
||||
assert(t.focus == C4F_TESTER_LIGHT_COLOR);
|
||||
frame(&f, &t, now + 570, C4F_INPUT_BUTTON_RIGHT, 0);
|
||||
assert(t.focus == C4F_TESTER_RESUME);
|
||||
frame(&f, &t, now + 670, C4F_INPUT_BUTTON_RIGHT, 0);
|
||||
assert(t.focus == C4F_TESTER_USER); /* held Right wraps within its row */
|
||||
frame(&f, &t, now + 680, 0, 0);
|
||||
frame(&f, &t, now + 690, C4F_INPUT_BUTTON_DOWN, 0);
|
||||
assert(t.focus == C4F_TESTER_RUMBLE_SMALL);
|
||||
frame(&f, &t, now + 1040, C4F_INPUT_BUTTON_DOWN, 0);
|
||||
assert(t.focus == C4F_TESTER_USER); /* held Down wraps within its column */
|
||||
frame(&f, &t, now + 1050, C4F_INPUT_BUTTON_LEFT, 0);
|
||||
assert(t.focus == C4F_TESTER_RESUME); /* direction change moves immediately */
|
||||
frame(&f, &t, now + 1060, C4F_INPUT_BUTTON_UP | C4F_INPUT_BUTTON_DOWN, 0);
|
||||
assert(t.focus == C4F_TESTER_RESUME);
|
||||
frame(&f, &t, now + 1070, C4F_INPUT_BUTTON_UP, 0);
|
||||
assert(t.focus == C4F_TESTER_RESUME); /* old held half is not a fresh press */
|
||||
frame(&f, &t, now + 1080, 0, 0);
|
||||
frame(&f, &t, now + 1090, C4F_INPUT_BUTTON_UP, 0);
|
||||
assert(t.focus == C4F_TESTER_RUMBLE_LARGE); /* top wraps to the bottom of its column */
|
||||
frame(&f, &t, now + 1100, 0, 0);
|
||||
frame(&f, &t, now + 1110, C4F_INPUT_BUTTON_UP, 0);
|
||||
assert(t.focus == C4F_TESTER_RESUME);
|
||||
now += 1120;
|
||||
frame(&f, &t, now, 0, 0);
|
||||
for (int i = 0; i < 4; i++) tap(&f, &t, &now, C4F_INPUT_BUTTON_LEFT);
|
||||
assert(t.focus == C4F_TESTER_RESUME);
|
||||
tap(&f, &t, &now, C4F_INPUT_BUTTON_LEFT);
|
||||
assert(t.focus == C4F_TESTER_LIGHT_COLOR); /* first column wraps to last */
|
||||
|
||||
/* Every tile has the same spatial neighbors for a controller tap and a
|
||||
* fresh HDMI-CEC direction. Row ends wrap within the row, never snake. */
|
||||
static const uint32_t directions[] = { C4F_INPUT_BUTTON_UP, C4F_INPUT_BUTTON_DOWN,
|
||||
C4F_INPUT_BUTTON_LEFT, C4F_INPUT_BUTTON_RIGHT };
|
||||
static const int neighbors[C4F_TESTER_ITEMS][4] = {
|
||||
{ 4, 4, 3, 1 }, { 5, 5, 0, 2 }, { 6, 6, 1, 3 }, { 7, 7, 2, 0 },
|
||||
{ 0, 0, 7, 5 }, { 1, 1, 4, 6 }, { 2, 2, 5, 7 }, { 3, 3, 6, 4 }
|
||||
};
|
||||
for (int rtl = 0; rtl < 2; rtl++) {
|
||||
t.rtl = rtl;
|
||||
for (int item = 0; item < C4F_TESTER_ITEMS; item++) {
|
||||
for (int direction = 0; direction < 4; direction++) {
|
||||
int visibleDirection = rtl && direction >= 2 ? 5 - direction : direction;
|
||||
t.focus = item;
|
||||
tap(&f, &t, &now, directions[direction]);
|
||||
assert(t.focus == neighbors[item][visibleDirection]);
|
||||
t.focus = item;
|
||||
frame(&f, &t, now += 10, 0, directions[direction]);
|
||||
assert(t.focus == neighbors[item][visibleDirection]);
|
||||
frame(&f, &t, now += 10, 0, 0);
|
||||
assert(t.focus == neighbors[item][visibleDirection]);
|
||||
}
|
||||
}
|
||||
}
|
||||
c4fTesterSuspend(&t, now + 1000);
|
||||
assert(!t.menu && !t.menuOpeners);
|
||||
for (int s = 0; s < C4F_TESTER_USERS; s++) assert(!t.readers[s].repeatDirection && !t.readers[s].repeatAt);
|
||||
c4fTesterClose(&t);
|
||||
puts("PASS 4-by-2 spatial wrap for controller/TV remote, immediate taps, 350/100 ms hold repeat and reset on focus loss");
|
||||
}
|
||||
|
||||
static void colorPicker(int rtl)
|
||||
{
|
||||
Fake f;
|
||||
static C4fTester t;
|
||||
setup(&f, &t);
|
||||
t.rtl = rtl;
|
||||
uint64_t now = openMenu(&f, &t);
|
||||
frame(&f, &t, now += 10, 0, 0);
|
||||
t.focus = C4F_TESTER_LIGHT_COLOR;
|
||||
frame(&f, &t, now += 10, C4F_INPUT_BUTTON_CROSS, 0);
|
||||
assert(t.colorMenu && !t.colorFine && f.pads[0].lit);
|
||||
assert(f.pads[0].rgb[0] == 255 && f.pads[0].rgb[1] == 255 && f.pads[0].rgb[2] == 255);
|
||||
frame(&f, &t, now += 500, C4F_INPUT_BUTTON_CROSS, 0);
|
||||
assert(!t.colorFine); /* The opening Cross is not also a precision toggle. */
|
||||
frame(&f, &t, now += 10, 0, 0);
|
||||
for (int i = 0; i < 18; i++) frame(&f, &t, now += 10, 0, C4F_INPUT_BUTTON_LEFT);
|
||||
assert(f.pads[0].rgb[0] == 0 && f.pads[0].rgb[1] == 255 && f.pads[0].rgb[2] == 255);
|
||||
frame(&f, &t, now += 10, 0, C4F_INPUT_BUTTON_CROSS);
|
||||
assert(t.colorFine);
|
||||
/* Every exact byte value can be selected by the TV remote, without a mouse. */
|
||||
for (int value = 1; value <= 255; value++) {
|
||||
frame(&f, &t, now += 10, 0, C4F_INPUT_BUTTON_RIGHT);
|
||||
assert(f.pads[0].rgb[0] == value);
|
||||
}
|
||||
frame(&f, &t, now += 10, 0, C4F_INPUT_BUTTON_RIGHT);
|
||||
assert(f.pads[0].rgb[0] == 255); /* clamp, never wrap */
|
||||
frame(&f, &t, now += 10, 0, C4F_INPUT_BUTTON_DOWN);
|
||||
assert(t.colorChannel == 1);
|
||||
for (int i = 0; i < 10; i++) frame(&f, &t, now += 10, 0, C4F_INPUT_BUTTON_LEFT);
|
||||
assert(f.pads[0].rgb[1] == 245 && f.pads[0].rgb[0] == 255);
|
||||
frame(&f, &t, now += 10, 0, C4F_INPUT_BUTTON_DOWN);
|
||||
assert(t.colorChannel == 2);
|
||||
for (int i = 0; i < 255; i++) frame(&f, &t, now += 10, 0, C4F_INPUT_BUTTON_LEFT);
|
||||
assert(f.pads[0].rgb[2] == 0 && !f.pads[1].lit);
|
||||
C4fTesterView v;
|
||||
c4fTesterView(&t, &v);
|
||||
assert(v.colorMenu && v.colorChannel == 2 && v.colorFine && v.lightSet);
|
||||
assert(v.light[0] == 255 && v.light[1] == 245 && v.light[2] == 0);
|
||||
frame(&f, &t, now += 10, 0, C4F_INPUT_BUTTON_CIRCLE);
|
||||
assert(t.menu && !t.colorMenu && f.pads[0].lit);
|
||||
frame(&f, &t, now += 10, 0, C4F_INPUT_BUTTON_CROSS);
|
||||
assert(t.colorMenu && t.colorChannel == 2 && f.pads[0].rgb[1] == 245);
|
||||
/* Hardware refusal never fabricates a successful preview or stored value. */
|
||||
f.pads[0].lightError = -5;
|
||||
frame(&f, &t, now += 10, 0, C4F_INPUT_BUTTON_RIGHT);
|
||||
assert(f.pads[0].rgb[2] == 0 && t.readers[0].light[2] == 0 && strstr(t.note, "refused"));
|
||||
f.pads[0].lightError = 0;
|
||||
frame(&f, &t, now += 10, 0, C4F_INPUT_BUTTON_RIGHT);
|
||||
assert(f.pads[0].rgb[2] == 1 && !t.note[0]);
|
||||
/* Held controller input keeps its normal repeat timing in the picker. */
|
||||
frame(&f, &t, now += 10, C4F_INPUT_BUTTON_RIGHT, 0);
|
||||
assert(f.pads[0].rgb[2] == 2);
|
||||
frame(&f, &t, now += 349, C4F_INPUT_BUTTON_RIGHT, 0);
|
||||
assert(f.pads[0].rgb[2] == 2);
|
||||
frame(&f, &t, ++now, C4F_INPUT_BUTTON_RIGHT, 0);
|
||||
assert(f.pads[0].rgb[2] == 3);
|
||||
frame(&f, &t, now += 10, 0, 0);
|
||||
frame(&f, &t, now += 10, 0, C4F_INPUT_BUTTON_CIRCLE);
|
||||
t.focus = C4F_TESTER_LIGHT_RESET;
|
||||
frame(&f, &t, now += 10, 0, C4F_INPUT_BUTTON_CROSS);
|
||||
assert(!t.readers[0].lightSet && !f.pads[0].lit);
|
||||
t.selected = 1;
|
||||
t.focus = C4F_TESTER_LIGHT_COLOR;
|
||||
frame(&f, &t, now += 10, 0, C4F_INPUT_BUTTON_CROSS);
|
||||
assert(t.colorMenu && f.pads[1].lit && !f.pads[0].lit);
|
||||
f.count = 1;
|
||||
frame(&f, &t, now = t.usersAt, 0, 0);
|
||||
assert(!t.colorMenu && !f.pads[1].lit && t.readers[1].state == C4F_TESTER_SIGNED_OUT);
|
||||
c4fTesterClose(&t);
|
||||
|
||||
setup(&f, &t);
|
||||
now = openMenu(&f, &t);
|
||||
frame(&f, &t, now += 10, 0, 0);
|
||||
t.focus = C4F_TESTER_LIGHT_COLOR;
|
||||
f.pads[0].lightError = -5;
|
||||
tap(&f, &t, &now, C4F_INPUT_BUTTON_CROSS);
|
||||
assert(!t.colorMenu && !t.readers[0].lightSet && !f.pads[0].lit);
|
||||
f.pads[0].lightError = 0;
|
||||
tap(&f, &t, &now, C4F_INPUT_BUTTON_CROSS);
|
||||
f.pads[0].connected = 0;
|
||||
frame(&f, &t, now += 10, 0, 0);
|
||||
assert(!t.colorMenu && !f.pads[0].lit && !t.readers[0].lightSet);
|
||||
c4fTesterClose(&t);
|
||||
puts("PASS RGB picker: all byte strengths, coarse/fine, controller repeat, remote control, selected user, refusal and cleanup");
|
||||
}
|
||||
|
||||
static void menuAvailability(void)
|
||||
{
|
||||
Fake f;
|
||||
static C4fTester t;
|
||||
setup(&f, &t);
|
||||
f.count = 1;
|
||||
c4fTesterOpen(&t, 0);
|
||||
frame(&f, &t, 0, 0, 0);
|
||||
C4fTesterView view;
|
||||
c4fTesterView(&t, &view);
|
||||
assert(c4fTesterItemAvailable(&view, C4F_TESTER_RESUME));
|
||||
assert(c4fTesterItemAvailable(&view, C4F_TESTER_EXIT));
|
||||
assert(!c4fTesterItemAvailable(&view, C4F_TESTER_USER));
|
||||
assert(c4fTesterItemAvailable(&view, C4F_TESTER_ORIENTATION));
|
||||
t.menu = t.armed = 1; t.focus = C4F_TESTER_USER;
|
||||
frame(&f, &t, 10, C4F_INPUT_BUTTON_CROSS, 0);
|
||||
assert(t.selected == 0 && strstr(t.note, "No other user"));
|
||||
frame(&f, &t, 20, 0, 0);
|
||||
f.pads[0].connected = 0;
|
||||
frame(&f, &t, 30, 0, 0);
|
||||
c4fTesterView(&t, &view);
|
||||
assert(!c4fTesterItemAvailable(&view, C4F_TESTER_RUMBLE_LARGE));
|
||||
assert(!c4fTesterItemAvailable(&view, C4F_TESTER_LIGHT_COLOR));
|
||||
assert(c4fTesterItemAvailable(&view, C4F_TESTER_RESUME));
|
||||
assert(c4fTesterItemAvailable(&view, C4F_TESTER_EXIT));
|
||||
/* A signed-out user may stay selected for its status screen. The one
|
||||
* remaining signed-in user must still be available as a switch target. */
|
||||
view.user[0].state = C4F_TESTER_SIGNED_OUT;
|
||||
view.user[1].used = 1;
|
||||
view.user[1].state = C4F_TESTER_READY;
|
||||
assert(c4fTesterItemAvailable(&view, C4F_TESTER_USER));
|
||||
c4fTesterClose(&t);
|
||||
puts("PASS menu availability matches activation: user count, disconnected hardware tools, Resume and Exit");
|
||||
}
|
||||
|
||||
static void failures(void)
|
||||
{
|
||||
Fake f;
|
||||
static C4fTester t;
|
||||
setup(&f, &t);
|
||||
c4fTesterOpen(&t, 0);
|
||||
frame(&f, &t, 0, 0, 0);
|
||||
/* A read failure releases the handle and retries a second later. */
|
||||
f.pads[1].readError = (int)0x80920003u;
|
||||
frame(&f, &t, 10, 0, 0);
|
||||
assert(t.readers[1].state == C4F_TESTER_FAILED && t.readers[1].handle < 0 && f.pads[1].closes == 1);
|
||||
f.pads[1].readError = 0;
|
||||
frame(&f, &t, 1009, 0, 0);
|
||||
assert(f.pads[1].opens == 1);
|
||||
frame(&f, &t, 1010, 0, 0);
|
||||
assert(f.pads[1].opens == 2 && t.readers[1].state == C4F_TESTER_READY);
|
||||
/* An open failure is shown with its code and retried. */
|
||||
c4fTesterClose(&t);
|
||||
setup(&f, &t);
|
||||
f.pads[1].openError = (int)0x80920001u;
|
||||
c4fTesterOpen(&t, 0);
|
||||
frame(&f, &t, 0, 0, 0);
|
||||
C4fTesterView v;
|
||||
c4fTesterView(&t, &v);
|
||||
assert(v.user[1].state == C4F_TESTER_FAILED && v.user[1].error == 0x80920001u);
|
||||
f.pads[1].openError = 0;
|
||||
frame(&f, &t, 1000, 0, 0);
|
||||
assert(t.readers[1].state == C4F_TESTER_READY);
|
||||
/* No controller: shown as such, the handle kept, nothing pressed. */
|
||||
f.pads[0].connected = 0;
|
||||
frame(&f, &t, 1010, C4F_INPUT_BUTTON_CROSS, 0);
|
||||
c4fTesterView(&t, &v);
|
||||
assert(v.user[0].state == C4F_TESTER_NO_CONTROLLER && !v.buttons && t.readers[0].handle >= 0);
|
||||
/* An action needs a controller to act on. */
|
||||
f.pads[0].connected = 1;
|
||||
frame(&f, &t, 1020, 0, 0);
|
||||
f.pads[0].connected = 0;
|
||||
t.menu = t.armed = 1;
|
||||
t.focus = C4F_TESTER_RUMBLE_LARGE;
|
||||
frame(&f, &t, 1030, 0, 0);
|
||||
f.pads[1].sample.buttons = C4F_INPUT_BUTTON_CROSS;
|
||||
frame(&f, &t, 1040, 0, 0);
|
||||
assert(!f.pads[0].vibrating && strstr(t.note, "no controller"));
|
||||
f.pads[1].sample.buttons = 0;
|
||||
c4fTesterClose(&t);
|
||||
puts("PASS read/open failures retried after a second, no-controller state, actions need a controller");
|
||||
}
|
||||
|
||||
static void logout(void)
|
||||
{
|
||||
Fake f;
|
||||
static C4fTester t;
|
||||
setup(&f, &t);
|
||||
f.count = 3; f.users[2] = 3;
|
||||
c4fTesterOpen(&t, 0);
|
||||
frame(&f, &t, 0, 0, 0);
|
||||
t.selected = 1;
|
||||
/* The user on screen signs out: kept, marked, its handle closed. */
|
||||
f.count = 2; f.users[1] = 3;
|
||||
frame(&f, &t, 1000, 0, 0);
|
||||
assert(t.selected == 1 && t.readers[1].state == C4F_TESTER_SIGNED_OUT && f.pads[1].closes == 1);
|
||||
C4fTesterView v;
|
||||
c4fTesterView(&t, &v);
|
||||
assert(v.user[1].used && v.user[1].state == C4F_TESTER_SIGNED_OUT);
|
||||
assert(v.user[v.selected].id == 2);
|
||||
/* Signs in again: read again. */
|
||||
f.count = 3; f.users[1] = 2; f.users[2] = 3;
|
||||
frame(&f, &t, 2000, 0, 0);
|
||||
frame(&f, &t, 2001, 0, 0);
|
||||
assert(t.readers[1].state == C4F_TESTER_READY && f.pads[1].opens == 2);
|
||||
c4fTesterView(&t, &v);
|
||||
assert(v.user[v.selected].id == 2);
|
||||
/* A user not on screen signs out: dropped at once. */
|
||||
f.count = 2;
|
||||
frame(&f, &t, 3000, 0, 0);
|
||||
assert(t.readers[2].user < 0 && f.pads[2].closes == 1);
|
||||
c4fTesterView(&t, &v);
|
||||
assert(!v.user[2].used && v.user[2].id == -1);
|
||||
/* A failed user list keeps what is known. */
|
||||
f.listError = -1;
|
||||
frame(&f, &t, 4000, 0, 0);
|
||||
assert(t.readers[0].user == 1 && t.readers[1].user == 2);
|
||||
c4fTesterClose(&t);
|
||||
puts("PASS sign-out keeps the user on screen as signed out; others dropped; sign-in reads again");
|
||||
}
|
||||
|
||||
static void remoteAndSuspend(void)
|
||||
{
|
||||
Fake f;
|
||||
static C4fTester t;
|
||||
setup(&f, &t);
|
||||
c4fTesterOpen(&t, 0);
|
||||
frame(&f, &t, 0, 0, 0);
|
||||
/* The remote's OK opens the menu, its Back closes it, and Back again leaves. */
|
||||
frame(&f, &t, 10, 0, C4F_INPUT_BUTTON_CROSS);
|
||||
assert(t.menu);
|
||||
frame(&f, &t, 20, 0, 0);
|
||||
assert(t.armed);
|
||||
frame(&f, &t, 30, 0, C4F_INPUT_BUTTON_RIGHT);
|
||||
assert(t.focus == C4F_TESTER_USER);
|
||||
frame(&f, &t, 40, 0, C4F_INPUT_BUTTON_CIRCLE);
|
||||
assert(t.active && !t.menu);
|
||||
/* A DS4's Circle is under test and does not leave. */
|
||||
frame(&f, &t, 50, C4F_INPUT_BUTTON_CIRCLE, 0);
|
||||
assert(t.active && !t.menu);
|
||||
/* Losing focus stops output and reopens every reader. */
|
||||
t.menu = t.armed = 1;
|
||||
t.focus = C4F_TESTER_RUMBLE_LARGE;
|
||||
frame(&f, &t, 60, 0, 0);
|
||||
frame(&f, &t, 70, C4F_INPUT_BUTTON_CROSS, 0);
|
||||
t.focus = C4F_TESTER_LIGHT_COLOR;
|
||||
frame(&f, &t, 80, 0, 0);
|
||||
frame(&f, &t, 90, C4F_INPUT_BUTTON_CROSS, 0);
|
||||
assert(f.pads[0].vibrating && f.pads[0].lit && f.pads[0].rgb[2] == 255);
|
||||
c4fTesterSuspend(&t, 5000);
|
||||
assert(!f.pads[0].vibrating && !f.pads[0].lit && !t.menu && t.active);
|
||||
assert(t.readers[0].handle < 0 && t.readers[1].handle < 0 && f.pads[1].closes == 1);
|
||||
frame(&f, &t, 5000, 0, 0);
|
||||
assert(t.readers[0].state == C4F_TESTER_READY && t.readers[1].state == C4F_TESTER_READY);
|
||||
assert(!frame(&f, &t, 5010, 0, C4F_INPUT_BUTTON_CIRCLE));
|
||||
assert(!t.active && f.pads[1].closes == 2);
|
||||
puts("PASS TV remote opens/closes the menu and leaves; focus loss stops output and reopens readers");
|
||||
}
|
||||
|
||||
static void launcherEntry(void)
|
||||
{
|
||||
C4fLauncherScreen screen;
|
||||
memset(&screen, 0, sizeof(screen));
|
||||
screen.autorun = C4F_AUTORUN_ON;
|
||||
assert(c4fActionAvailable(&screen, C4F_ACTION_TESTER, 0));
|
||||
assert(!strcmp(c4fActionLabel(&screen, C4F_ACTION_TESTER), "Test controllers"));
|
||||
C4fLauncherAction actions[8];
|
||||
int count = c4fActionList(&screen, 0, actions, 8), found = 0;
|
||||
for (int i = 0; i < count; i++) found |= actions[i] == C4F_ACTION_TESTER;
|
||||
assert(found && actions[count - 1] == C4F_ACTION_CLOSE);
|
||||
screen.busy = 1;
|
||||
assert(c4fActionAvailable(&screen, C4F_ACTION_TESTER, 0));
|
||||
screen.locked = 1;
|
||||
assert(!c4fActionAvailable(&screen, C4F_ACTION_TESTER, 0));
|
||||
for (int i = 0; i < C4F_TESTER_ITEMS; i++) assert(c4fTesterItemLabel(i)[0]);
|
||||
/* A DS4's Options opens it straight from the launcher, even while busy; the
|
||||
* diagnostic build keeps Options for its kernel-log capture. */
|
||||
memset(&screen, 0, sizeof(screen));
|
||||
screen.autorun = C4F_AUTORUN_ON;
|
||||
uint32_t standard, remote;
|
||||
assert(c4fNavigate(&screen, C4F_INPUT_BUTTON_OPTIONS, 0, 0, &standard, &remote) == C4F_ACTION_TESTER && !standard);
|
||||
assert(c4fNavigate(&screen, C4F_INPUT_BUTTON_OPTIONS, 0, 1, &standard, &remote) == C4F_ACTION_NONE &&
|
||||
standard == C4F_INPUT_BUTTON_OPTIONS);
|
||||
screen.busy = 1;
|
||||
assert(c4fNavigate(&screen, C4F_INPUT_BUTTON_OPTIONS, 0, 0, &standard, &remote) == C4F_ACTION_TESTER);
|
||||
screen.locked = 1;
|
||||
assert(c4fNavigate(&screen, C4F_INPUT_BUTTON_OPTIONS, 0, 0, &standard, &remote) == C4F_ACTION_NONE);
|
||||
puts("PASS Test controllers: in Actions unless locked, Options opens it, every menu item has a label");
|
||||
}
|
||||
|
||||
static void unicodeNames(void)
|
||||
{
|
||||
Fake f;
|
||||
static C4fTester t;
|
||||
const char *names[] = { "João", "أحمد DS4", "سام (Sam) 123", "أحمدمحمدأحمدمحمد" };
|
||||
for (unsigned i = 0; i < sizeof(names) / sizeof(names[0]); i++) {
|
||||
setup(&f, &t); f.nameOverride = names[i];
|
||||
c4fTesterOpen(&t, 0);
|
||||
C4fTesterView view; c4fTesterView(&t, &view);
|
||||
assert(!strcmp(view.user[0].name, names[i]));
|
||||
c4fTesterClose(&t);
|
||||
}
|
||||
puts("PASS Portuguese, Arabic and mixed UTF-8 usernames survive the input reader unchanged");
|
||||
}
|
||||
|
||||
int main(void)
|
||||
{
|
||||
holdTiming();
|
||||
readsUsers();
|
||||
unicodeNames();
|
||||
menuActions();
|
||||
switchUsers();
|
||||
menuOpeningSafety();
|
||||
gridNavigationAndRepeat();
|
||||
colorPicker(0);
|
||||
colorPicker(1);
|
||||
menuAvailability();
|
||||
failures();
|
||||
logout();
|
||||
remoteAndSuspend();
|
||||
launcherEntry();
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/* Exercise the shipped Unicode layout/cache internals as well as public
|
||||
* measurement and drawing; the console font may be supplied by the harness. */
|
||||
#include <assert.h>
|
||||
#include <stdio.h>
|
||||
#include <math.h>
|
||||
#include "../launcher/text.c"
|
||||
|
||||
static uint32_t pixels[800 * 300];
|
||||
static C4fCanvas canvas = { pixels, 800, 300 };
|
||||
static const C4fColor white = { 255, 255, 255, 255 };
|
||||
|
||||
static uint64_t hash(void)
|
||||
{
|
||||
uint64_t h = 1469598103934665603ULL;
|
||||
for (size_t i = 0; i < sizeof(pixels) / sizeof(pixels[0]); i++) { h ^= pixels[i]; h *= 1099511628211ULL; }
|
||||
return h;
|
||||
}
|
||||
|
||||
static void draw(const char *text)
|
||||
{
|
||||
memset(pixels, 0, sizeof(pixels));
|
||||
float width = c4fTextWidthRaw(C4F_FONT_REGULAR, 36, text);
|
||||
float drawn = c4fTextRaw(&canvas, C4F_FONT_REGULAR, 36, 100, 120, white, text);
|
||||
assert(width > 0 && fabsf(width - drawn) < .01f);
|
||||
}
|
||||
|
||||
int main(int argc, char **argv)
|
||||
{
|
||||
assert(c4fDrawInit() == 0);
|
||||
assert(c4fTextSetArabicFonts((const unsigned char *)"invalid", 7, (const unsigned char *)"invalid", 7) == -1);
|
||||
if (argc == 3) {
|
||||
size_t lightSize = 0, regularSize = 0;
|
||||
unsigned char *light = c4fReadFont(argv[1], &lightSize), *regular = c4fReadFont(argv[2], ®ularSize);
|
||||
assert(light && regular);
|
||||
draw("مرحبا");
|
||||
unsigned generation = c4fTextTranslationGeneration();
|
||||
assert(c4fTextSetArabicFonts(light, lightSize, regular, regularSize) == 0);
|
||||
assert(c4fTextTranslationGeneration() != generation && c4fRasterBytes == 0);
|
||||
/* The bytes stay alive until process exit, just as in the launcher. */
|
||||
} else if (argc == 2 && !strcmp(argv[1], "--system")) assert(!c4fTextLoadSystemFonts());
|
||||
else { assert(argc == 1); assert(c4fTextLoadSystemFonts() == -1); }
|
||||
const char *technical = "Installed dev; 192.168.1.20:4264";
|
||||
assert(!c4fTextNeedsShaping(technical, (int)strlen(technical)));
|
||||
assert(c4fTextNeedsShaping("مرحبا", (int)strlen("مرحبا")));
|
||||
C4fTextLayout *joined = c4fShape(C4F_FONT_REGULAR, 36, "ببب", (int)strlen("ببب"));
|
||||
assert(joined && joined->count >= 3 && joined->width > 0);
|
||||
unsigned nominal = (unsigned)stbtt_FindGlyphIndex(&c4fArabicSTB[C4F_FONT_REGULAR], 0x0628);
|
||||
int contextual = 0;
|
||||
for (int i = 0; i < joined->count; i++) {
|
||||
assert(joined->glyphs[i].id);
|
||||
contextual |= joined->glyphs[i].id != nominal;
|
||||
}
|
||||
assert(contextual); /* Arabic joining actually selects contextual glyph IDs. */
|
||||
C4fTextLayout *marks = c4fShape(C4F_FONT_REGULAR, 36, "مَرْحَبًا", (int)strlen("مَرْحَبًا"));
|
||||
assert(marks && marks->count > 5);
|
||||
int positioned = 0;
|
||||
for (int i = 0; i < marks->count; i++) positioned |= marks->glyphs[i].y != 0;
|
||||
assert(positioned); /* Diacritics use shaping offsets rather than spacing. */
|
||||
|
||||
const char *mixed = "مرحبا \xE2\x81\xA8" "192.168.1.20" "\xE2\x81\xA9";
|
||||
C4fTextLayout *layout = c4fShape(C4F_FONT_REGULAR, 36, mixed, (int)strlen(mixed));
|
||||
assert(layout);
|
||||
unsigned one = (unsigned)stbtt_FindGlyphIndex(&c4fArabicSTB[C4F_FONT_REGULAR], '1');
|
||||
unsigned nine = (unsigned)stbtt_FindGlyphIndex(&c4fArabicSTB[C4F_FONT_REGULAR], '9');
|
||||
unsigned two = (unsigned)stbtt_FindGlyphIndex(&c4fArabicSTB[C4F_FONT_REGULAR], '2');
|
||||
int digits = 0;
|
||||
for (int i = 0; i + 2 < layout->count; i++)
|
||||
digits |= layout->glyphs[i].id == one && layout->glyphs[i + 1].id == nine && layout->glyphs[i + 2].id == two;
|
||||
assert(digits); /* The IP's digit sequence stays LTR inside Arabic. */
|
||||
draw(mixed); uint64_t original = hash();
|
||||
for (int i = 0; i < 600; i++) {
|
||||
char sample[128]; snprintf(sample, sizeof(sample), "المستخدم Alex (%d)", i);
|
||||
c4fTextRaw(&canvas, i & 1, (float)(12 + i % 110), 50, 150, white, sample);
|
||||
assert(c4fRasterBytes <= C4F_RASTER_BYTES);
|
||||
}
|
||||
draw(mixed); assert(hash() == original); /* Cache eviction cannot change layout/raster results. */
|
||||
c4fSetTextDirection(1);
|
||||
memset(pixels, 0, sizeof(pixels));
|
||||
assert(c4fTextWrapped(&canvas, C4F_FONT_REGULAR, 36, 100, 120, 500, 60, 1, white, "مرحبا") == 1);
|
||||
int left = 800, right = -1;
|
||||
for (int y = 0; y < canvas.h; y++) for (int x = 0; x < canvas.w; x++) if (pixels[y * canvas.w + x]) {
|
||||
if (x < left) left = x;
|
||||
if (x > right) right = x;
|
||||
}
|
||||
assert(left > 350 && right <= 610);
|
||||
c4fSetTextDirection(0);
|
||||
draw("مَرْحَبًا Alex (PS4) 123");
|
||||
puts("PASS native Unicode: Arabic joining, marks, mixed-direction numbers, matching width/draw, RTL wrap, bounded cache eviction");
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,513 @@
|
||||
// Controller layout editing through real pointer events, in an offline page.
|
||||
// Only the test's in-memory copy exposes state; the shipped page has no hooks.
|
||||
const fs = require('node:fs');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const faceButtons = ['TRIANGLE', 'CIRCLE', 'CROSS', 'SQUARE'];
|
||||
const faceSelector = button => `#padView .face-btn[data-btn="${button}"]`;
|
||||
const settle = async page => {
|
||||
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
|
||||
await page.waitForFunction(() => [...document.querySelectorAll('#padView .face-btn')].every(node => getComputedStyle(node).transform === 'none'));
|
||||
};
|
||||
const snapshot = (page, root = '#padView') => page.evaluate(({ buttons, root }) => Object.fromEntries(buttons.map(button => {
|
||||
const node = document.querySelector(`${root} .face-btn[data-btn="${button}"]`);
|
||||
const r = node.getBoundingClientRect();
|
||||
return [button, { x: r.x + r.width / 2, y: r.y + r.height / 2, width: r.width, height: r.height, hidden: node.hidden }];
|
||||
})), { buttons: faceButtons, root });
|
||||
|
||||
async function drag(page, button, dx, dy) {
|
||||
const r = await page.locator(faceSelector(button)).boundingBox();
|
||||
assert(r && r.width > 0, button + ' has a visible hit target');
|
||||
await page.mouse.move(r.x + r.width / 2, r.y + r.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(r.x + r.width / 2 + dx, r.y + r.height / 2 + dy, { steps: 5 });
|
||||
await page.mouse.up();
|
||||
await settle(page);
|
||||
}
|
||||
|
||||
async function touchDrag(page, button, dx, dy) {
|
||||
const r = await page.locator(faceSelector(button)).boundingBox();
|
||||
const cdp = await page.context().newCDPSession(page);
|
||||
const x = r.x + r.width / 2, y = r.y + r.height / 2;
|
||||
const point = (x, y) => [{ x, y, id: 1, radiusX: 5, radiusY: 5, force: 1 }];
|
||||
try {
|
||||
await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: point(x, y) });
|
||||
for (let step = 1; step <= 5; step++)
|
||||
await cdp.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: point(x + dx * step / 5, y + dy * step / 5) });
|
||||
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
|
||||
await settle(page);
|
||||
} finally { await cdp.detach(); }
|
||||
}
|
||||
|
||||
function closeTo(actual, expected, label, tolerance = 1) {
|
||||
assert(Math.abs(actual - expected) <= tolerance, `${label}: expected ${expected}, got ${actual}`);
|
||||
}
|
||||
|
||||
function sameButtons(actual, expected, except = '') {
|
||||
for (const button of faceButtons.filter(button => button !== except)) for (const key of ['x', 'y', 'width', 'height'])
|
||||
closeTo(actual[button][key], expected[button][key], button + ' ' + key);
|
||||
}
|
||||
|
||||
async function resize(page, percent) {
|
||||
await page.locator('#lbSize').evaluate((slider, percent) => {
|
||||
slider.value = percent; slider.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}, String(percent));
|
||||
await settle(page);
|
||||
}
|
||||
|
||||
async function checkOrientationOrdering(browser, fixture) {
|
||||
const context = await browser.newContext({ viewport: { width: 1920, height: 1080 } });
|
||||
await context.addInitScript(() => {
|
||||
const add = MediaQueryList.prototype.addEventListener;
|
||||
window.pendingOrientation = [];
|
||||
MediaQueryList.prototype.addEventListener = function(type, listener, options) {
|
||||
if (type === 'change' && this.media === '(orientation: portrait)') {
|
||||
return add.call(this, type, event => {
|
||||
if (window.deferOrientation) window.pendingOrientation.push(() => listener.call(this, event));
|
||||
else listener.call(this, event);
|
||||
}, options);
|
||||
}
|
||||
return add.call(this, type, listener, options);
|
||||
};
|
||||
});
|
||||
const page = await context.newPage();
|
||||
await page.route('**/*', fixture);
|
||||
try {
|
||||
await page.goto('http://layout.test/');
|
||||
await page.waitForFunction(() => !!window.c4fLayoutTest);
|
||||
await page.evaluate(() => { c4fLayoutTest.openPad(0); c4fLayoutTest.editor.open(); c4fLayoutTest.editor.select('face'); });
|
||||
await page.selectOption('#lbFaceMode', 'separate');
|
||||
await drag(page, 'CROSS', -5000, 0);
|
||||
await drag(page, 'CROSS', 10, 0);
|
||||
const landscape = await page.evaluate(() => c4fLayoutTest.getLayout());
|
||||
await page.evaluate(() => { window.deferOrientation = true; });
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.waitForFunction(() => pendingOrientation.length > 0);
|
||||
await settle(page);
|
||||
assert.deepEqual(await page.evaluate(() => c4fLayoutTest.getLayout()), landscape,
|
||||
'resize must not fit the outgoing landscape layout to portrait bounds');
|
||||
await page.evaluate(() => {
|
||||
window.deferOrientation = false;
|
||||
window.pendingOrientation.splice(0).forEach(run => run());
|
||||
});
|
||||
await settle(page);
|
||||
assert.equal(await page.evaluate(() => c4fLayoutTest.editor.active), false);
|
||||
assert.deepEqual(await page.evaluate(() => JSON.parse(localStorage.getItem('c4f.layout.landscape'))), landscape,
|
||||
'rotation saves the original landscape edit before loading portrait');
|
||||
assert.equal(await page.evaluate(() => c4fLayoutTest.getLayout().face.separate), false);
|
||||
await page.setViewportSize({ width: 1920, height: 1080 });
|
||||
await settle(page);
|
||||
assert.deepEqual(await page.evaluate(() => c4fLayoutTest.getLayout()), landscape);
|
||||
console.log('PASS controller layout: resize before orientation preserves the outgoing edit');
|
||||
} finally { await context.close(); }
|
||||
}
|
||||
|
||||
async function checkComponentResets(browser, fixture) {
|
||||
const context = await browser.newContext({ viewport: { width: 1000, height: 600 }, locale: 'en-US' });
|
||||
const page = await context.newPage(), errors = [];
|
||||
page.on('pageerror', error => errors.push(error.message));
|
||||
await page.route('**/*', fixture);
|
||||
const layout = () => page.evaluate(() => c4fLayoutTest.getLayout());
|
||||
const select = id => page.evaluate(id => c4fLayoutTest.editor.select(id), id);
|
||||
const open = () => page.evaluate(() => c4fLayoutTest.editor.open());
|
||||
async function move(id, dx, dy) {
|
||||
const r = await page.locator(`#padView [data-ctl="${id}"]`).boundingBox();
|
||||
await page.mouse.move(r.x + r.width / 2, r.y + r.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(r.x + r.width / 2 + dx, r.y + r.height / 2 + dy, { steps: 5 });
|
||||
await page.mouse.up();
|
||||
await settle(page);
|
||||
}
|
||||
try {
|
||||
await page.goto('http://layout.test/');
|
||||
await page.waitForFunction(() => !!window.c4fLayoutTest);
|
||||
await page.evaluate(() => c4fLayoutTest.openPad(0));
|
||||
await settle(page);
|
||||
const defaults = await layout(), defaultButtons = await snapshot(page);
|
||||
await open();
|
||||
await select('r1'); await resize(page, 125); await page.click('#lbHide');
|
||||
// Buttons, sticks, D-pad and touchpad all use the same scoped Reset action.
|
||||
for (const id of ['l1', 'lstick', 'dpad', 'touchpad']) {
|
||||
await move(id, 30, 15); await select(id); await resize(page, 150); await page.click('#lbHide');
|
||||
const before = await layout();
|
||||
await page.click('#lbResetControl');
|
||||
const after = await layout();
|
||||
assert.deepEqual(after[id], defaults[id], id + ': Reset restores position, size and visibility');
|
||||
for (const other of Object.keys(before).filter(other => other !== id))
|
||||
assert.deepEqual(after[other], before[other], id + ': Reset preserves ' + other);
|
||||
assert.equal(await page.locator('#lbSize').inputValue(), '100');
|
||||
assert.equal(await page.evaluate(() => c4fLayoutTest.editor.selected), id);
|
||||
}
|
||||
// A local reset is still an unsaved edit: Cancel restores saved custom data.
|
||||
await select('l1'); await resize(page, 150); await page.click('#lbHide');
|
||||
await page.click('#lbDone');
|
||||
const saved = await layout();
|
||||
await open(); await select('l1'); await page.click('#lbResetControl'); await page.click('#lbCancel');
|
||||
assert.deepEqual(await layout(), saved, 'Cancel undoes a component reset');
|
||||
await open(); await select('l1'); await page.click('#lbResetControl'); await page.click('#lbDone');
|
||||
const resetSaved = await layout();
|
||||
assert.deepEqual(resetSaved.l1, defaults.l1);
|
||||
assert.deepEqual(resetSaved.r1, saved.r1);
|
||||
await page.reload(); await page.waitForFunction(() => !!window.c4fLayoutTest);
|
||||
await page.evaluate(() => c4fLayoutTest.openPad(0)); await settle(page);
|
||||
assert.deepEqual(await layout(), resetSaved, 'Done persists only the component reset across reload');
|
||||
|
||||
// Group scaling and rebasing must not make an individual reset move siblings.
|
||||
await open(); await select('face'); await resize(page, 150);
|
||||
await page.selectOption('#lbFaceMode', 'separate');
|
||||
await drag(page, 'CROSS', -100, 15);
|
||||
await select('face:CROSS'); await resize(page, 125); await page.click('#lbHide');
|
||||
await select('face:TRIANGLE'); await page.click('#lbHide');
|
||||
const custom = await layout(), customButtons = await snapshot(page);
|
||||
await select('face:CROSS'); await page.click('#lbResetControl');
|
||||
const reset = await layout(), resetButtons = await snapshot(page);
|
||||
sameButtons(resetButtons, customButtons, 'CROSS');
|
||||
for (const key of ['x', 'y', 'width', 'height'])
|
||||
closeTo(resetButtons.CROSS[key], defaultButtons.CROSS[key], 'Cross reset restores standard physical ' + key);
|
||||
assert.equal(reset.face.buttons.CROSS.hide, false);
|
||||
assert.equal(reset.face.buttons.TRIANGLE.hide, true);
|
||||
assert.equal(reset.face.separate, true, 'scoped reset preserves separate editing');
|
||||
for (const id of Object.keys(reset).filter(id => id !== 'face')) assert.deepEqual(reset[id], custom[id]);
|
||||
await page.click('#lbDone');
|
||||
assert.equal(await page.locator(faceSelector('TRIANGLE')).isHidden(), true);
|
||||
assert.equal(await page.locator(faceSelector('CROSS')).isVisible(), true);
|
||||
const faceSaved = await layout();
|
||||
await page.reload(); await page.waitForFunction(() => !!window.c4fLayoutTest);
|
||||
await page.evaluate(() => c4fLayoutTest.openPad(0)); await settle(page);
|
||||
assert.deepEqual(await layout(), faceSaved, 'compensated individual size and position survive reload');
|
||||
|
||||
// Resetting a group resets its members while preserving its editing mode.
|
||||
await open(); await select('face'); await page.click('#lbResetControl');
|
||||
assert.deepEqual((await layout()).face, { ...defaults.face, separate: true });
|
||||
for (const mode of ['together', 'separate']) {
|
||||
await page.selectOption('#lbFaceMode', mode); await select('face'); await resize(page, 125);
|
||||
await page.click('#lbResetControl');
|
||||
assert.deepEqual((await layout()).face, { ...defaults.face, separate: mode === 'separate' });
|
||||
}
|
||||
// One child can be restored from a hidden group without exposing its siblings.
|
||||
await page.click('#lbHide'); await select('face:CROSS'); await page.click('#lbResetControl');
|
||||
await page.click('#lbDone');
|
||||
assert.equal(await page.locator(faceSelector('CROSS')).isVisible(), true);
|
||||
for (const button of faceButtons.filter(button => button !== 'CROSS'))
|
||||
assert.equal(await page.locator(faceSelector(button)).isHidden(), true, button + ': hidden sibling stays hidden');
|
||||
assert.equal(await page.evaluate(() => c4fLayoutTest.screenSource.buttons), 0, 'Reset never presses controller inputs');
|
||||
assert.deepEqual(errors, []);
|
||||
console.log('PASS component Reset: default position/size/visibility, unchanged siblings, scaled face groups, hidden groups, mode preservation, Cancel and saved reload');
|
||||
} finally { await context.close(); }
|
||||
}
|
||||
|
||||
module.exports = async function checkControllerLayout(browser) {
|
||||
// The fixture deliberately does not connect or create a virtual controller.
|
||||
const hook = '\nwindow.c4fLayoutTest = { editor, openPad, mapper, getLayout: () => JSON.parse(JSON.stringify(layout)), screenSource, mergedState };\n';
|
||||
const original = fs.readFileSync('/src/client/index.html', 'utf8');
|
||||
const html = original.replace(' connect(address);', ' // No signaling in this offline fixture.')
|
||||
.replace('\n })();', hook + ' })();');
|
||||
assert.notEqual(html, original);
|
||||
const fixture = route => {
|
||||
const url = route.request().url();
|
||||
if (/^http:\/\/layout\.test\/(?:\?.*)?$/.test(url)) return route.fulfill({ contentType: 'text/html', body: html });
|
||||
const font = url === 'http://layout.test/fonts/arabic-light.otf' ? 'SSTArabic-Light.otf'
|
||||
: url === 'http://layout.test/fonts/arabic-regular.otf' ? 'SSTArabic-Roman.otf' : '';
|
||||
// Optional private fixtures validate the real console font without copying
|
||||
// Sony data into the repository or requiring console/network access.
|
||||
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();
|
||||
};
|
||||
const context = await browser.newContext({ viewport: { width: 1000, height: 600 }, locale: 'en-US' });
|
||||
const page = await context.newPage(), errors = [];
|
||||
page.on('pageerror', error => errors.push(error.message));
|
||||
await page.route('**/*', fixture);
|
||||
try {
|
||||
await page.goto('http://layout.test/');
|
||||
await page.waitForFunction(() => !!window.c4fLayoutTest);
|
||||
await page.evaluate(() => c4fLayoutTest.openPad(0));
|
||||
await settle(page);
|
||||
await page.evaluate(() => { c4fLayoutTest.editor.open(); c4fLayoutTest.editor.select('face'); });
|
||||
assert.equal(await page.locator('#lbFaceMode').inputValue(), 'together', 'existing layouts keep grouped editing by default');
|
||||
let before = await snapshot(page);
|
||||
await drag(page, 'TRIANGLE', -80, -20);
|
||||
let after = await snapshot(page);
|
||||
for (const button of faceButtons) {
|
||||
closeTo(after[button].x - before[button].x, -80, button + ' grouped horizontal drag', 3);
|
||||
closeTo(after[button].y - before[button].y, -20, button + ' grouped vertical drag', 3);
|
||||
}
|
||||
assert.equal(await page.evaluate(() => c4fLayoutTest.screenSource.buttons), 0, 'editing does not press game buttons');
|
||||
|
||||
before = after;
|
||||
await page.selectOption('#lbFaceMode', 'separate');
|
||||
await settle(page);
|
||||
sameButtons(await snapshot(page), before);
|
||||
await drag(page, 'CROSS', 20, 25);
|
||||
after = await snapshot(page);
|
||||
sameButtons(after, before, 'CROSS');
|
||||
closeTo(after.CROSS.x - before.CROSS.x, 20, 'Cross independent horizontal drag', 3);
|
||||
closeTo(after.CROSS.y - before.CROSS.y, 25, 'Cross independent vertical drag', 3);
|
||||
assert.equal(await page.evaluate(() => c4fLayoutTest.editor.selected), 'face:CROSS');
|
||||
assert.equal(await page.evaluate(() => c4fLayoutTest.screenSource.buttons), 0, 'independent editing cannot generate input');
|
||||
before = after;
|
||||
await resize(page, 150);
|
||||
after = await snapshot(page);
|
||||
sameButtons(after, before, 'CROSS');
|
||||
closeTo(after.CROSS.width / before.CROSS.width, 1.5, 'Cross independent size', .02);
|
||||
await page.click('#lbDone');
|
||||
const saved = await page.evaluate(() => c4fLayoutTest.getLayout());
|
||||
assert.equal(saved.face.separate, true);
|
||||
assert.equal(saved.face.buttons.CROSS.size, 150);
|
||||
const savedBounds = await snapshot(page);
|
||||
assert.deepEqual(await page.evaluate(() => JSON.parse(localStorage.getItem('c4f.layout.landscape'))), saved);
|
||||
await page.reload();
|
||||
await page.waitForFunction(() => !!window.c4fLayoutTest);
|
||||
await page.evaluate(() => c4fLayoutTest.openPad(0));
|
||||
await settle(page);
|
||||
|
||||
if (process.env.C4F_LAYOUT_PREVIEW_DIR) {
|
||||
fs.mkdirSync(process.env.C4F_LAYOUT_PREVIEW_DIR, { recursive: true });
|
||||
await page.evaluate(() => { c4fLayoutTest.editor.open(); c4fLayoutTest.editor.select('face:CROSS'); document.querySelector('#toast').style.visibility = 'hidden'; });
|
||||
await page.screenshot({ path: process.env.C4F_LAYOUT_PREVIEW_DIR + '/en-layout-landscape.png' });
|
||||
await page.click('#lbCancel');
|
||||
}
|
||||
assert.deepEqual(await page.evaluate(() => c4fLayoutTest.getLayout()), saved, 'individual positions and sizes survive a reload');
|
||||
sameButtons(await snapshot(page), savedBounds);
|
||||
|
||||
// The mapping picture is a standard DS4 reference, regardless of the
|
||||
// persisted touch layout that was cloned when this document initialized.
|
||||
await page.evaluate(() => c4fLayoutTest.mapper.open('keyboard'));
|
||||
await settle(page);
|
||||
assert.equal(await page.locator('#mapView').isVisible(), true);
|
||||
const picture = await snapshot(page, '#mapView');
|
||||
for (const button of faceButtons) {
|
||||
closeTo(picture[button].width, picture.TRIANGLE.width, button + ': mapping picture retains standard size');
|
||||
closeTo(picture[button].height, picture.TRIANGLE.height, button + ': mapping picture retains standard height');
|
||||
}
|
||||
closeTo(picture.TRIANGLE.x, picture.CROSS.x, 'mapping picture vertical column');
|
||||
closeTo(picture.SQUARE.y, picture.CIRCLE.y, 'mapping picture horizontal row');
|
||||
closeTo(picture.TRIANGLE.x, (picture.SQUARE.x + picture.CIRCLE.x) / 2, 'mapping picture horizontal centre');
|
||||
closeTo(picture.SQUARE.y, (picture.TRIANGLE.y + picture.CROSS.y) / 2, 'mapping picture vertical centre');
|
||||
assert(picture.TRIANGLE.y < picture.CROSS.y && picture.SQUARE.x < picture.CIRCLE.x, 'mapping picture retains its standard diamond');
|
||||
await page.evaluate(() => c4fLayoutTest.mapper.close());
|
||||
await settle(page);
|
||||
assert.equal(await page.locator('#padView').isVisible(), true);
|
||||
assert.deepEqual(await page.evaluate(() => c4fLayoutTest.getLayout()), saved, 'leaving mapping retains the saved custom touch arrangement');
|
||||
sameButtons(await snapshot(page), savedBounds);
|
||||
|
||||
// The physical buttons retain their original input bits after repositioning.
|
||||
for (const [button, bit] of [['TRIANGLE', 0x1000], ['CIRCLE', 0x2000], ['CROSS', 0x4000], ['SQUARE', 0x8000]]) {
|
||||
const r = await page.locator(faceSelector(button)).boundingBox();
|
||||
await page.mouse.move(r.x + r.width / 2, r.y + r.height / 2);
|
||||
await page.mouse.down();
|
||||
assert.equal(await page.evaluate(() => c4fLayoutTest.screenSource.buttons), bit, button + ' keeps its input mapping');
|
||||
await page.mouse.up();
|
||||
await page.waitForFunction(() => c4fLayoutTest.screenSource.buttons === 0);
|
||||
}
|
||||
await settle(page);
|
||||
|
||||
// Regrouping must keep the custom arrangement and transform it as one unit.
|
||||
await page.evaluate(() => { c4fLayoutTest.editor.open(); c4fLayoutTest.editor.select('face'); });
|
||||
before = await snapshot(page);
|
||||
await page.selectOption('#lbFaceMode', 'together');
|
||||
await settle(page);
|
||||
sameButtons(await snapshot(page), before);
|
||||
await drag(page, 'TRIANGLE', -35, -15);
|
||||
after = await snapshot(page);
|
||||
for (const button of faceButtons) {
|
||||
closeTo(after[button].x - before[button].x, -35, button + ' regrouped horizontal drag', 3);
|
||||
closeTo(after[button].y - before[button].y, -15, button + ' regrouped vertical drag', 3);
|
||||
}
|
||||
before = after;
|
||||
await resize(page, 125);
|
||||
after = await snapshot(page);
|
||||
for (const button of faceButtons) closeTo(after[button].width / before[button].width, 1.25, button + ' group resize', .02);
|
||||
await page.click('#lbCancel');
|
||||
assert.deepEqual(await page.evaluate(() => c4fLayoutTest.getLayout()), saved, 'Cancel restores mode, child positions and sizes');
|
||||
sameButtons(await snapshot(page), savedBounds);
|
||||
|
||||
// Individual hiding affects one hit target and can be undone from the editor.
|
||||
await page.evaluate(() => { c4fLayoutTest.editor.open(); c4fLayoutTest.editor.select('face:CROSS'); });
|
||||
await page.click('#lbHide');
|
||||
await page.click('#lbDone');
|
||||
assert.equal(await page.locator(faceSelector('CROSS')).isHidden(), true);
|
||||
for (const button of faceButtons.filter(button => button !== 'CROSS')) assert.equal(await page.locator(faceSelector(button)).isVisible(), true);
|
||||
await page.evaluate(() => { c4fLayoutTest.editor.open(); c4fLayoutTest.editor.select('face:CROSS'); });
|
||||
assert.equal(await page.locator(faceSelector('CROSS')).isVisible(), true, 'hidden buttons remain editable');
|
||||
await page.click('#lbHide');
|
||||
await page.click('#lbDone');
|
||||
|
||||
// Leaving the orientation saves the active edit in its original orientation.
|
||||
await page.evaluate(() => c4fLayoutTest.editor.open());
|
||||
await drag(page, 'CROSS', 20, -10);
|
||||
const landscape = await page.evaluate(() => c4fLayoutTest.getLayout());
|
||||
await page.setViewportSize({ width: 600, height: 1000 });
|
||||
await page.waitForFunction(() => !c4fLayoutTest.editor.active && matchMedia('(orientation: portrait)').matches);
|
||||
assert.deepEqual(await page.evaluate(() => JSON.parse(localStorage.getItem('c4f.layout.landscape'))), landscape);
|
||||
assert.equal(await page.evaluate(() => c4fLayoutTest.getLayout().face.separate), false, 'portrait starts with its own grouped default');
|
||||
if (await page.locator('#portraitOk').isVisible()) await page.click('#portraitOk');
|
||||
await page.evaluate(() => { c4fLayoutTest.editor.open(); c4fLayoutTest.editor.select('face'); });
|
||||
await page.selectOption('#lbFaceMode', 'separate');
|
||||
await drag(page, 'SQUARE', -20, 25);
|
||||
await page.click('#lbDone');
|
||||
const portrait = await page.evaluate(() => c4fLayoutTest.getLayout());
|
||||
await page.setViewportSize({ width: 1000, height: 600 });
|
||||
await page.waitForFunction(() => !matchMedia('(orientation: portrait)').matches);
|
||||
await settle(page);
|
||||
assert.deepEqual(await page.evaluate(() => c4fLayoutTest.getLayout()), landscape, 'landscape retains its separate positions');
|
||||
assert.deepEqual(await page.evaluate(() => JSON.parse(localStorage.getItem('c4f.layout.portrait'))), portrait);
|
||||
|
||||
// Reset remains a two-tap operation; Cancel also restores a reset layout.
|
||||
await page.evaluate(() => c4fLayoutTest.editor.open());
|
||||
await page.click('#lbReset');
|
||||
assert.deepEqual(await page.evaluate(() => c4fLayoutTest.getLayout()), landscape, 'first reset tap is non-destructive');
|
||||
await page.click('#lbReset');
|
||||
assert.equal(await page.evaluate(() => c4fLayoutTest.getLayout().face.separate), false);
|
||||
await page.click('#lbCancel');
|
||||
assert.deepEqual(await page.evaluate(() => c4fLayoutTest.getLayout()), landscape);
|
||||
await page.evaluate(() => c4fLayoutTest.editor.open());
|
||||
await page.click('#lbReset'); await page.click('#lbReset'); await page.click('#lbDone');
|
||||
assert.equal(await page.evaluate(() => JSON.parse(localStorage.getItem('c4f.layout.landscape')).face.separate), false, 'Done persists the reset');
|
||||
|
||||
// Old layout records have no child metadata: preserve their grouped position.
|
||||
await page.evaluate(() => localStorage.setItem('c4f.layout.landscape', JSON.stringify({ face: { x: 73, y: 51, w: 32, h: 32, hide: false } })));
|
||||
await page.reload(); await page.waitForFunction(() => !!window.c4fLayoutTest);
|
||||
await page.evaluate(() => { c4fLayoutTest.openPad(0); c4fLayoutTest.editor.open(); c4fLayoutTest.editor.select('face'); });
|
||||
const legacy = await page.evaluate(() => c4fLayoutTest.getLayout().face);
|
||||
assert.deepEqual([legacy.x, legacy.y, legacy.w, legacy.h, legacy.hide, legacy.separate], [73, 51, 32, 32, false, false]);
|
||||
before = await snapshot(page);
|
||||
await page.selectOption('#lbFaceMode', 'separate');
|
||||
await settle(page);
|
||||
sameButtons(await snapshot(page), before);
|
||||
|
||||
// Individually moved controls and their regrouped bounds stay on screen.
|
||||
await drag(page, 'CROSS', -2000, -2000);
|
||||
const edge = await snapshot(page);
|
||||
assert(edge.CROSS.x - edge.CROSS.width / 2 >= -.6 && edge.CROSS.y - edge.CROSS.height / 2 >= -.6, 'individual drag clamps the entire button');
|
||||
sameButtons(edge, before, 'CROSS');
|
||||
await page.selectOption('#lbFaceMode', 'together');
|
||||
await drag(page, 'CROSS', 30, 30);
|
||||
const outsideDrag = await snapshot(page);
|
||||
for (const button of faceButtons) {
|
||||
closeTo(outsideDrag[button].x - edge[button].x, 30, button + ': grouped drag starts on an out-of-parent child', 3);
|
||||
closeTo(outsideDrag[button].y - edge[button].y, 30, button + ': out-of-parent child moves the group vertically', 3);
|
||||
}
|
||||
await drag(page, 'TRIANGLE', 2000, 2000);
|
||||
for (const [button, r] of Object.entries(await snapshot(page)))
|
||||
assert(r.x - r.width / 2 >= -.6 && r.y - r.height / 2 >= -.6 && r.x + r.width / 2 <= 1000.6 && r.y + r.height / 2 <= 600.6, button + ': regrouped custom arrangement stays on screen');
|
||||
await resize(page, 200);
|
||||
for (const [button, r] of Object.entries(await snapshot(page)))
|
||||
assert(r.x - r.width / 2 >= -.6 && r.y - r.height / 2 >= -.6 && r.x + r.width / 2 <= 1000.6 && r.y + r.height / 2 <= 600.6, button + ': grouped resize clamps the full custom arrangement');
|
||||
await page.click('#lbCancel');
|
||||
const stable = await page.evaluate(() => c4fLayoutTest.getLayout());
|
||||
await page.setViewportSize({ width: 1200, height: 700 });
|
||||
await settle(page);
|
||||
assert.deepEqual(await page.evaluate(() => c4fLayoutTest.getLayout()), stable, 'same-orientation resize preserves the saved layout');
|
||||
await page.setViewportSize({ width: 900, height: 620 });
|
||||
await settle(page);
|
||||
assert.deepEqual(await page.evaluate(() => c4fLayoutTest.getLayout()), stable);
|
||||
for (const [button, r] of Object.entries(await snapshot(page)))
|
||||
assert(r.x - r.width / 2 >= -.6 && r.y - r.height / 2 >= -.6 && r.x + r.width / 2 <= 900.6 && r.y + r.height / 2 <= 620.6, button + ': same-orientation resize keeps the hit target in view');
|
||||
|
||||
// A desktop arrangement spanning most of the screen also fits a phone in
|
||||
// landscape, where --u and the viewport change by different proportions.
|
||||
await page.setViewportSize({ width: 1920, height: 1080 });
|
||||
await page.evaluate(() => localStorage.removeItem('c4f.layout.landscape'));
|
||||
await page.reload(); await page.waitForFunction(() => !!window.c4fLayoutTest);
|
||||
await page.evaluate(() => { c4fLayoutTest.openPad(0); c4fLayoutTest.editor.open(); c4fLayoutTest.editor.select('face'); });
|
||||
await page.selectOption('#lbFaceMode', 'separate');
|
||||
await drag(page, 'CROSS', -5000, 0);
|
||||
await page.click('#lbDone');
|
||||
await page.setViewportSize({ width: 844, height: 390 });
|
||||
await settle(page);
|
||||
for (const [button, r] of Object.entries(await snapshot(page))) {
|
||||
assert(r.x - r.width / 2 >= -.6 && r.y - r.height / 2 >= -.6 && r.x + r.width / 2 <= 844.6 && r.y + r.height / 2 <= 390.6, button + ': saved desktop edge position remains accessible on a landscape phone');
|
||||
assert.equal(await page.evaluate(({ button, x, y }) => document.elementFromPoint(x, y)?.closest('.face-btn')?.dataset.btn === button,
|
||||
{ button, x: r.x, y: r.y }), true, button + ': phone button centre receives pointer input');
|
||||
}
|
||||
|
||||
// A hidden ancestor produces zero child rectangles, even when the child
|
||||
// itself has no hidden attribute. Safe-area offsets must not turn those
|
||||
// zero rectangles into false overflow and repeatedly move the saved group.
|
||||
await page.evaluate(() => { c4fLayoutTest.editor.open(); c4fLayoutTest.editor.select('face'); });
|
||||
await page.click('#lbHide'); await page.click('#lbDone');
|
||||
assert.equal(await page.locator('#padView [data-ctl="face"]').isHidden(), true);
|
||||
const hiddenLayout = await page.evaluate(() => c4fLayoutTest.getLayout());
|
||||
await page.locator('#padView .pad-area').evaluate(node => { node.style.inset = '20px'; });
|
||||
for (const [width, height] of [[1000, 500], [900, 450], [1200, 700], [844, 390], [1000, 500], [844, 390]]) {
|
||||
await page.setViewportSize({ width, height });
|
||||
await settle(page);
|
||||
assert.deepEqual(await page.evaluate(() => c4fLayoutTest.getLayout()), hiddenLayout,
|
||||
'hidden face group never drifts during viewport resize with 20px safe-area inset');
|
||||
}
|
||||
assert.deepEqual(errors, []);
|
||||
console.log('PASS controller layout: grouped compatibility, separate drag/size/hide and input, regrouping, save/reload, Cancel/reset, orientation, landscape phone fitting and hidden safe-area stability');
|
||||
} finally { await context.close(); }
|
||||
|
||||
await checkOrientationOrdering(browser, fixture);
|
||||
await checkComponentResets(browser, fixture);
|
||||
|
||||
// Compact editor controls fit translated labels; controller geometry stays LTR.
|
||||
for (const locale of ['en-US', 'pt-BR', 'ar-SA']) for (const width of [320, 390, 1000]) {
|
||||
const narrow = await browser.newContext({ viewport: { width, height: width === 1000 ? 600 : 900 }, locale, hasTouch: width < 1000 });
|
||||
const p = await narrow.newPage();
|
||||
await p.route('**/*', fixture);
|
||||
try {
|
||||
await p.goto('http://layout.test/'); await p.waitForFunction(() => !!window.c4fLayoutTest);
|
||||
if (locale === 'ar-SA') {
|
||||
await p.evaluate(() => c4fI18n.loadConsoleFonts('http://layout.test/'));
|
||||
if (process.env.C4F_TEST_PS4_FONTS) assert.deepEqual(await p.evaluate(() =>
|
||||
[...document.fonts].filter(font => font.family.replaceAll(String.fromCharCode(34), '') === 'SST Arabic' && font.status === 'loaded').map(font => font.weight).sort()), ['300', '400'],
|
||||
'both actual console font weights load before Arabic panel measurements');
|
||||
await p.evaluate(() => document.fonts.ready);
|
||||
}
|
||||
await p.evaluate(() => c4fLayoutTest.openPad(0));
|
||||
if (await p.locator('#portraitOk').isVisible()) await p.click('#portraitOk');
|
||||
await p.evaluate(() => { c4fLayoutTest.editor.open(); c4fLayoutTest.editor.select('face'); });
|
||||
await p.selectOption('#lbFaceMode', 'separate');
|
||||
for (const id of [...Object.keys(await p.evaluate(() => c4fLayoutTest.getLayout())), ...faceButtons.map(button => 'face:' + button)]) {
|
||||
await p.evaluate(id => c4fLayoutTest.editor.select(id), id);
|
||||
const r = await p.locator('#layoutPanel').boundingBox();
|
||||
assert(r.x >= -1 && r.x + r.width <= width + 1, locale + ': ' + id + ' panel fits ' + width + 'px');
|
||||
assert.equal(await p.locator('#layoutPanel').evaluate(node => node.scrollWidth <= node.clientWidth + 1), true,
|
||||
locale + ': ' + id + ' panel has no internal overflow');
|
||||
assert.equal(await p.locator('#lbResetControl').isVisible(), true, locale + ': ' + id + ' has its own Reset');
|
||||
}
|
||||
await p.evaluate(() => c4fLayoutTest.editor.select('face:CROSS'));
|
||||
await settle(p);
|
||||
const panel = await p.locator('#layoutPanel').boundingBox();
|
||||
assert(panel.x >= -1 && panel.x + panel.width <= width + 1, locale + ': selection panel fits ' + width + 'px');
|
||||
assert.equal(await p.locator('#layoutPanel').evaluate(e => e.scrollWidth <= e.clientWidth + 1), true, locale + ': no internal panel overflow');
|
||||
assert.equal(await p.locator('#lbResetControl').isVisible(), true, locale + ': component Reset is visible beside Hide');
|
||||
assert.equal(await p.locator('#lbFaceMode').evaluate(select => {
|
||||
const style = getComputedStyle(select), canvas = document.createElement('canvas');
|
||||
const context = canvas.getContext('2d'); context.font = style.font;
|
||||
const text = Math.max(...[...select.options].map(option => context.measureText(option.textContent).width));
|
||||
const padding = parseFloat(style.paddingLeft) + parseFloat(style.paddingRight);
|
||||
return text + padding + 20 <= select.clientWidth + 1;
|
||||
}), true, locale + ': full mode labels fit beside the native selector arrow');
|
||||
const positions = await snapshot(p);
|
||||
assert(positions.TRIANGLE.y < positions.CROSS.y && positions.SQUARE.x < positions.CIRCLE.x, locale + ': face button positions never mirror');
|
||||
await drag(p, 'CROSS', -25, 15);
|
||||
const moved = await snapshot(p);
|
||||
sameButtons(moved, positions, 'CROSS');
|
||||
closeTo(moved.CROSS.x - positions.CROSS.x, -25, locale + ': physical horizontal drag', 2);
|
||||
if (width === 320) {
|
||||
await touchDrag(p, 'CROSS', 15, 10);
|
||||
const touched = await snapshot(p);
|
||||
sameButtons(touched, moved, 'CROSS');
|
||||
closeTo(touched.CROSS.x - moved.CROSS.x, 15, locale + ': real touch independently moves Cross horizontally', 2);
|
||||
closeTo(touched.CROSS.y - moved.CROSS.y, 10, locale + ': real touch independently moves Cross vertically', 2);
|
||||
assert.equal(await p.evaluate(() => c4fLayoutTest.screenSource.buttons), 0, 'touch editing does not press Cross');
|
||||
}
|
||||
if (process.env.C4F_LAYOUT_PREVIEW_DIR && locale !== 'pt-BR') {
|
||||
await p.evaluate(() => document.querySelector('#toast').style.visibility = 'hidden');
|
||||
await p.screenshot({ path: process.env.C4F_LAYOUT_PREVIEW_DIR + '/' + (locale === 'ar-SA' ? 'ar' : 'en') + '-layout-' + width + '.png' });
|
||||
}
|
||||
await p.click('#lbDone');
|
||||
assert.equal(await p.evaluate(() => c4fLayoutTest.getLayout().face.separate), true);
|
||||
} finally { await narrow.close(); }
|
||||
}
|
||||
console.log('PASS controller layout: English/Portuguese/Arabic controls fit 320/390px and landscape; real touch dragging preserves physical directions and input neutrality');
|
||||
};
|
||||
|
||||
if (require.main === module) (async () => {
|
||||
const browser = await require('playwright').chromium.launch({ headless: true });
|
||||
try { await module.exports(browser); } finally { await browser.close(); }
|
||||
})().catch(error => { console.error(error); process.exitCode = 1; });
|
||||
@@ -48,6 +48,26 @@ for (let byte = 0; byte <= 255; byte++) {
|
||||
const result = M.toSource(M.fromSource(original));
|
||||
assert.equal(result.lx, byte); assert.equal(result.l2, byte);
|
||||
}
|
||||
// HID reports carry independent digital trigger flags and pressure bytes. A
|
||||
// pressed flag must not turn a partial analog pull into full pressure.
|
||||
for (let byte = 0; byte <= 255; byte++) {
|
||||
const hid = { buttons: B.L2 | B.R2, lx: 128, ly: 128, rx: 128, ry: 128, l2: byte, r2: 255 - byte };
|
||||
const result = M.toSource(M.fromSource(hid, true));
|
||||
assert.equal(result.l2, byte, 'HID L2 pressure with its digital flag set');
|
||||
assert.equal(result.r2, 255 - byte, 'HID R2 pressure with its digital flag set');
|
||||
assert.equal(result.buttons, B.L2 | B.R2, 'HID digital flags remain independent');
|
||||
}
|
||||
const triggerSource = { buttons: B.L2 | B.R2, lx: 128, ly: 128, rx: 128, ry: 128, l2: 64, r2: 160 };
|
||||
const digitalTriggers = M.toSource(M.fromSource({ ...triggerSource, l2: 0, r2: 0 }));
|
||||
assert.equal(digitalTriggers.l2, 255, 'touch and keyboard L2 stay digital');
|
||||
assert.equal(digitalTriggers.r2, 255, 'touch and keyboard R2 stay digital');
|
||||
const triggerMerge = M.toSource(M.merge([M.fromSource(triggerSource, true),
|
||||
M.fromSource({ ...triggerSource, l2: 192, r2: 32 }, true)]));
|
||||
assert.equal(triggerMerge.l2, 192); assert.equal(triggerMerge.r2, 160);
|
||||
const mixedTrigger = M.toSource(M.merge([M.fromSource(triggerSource, true),
|
||||
M.fromSource({ ...triggerSource, buttons: B.L2, l2: 0, r2: 0 })]));
|
||||
assert.equal(mixedTrigger.l2, 255); assert.equal(mixedTrigger.r2, 160);
|
||||
console.log('PASS HID trigger pressure with digital flags, digital touch/keys and mixed-source maximum');
|
||||
|
||||
p = fresh(); p.calibration = { 0: { type: 'center', min: -.8, center: .2, max: .9 },
|
||||
1: { type: 'pedal', released: -1, full: 1 }, 2: { type: 'pedal', released: 1, full: -1 } };
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
"""The vendored Mbed TLS, built once for the host tests: build/mbedtls-host/libmbedtls.a.
|
||||
It is rebuilt only when a source or the configuration is newer than the library."""
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
LIBRARY = ROOT / 'build/mbedtls-host/libmbedtls.a'
|
||||
FLAGS = ['-Iinclude', '-Ivendor/mbedtls/include', '-DMBEDTLS_CONFIG_FILE=<c4f_mbedtls_config.h>']
|
||||
|
||||
|
||||
def library():
|
||||
sources = sorted((ROOT / 'vendor/mbedtls/library').glob('*.c'))
|
||||
inputs = sources + list((ROOT / 'vendor/mbedtls').rglob('*.h')) + [ROOT / 'include/c4f_mbedtls_config.h']
|
||||
newest = max(p.stat().st_mtime for p in inputs)
|
||||
if LIBRARY.exists() and LIBRARY.stat().st_mtime >= newest:
|
||||
return str(LIBRARY)
|
||||
LIBRARY.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def compile_one(source):
|
||||
target = LIBRARY.parent / (source.stem + '.o')
|
||||
subprocess.run(['clang-18', '-std=gnu11', '-O2', '-g', *FLAGS, '-c', str(source), '-o', str(target)],
|
||||
check=True, cwd=ROOT)
|
||||
return str(target)
|
||||
|
||||
with ThreadPoolExecutor() as pool:
|
||||
objects = list(pool.map(compile_one, sources))
|
||||
LIBRARY.unlink(missing_ok=True)
|
||||
subprocess.run(['ar', 'rcs', str(LIBRARY), *objects], check=True)
|
||||
return str(LIBRARY)
|
||||
@@ -0,0 +1,53 @@
|
||||
// Browser integration stays on loopback for secure-context APIs (WebHID and
|
||||
// motion). Only the SDP exchange goes through the container's real interface:
|
||||
// Chromium excludes remote loopback candidates from WebRTC connectivity.
|
||||
const os = require('node:os');
|
||||
const fs = require('node:fs');
|
||||
const address = Object.values(os.networkInterfaces()).flat().find(i => i.family === 'IPv4' && !i.internal)?.address;
|
||||
const serviceBinary = '/src/build/web-rtc-host-test';
|
||||
|
||||
async function relayOffer(page) {
|
||||
if (!address) throw new Error('The RTC browser test needs a non-loopback container interface');
|
||||
await page.route('http://127.0.0.1:4264/rtc', async route => {
|
||||
const host = address + ':4264';
|
||||
const original = route.request().headers();
|
||||
const headers = { ...original, host, origin: 'http://' + host };
|
||||
const response = await route.fetch({ url: 'http://' + host + '/rtc', headers });
|
||||
await route.fulfill({ response, headers: { ...response.headers(), 'access-control-allow-origin': original.origin || '*' } });
|
||||
});
|
||||
}
|
||||
|
||||
// Keep the existing controller-behavior assertions readable while recording the
|
||||
// actual wire messages separately for sequence/history/channel assertions.
|
||||
function recordRtcMessages() {
|
||||
window.rtcMessages = [];
|
||||
window.controllerMessages = [];
|
||||
window.testClaims = [];
|
||||
window.testInputs = [];
|
||||
const dataSend = RTCDataChannel.prototype.send;
|
||||
RTCDataChannel.prototype.send = function(data) {
|
||||
try {
|
||||
const message = JSON.parse(data);
|
||||
rtcMessages.push({ channel: this.id, message });
|
||||
let normalized = message;
|
||||
if (message.method === 'i') {
|
||||
const p = message.params;
|
||||
normalized = { ...message, method: 'u', params: [p[0], ...p.slice(2, 10 + 3 * p[9])] };
|
||||
} else if (message.method === 'm') {
|
||||
normalized = { ...message, params: [message.params[0], ...message.params.slice(2)] };
|
||||
}
|
||||
controllerMessages.push(normalized);
|
||||
if (normalized.method === 'claim') testClaims.push(normalized.params);
|
||||
if (normalized.method === 'u') testInputs.push(normalized.params);
|
||||
} catch {}
|
||||
return dataSend.call(this, data);
|
||||
};
|
||||
}
|
||||
|
||||
function copyService(destination) {
|
||||
if (!fs.existsSync(serviceBinary)) throw new Error('Build the production RTC host service with tests/run.py first');
|
||||
fs.copyFileSync(serviceBinary, destination);
|
||||
fs.chmodSync(destination, 0o755);
|
||||
}
|
||||
|
||||
module.exports = { address, relayOffer, recordRtcMessages, copyService };
|
||||
@@ -0,0 +1,101 @@
|
||||
// The WebRTC transport against real browsers: Chromium and Firefox open the two
|
||||
// data channels to the service (build/web-rtc-host-test, the PS4 stubbed) and talk
|
||||
// to it. The production-only HTTP surface refuses WebSocket. The page is
|
||||
// opened by the container's own address, not 127.0.0.1:
|
||||
// browsers leave loopback out of WebRTC. Run in the Playwright Docker image:
|
||||
// NODE_PATH=… node tests/rtc_e2e.cjs
|
||||
const pw = require('playwright');
|
||||
const fs = require('node:fs'), os = require('node:os'), assert = require('node:assert/strict'), { spawn } = require('node:child_process');
|
||||
|
||||
const address = Object.values(os.networkInterfaces()).flat().find(i => i.family === 'IPv4' && !i.internal).address;
|
||||
fs.copyFileSync('/src/build/web-rtc-host-test', '/tmp/c4f-rtc-service');
|
||||
fs.chmodSync('/tmp/c4f-rtc-service', 0o755);
|
||||
|
||||
let server, log = '';
|
||||
function startService(env = {}) {
|
||||
server = spawn('/tmp/c4f-rtc-service', [], { env: { ...process.env, C4F_TEST_KLOG_FD: '0', ...env }, stdio: ['pipe', 'pipe', 'pipe'] });
|
||||
server.stderr.on('data', d => { log += d; });
|
||||
server.stdout.on('data', d => { log += d; });
|
||||
}
|
||||
async function stopService() {
|
||||
const old = server;
|
||||
if (!old) return;
|
||||
server = null;
|
||||
old.kill();
|
||||
await new Promise(done => old.exitCode !== null || old.signalCode !== null ? done() : old.once('exit', done));
|
||||
}
|
||||
|
||||
// In the page: an offer to POST /rtc, the two negotiated channels, then a few
|
||||
// round trips on each. Returns what happened.
|
||||
async function talk(page, { echoes = 20 } = {}) {
|
||||
return page.evaluate(async ({ echoes }) => {
|
||||
const pc = new RTCPeerConnection();
|
||||
try {
|
||||
const control = pc.createDataChannel('control', { negotiated: true, id: 0 });
|
||||
const input = pc.createDataChannel('input', { negotiated: true, id: 1, ordered: false, maxRetransmits: 0 });
|
||||
const pushed = [];
|
||||
const pending = new Map();
|
||||
control.onmessage = ev => {
|
||||
const m = JSON.parse(ev.data);
|
||||
if (m.id && pending.has(m.id)) { pending.get(m.id)(m); pending.delete(m.id); } else pushed.push(m);
|
||||
};
|
||||
const started = performance.now();
|
||||
await pc.setLocalDescription(await pc.createOffer());
|
||||
const reply = await fetch('/rtc', { method: 'POST', headers: { 'Content-Type': 'text/plain' }, body: pc.localDescription.sdp });
|
||||
if (!reply.ok) return { error: 'offer refused: ' + reply.status + ' ' + await reply.text() };
|
||||
const answer = await reply.text();
|
||||
await pc.setRemoteDescription({ type: 'answer', sdp: answer });
|
||||
const open = ch => new Promise((done, fail) => {
|
||||
if (ch.readyState === 'open') return done();
|
||||
ch.onopen = done; ch.onerror = fail;
|
||||
setTimeout(() => fail(new Error(ch.label + ' did not open: ' + pc.connectionState + '/' + pc.iceConnectionState)), 10000);
|
||||
});
|
||||
try { await Promise.all([open(control), open(input)]); } catch (e) { return { error: e.message, answer }; }
|
||||
const openedMs = performance.now() - started;
|
||||
const call = (id, method, params = []) => new Promise((done, fail) => {
|
||||
const timer = setTimeout(() => { pending.delete(id); fail(new Error(method + ' reply timed out')); }, 5000);
|
||||
pending.set(id, value => { clearTimeout(timer); done(value); });
|
||||
control.send(JSON.stringify({ id, method, params }));
|
||||
});
|
||||
const info = await call(1, 'info');
|
||||
// The service's first push is the status, sent the moment the channel opened.
|
||||
await new Promise(done => setTimeout(done, 200));
|
||||
const status = pushed.find(m => m.method === 's');
|
||||
// Echoes on the unordered channel.
|
||||
const back = new Map();
|
||||
input.onmessage = ev => { const m = JSON.parse(ev.data); if (m.method === 'e') back.set(m.params[0], performance.now() - m.params[1]); };
|
||||
for (let i = 1; i <= echoes; i++) { input.send(JSON.stringify({ method: 'e', params: [i, Math.round(performance.now())] })); await new Promise(d => setTimeout(d, 20)); }
|
||||
await new Promise(done => setTimeout(done, 500));
|
||||
// A big reply (the invite QR code) comes in pieces over the reliable channel.
|
||||
const invite = await call(2, 'invite', []);
|
||||
return { openedMs, info: info.result, status: status && status.params, echoes: back.size, invite: invite.result || invite.error };
|
||||
} finally { pc.close(); }
|
||||
}, { echoes });
|
||||
}
|
||||
|
||||
(async () => {
|
||||
startService();
|
||||
try {
|
||||
const results = {};
|
||||
for (const name of ['chromium', 'firefox']) {
|
||||
const browser = await pw[name].launch(name === 'firefox' ? { firefoxUserPrefs: { 'media.peerconnection.ice.loopback': true } } : {});
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
page.on('pageerror', e => console.error(name, 'page error:', e.message));
|
||||
for (let tries = 0; ; tries++) {
|
||||
try { await page.goto(`http://${address}:4264/`); break; } catch (e) { if (tries > 50) throw e; await new Promise(d => setTimeout(d, 100)); }
|
||||
}
|
||||
const r = await talk(page);
|
||||
results[name] = r;
|
||||
if (r.error) { console.error(log); throw new Error(name + ': ' + r.error + '\n' + (r.answer || '')); }
|
||||
assert.equal(r.info.protocol, 3, name);
|
||||
assert.equal(r.info.input, true, name);
|
||||
assert.ok(r.status && r.status.pads.length === 4, name + ': status pushed on open');
|
||||
assert.equal(r.echoes, 20, name + ': healthy-link echoes');
|
||||
assert.ok(r.invite && r.invite.size > 20, name + ': invite ' + JSON.stringify(r.invite));
|
||||
console.log(`PASS rtc ${name}: open in ${r.openedMs.toFixed(0)} ms, info, status push, ${r.echoes}/20 echoes, big reply in pieces`);
|
||||
} finally { await browser.close(); }
|
||||
}
|
||||
if (!/rtc: peer \d+ open/.test(log)) { console.error(log); throw new Error('the service did not log an open peer'); }
|
||||
} finally { await stopService(); }
|
||||
})().catch(e => { console.error(e); process.exit(1); });
|
||||
@@ -0,0 +1,199 @@
|
||||
// Real production RTC application behavior through a controllable local UDP
|
||||
// relay. All sockets stay inside the Docker test container; no console access.
|
||||
const { chromium } = require('playwright');
|
||||
const assert = require('node:assert/strict');
|
||||
const dgram = require('node:dgram');
|
||||
const { spawn } = require('node:child_process');
|
||||
const { address, copyService, relayOffer } = require('./rtc_browser_helpers.cjs');
|
||||
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
|
||||
|
||||
async function relay() {
|
||||
const down = dgram.createSocket('udp4'), up = dgram.createSocket('udp4');
|
||||
await Promise.all([new Promise(resolve => down.bind(0, '0.0.0.0', resolve)),
|
||||
new Promise(resolve => up.bind(0, '0.0.0.0', resolve))]);
|
||||
let client, mode = 'pass';
|
||||
const held = [];
|
||||
function forward(socket, data, port, host) {
|
||||
if (mode === 'drop') return;
|
||||
const deliver = () => socket.send(data, port, host);
|
||||
if (mode === 'hold') held.push(deliver); else deliver();
|
||||
}
|
||||
down.on('message', (data, from) => {
|
||||
client = from;
|
||||
forward(up, Buffer.from(data), 4264, address);
|
||||
});
|
||||
up.on('message', data => { if (client) forward(down, Buffer.from(data), client.port, client.address); });
|
||||
return { port: down.address().port,
|
||||
mode(value) { mode = value; },
|
||||
release() { for (const deliver of held.splice(0)) deliver(); },
|
||||
async close() { held.length = 0; await Promise.all([new Promise(r => down.close(r)), new Promise(r => up.close(r))]); }
|
||||
};
|
||||
}
|
||||
|
||||
async function connect(page, port) {
|
||||
return page.evaluate(async port => {
|
||||
const pc = new RTCPeerConnection({ iceServers: [] });
|
||||
const control = pc.createDataChannel('control', { negotiated: true, id: 0 });
|
||||
const input = pc.createDataChannel('input', { negotiated: true, id: 1, ordered: false, maxRetransmits: 0 });
|
||||
const pending = new Map(), pushes = [];
|
||||
let nextId = 1;
|
||||
control.onmessage = event => {
|
||||
const msg = JSON.parse(event.data), request = pending.get(msg.id);
|
||||
if (request) { clearTimeout(request.timer); pending.delete(msg.id); request.resolve(msg); }
|
||||
else pushes.push(msg);
|
||||
};
|
||||
input.onmessage = event => pushes.push(JSON.parse(event.data));
|
||||
function request(method, params = [], onInput = false) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const id = nextId++;
|
||||
const timer = setTimeout(() => { pending.delete(id); reject(new Error('Request timed out: ' + method)); }, 5000);
|
||||
pending.set(id, { resolve, timer });
|
||||
(onInput ? input : control).send(JSON.stringify({ id, method, params }));
|
||||
});
|
||||
}
|
||||
await pc.setLocalDescription(await pc.createOffer());
|
||||
const response = await fetch('/rtc', { method: 'POST', headers: { 'Content-Type': 'text/plain' }, body: pc.localDescription.sdp });
|
||||
if (!response.ok) throw new Error('Offer refused: ' + response.status);
|
||||
let answer = await response.text();
|
||||
if (port) answer = answer.replace(/(a=candidate:\S+ \d+ \S+ \d+ \S+ )\d+/g, (_match, prefix) => prefix + port);
|
||||
await pc.setRemoteDescription({ type: 'answer', sdp: answer });
|
||||
await Promise.all([control, input].map(channel => new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error('Channel did not open')), 10000);
|
||||
if (channel.readyState === 'open') { clearTimeout(timer); resolve(); }
|
||||
else channel.onopen = () => { clearTimeout(timer); resolve(); };
|
||||
})));
|
||||
window.rtcInputTest = { pc, control, input, pushes, request,
|
||||
state(seq, buttons = 0, lx = 128, history = []) {
|
||||
input.send(JSON.stringify({ method: 'i', params: [0, seq, buttons, lx, 128, 128, 128, 0, 0, 0, history.length, ...history.flat()] }));
|
||||
},
|
||||
motion(seq, acceleration) {
|
||||
input.send(JSON.stringify({ method: 'm', params: [0, seq, 0, 0, 0, 10000, acceleration, 0, 0, 0, 0, 0] }));
|
||||
}
|
||||
};
|
||||
return request('info');
|
||||
}, port);
|
||||
}
|
||||
|
||||
(async () => {
|
||||
let service, browser, proxy;
|
||||
let trace = '', partial = '';
|
||||
const frames = [];
|
||||
const frameAt = (buttons, lx = undefined) => rows => rows.some(row => +row[3] === buttons && (lx === undefined || +row[4] === lx));
|
||||
async function waitFrames(start, condition, timeout = 2500) {
|
||||
const deadline = Date.now() + timeout;
|
||||
while (!condition(frames.slice(start))) {
|
||||
if (Date.now() > deadline) throw new Error('Expected controller report not seen\n' + trace.slice(-5000));
|
||||
await sleep(10);
|
||||
}
|
||||
}
|
||||
try {
|
||||
if (!address) throw new Error('Run the browser checks with a Docker network interface; --network none has only loopback');
|
||||
copyService('/tmp/c4f-rtc-input-service');
|
||||
service = spawn('/tmp/c4f-rtc-input-service', [], { env: { ...process.env, C4F_TEST_KLOG_FD: '0' }, stdio: ['pipe', 'pipe', 'pipe'] });
|
||||
service.stdout.on('data', data => {
|
||||
trace += data; partial += data;
|
||||
const lines = partial.split('\n'); partial = lines.pop();
|
||||
for (const line of lines) if (line.startsWith('FRAME ')) frames.push(line.split(' '));
|
||||
});
|
||||
service.stderr.on('data', data => { trace += data; });
|
||||
browser = await chromium.launch();
|
||||
const context = await browser.newContext();
|
||||
await context.grantPermissions(['local-network-access']);
|
||||
const page = await context.newPage();
|
||||
await relayOffer(page);
|
||||
// Supply an empty same-origin document: this test owns the RTC peers.
|
||||
await page.route('http://127.0.0.1:4264/', async route => route.fulfill({ response: await route.fetch(), body: '<!doctype html><title>RTC input test</title>' }));
|
||||
await page.goto('http://127.0.0.1:4264/');
|
||||
for (let tries = 0; ; tries++) {
|
||||
try { if ((await page.request.get(`http://${address}:4264/`)).ok()) break; }
|
||||
catch (error) { if (tries > 50) throw error; }
|
||||
await sleep(100);
|
||||
}
|
||||
proxy = await relay();
|
||||
const info = await connect(page, proxy.port);
|
||||
assert.equal(info.result.protocol, 3);
|
||||
const claim = await page.evaluate(() => rtcInputTest.request('claim', [0]));
|
||||
assert.equal(claim.result.pads[0].mine, true);
|
||||
let start = frames.length;
|
||||
await page.evaluate(() => rtcInputTest.state(1));
|
||||
await waitFrames(start, frameAt(0));
|
||||
|
||||
// The press and release packets were absent, but their bounded history
|
||||
// repairs a quick tap. Analog state is current, not reconstructed history.
|
||||
start = frames.length;
|
||||
await page.evaluate(() => rtcInputTest.state(4, 0, 17, [[2, 0x4000], [3, 0]]));
|
||||
await waitFrames(start, rows => frameAt(0x4000, 17)(rows) && frameAt(0, 17)(rows));
|
||||
await page.evaluate(() => { rtcInputTest.state(3, 0x2000); rtcInputTest.state(4, 0x2000); });
|
||||
await sleep(80);
|
||||
assert.ok(frames.slice(start).every(row => +row[3] !== 0x2000), 'late or duplicate state restored an old button');
|
||||
|
||||
const wrongControl = await page.evaluate(() => rtcInputTest.request('ping', [], true));
|
||||
assert.equal(wrongControl.error.code, 400);
|
||||
const wrongInput = await page.evaluate(() => rtcInputTest.request('i', [0, 5, 0, 128, 128, 128, 128, 0, 0, 0, 0]));
|
||||
assert.equal(wrongInput.error.code, 400);
|
||||
const malformedHistory = await page.evaluate(() => rtcInputTest.request('i', [0, 5, 0, 128, 128, 128, 128, 0, 0, 0, 2, 4, 0, 3, 0], true));
|
||||
assert.equal(malformedHistory.error.code, 400);
|
||||
console.log('PASS RTC input: explicit claim, recovered tap, ordered history, duplicate/late rejection, channel separation');
|
||||
|
||||
start = frames.length;
|
||||
await page.evaluate(() => { rtcInputTest.motion(10, 1000); rtcInputTest.motion(9, -1000); rtcInputTest.state(5, 0, 33); });
|
||||
await waitFrames(start, rows => rows.some(row => +row[4] === 33 && +row[15] === 1));
|
||||
assert.ok(frames.slice(start).every(row => +row[15] !== -1), 'old motion overtook new motion');
|
||||
|
||||
// A genuinely held datagram can arrive after a newer one. No retransmit
|
||||
// setting can remove it from this queue; receiver sequences must reject it.
|
||||
proxy.mode('hold');
|
||||
await page.evaluate(() => rtcInputTest.state(6, 0x2000));
|
||||
await sleep(100);
|
||||
proxy.mode('pass');
|
||||
start = frames.length;
|
||||
await page.evaluate(() => rtcInputTest.state(7, 0, 44));
|
||||
await waitFrames(start, frameAt(0, 44));
|
||||
proxy.release();
|
||||
await sleep(100);
|
||||
assert.ok(frames.slice(start).every(row => +row[3] !== 0x2000), 'held old datagram regressed input');
|
||||
|
||||
// Drop both directions for 900ms, including several full-state messages.
|
||||
// Afterward repeat recent events and exact final state. Timing lost during
|
||||
// the outage is not claimed to be recoverable.
|
||||
proxy.mode('drop');
|
||||
start = frames.length;
|
||||
await page.evaluate(() => { rtcInputTest.state(8, 0x4000); rtcInputTest.state(9, 0); });
|
||||
await sleep(900);
|
||||
assert.ok(frames.slice(start).every(row => +row[3] === 0), 'dropped traffic reached the service');
|
||||
proxy.mode('pass');
|
||||
await page.evaluate(() => rtcInputTest.state(10, 0, 55, [[8, 0x4000], [9, 0]]));
|
||||
await waitFrames(start, rows => frameAt(0x4000, 55)(rows) && frameAt(0, 55)(rows));
|
||||
console.log('PASS RTC impairment: stale held datagram rejected, 900ms bidirectional loss, bounded tap recovery and exact final state');
|
||||
|
||||
// A stale reset must not forget its sequence floor. Motion by itself must
|
||||
// not keep an old held button alive.
|
||||
await page.evaluate(() => rtcInputTest.state(11, 0x20));
|
||||
await waitFrames(frames.length, frameAt(0x20));
|
||||
start = frames.length;
|
||||
for (let i = 11; i < 24; i++) {
|
||||
await page.evaluate(seq => rtcInputTest.motion(seq, 0), i);
|
||||
await sleep(250);
|
||||
}
|
||||
await waitFrames(start, frameAt(0));
|
||||
const status = await page.evaluate(() => rtcInputTest.request('status'));
|
||||
assert.equal(status.result.pads[0].state, 'paused');
|
||||
start = frames.length;
|
||||
await page.evaluate(() => rtcInputTest.state(10, 0x4000));
|
||||
await sleep(100);
|
||||
assert.ok(frames.slice(start).every(row => +row[3] === 0), 'stale reset discarded sequence floor');
|
||||
await page.evaluate(() => rtcInputTest.state(12, 0));
|
||||
await waitFrames(start, frameAt(0));
|
||||
assert.equal((await page.evaluate(() => rtcInputTest.request('status'))).result.pads[0].state, 'select');
|
||||
console.log('PASS RTC freshness: motion cannot renew held buttons; stale reset preserves sequence floor; newer input resumes');
|
||||
|
||||
await page.evaluate(() => rtcInputTest.pc.close());
|
||||
} finally {
|
||||
if (browser) await browser.close();
|
||||
if (proxy) await proxy.close();
|
||||
if (service) {
|
||||
service.kill();
|
||||
await new Promise(resolve => service.exitCode !== null || service.signalCode !== null ? resolve() : service.once('exit', resolve));
|
||||
}
|
||||
}
|
||||
})().catch(error => { console.error(error); process.exitCode = 1; });
|
||||
@@ -0,0 +1,116 @@
|
||||
/* Deterministic tests of the real RTC datagram boundary. No sockets or PS4:
|
||||
* force the sendto outcomes that are difficult to reproduce on a healthy LAN. */
|
||||
#include <errno.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/socket.h>
|
||||
|
||||
static ssize_t c4fTestSendto(int, const void *, size_t, int, const struct sockaddr *, socklen_t);
|
||||
#define sendto c4fTestSendto
|
||||
#include "../src/rtc.c"
|
||||
#undef sendto
|
||||
|
||||
#define CHECK(cond) do { if (!(cond)) { fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, #cond); exit(1); } } while (0)
|
||||
|
||||
static uint64_t fakeNow = 1000;
|
||||
static int sendError;
|
||||
static unsigned writes;
|
||||
static uint8_t wire[256][C4F_RTC_MTU];
|
||||
static size_t wireLen[256];
|
||||
static uint16_t wirePort[256];
|
||||
|
||||
uint64_t c4fTimeMs(void) { return fakeNow; }
|
||||
void c4fLog(const char *fmt, ...) { (void)fmt; }
|
||||
|
||||
static ssize_t c4fTestSendto(int fd, const void *data, size_t len, int flags,
|
||||
const struct sockaddr *to, socklen_t tolen)
|
||||
{
|
||||
(void)fd; (void)flags; (void)tolen;
|
||||
if (sendError) { errno = sendError; return -1; }
|
||||
CHECK(writes < 256 && len <= C4F_RTC_MTU);
|
||||
memcpy(wire[writes], data, len); wireLen[writes] = len;
|
||||
wirePort[writes] = ((const struct sockaddr_in *)to)->sin_port;
|
||||
writes++;
|
||||
return (ssize_t)len;
|
||||
}
|
||||
|
||||
static C4fRtcPeer *peer(C4fRtc *rtc, unsigned slot)
|
||||
{
|
||||
C4fRtcPeer *p = calloc(1, sizeof(*p));
|
||||
CHECK(p);
|
||||
p->rtc = rtc; p->addrKnown = 1; p->addr.sin_family = AF_INET; p->addr.sin_port = htons((uint16_t)(6000 + slot));
|
||||
rtc->peers[slot] = p;
|
||||
return p;
|
||||
}
|
||||
|
||||
static void dispose(C4fRtc *rtc)
|
||||
{
|
||||
for (unsigned i = 0; i < C4F_RTC_PEERS; i++) {
|
||||
C4fRtcPeer *p = rtc->peers[i];
|
||||
if (!p) continue;
|
||||
while (p->out) { C4fRtcDatagram *d = p->out; p->out = d->next; free(d); }
|
||||
free(p); rtc->peers[i] = NULL;
|
||||
}
|
||||
}
|
||||
|
||||
int main(void)
|
||||
{
|
||||
C4fRtc rtc = { .fd = 77 };
|
||||
C4fRtcPeer *p = peer(&rtc, 0);
|
||||
uint8_t a[] = { 1, 2, 3 }, b[] = { 4, 5, 6, 7 };
|
||||
sendError = EAGAIN;
|
||||
CHECK(c4fRtcBioSend(p, a, sizeof(a)) == sizeof(a));
|
||||
CHECK(c4fRtcBioSend(p, b, sizeof(b)) == sizeof(b));
|
||||
CHECK(p->outPackets == 2 && !p->datagramsOut && c4fRtcWantsWrite(&rtc));
|
||||
memset(a, 99, sizeof(a)); memset(b, 99, sizeof(b)); /* caller's buffers can be reused immediately */
|
||||
c4fRtcWritable(&rtc);
|
||||
CHECK(p->outPackets == 2 && !writes);
|
||||
sendError = 0;
|
||||
c4fRtcWritable(&rtc);
|
||||
CHECK(writes == 2 && wireLen[0] == 3 && wire[0][0] == 1 && wire[1][0] == 4);
|
||||
CHECK(!p->outPackets && p->datagramsOut == 2 && !c4fRtcWantsWrite(&rtc));
|
||||
CHECK(wirePort[0] == htons(6000));
|
||||
CHECK(c4fRtcBioSend(p, a, sizeof(a)) == sizeof(a) && writes == 3 && wire[2][0] == 99);
|
||||
|
||||
/* ENOBUFS can occur on a writable UDP socket. Back off briefly instead of
|
||||
* spinning select continuously or pretending that the datagram was sent. */
|
||||
sendError = ENOBUFS;
|
||||
CHECK(c4fRtcBioSend(p, a, sizeof(a)) == sizeof(a) && !c4fRtcWantsWrite(&rtc));
|
||||
CHECK(c4fRtcWait(&rtc, fakeNow, 50) <= 2);
|
||||
fakeNow += 2;
|
||||
CHECK(c4fRtcWantsWrite(&rtc));
|
||||
CHECK(c4fRtcWait(&rtc, fakeNow, 50) > 0); /* expired backoff must not force a busy select loop */
|
||||
sendError = 0; c4fRtcWritable(&rtc);
|
||||
CHECK(!p->out && writes == 4);
|
||||
|
||||
/* Bounded queues fail explicitly, and permanent socket failures are not
|
||||
* counted as successful datagrams. */
|
||||
sendError = EAGAIN;
|
||||
for (unsigned i = 0; i < C4F_RTC_OUT_PACKETS; i++) CHECK(c4fRtcBioSend(p, a, sizeof(a)) == sizeof(a));
|
||||
CHECK(c4fRtcBioSend(p, a, sizeof(a)) < 0 && p->failed && p->outPackets == C4F_RTC_OUT_PACKETS);
|
||||
CHECK(p->datagramsOut == 4);
|
||||
dispose(&rtc);
|
||||
|
||||
p = peer(&rtc, 0);
|
||||
sendError = ECONNREFUSED;
|
||||
CHECK(c4fRtcBioSend(p, a, sizeof(a)) < 0 && p->failed && !p->out && !p->datagramsOut);
|
||||
dispose(&rtc);
|
||||
|
||||
/* Each peer gets a bounded drain and the first served peer rotates. */
|
||||
p = peer(&rtc, 0);
|
||||
C4fRtcPeer *other = peer(&rtc, 1);
|
||||
sendError = EAGAIN;
|
||||
for (unsigned i = 0; i < 12; i++) {
|
||||
CHECK(c4fRtcBioSend(p, a, sizeof(a)) == sizeof(a));
|
||||
CHECK(c4fRtcBioSend(other, b, sizeof(b)) == sizeof(b));
|
||||
}
|
||||
sendError = 0; c4fRtcWritable(&rtc);
|
||||
CHECK(p->outPackets == 4 && other->outPackets == 4);
|
||||
c4fRtcWritable(&rtc);
|
||||
CHECK(!p->out && !other->out);
|
||||
dispose(&rtc);
|
||||
puts("PASS rtc I/O: owned ciphertext retry/order, EAGAIN/ENOBUFS, bounded queues, fatal errors and fair peer drains");
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,433 @@
|
||||
/* STUN and SCTP, as the WebRTC transport uses them, on the host. STUN against the
|
||||
* RFC 5769 test vectors; SCTP driven packet by packet the way a browser would. */
|
||||
#include <arpa/inet.h>
|
||||
#include <assert.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "c4f_sctp.h"
|
||||
#include "c4f_stun.h"
|
||||
|
||||
#define CHECK(cond) do { if (!(cond)) { fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, #cond); exit(1); } } while (0)
|
||||
|
||||
/* ------------------------------------------------------------ STUN */
|
||||
|
||||
static const uint8_t rfcRequest[] = {
|
||||
0x00,0x01,0x00,0x58, 0x21,0x12,0xa4,0x42, 0xb7,0xe7,0xa7,0x01, 0xbc,0x34,0xd6,0x86, 0xfa,0x87,0xdf,0xae,
|
||||
0x80,0x22,0x00,0x10, 0x53,0x54,0x55,0x4e, 0x20,0x74,0x65,0x73, 0x74,0x20,0x63,0x6c, 0x69,0x65,0x6e,0x74,
|
||||
0x00,0x24,0x00,0x04, 0x6e,0x00,0x01,0xff,
|
||||
0x80,0x29,0x00,0x08, 0x93,0x2f,0xf9,0xb1, 0x51,0x26,0x3b,0x36,
|
||||
0x00,0x06,0x00,0x09, 0x65,0x76,0x74,0x6a, 0x3a,0x68,0x36,0x76, 0x59,0x20,0x20,0x20,
|
||||
0x00,0x08,0x00,0x14, 0x9a,0xea,0xa7,0x0c, 0xbf,0xd8,0xcb,0x56, 0x78,0x1e,0xf2,0xb5, 0xb2,0xd3,0xf2,0x49, 0xc1,0xb5,0x71,0xa2,
|
||||
0x80,0x28,0x00,0x04, 0xe5,0x7a,0x3b,0xcf,
|
||||
};
|
||||
static const uint8_t rfcResponse[] = {
|
||||
0x01,0x01,0x00,0x3c, 0x21,0x12,0xa4,0x42, 0xb7,0xe7,0xa7,0x01, 0xbc,0x34,0xd6,0x86, 0xfa,0x87,0xdf,0xae,
|
||||
0x80,0x22,0x00,0x0b, 0x74,0x65,0x73,0x74, 0x20,0x76,0x65,0x63, 0x74,0x6f,0x72,0x20,
|
||||
0x00,0x20,0x00,0x08, 0x00,0x01,0xa1,0x47, 0xe1,0x12,0xa6,0x43,
|
||||
0x00,0x08,0x00,0x14, 0x2b,0x91,0xf5,0x99, 0xfd,0x9e,0x90,0xc3, 0x8c,0x74,0x89,0xf9, 0x2a,0xf9,0xba,0x53, 0xf0,0x6b,0xe7,0xd7,
|
||||
0x80,0x28,0x00,0x04, 0xc0,0x7d,0x4c,0x96,
|
||||
};
|
||||
static const char rfcPassword[] = "VOkJxbRl1RmTxUk/WvJxBt";
|
||||
|
||||
static void testStun(void)
|
||||
{
|
||||
CHECK(c4fCrc32((const uint8_t *)"123456789", 9) == 0xCBF43926u);
|
||||
CHECK(c4fCrc32c((const uint8_t *)"123456789", 9) == 0xE3069283u);
|
||||
CHECK(c4fIsStun(rfcRequest, sizeof(rfcRequest)) && !c4fIsDtls(rfcRequest, sizeof(rfcRequest)));
|
||||
size_t size;
|
||||
const uint8_t *user = c4fStunUsername(rfcRequest, sizeof(rfcRequest), &size);
|
||||
CHECK(user && size == 9 && !memcmp(user, "evtj:h6vY", 9));
|
||||
CHECK(c4fStunVerify(rfcRequest, sizeof(rfcRequest), rfcPassword));
|
||||
CHECK(!c4fStunVerify(rfcRequest, sizeof(rfcRequest), "wrong password"));
|
||||
CHECK(c4fStunVerify(rfcResponse, sizeof(rfcResponse), rfcPassword));
|
||||
/* A changed byte breaks the integrity check. */
|
||||
uint8_t bent[sizeof(rfcRequest)];
|
||||
memcpy(bent, rfcRequest, sizeof(bent));
|
||||
bent[47] ^= 1;
|
||||
CHECK(!c4fStunVerify(bent, sizeof(bent), rfcPassword));
|
||||
CHECK(!c4fStunUsername(rfcResponse, sizeof(rfcResponse), &size)); /* a response, not a request */
|
||||
|
||||
/* Our answer to the RFC's request, from 192.0.2.1:32853, carries the same
|
||||
* XOR-MAPPED-ADDRESS as the RFC's sample response and checks out. */
|
||||
uint8_t answer[C4F_STUN_ANSWER_SIZE];
|
||||
int nominate = -1;
|
||||
uint32_t address = inet_addr("192.0.2.1");
|
||||
size_t n = c4fStunAnswer(rfcRequest, sizeof(rfcRequest), rfcPassword, address, htons(32853), answer, &nominate);
|
||||
CHECK(n == C4F_STUN_ANSWER_SIZE && nominate == 0);
|
||||
CHECK(answer[0] == 0x01 && answer[1] == 0x01 && !memcmp(answer + 4, rfcRequest + 4, 16));
|
||||
CHECK(!memcmp(answer + 20, rfcResponse + 36, 12));
|
||||
CHECK(c4fStunVerify(answer, n, rfcPassword));
|
||||
CHECK(!c4fStunAnswer(rfcRequest, sizeof(rfcRequest), "another", address, htons(32853), answer, &nominate));
|
||||
puts("PASS stun: RFC 5769 request and response, integrity, fingerprint, our XOR-MAPPED-ADDRESS answer");
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ SCTP */
|
||||
|
||||
typedef struct { uint8_t data[2048]; size_t len; } Blob;
|
||||
static Blob sent[512], delivered[64];
|
||||
static uint16_t deliveredStream[64];
|
||||
static int sentCount, deliveredCount;
|
||||
static int rejectSend;
|
||||
static uint32_t randomState = 0x12345678;
|
||||
|
||||
static int opSend(void *user, const uint8_t *p, size_t len)
|
||||
{
|
||||
(void)user;
|
||||
if (rejectSend) return -1;
|
||||
CHECK(sentCount < 512 && len <= C4F_SCTP_MTU);
|
||||
/* Every packet we send carries a correct CRC32c, least significant byte first. */
|
||||
uint8_t copy[2048];
|
||||
memcpy(copy, p, len);
|
||||
memset(copy + 8, 0, 4);
|
||||
uint32_t crc = c4fCrc32c(copy, len);
|
||||
CHECK(p[8] == (uint8_t)crc && p[9] == (uint8_t)(crc >> 8) && p[10] == (uint8_t)(crc >> 16) && p[11] == (uint8_t)(crc >> 24));
|
||||
memcpy(sent[sentCount].data, p, len); sent[sentCount++].len = len;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static void opDeliver(void *user, uint16_t stream, uint32_t ppid, const uint8_t *d, size_t len)
|
||||
{
|
||||
(void)user;
|
||||
CHECK(deliveredCount < 64 && ppid == C4F_SCTP_PPID_STRING);
|
||||
memcpy(delivered[deliveredCount].data, d, len); delivered[deliveredCount].len = len;
|
||||
deliveredStream[deliveredCount++] = stream;
|
||||
}
|
||||
|
||||
static void opRandom(void *user, void *out, size_t len)
|
||||
{
|
||||
(void)user;
|
||||
for (size_t i = 0; i < len; i++) { randomState = randomState * 1103515245u + 12345u; ((uint8_t *)out)[i] = (uint8_t)(randomState >> 16); }
|
||||
}
|
||||
|
||||
static uint16_t g16(const uint8_t *p) { return (uint16_t)(p[0] << 8 | p[1]); }
|
||||
static uint32_t g32(const uint8_t *p) { return (uint32_t)p[0] << 24 | (uint32_t)p[1] << 16 | (uint32_t)p[2] << 8 | p[3]; }
|
||||
static void p16(uint8_t *p, uint16_t v) { p[0] = v >> 8; p[1] = (uint8_t)v; }
|
||||
static void p32(uint8_t *p, uint32_t v) { p[0] = v >> 24; p[1] = (uint8_t)(v >> 16); p[2] = (uint8_t)(v >> 8); p[3] = (uint8_t)v; }
|
||||
|
||||
/* A browser-side packet under construction. */
|
||||
typedef struct { uint8_t b[2048]; size_t len; } Pkt;
|
||||
static void pkStart(Pkt *k, uint32_t tag) { memset(k, 0, sizeof(*k)); p16(k->b, 5000); p16(k->b + 2, 5000); p32(k->b + 4, tag); k->len = 12; }
|
||||
static uint8_t *pkChunk(Pkt *k, uint8_t type, uint8_t flags, size_t vlen)
|
||||
{
|
||||
uint8_t *c = k->b + k->len;
|
||||
c[0] = type; c[1] = flags; p16(c + 2, (uint16_t)(4 + vlen));
|
||||
k->len += (4 + vlen + 3) & ~(size_t)3;
|
||||
return c + 4;
|
||||
}
|
||||
static void pkData(Pkt *k, uint8_t flags, uint32_t tsn, uint16_t stream, uint16_t ssn, const char *text)
|
||||
{
|
||||
size_t n = strlen(text);
|
||||
uint8_t *v = pkChunk(k, 0, flags, 12 + n);
|
||||
p32(v, tsn); p16(v + 4, stream); p16(v + 6, ssn); p32(v + 8, C4F_SCTP_PPID_STRING); memcpy(v + 12, text, n);
|
||||
}
|
||||
static void pkSend(C4fSctp *s, Pkt *k, uint64_t now)
|
||||
{
|
||||
uint32_t crc = c4fCrc32c(k->b, k->len);
|
||||
k->b[8] = (uint8_t)crc; k->b[9] = (uint8_t)(crc >> 8); k->b[10] = (uint8_t)(crc >> 16); k->b[11] = (uint8_t)(crc >> 24);
|
||||
c4fSctpReceive(s, k->b, k->len, now);
|
||||
}
|
||||
|
||||
/* The first chunk of type `type` in a sent packet, or NULL. */
|
||||
static const uint8_t *findChunk(const Blob *b, uint8_t type)
|
||||
{
|
||||
for (size_t at = 12; at + 4 <= b->len; at += (g16(b->data + at + 2) + 3) & ~3u)
|
||||
if (b->data[at] == type) return b->data + at;
|
||||
return NULL;
|
||||
}
|
||||
|
||||
static int isDelivered(int i, uint16_t stream, const char *text)
|
||||
{
|
||||
return i < deliveredCount && deliveredStream[i] == stream && delivered[i].len == strlen(text) &&
|
||||
!memcmp(delivered[i].data, text, delivered[i].len);
|
||||
}
|
||||
|
||||
static void testSctp(void)
|
||||
{
|
||||
C4fSctp s;
|
||||
C4fSctpOps ops = { NULL, opSend, opDeliver, opRandom };
|
||||
c4fSctpInit(&s, &ops);
|
||||
uint64_t now = 1000;
|
||||
const uint32_t browserTag = 0x11223344, browserTsn = 1000;
|
||||
|
||||
/* INIT -> INIT-ACK with a cookie, partial reliability and stream resets. */
|
||||
Pkt k;
|
||||
pkStart(&k, 0);
|
||||
uint8_t *v = pkChunk(&k, 1, 0, 16);
|
||||
p32(v, browserTag); p32(v + 4, 131072); p16(v + 8, 65535); p16(v + 10, 65535); p32(v + 12, browserTsn);
|
||||
pkSend(&s, &k, now);
|
||||
CHECK(sentCount == 1 && g32(sent[0].data + 4) == browserTag);
|
||||
const uint8_t *ack = findChunk(&sent[0], 2);
|
||||
CHECK(ack && s.state == C4F_SCTP_COOKIE_WAIT);
|
||||
uint32_t ourTag = g32(ack + 4), ourTsn = g32(ack + 16);
|
||||
CHECK(ourTag == s.localTag && g16(ack + 20) == 7 && g16(ack + 22) == 20);
|
||||
uint8_t cookie[16];
|
||||
memcpy(cookie, ack + 24, 16);
|
||||
CHECK(g16(ack + 40) == 0x8008 && ack[44] == 0xC0 && ack[45] == 0x82 && g16(ack + 48) == 0xC000);
|
||||
|
||||
/* A wrong cookie is ignored; the right one establishes. */
|
||||
pkStart(&k, ourTag); v = pkChunk(&k, 10, 0, 16); memset(v, 0xAA, 16); pkSend(&s, &k, now);
|
||||
CHECK(s.state == C4F_SCTP_COOKIE_WAIT && sentCount == 1);
|
||||
pkStart(&k, ourTag); v = pkChunk(&k, 10, 0, 16); memcpy(v, cookie, 16); pkSend(&s, &k, now);
|
||||
CHECK(s.state == C4F_SCTP_ESTABLISHED && sentCount == 2 && findChunk(&sent[1], 11));
|
||||
|
||||
/* A packet under another tag, or with a bad checksum, is dropped. */
|
||||
pkStart(&k, 0x99999999); pkData(&k, 3, browserTsn, 0, 0, "nope"); pkSend(&s, &k, now);
|
||||
pkStart(&k, ourTag); pkData(&k, 3, browserTsn, 0, 0, "nope"); k.b[k.len - 1] ^= 0x55;
|
||||
c4fSctpReceive(&s, k.b, k.len, now);
|
||||
CHECK(deliveredCount == 0 && s.badChecksums == 1);
|
||||
|
||||
/* In order on stream 0, then a gap, then unordered on stream 1 delivered at once. */
|
||||
pkStart(&k, ourTag); pkData(&k, 3, browserTsn, 0, 0, "hello"); pkSend(&s, &k, now);
|
||||
CHECK(isDelivered(0, 0, "hello") && s.cumTsn == browserTsn);
|
||||
const uint8_t *sack = findChunk(&sent[sentCount - 1], 3);
|
||||
CHECK(sack && g32(sack + 4) == browserTsn && g16(sack + 12) == 0);
|
||||
pkStart(&k, ourTag); pkData(&k, 7, browserTsn + 2, 1, 0, "fast"); pkSend(&s, &k, now);
|
||||
CHECK(isDelivered(1, 1, "fast") && s.cumTsn == browserTsn);
|
||||
sack = findChunk(&sent[sentCount - 1], 3);
|
||||
CHECK(g32(sack + 4) == browserTsn && g16(sack + 12) == 1 && g16(sack + 16) == 2 && g16(sack + 18) == 2);
|
||||
pkStart(&k, ourTag); pkData(&k, 3, browserTsn + 1, 0, 1, "second"); pkSend(&s, &k, now);
|
||||
CHECK(isDelivered(2, 0, "second") && s.cumTsn == browserTsn + 2);
|
||||
/* The same TSN again: acknowledged, not delivered twice. */
|
||||
pkStart(&k, ourTag); pkData(&k, 3, browserTsn + 1, 0, 1, "second"); pkSend(&s, &k, now);
|
||||
CHECK(deliveredCount == 3 && s.duplicates == 1);
|
||||
|
||||
/* Out of order on the ordered stream: held until its turn. */
|
||||
pkStart(&k, ourTag); pkData(&k, 3, browserTsn + 4, 0, 3, "four"); pkSend(&s, &k, now);
|
||||
CHECK(deliveredCount == 3);
|
||||
pkStart(&k, ourTag); pkData(&k, 3, browserTsn + 3, 0, 2, "three"); pkSend(&s, &k, now);
|
||||
CHECK(isDelivered(3, 0, "three") && isDelivered(4, 0, "four") && s.cumTsn == browserTsn + 4);
|
||||
|
||||
/* A message in three pieces, arriving last, first, middle. */
|
||||
pkStart(&k, ourTag); pkData(&k, 1, browserTsn + 7, 0, 4, "ghi"); pkSend(&s, &k, now);
|
||||
pkStart(&k, ourTag); pkData(&k, 2, browserTsn + 5, 0, 4, "abc"); pkSend(&s, &k, now);
|
||||
CHECK(deliveredCount == 5);
|
||||
pkStart(&k, ourTag); pkData(&k, 0, browserTsn + 6, 0, 4, "def"); pkSend(&s, &k, now);
|
||||
CHECK(isDelivered(5, 0, "abcdefghi") && s.inBytes == 0);
|
||||
|
||||
/* FORWARD-TSN skips an unordered piece that will never be finished. */
|
||||
pkStart(&k, ourTag); pkData(&k, 6, browserTsn + 8, 1, 0, "lost-fir"); pkSend(&s, &k, now);
|
||||
pkStart(&k, ourTag); v = pkChunk(&k, 192, 0, 4); p32(v, browserTsn + 9); pkSend(&s, &k, now);
|
||||
CHECK(s.cumTsn == browserTsn + 9 && s.inBytes == 0 && deliveredCount == 6);
|
||||
pkStart(&k, ourTag); pkData(&k, 7, browserTsn + 10, 1, 0, "after"); pkSend(&s, &k, now);
|
||||
CHECK(isDelivered(6, 1, "after"));
|
||||
|
||||
/* Sending: reliable and in order on stream 0, acknowledged by a SACK. */
|
||||
int before = sentCount;
|
||||
CHECK(c4fSctpSend(&s, 0, C4F_SCTP_PPID_STRING, (const uint8_t *)"reply", 5, now) == 0);
|
||||
CHECK(sentCount == before + 1);
|
||||
const uint8_t *data = findChunk(&sent[before], 0);
|
||||
CHECK(data && data[1] == 3 && g32(data + 4) == ourTsn && g16(data + 8) == 0 && g16(data + 10) == 0 &&
|
||||
g32(data + 12) == C4F_SCTP_PPID_STRING && !memcmp(data + 16, "reply", 5));
|
||||
CHECK(c4fSctpDeadline(&s) == now + 200);
|
||||
pkStart(&k, ourTag); v = pkChunk(&k, 3, 0, 12); p32(v, ourTsn); p32(v + 4, 131072); pkSend(&s, &k, now + 5);
|
||||
CHECK(s.out == NULL && c4fSctpDeadline(&s) == 0);
|
||||
|
||||
/* Not acknowledged: resent, same TSN, after the timeout. */
|
||||
now += 1000;
|
||||
before = sentCount;
|
||||
CHECK(c4fSctpSend(&s, 0, C4F_SCTP_PPID_STRING, (const uint8_t *)"again", 5, now) == 0);
|
||||
uint64_t due = c4fSctpDeadline(&s);
|
||||
CHECK(due > now && due <= now + 200);
|
||||
c4fSctpTick(&s, due - 1);
|
||||
CHECK(sentCount == before + 1);
|
||||
c4fSctpTick(&s, due);
|
||||
CHECK(sentCount == before + 2 && s.resent == 1);
|
||||
data = findChunk(&sent[before + 1], 0);
|
||||
CHECK(data && g32(data + 4) == ourTsn + 1 && !memcmp(data + 16, "again", 5));
|
||||
pkStart(&k, ourTag); v = pkChunk(&k, 3, 0, 12); p32(v, ourTsn + 1); p32(v + 4, 131072); pkSend(&s, &k, due + 1);
|
||||
CHECK(s.out == NULL);
|
||||
|
||||
/* Unordered on stream 1 is never resent: given up on, then skipped with FORWARD-TSN. */
|
||||
now += 1000;
|
||||
before = sentCount;
|
||||
CHECK(c4fSctpSend(&s, 1, C4F_SCTP_PPID_STRING, (const uint8_t *)"rumble", 6, now) == 0);
|
||||
data = findChunk(&sent[before], 0);
|
||||
CHECK(data && data[1] == 7);
|
||||
due = c4fSctpDeadline(&s);
|
||||
c4fSctpTick(&s, due);
|
||||
const uint8_t *fwd = findChunk(&sent[sentCount - 1], 192);
|
||||
CHECK(s.abandoned == 1 && fwd && g32(fwd + 4) == ourTsn + 2 && !findChunk(&sent[sentCount - 1], 0));
|
||||
pkStart(&k, ourTag); v = pkChunk(&k, 3, 0, 12); p32(v, ourTsn + 2); p32(v + 4, 131072); pkSend(&s, &k, due + 1);
|
||||
CHECK(s.out == NULL && c4fSctpDeadline(&s) == 0);
|
||||
|
||||
/* Three newly acknowledged later TSNs trigger fast retransmit. Repeated
|
||||
* copies of an old SACK do not manufacture more evidence of a hole. */
|
||||
now += 1000;
|
||||
c4fSctpSend(&s, 0, C4F_SCTP_PPID_STRING, (const uint8_t *)"A", 1, now);
|
||||
c4fSctpSend(&s, 0, C4F_SCTP_PPID_STRING, (const uint8_t *)"B", 1, now);
|
||||
c4fSctpSend(&s, 0, C4F_SCTP_PPID_STRING, (const uint8_t *)"C", 1, now);
|
||||
c4fSctpSend(&s, 0, C4F_SCTP_PPID_STRING, (const uint8_t *)"D", 1, now);
|
||||
before = sentCount;
|
||||
for (int i = 0; i < 3; i++) {
|
||||
pkStart(&k, ourTag); v = pkChunk(&k, 3, 0, 16); p32(v, ourTsn + 2); p32(v + 4, 131072); p16(v + 8, 1); p16(v + 12, 2); p16(v + 14, 2);
|
||||
pkSend(&s, &k, now + 10);
|
||||
}
|
||||
CHECK(s.resent == 1 && sentCount == before);
|
||||
for (int i = 3; i <= 4; i++) {
|
||||
pkStart(&k, ourTag); v = pkChunk(&k, 3, 0, 16); p32(v, ourTsn + 2); p32(v + 4, 131072);
|
||||
p16(v + 8, 1); p16(v + 12, 2); p16(v + 14, (uint16_t)i); pkSend(&s, &k, now + 10);
|
||||
}
|
||||
CHECK(s.resent == 2 && sentCount == before + 1);
|
||||
data = findChunk(&sent[before], 0);
|
||||
CHECK(data && g32(data + 4) == ourTsn + 3 && data[16] == 'A');
|
||||
pkStart(&k, ourTag); v = pkChunk(&k, 3, 0, 12); p32(v, ourTsn + 6); p32(v + 4, 131072); pkSend(&s, &k, now + 20);
|
||||
CHECK(s.out == NULL);
|
||||
|
||||
/* A message bigger than a packet goes in pieces, the same SSN on each. */
|
||||
char big[3000];
|
||||
for (size_t i = 0; i < sizeof(big); i++) big[i] = (char)('a' + i % 26);
|
||||
before = sentCount;
|
||||
CHECK(c4fSctpSend(&s, 0, C4F_SCTP_PPID_STRING, (const uint8_t *)big, sizeof(big), now) == 0);
|
||||
CHECK(sentCount == before + 3);
|
||||
size_t total = 0;
|
||||
for (int i = 0; i < 3; i++) {
|
||||
data = findChunk(&sent[before + i], 0);
|
||||
CHECK(data && g16(data + 10) == 6);
|
||||
CHECK((data[1] & 3) == (i == 0 ? 2 : i == 2 ? 1 : 0));
|
||||
total += g16(data + 2) - 16u;
|
||||
}
|
||||
CHECK(total == sizeof(big));
|
||||
pkStart(&k, ourTag); v = pkChunk(&k, 3, 0, 12); p32(v, ourTsn + 9); p32(v + 4, 131072); pkSend(&s, &k, now + 30);
|
||||
CHECK(s.out == NULL);
|
||||
|
||||
/* HEARTBEAT is echoed; a stream reset is performed and answered. */
|
||||
pkStart(&k, ourTag); v = pkChunk(&k, 4, 0, 8); p16(v, 1); p16(v + 2, 8); memcpy(v + 4, "beat", 4); pkSend(&s, &k, now);
|
||||
const uint8_t *hb = findChunk(&sent[sentCount - 1], 5);
|
||||
CHECK(hb && !memcmp(hb + 8, "beat", 4));
|
||||
pkStart(&k, ourTag); v = pkChunk(&k, 130, 0, 18);
|
||||
p16(v, 13); p16(v + 2, 18); p32(v + 4, 777); p32(v + 8, 0); p32(v + 12, browserTsn + 10); p16(v + 16, 0);
|
||||
pkSend(&s, &k, now);
|
||||
const uint8_t *rc = findChunk(&sent[sentCount - 1], 130);
|
||||
CHECK(rc && g16(rc + 4) == 16 && g32(rc + 8) == 777 && g32(rc + 12) == 1 && s.nextSsn[0] == 0);
|
||||
|
||||
/* ABORT ends it. */
|
||||
pkStart(&k, ourTag); pkChunk(&k, 6, 0, 0); pkSend(&s, &k, now);
|
||||
CHECK(s.state == C4F_SCTP_ENDED && c4fSctpSend(&s, 0, 51, (const uint8_t *)"x", 1, now) == -1);
|
||||
c4fSctpFree(&s);
|
||||
puts("PASS sctp: INIT/cookie, tags, checksums, ordering, gaps, reassembly, FORWARD-TSN, resend, give-up, fast resend, fragments, heartbeat, reset, abort");
|
||||
}
|
||||
|
||||
static void openAssociation(C4fSctp *s, uint64_t now)
|
||||
{
|
||||
C4fSctpOps ops = { NULL, opSend, opDeliver, opRandom };
|
||||
sentCount = deliveredCount = rejectSend = 0;
|
||||
c4fSctpInit(s, &ops);
|
||||
Pkt k;
|
||||
pkStart(&k, 0);
|
||||
uint8_t *v = pkChunk(&k, 1, 0, 16);
|
||||
p32(v, 0xabcdef01); p32(v + 4, 131072); p16(v + 8, 2); p16(v + 10, 2); p32(v + 12, 1000);
|
||||
pkSend(s, &k, now);
|
||||
pkStart(&k, s->localTag); v = pkChunk(&k, 10, 0, 16); memcpy(v, s->cookie, 16); pkSend(s, &k, now);
|
||||
CHECK(s->state == C4F_SCTP_ESTABLISHED);
|
||||
sentCount = 0;
|
||||
}
|
||||
|
||||
static void acknowledge(C4fSctp *s, uint32_t cum, uint32_t window, uint64_t now)
|
||||
{
|
||||
Pkt k;
|
||||
pkStart(&k, s->localTag);
|
||||
uint8_t *v = pkChunk(&k, 3, 0, 12);
|
||||
p32(v, cum); p32(v + 4, window); pkSend(s, &k, now);
|
||||
}
|
||||
|
||||
static void testWindows(void)
|
||||
{
|
||||
C4fSctp s;
|
||||
uint64_t now = 1000;
|
||||
openAssociation(&s, now);
|
||||
uint32_t first = s.nextTsn;
|
||||
char big[C4F_SCTP_MAX_MESSAGE];
|
||||
memset(big, 'q', sizeof(big));
|
||||
CHECK(c4fSctpSend(&s, 0, 51, (const uint8_t *)big, sizeof(big), now) == 0);
|
||||
CHECK(sentCount > 0 && sentCount <= 4 && s.flightBytes <= s.cwnd && s.outBytes == sizeof(big));
|
||||
CHECK(s.highestSent < s.nextTsn - 1); /* remaining fragments queued, not falsely sent */
|
||||
uint32_t beforeSeq = s.nextTsn;
|
||||
CHECK(c4fSctpSend(&s, 1, 51, (const uint8_t *)"stale", 5, now) == -1 && s.nextTsn == beforeSeq);
|
||||
int before = sentCount;
|
||||
acknowledge(&s, s.nextTsn - 1, 131072, now + 1); /* cannot ACK unsent queued data */
|
||||
CHECK(sentCount == before && s.peerCumTsn == first - 1);
|
||||
size_t bytes = 0;
|
||||
for (unsigned tries = 0; s.out && tries < 32; tries++) {
|
||||
acknowledge(&s, s.highestSent, 131072, ++now);
|
||||
CHECK(s.flightBytes <= s.cwnd);
|
||||
}
|
||||
CHECK(!s.out && s.outBytes == 0 && s.flightBytes == 0 && !c4fSctpDeadline(&s));
|
||||
for (int i = 0; i < sentCount; i++) {
|
||||
const uint8_t *data = findChunk(&sent[i], 0);
|
||||
CHECK(data); bytes += g16(data + 2) - 16u;
|
||||
}
|
||||
CHECK(bytes == sizeof(big));
|
||||
c4fSctpFree(&s);
|
||||
|
||||
/* A large already-open window still emits a bounded burst, but schedules
|
||||
* its continuation promptly instead of waiting for the retransmit timer. */
|
||||
openAssociation(&s, now);
|
||||
s.cwnd = 30000;
|
||||
CHECK(c4fSctpSend(&s, 0, 51, (const uint8_t *)big, sizeof(big), now) == 0 && sentCount == 4);
|
||||
CHECK(c4fSctpDeadline(&s) == now + 1);
|
||||
c4fSctpTick(&s, now + 1); CHECK(sentCount == 8);
|
||||
c4fSctpFree(&s);
|
||||
|
||||
/* Receive-window closure buffers reliable control and rejects replaceable
|
||||
* state. A timed probe prevents a lost window update from deadlocking it. */
|
||||
openAssociation(&s, now);
|
||||
acknowledge(&s, s.highestSent, 0, now);
|
||||
first = s.nextTsn;
|
||||
CHECK(c4fSctpSend(&s, 0, 51, (const uint8_t *)big, 2000, now) == 0 && sentCount == 0);
|
||||
CHECK(c4fSctpSend(&s, 1, 51, (const uint8_t *)"stale", 5, now) == -1);
|
||||
uint64_t due = c4fSctpDeadline(&s);
|
||||
CHECK(due == now + s.rto);
|
||||
c4fSctpTick(&s, due - 1); CHECK(sentCount == 0);
|
||||
c4fSctpTick(&s, due); CHECK(sentCount == 1 && s.highestSent == first);
|
||||
acknowledge(&s, first - 1, 0, due + 1); CHECK(sentCount == 1);
|
||||
acknowledge(&s, first, 131072, due + 2); CHECK(sentCount == 2);
|
||||
acknowledge(&s, s.highestSent, 131072, due + 3); CHECK(!s.out && !s.flightBytes);
|
||||
|
||||
/* A too-small positive receive window is also respected before the probe. */
|
||||
acknowledge(&s, s.highestSent, 20, due + 4);
|
||||
before = sentCount;
|
||||
CHECK(c4fSctpSend(&s, 0, 51, (const uint8_t *)big, 500, due + 4) == 0 && sentCount == before);
|
||||
acknowledge(&s, s.highestSent, 131072, due + 5); CHECK(sentCount == before + 1);
|
||||
acknowledge(&s, s.highestSent, 131072, due + 6);
|
||||
c4fSctpFree(&s);
|
||||
|
||||
openAssociation(&s, now);
|
||||
CHECK(c4fSctpSend(&s, 0, 51, (const uint8_t *)big, 3000, now) == 0);
|
||||
before = sentCount; due = c4fSctpDeadline(&s);
|
||||
c4fSctpTick(&s, due);
|
||||
CHECK(s.cwnd == C4F_SCTP_MTU && sentCount == before + 1 && s.resent == 1);
|
||||
c4fSctpFree(&s);
|
||||
|
||||
/* The output callback cannot silently reject reliable DATA. */
|
||||
openAssociation(&s, now);
|
||||
CHECK(c4fSctpSend(&s, 0, 51, (const uint8_t *)"keep", 4, now) == 0);
|
||||
uint32_t savedTag = s.localTag, savedNext = s.nextTsn;
|
||||
size_t savedBytes = s.outBytes;
|
||||
Pkt retry;
|
||||
pkStart(&retry, 0);
|
||||
uint8_t *init = pkChunk(&retry, 1, 0, 16);
|
||||
p32(init, 0xabcdef01); p32(init + 4, 131072); p16(init + 8, 2); p16(init + 10, 2); p32(init + 12, 1000);
|
||||
pkSend(&s, &retry, now + 1);
|
||||
const uint8_t *initAck = findChunk(&sent[sentCount - 1], 2);
|
||||
CHECK(initAck && s.state == C4F_SCTP_ESTABLISHED && s.localTag == savedTag && s.nextTsn == savedNext &&
|
||||
s.outBytes == savedBytes && g32(initAck + 16) == s.localInitialTsn);
|
||||
/* A declared chunk beyond the datagram boundary cannot reset the peer. */
|
||||
pkStart(&retry, 0); init = pkChunk(&retry, 1, 0, 16); p16(init - 2, 65535);
|
||||
pkSend(&s, &retry, now + 2);
|
||||
CHECK(s.state == C4F_SCTP_ESTABLISHED && s.localTag == savedTag && s.outBytes == savedBytes);
|
||||
rejectSend = 1;
|
||||
CHECK(c4fSctpSend(&s, 0, 51, (const uint8_t *)"fatal", 5, now) == -1 && s.state == C4F_SCTP_ENDED);
|
||||
rejectSend = 0;
|
||||
c4fSctpFree(&s);
|
||||
puts("PASS sctp windows: bounded congestion, queued fragments, receive credit/probes, stale-send rejection, timeout burst and send failures");
|
||||
}
|
||||
|
||||
int main(void)
|
||||
{
|
||||
testStun();
|
||||
testSctp();
|
||||
testWindows();
|
||||
return 0;
|
||||
}
|
||||
@@ -10,7 +10,8 @@ import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SUITES = ['test_logging', 'test_frontend', 'test_web', 'test_recovery', 'test_launcher']
|
||||
SUITES = ['test_logging', 'test_frontend', 'test_web', 'test_recovery', 'test_launcher',
|
||||
'test_rtc', 'test_text_dependencies', 'test_launcher_text']
|
||||
|
||||
|
||||
def main():
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
"""The page's own claim and Gamepad functions, run under Node."""
|
||||
"""The page's own claim, Gamepad and language functions, run under Node."""
|
||||
import subprocess
|
||||
|
||||
|
||||
def main():
|
||||
subprocess.run(['node', 'tests/frontend_test.cjs'], check=True, timeout=30)
|
||||
subprocess.run(['node', 'tests/mapping_test.cjs'], check=True, timeout=30)
|
||||
subprocess.run(['node', 'tests/i18n_test.cjs'], check=True, timeout=30)
|
||||
subprocess.run(['python3', '-B', 'tests/release_notes_test.py'], check=True, timeout=10)
|
||||
|
||||
|
||||
|
||||
@@ -45,8 +45,40 @@ def one_reply(address, reply, check=None):
|
||||
return thread
|
||||
|
||||
|
||||
def check_latency_status():
|
||||
def probe(body):
|
||||
raw = body if isinstance(body, bytes) else json.dumps(body, separators=(',', ':')).encode()
|
||||
thread = one_reply('127.0.0.1', b'HTTP/1.1 200 OK\r\nContent-Length: %d\r\nConnection: close\r\n\r\n' % len(raw) + raw)
|
||||
result = command('latency')
|
||||
thread.join(timeout=3)
|
||||
assert not thread.is_alive()
|
||||
return result
|
||||
|
||||
assert probe(STATUS).startswith('1 0 '), 'old services must still be recognized'
|
||||
state = dict(json.loads(STATUS), latency=True)
|
||||
for slot, (user, rtt, age) in enumerate(((0x1A2B3C4D, 8, 12), (0x1A2B3C4D, 11, 0),
|
||||
(0x7FFFFFFE, -1, -1), (-1, -1, -1))):
|
||||
state.update({f'pad{slot}User': user, f'pad{slot}Rtt': rtt, f'pad{slot}Age': age})
|
||||
answer = probe(state).split()
|
||||
assert answer[:2] == ['1', '1'], answer
|
||||
assert answer[2:] == [f'1:{0x1A2B3C4D}:8:12', f'1:{0x1A2B3C4D}:11:0',
|
||||
f'1:{0x7FFFFFFE}:-1:-1', '0:0:-1:-1'], answer
|
||||
incomplete = dict(state); del incomplete['pad3Age']
|
||||
assert probe(incomplete).startswith('1 0 '), 'incomplete optional telemetry must not break service control'
|
||||
for key, value in (('pad0User', 4294967296), ('pad0Rtt', -2), ('pad0Rtt', 60001),
|
||||
('pad0Rtt', '8'), ('pad0Age', 2147483648), ('latency', 1)):
|
||||
invalid = dict(state); invalid[key] = value
|
||||
assert probe(invalid).startswith('-1 '), (key, value)
|
||||
duplicate = json.dumps(state, separators=(',', ':')).encode()[:-1] + b',"pad0Rtt":9}'
|
||||
assert probe(duplicate).startswith('-1 ')
|
||||
zero = dict(state, pad0Rtt=0, pad0Age=0)
|
||||
assert f'1:{0x1A2B3C4D}:0:0' in probe(zero), 'a measured sub-ms RTT can round to zero'
|
||||
print('PASS launcher connection RTT: optional old API, four slots, identity, unknown values and malformed telemetry', flush=True)
|
||||
|
||||
|
||||
def main():
|
||||
web.build()
|
||||
subprocess.run([sys.executable, '-B', 'tests/test_launcher_sampling.py'], check=True)
|
||||
# The launcher's own input reader and action navigation, with only the
|
||||
# native PS4 pad calls replaced. This also exercises a remote-only launch.
|
||||
for diagnostic in (False, True):
|
||||
@@ -55,6 +87,17 @@ def main():
|
||||
*(['-DC4F_DIAG'] if diagnostic else []), '-Ilauncher', 'launcher/input.c',
|
||||
'launcher/navigation.c', 'tests/launcher_input_test.c', '-o', binary], check=True)
|
||||
subprocess.run([binary], check=True)
|
||||
# The controller test's logic, against simulated users and pads.
|
||||
binary = 'build/launcher-tester-diag-host-test' if diagnostic else 'build/launcher-tester-host-test'
|
||||
subprocess.run(['clang-18', '-std=gnu11', '-D_DEFAULT_SOURCE', '-Wall', '-Wextra', '-Werror',
|
||||
*(['-DC4F_DIAG'] if diagnostic else []), '-Ilauncher', 'launcher/tester.c',
|
||||
'launcher/navigation.c', 'tests/launcher_tester_test.c', '-lm', '-o', binary], check=True)
|
||||
subprocess.run([binary], check=True)
|
||||
# The app's Portuguese table and when it is used.
|
||||
subprocess.run(['clang-18', '-std=gnu11', '-D_DEFAULT_SOURCE', '-Wall', '-Wextra', '-Werror', '-Ilauncher',
|
||||
'tests/launcher_language_test.c', 'launcher/message.c', '-o', 'build/launcher-language-host-test'], check=True)
|
||||
subprocess.run(['build/launcher-language-host-test'], check=True)
|
||||
subprocess.run([sys.executable, '-B', 'tests/test_launcher_language_render.py'], check=True)
|
||||
subprocess.run(['clang-18', '-std=gnu11', '-D_DEFAULT_SOURCE', '-Wall', '-Wextra', '-Werror',
|
||||
'-ffunction-sections', '-fdata-sections', '-Ilauncher', '-Itests/include',
|
||||
'tests/diag_test.c', '-Wl,--gc-sections,--wrap=write,--wrap=fsync', '-pthread',
|
||||
@@ -117,6 +160,7 @@ def main():
|
||||
assert out.startswith('-1 ') and '127.0.0.1 refused; 127.0.0.2 HTTP 403' in out, out
|
||||
assert command('probe', C4F_TEST_HOST='127.0.0.2').startswith('0 ')
|
||||
print('PASS console-address fallback, with the reason when neither path answers', flush=True)
|
||||
check_latency_status()
|
||||
|
||||
received = bytearray()
|
||||
child = []
|
||||
@@ -185,9 +229,16 @@ def main():
|
||||
sys.path.insert(0, str(ROOT / 'tools'))
|
||||
import package_launcher as package
|
||||
package.build_art_tool()
|
||||
# A fresh renderer is the reference for every warm cache transition; no
|
||||
# timing limit here depends on the speed of the CI machine.
|
||||
import test_tester_render
|
||||
test_tester_render.check(test_tester_render.build())
|
||||
for state in ('setup', 'running', 'stopped', 'payloader-unavailable', 'outdated', 'mismatch', 'confirm', 'busy', 'locked', 'unknown',
|
||||
'remote-actions-stopped', 'remote-actions-running', 'remote-confirm-cancel',
|
||||
'remote-confirm-stop', 'remote-actions-busy', 'remote-actions-unavailable'):
|
||||
'remote-confirm-stop', 'remote-actions-busy', 'remote-actions-unavailable',
|
||||
'tester', 'tester-motion', 'tester-menu', 'tester-remote', 'tester-disconnected', 'tester-error',
|
||||
'tester-menu-user', 'tester-menu-unavailable', 'tester-menu-error', 'tester-menu-output', 'tester-menu-no-users',
|
||||
'tester-color', 'tester-color-green', 'tester-color-fine', 'tester-color-black', 'tester-color-white', 'tester-color-error'):
|
||||
png = ROOT / f'build/launcher-preview-{state}.png'
|
||||
package.render(['screen', state], png, 1920, 1080)
|
||||
assert png.stat().st_size > 50000, png
|
||||
@@ -198,6 +249,7 @@ def main():
|
||||
assert png.stat().st_size > 50000, png
|
||||
package.render(['icon', '512'], ROOT / 'build/launcher-icon.png', 512, 512)
|
||||
assert subprocess.run([str(package.ART), 'screen', 'nonsense', '/dev/null']).returncode != 0
|
||||
assert subprocess.run([str(package.ART), 'screen', 'tester-nonsense', '/dev/null']).returncode != 0
|
||||
# The icon the payload serves is committed, so the payload build needs no
|
||||
# renderer. Catch it drifting away from the app's own icon.
|
||||
fresh = ROOT / 'build/icon-192.png'
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Language changes must invalidate every text-bearing tester cache."""
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
import test_tester_render
|
||||
|
||||
|
||||
def main():
|
||||
binary = test_tester_render.build(
|
||||
output=Path('build/launcher/language-render-test').resolve(),
|
||||
fixture='tests/launcher_language_render_test.c', extra_sources=['launcher/language.c'])
|
||||
subprocess.run([str(binary), 'names'], check=True, timeout=30)
|
||||
for language in ('en', 'pt', 'ar'):
|
||||
for variant in range(6):
|
||||
fresh = subprocess.check_output([str(binary), language, 'fresh', str(variant)], timeout=60)
|
||||
warm = subprocess.check_output([str(binary), language, 'warm', str(variant)], timeout=60)
|
||||
assert fresh == warm, (language, variant, fresh, warm)
|
||||
print('PASS all RGB focus/precision variants match fresh rendering after both language-switch directions', flush=True)
|
||||
for language in ('pt', 'ar'):
|
||||
expected = []
|
||||
for variant in range(4):
|
||||
fresh = subprocess.check_output([str(binary), 'metadata', language, 'fresh', str(variant)], timeout=60)
|
||||
warm = subprocess.check_output([str(binary), 'metadata', language, 'warm', str(variant)], timeout=60)
|
||||
assert fresh == warm, ('metadata', language, variant, fresh, warm)
|
||||
expected.append(fresh)
|
||||
assert expected[0] != expected[1], ('generated name was not visibly distinct', language)
|
||||
assert expected[2] != expected[3], ('message argument provenance was not visibly distinct', language)
|
||||
print('PASS generated names and message provenance invalidate tools cache in both directions', flush=True)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Exercise the shipped sampler independently of the native display APIs."""
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def main():
|
||||
source = (ROOT / 'launcher/main.cpp').read_text()
|
||||
commands_at = source.index('enum { C4F_DO_NOTHING')
|
||||
commands = source[commands_at:source.index('};', commands_at) + 2]
|
||||
start = source.index('static void *c4fInputWorker(')
|
||||
stop = source.index('\nint main(void)', start)
|
||||
out = ROOT / 'build/launcher-sampling-source.inc'
|
||||
out.parent.mkdir(exist_ok=True)
|
||||
out.write_text(commands + '\n' + source[start:stop])
|
||||
objects = []
|
||||
for name in ('input', 'tester', 'navigation'):
|
||||
obj = f'build/sampling-{name}.o'
|
||||
subprocess.run(['clang-18', '-std=gnu11', '-D_DEFAULT_SOURCE', '-Wall', '-Wextra', '-Werror',
|
||||
'-Ilauncher', '-c', f'launcher/{name}.c', '-o', obj], cwd=ROOT, check=True)
|
||||
objects.append(obj)
|
||||
subprocess.run(['clang++-18', '-std=c++17', '-D_DEFAULT_SOURCE', '-Wall', '-Wextra', '-Werror',
|
||||
'-Ilauncher', '-Ibuild', 'tests/launcher_sampling_test.cpp', *objects,
|
||||
'-pthread', '-lm', '-o', 'build/launcher-sampling-test'], cwd=ROOT, check=True)
|
||||
subprocess.run(['build/launcher-sampling-test'], cwd=ROOT, check=True, timeout=10)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Build and test the shipped Arabic renderer, entirely inside Docker."""
|
||||
import subprocess
|
||||
import sys
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('--font-dir', type=Path, help='Optional privately supplied PS4 fonts; never bundled')
|
||||
args = parser.parse_args()
|
||||
sys.path.insert(0, str(ROOT / 'tools'))
|
||||
import package_launcher as package
|
||||
package.embed_fonts()
|
||||
includes, libraries = package.text_host_flags()
|
||||
output = ROOT / 'build/launcher/text-render-host-test'
|
||||
flags = ['clang-18', '-std=gnu11', '-O2', '-D_DEFAULT_SOURCE', '-Ilauncher', '-Ivendor/stb', *includes]
|
||||
stb = output.with_suffix('.stb.o')
|
||||
subprocess.run([*flags, '-w', '-c', 'launcher/stb_truetype.c', '-o', str(stb)], cwd=ROOT, check=True)
|
||||
subprocess.run([*flags, '-Wall', '-Wextra', '-Werror', 'tests/launcher_text_test.c',
|
||||
'launcher/draw.c', str(stb), str(package.FONTS), *libraries, '-lm', '-o', str(output)], cwd=ROOT, check=True)
|
||||
subprocess.run([str(output)], cwd=ROOT, check=True, timeout=30)
|
||||
if args.font_dir:
|
||||
subprocess.run([str(output), str(args.font_dir / 'SSTArabic-Light.otf'),
|
||||
str(args.font_dir / 'SSTArabic-Roman.otf')], cwd=ROOT, check=True, timeout=30)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -7,6 +7,7 @@ import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
import test_web as web
|
||||
import mbedtls_host
|
||||
|
||||
|
||||
def connect_again():
|
||||
@@ -21,8 +22,9 @@ def connect_again():
|
||||
|
||||
def main():
|
||||
subprocess.run(['clang-18', '-std=gnu11', '-Wall', '-Wextra', '-Werror', '-g',
|
||||
'-Iinclude', '-Ivendor/jsmn', '-Ivendor/qrcodegen', 'src/net.c', 'src/web.c', 'src/klog_line.c', 'vendor/qrcodegen/qrcodegen.c',
|
||||
'tests/web_stub.c', 'tests/recovery_faults.c', 'build/client.c', 'build/assets.c', '-pthread',
|
||||
'-Iinclude', '-Ivendor/jsmn', '-Ivendor/qrcodegen', '-DC4F_TEST_WEBSOCKET', *mbedtls_host.FLAGS,
|
||||
'src/net.c', 'src/web.c', 'src/rtc.c', 'src/stun.c', 'src/sctp.c', 'src/klog_line.c', 'vendor/qrcodegen/qrcodegen.c',
|
||||
'tests/web_stub.c', 'tests/recovery_faults.c', 'build/client.c', 'build/assets.c', mbedtls_host.library(), '-pthread',
|
||||
'-Wl,--wrap=select,--wrap=accept,--wrap=getsockopt', '-o', str(web.BINARY)], check=True)
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
fault = Path(directory) / 'fault'
|
||||
@@ -48,7 +50,8 @@ def main():
|
||||
assert server.c.request('claim', [0])['result']['pads'][0]['mine']
|
||||
rows = server.rows()
|
||||
split = next(i for i, row in enumerate(rows) if 'network failed' in row)
|
||||
assert all(row.split()[3] == '0' for row in rows[split:] if row.startswith('FRAME'))
|
||||
held = [row for row in rows[split:] if row.startswith('FRAME') and row.split()[3] != '0']
|
||||
assert not held, f'{name}: non-neutral frames after network reset:\n' + '\n'.join(held)
|
||||
assert len([r for r in rows if r.startswith('ADD')]) == 1
|
||||
assert any('listener recovered' in row for row in rows)
|
||||
print(f'PASS {name} failure: listener returns, old buttons cleared, no duplicate pad', flush=True)
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
"""RTC codecs and the production HTTP surface, built and run inside Docker."""
|
||||
import os
|
||||
from pathlib import Path
|
||||
import socket
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
import mbedtls_host
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
BINARY = ROOT / 'build/web-rtc-host-test'
|
||||
FLAGS = ['clang-18', '-std=gnu11', '-Wall', '-Wextra', '-Werror', '-g',
|
||||
'-Iinclude', '-Ivendor/jsmn', '-Ivendor/qrcodegen', *mbedtls_host.FLAGS]
|
||||
SOURCES = ['src/net.c', 'src/web.c', 'src/rtc.c', 'src/stun.c', 'src/sctp.c',
|
||||
'src/klog_line.c', 'vendor/qrcodegen/qrcodegen.c', 'tests/web_stub.c',
|
||||
'build/client.c', 'build/assets.c']
|
||||
|
||||
|
||||
def build():
|
||||
subprocess.run(['python3', 'tools/embed_client.py', 'client/index.html', 'build/client.c'], check=True)
|
||||
subprocess.run(['python3', 'tools/embed_file.py', 'build/assets.c',
|
||||
'c4fManifest=client/manifest.webmanifest', 'c4fIcon=client/icon-192.png'], check=True)
|
||||
library = mbedtls_host.library()
|
||||
subprocess.run([*FLAGS, 'src/stun.c', 'src/sctp.c', 'tests/rtc_test.c', library,
|
||||
'-o', 'build/rtc-test'], check=True)
|
||||
subprocess.run(['build/rtc-test'], check=True, timeout=30)
|
||||
subprocess.run([*FLAGS, 'src/stun.c', 'src/sctp.c', 'tests/rtc_io_test.c', library,
|
||||
'-o', 'build/rtc-io-test'], check=True)
|
||||
subprocess.run(['build/rtc-io-test'], check=True, timeout=30)
|
||||
# No C4F_TEST_WEBSOCKET: browser checks exercise the production protocol.
|
||||
subprocess.run([*FLAGS, *SOURCES, library, '-pthread', '-o', str(BINARY)], check=True)
|
||||
|
||||
|
||||
def http(request):
|
||||
with socket.create_connection(('127.0.0.1', 4264), timeout=3) as connection:
|
||||
connection.sendall(request)
|
||||
result = bytearray()
|
||||
while chunk := connection.recv(65536):
|
||||
result.extend(chunk)
|
||||
return bytes(result)
|
||||
|
||||
|
||||
def main():
|
||||
build()
|
||||
with tempfile.TemporaryFile() as trace:
|
||||
service = subprocess.Popen([str(BINARY)], stdin=subprocess.PIPE, stdout=trace, stderr=trace,
|
||||
env={**os.environ, 'C4F_TEST_KLOG_FD': '0'})
|
||||
try:
|
||||
deadline = time.monotonic() + 5
|
||||
while True:
|
||||
try:
|
||||
page = http(b'GET / HTTP/1.1\r\nHost: 127.0.0.1:4264\r\n\r\n')
|
||||
break
|
||||
except OSError:
|
||||
if time.monotonic() > deadline:
|
||||
trace.seek(0)
|
||||
raise AssertionError(trace.read().decode(errors='replace'))
|
||||
time.sleep(.03)
|
||||
assert page.startswith(b'HTTP/1.1 200')
|
||||
headers = page.split(b'\r\n\r\n', 1)[0]
|
||||
assert b"connect-src 'self';" in headers and b'ws:' not in headers
|
||||
upgrade = (b'GET /ws HTTP/1.1\r\nHost: 127.0.0.1:4264\r\nUpgrade: websocket\r\n'
|
||||
b'Connection: Upgrade\r\nSec-WebSocket-Version: 13\r\n'
|
||||
b'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\r\n')
|
||||
assert http(upgrade).startswith(b'HTTP/1.1 404'), 'production accepted a WebSocket'
|
||||
request = b'POST /rtc HTTP/1.1\r\nHost: 127.0.0.1:4264\r\n'
|
||||
assert b'403' in http(request + b'Origin: https://evil.example\r\nContent-Length: 3\r\n\r\nv=0')
|
||||
assert b'400' in http(request + b'Content-Length: 3\r\n\r\nv=0')
|
||||
saved_error = http(request + b'Origin: null\r\nContent-Length: 3\r\n\r\nv=0')
|
||||
assert b'400' in saved_error and b'Access-Control-Allow-Origin: null' in saved_error
|
||||
assert b'400' in http(request + b'Content-Length: 3\r\nContent-Length: 3\r\n\r\nv=0')
|
||||
assert b'400' in http(request + b'Transfer-Encoding: chunked\r\n\r\n0\r\n\r\n')
|
||||
assert b'413' in http(request + b'Content-Length: 65535\r\n\r\n')
|
||||
assert b'400' in http(request + b'Origin: null\r\nOrigin: null\r\nContent-Length: 3\r\n\r\nv=0')
|
||||
preflight = http(b'OPTIONS /rtc HTTP/1.1\r\nHost: 127.0.0.1:4264\r\nOrigin: null\r\n\r\n')
|
||||
assert b'204 No Content' in preflight and b'Access-Control-Allow-Origin: null' in preflight
|
||||
assert b'Access-Control-Allow-Private-Network: true' in preflight
|
||||
assert b'403' in http(b'OPTIONS /rtc HTTP/1.1\r\nHost: 127.0.0.1:4264\r\nOrigin: https://evil.example\r\n\r\n')
|
||||
status = http(b'GET /api/status HTTP/1.1\r\nHost: 127.0.0.1:4264\r\nX-Control4Free-Launcher: 1\r\n\r\n')
|
||||
assert b'200 OK' in status and b'"application":"Control4Free"' in status
|
||||
print('PASS production transport: HTTP page/API, WebSocket refused, RTC origin/body bounds and saved-page preflight', flush=True)
|
||||
finally:
|
||||
service.terminate()
|
||||
service.wait(timeout=5)
|
||||
service.stdin.close()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,95 @@
|
||||
"""Cache regression and optional host timings for the shipped tester renderer.
|
||||
|
||||
Run in the build Docker image. Timings measure CPU drawing on this host, exclude
|
||||
display/copy/input scheduling, and must not be reported as PS4 frame timings.
|
||||
"""
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
import statistics
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def build(renderer=None, output=None, fixture=None, extra_sources=()):
|
||||
sys.path.insert(0, str(ROOT / 'tools'))
|
||||
import package_launcher as package
|
||||
package.embed_fonts()
|
||||
text_includes, text_libraries = package.text_host_flags()
|
||||
output = Path(output or ROOT / 'build/launcher/tester-render-host-test')
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
version = (ROOT / 'VERSION').read_text().strip()
|
||||
flags = ['clang-18', '-std=gnu11', '-O2', '-D_DEFAULT_SOURCE', '-Ilauncher',
|
||||
'-Ivendor/stb', '-Ivendor/qrcodegen', f'-DC4F_LAUNCHER_VERSION="{version}"', *text_includes]
|
||||
stb = output.with_suffix('.stb.o')
|
||||
subprocess.run([*flags, '-w', '-c', 'launcher/stb_truetype.c', '-o', str(stb)], cwd=ROOT, check=True)
|
||||
subprocess.run([*flags, '-Wall', '-Wextra', '-Werror', str(fixture or 'tests/tester_render_test.c'),
|
||||
'launcher/screen.c', 'launcher/navigation.c', 'launcher/tester.c',
|
||||
str(renderer or 'launcher/tester_draw.c'), 'launcher/draw.c', 'launcher/text.c', 'launcher/logo.c',
|
||||
'launcher/message.c', 'vendor/qrcodegen/qrcodegen.c', str(package.FONTS), str(stb), *map(str, extra_sources),
|
||||
*([] if 'launcher/language.c' in extra_sources else ['launcher/language.c', 'tests/language_system_stub.c']),
|
||||
*text_libraries, '-lm', '-o', str(output)],
|
||||
cwd=ROOT, check=True)
|
||||
return output
|
||||
|
||||
|
||||
def capture(binary, *args):
|
||||
return subprocess.check_output([str(binary), *map(str, args)], cwd=ROOT, text=True, timeout=120).splitlines()
|
||||
|
||||
|
||||
def check(binary):
|
||||
names = capture(binary, '--list')
|
||||
expected = dict(capture(binary, '--hash', i)[0].split() for i in range(len(names)))
|
||||
sequence = capture(binary)
|
||||
assert len(sequence) == 3 * len(names)
|
||||
for row in sequence:
|
||||
name, actual = row.split()
|
||||
assert actual == expected[name], f'render cache retained a previous state for {name}: {actual} != {expected[name]}'
|
||||
for name in names[1:]:
|
||||
assert expected[name] != expected['idle'], f'{name} did not change the idle display'
|
||||
focus = [expected[name] for name in ('menu', 'menu-user', 'menu-orientation', 'menu-color',
|
||||
'menu-large', 'menu-small', 'menu-reset', 'menu-exit')]
|
||||
assert len(set(focus)) == len(focus), 'menu focus is not visibly distinct'
|
||||
picker = [expected[name] for name in names if name.startswith('color-')]
|
||||
assert len(set(picker)) == len(picker), 'RGB selection, precision, color, error or user is not visibly distinct'
|
||||
for a, b in (('menu-unavailable', 'menu-large'), ('menu-error', 'menu-unavailable'),
|
||||
('menu-output', 'menu-large'), ('menu-no-users', 'menu-unavailable'),
|
||||
('menu-other-user', 'menu-large'), ('menu-renamed-user', 'menu-large')):
|
||||
assert expected[a] != expected[b], f'menu state is not visibly distinct: {a} and {b}'
|
||||
for prefix, base in (('', 'idle'), ('menu-', 'menu-large')):
|
||||
states = [base, *(prefix + 'rtt-' + suffix for suffix in (
|
||||
'known', 'updated', 'measuring', 'no-page', 'unavailable', 'four', 'four-measuring', 'other-slot'))]
|
||||
assert len({expected[name] for name in states}) == len(states), f'{prefix}RTT states are not visibly distinct'
|
||||
assert expected[prefix + 'rtt-unavailable-stale'] == expected[prefix + 'rtt-unavailable'], \
|
||||
'unavailable connection displayed stale RTT values'
|
||||
no_user = 'menu-no-users' if prefix else 'no-users'
|
||||
assert expected[prefix + 'rtt-no-user'] == expected[no_user], 'RTT displayed without a selected user'
|
||||
print(f'PASS tester renderer: {len(names)} fresh references and {len(sequence)} warm transitions match', flush=True)
|
||||
|
||||
|
||||
def benchmark(binary, samples=120):
|
||||
names = capture(binary, '--list')
|
||||
for label, name, mode in (('cold-idle', 'idle', '--cold'), ('cold-menu', 'menu', '--cold'),
|
||||
('first-menu-after-idle', 'menu', '--entry')):
|
||||
times = sorted(float(capture(binary, mode, names.index(name))[0].split('ms=')[1]) for _ in range(12))
|
||||
print(f'bench {label} median_ms={statistics.median(times):.3f} '
|
||||
f'p95_ms={times[int((len(times) - 1) * .95)]:.3f} max_ms={times[-1]:.3f} samples={len(times)}', flush=True)
|
||||
for row in capture(binary, '--benchmark', samples):
|
||||
print(row, flush=True)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--benchmark', action='store_true')
|
||||
parser.add_argument('--renderer', type=Path)
|
||||
parser.add_argument('--output', type=Path)
|
||||
args = parser.parse_args()
|
||||
binary = build(args.renderer, args.output)
|
||||
check(binary)
|
||||
if args.benchmark:
|
||||
benchmark(binary)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||