Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d3eb00e52 | ||
|
|
1b4ee87294 | ||
|
|
200e2bfc0b | ||
|
|
be950bf7b5 | ||
|
|
028cb9f2f4 | ||
|
|
6d9949f88b | ||
|
|
627e2cec63 | ||
|
|
baf36d648e | ||
|
|
183f9dfaee | ||
|
|
1cc59c12dc |
No files matched your search
@@ -3,6 +3,23 @@
|
||||
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.1.0 — 2026-10-06
|
||||
|
||||
Rumble and the light bar.
|
||||
|
||||
- When a game rumbles a controller, the device driving it feels it: an Android phone buzzes, and a gamepad
|
||||
playing as that controller rumbles too, though not every controller supports vibration in the browser. A
|
||||
gamepad that can't be rumbled says so on its row.
|
||||
- The page glows in the controller's light-bar colour, the player colour the PS4 gives each user or whatever
|
||||
the game sets, brightened from the quarter strength the PS4 uses.
|
||||
- Each controller shows the name of the PS4 user signed in on it.
|
||||
- An **Invite** button shows the page's address as a QR code, so friends can scan it and join. The console
|
||||
makes the code, so the page still needs no libraries.
|
||||
- The page says "Choose user on PS4" rather than "on TV": not everyone plays on a TV.
|
||||
- Protocol: the status reports the light bar's colour in `color` and the signed-in user in `user`; the
|
||||
service sends rumble as `{"method": "v", "params": [pad, large, small]}`; a new `invite` method returns the
|
||||
QR code. See [docs/protocol.md](docs/protocol.md).
|
||||
|
||||
## 1.0.0 — 2026-10-05
|
||||
|
||||
First release.
|
||||
@@ -26,4 +43,4 @@ the PS4's own *"Who's using this controller?"* screen deciding who each one is.
|
||||
- Open on the local network by design, with no pairing. What that does and does not protect is written down in
|
||||
[SECURITY.md](SECURITY.md).
|
||||
|
||||
Tested on firmware 10.01 with GoldHEN v2.4b18.10. Rumble, light bar and motion are not passed on yet.
|
||||
Tested on firmware 10.01 with GoldHEN v2.4b18.10. Rumble, light bar and motion are not passed on.
|
||||
@@ -65,7 +65,7 @@ address, or add `?host=<ps4-ip>:4264` to the page's URL.
|
||||
Match the code around your change:
|
||||
- C with GNU extensions (gnu11), 4-space indent, `camelCase` functions with a `c4f` prefix, `C4f` types and
|
||||
`C4F_` macros;
|
||||
- log with `c4fLog`. `c4fNotify` shows a notification on the TV: keep those rare;
|
||||
- log with `c4fLog`. `c4fNotify` shows a notification on the PS4's screen: keep those rare;
|
||||
- comments explain *why*, not *what*;
|
||||
- only button bits confirmed on hardware go in `include/c4f_sce.h`;
|
||||
- the page is one file with no build step and no libraries: keep it that way;
|
||||
@@ -83,8 +83,8 @@ Build without new warnings; the app is built with `-Werror`.
|
||||
|
||||
## Reviewing AI-written code
|
||||
|
||||
Much of Control4Free was written with the help of AI models (Claude by Anthropic and Codex by OpenAI). It has
|
||||
been tested, on the console as well, but AI-written code can look right and still be wrong. Please read it
|
||||
Much of Control4Free was written with the help of AI models (Claude Opus 5.5 by Anthropic and Astra GPT-6 by
|
||||
OpenAI). It has been tested, on the console as well, but AI-written code can look right and still be wrong. Please read it
|
||||
critically: if you find dead code, wrong assumptions or needless complexity,
|
||||
[open an issue](https://github.com/MoHadiShibli/Control4Free/issues) or send a pull request.
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ VERSION := $(shell cat VERSION)
|
||||
NAME := control4free
|
||||
ELF := build/$(NAME).elf
|
||||
OBJDIR := build/payload
|
||||
SOURCES := src/main.c src/log.c src/vda.c src/klog_line.c src/web.c src/net.c
|
||||
SOURCES := src/main.c src/log.c src/vda.c src/klog_line.c src/feedback.c src/web.c src/net.c
|
||||
|
||||
# Research build: logs what scePadVirtualDeviceGetRemoteSetting returns. Its ELF
|
||||
# gets its own name, so it can never be mistaken for a release.
|
||||
@@ -29,11 +29,11 @@ ifdef C4F_PROBE
|
||||
OBJDIR := build/probe
|
||||
endif
|
||||
|
||||
OBJECTS := $(SOURCES:src/%.c=$(OBJDIR)/%.o) $(OBJDIR)/client.o $(OBJDIR)/assets.o
|
||||
OBJECTS := $(SOURCES:src/%.c=$(OBJDIR)/%.o) $(OBJDIR)/client.o $(OBJDIR)/assets.o $(OBJDIR)/qrcodegen.o
|
||||
|
||||
CFLAGS += -std=gnu11 -Wall -Wextra -Wpointer-arith -g -O2
|
||||
CFLAGS += -MMD -MP
|
||||
CFLAGS += -Iinclude -Ivendor/jsmn -DC4F_VERSION='"$(VERSION)"' $(EXTRA_CFLAGS)
|
||||
CFLAGS += -Iinclude -Ivendor/jsmn -Ivendor/qrcodegen -DC4F_VERSION='"$(VERSION)"' $(EXTRA_CFLAGS)
|
||||
LDFLAGS += -lScePad -lSceUserService -ldl -lpthread
|
||||
|
||||
.PHONY: all clean
|
||||
@@ -46,12 +46,18 @@ all: $(ELF)
|
||||
$(OBJDIR) build:
|
||||
mkdir -p $@
|
||||
|
||||
$(OBJDIR)/%.o: src/%.c | $(OBJDIR)
|
||||
# VERSION is compiled into the objects, and make doesn't notice a changed flag,
|
||||
# so a new version has to rebuild them.
|
||||
$(OBJDIR)/%.o: src/%.c VERSION | $(OBJDIR)
|
||||
$(CC) $(CFLAGS) -c $< -o $@
|
||||
|
||||
build/client.c: client/index.html tools/embed_client.py | build
|
||||
python3 tools/embed_client.py $< $@
|
||||
|
||||
# The page's Invite QR code (MIT, the same copy the app uses).
|
||||
$(OBJDIR)/qrcodegen.o: vendor/qrcodegen/qrcodegen.c | $(OBJDIR)
|
||||
$(CC) $(CFLAGS) -c $< -o $@
|
||||
|
||||
$(OBJDIR)/client.o: build/client.c | $(OBJDIR)
|
||||
$(CC) $(CFLAGS) -c $< -o $@
|
||||
|
||||
|
||||
@@ -31,11 +31,17 @@ sees a real controller, so it works wherever a DualShock does:
|
||||
## Features
|
||||
|
||||
- **Up to four controllers**, from as many phones, tablets or computers as you like.
|
||||
- **See who's playing.** Each controller shows the name of the PS4 user signed in on it.
|
||||
- **Invite friends with a QR code.** One button on the page shows its address as a QR code: friends scan it
|
||||
and they're in.
|
||||
- **A full DualShock 4 on the screen**: analog sticks, a two-finger touchpad, L3/R3, PS, Share and Options.
|
||||
Move, resize or hide any button with the layout editor; each device keeps its own layout.
|
||||
- **The gamepads you already have.** Connect one to the phone or PC and give it a controller. One computer
|
||||
can run all four.
|
||||
- **A keyboard**, with keys you can change.
|
||||
- **Rumble and light bar.** When a game rumbles, your phone buzzes (Android), and so does your gamepad,
|
||||
though not every controller supports vibration in the browser. The page glows in the controller's
|
||||
light-bar colour, the one the PS4 or the game sets.
|
||||
- **Fast.** Input reaches the console the moment it arrives, at a DualShock 4's own rate while you play.
|
||||
- **An app on the PS4** that shows the address and a QR code, how many controllers are connected, and starts
|
||||
or stops Control4Free.
|
||||
@@ -47,13 +53,13 @@ sees a real controller, so it works wherever a DualShock does:
|
||||
|
||||
## Quick start
|
||||
|
||||
1. Download `Control4Free-1.0.0.pkg` from the [latest release](https://github.com/MoHadiShibli/Control4Free/releases/latest).
|
||||
1. Download `Control4Free-<version>.pkg` from the [latest release](https://github.com/MoHadiShibli/Control4Free/releases/latest).
|
||||
2. Install it with GoldHEN's Package Installer: **Settings → Debug Settings → Game → Package Installer**, from a
|
||||
USB drive or `/data/pkg/`.
|
||||
3. Open **Control4Free** from **Library → Applications** and press **Cross**. It sets up auto-start and
|
||||
starts Control4Free.
|
||||
4. On your phone or PC, scan the QR code or open the address shown, for example `http://192.168.1.20:4264`.
|
||||
Pick a controller, then choose your user on the TV with the D-pad and Cross.
|
||||
Pick a controller, then choose your user on the PS4.
|
||||
|
||||
New to GoldHEN packages? The **[installation guide](docs/installation.md)** walks through every step.
|
||||
|
||||
@@ -85,8 +91,10 @@ New to GoldHEN packages? The **[installation guide](docs/installation.md)** walk
|
||||
|
||||
## Limitations
|
||||
|
||||
- **No rumble, light bar or motion yet.** The PS4 does send rumble and light-bar changes to the virtual
|
||||
controllers; passing them to the phone is planned for 1.1.
|
||||
- **No motion controls.** Browsers only give phone motion sensors to secure (`https`) pages, and the
|
||||
console's page is plain `http`.
|
||||
- **iPhones don't vibrate.** Safari doesn't let pages use the phone's vibration. A gamepad connected to the
|
||||
iPhone can still rumble, if the browser supports it.
|
||||
- **One controller is added at a time.** If two people pick at once, the second is asked to try again a
|
||||
moment later.
|
||||
- **Rest mode signs everyone out**, so controllers have to be picked again after the PS4 wakes up, just like
|
||||
@@ -102,30 +110,16 @@ project and read every report. If Control4Free is useful to you, you can help ke
|
||||
|
||||
## Credits
|
||||
|
||||
- [seregonwar/SplashDown](https://github.com/seregonwar/SplashDown): Control4Free's virtual-device code is
|
||||
ported from its `psbutton.c`, the first working use of this API on a PS4.
|
||||
- [GoldHEN](https://github.com/GoldHEN/GoldHEN), its PayLoader and AutoRun, and the
|
||||
[GoldHEN Plugins SDK](https://github.com/GoldHEN/GoldHEN_Plugins_SDK).
|
||||
- [ps4-payload-sdk](https://github.com/ps4-payload-dev/sdk) by John Törnblom builds the payload, and the
|
||||
[OpenOrbis PS4 Toolchain](https://github.com/OpenOrbis/OpenOrbis-PS4-Toolchain) builds the app and its package.
|
||||
- [Ghostcontrol](https://github.com/srbraboo/Ghostcontrol-PS5-USB-Controller-Patcher) was a research reference.
|
||||
- [Apollo Save Tool](https://github.com/bucanero/apollo-ps4) showed the package settings that file an app under
|
||||
Applications.
|
||||
- [jsmn](https://github.com/zserge/jsmn), Project Nayuki's
|
||||
[QR Code generator](https://github.com/nayuki/QR-Code-generator),
|
||||
[stb_truetype](https://github.com/nothings/stb) and the [Roboto](https://github.com/googlefonts/roboto) font.
|
||||
- The on-screen buttons are drawn after the
|
||||
[DualShock 4 layout diagram](https://commons.wikimedia.org/wiki/File:Dualshock_4_Layout.svg) by Tokyoship
|
||||
(CC BY 3.0).
|
||||
|
||||
Full licence details are in [THIRD_PARTY.md](THIRD_PARTY.md).
|
||||
Control4Free's virtual-device code is ported from [SplashDown](https://github.com/seregonwar/SplashDown) by
|
||||
seregonwar. The other code, fonts and artwork it uses, and their licenses, are listed in
|
||||
[THIRD_PARTY.md](THIRD_PARTY.md).
|
||||
|
||||
### Built with AI help
|
||||
|
||||
Control4Free was built with the help of AI models, Claude by Anthropic and Codex by OpenAI, which worked on the
|
||||
code, tests and documentation with me. AI-written code can look right and still be wrong. Reviews are very
|
||||
welcome: if you spot AI slop (dead code, wrong assumptions, needless complexity), please
|
||||
[open an issue](https://github.com/MoHadiShibli/Control4Free/issues) or send a pull request.
|
||||
Control4Free was built with the help of AI models, **Claude Opus 5.5** by Anthropic and **Astra GPT-6** by
|
||||
OpenAI, which worked on the code, tests and documentation with me. AI-written code can look right and still be
|
||||
wrong. Reviews are very welcome: if you spot AI slop (dead code, wrong assumptions, needless complexity),
|
||||
please [open an issue](https://github.com/MoHadiShibli/Control4Free/issues) or send a pull request.
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -16,9 +16,10 @@ can reach your PS4 over the network can:
|
||||
|
||||
- take a free controller and play, on the home screen, at sign-in and in games;
|
||||
- press PS and Share, which reach the system menus;
|
||||
- stop Control4Free, which disconnects every controller.
|
||||
- stop Control4Free, which disconnects every controller;
|
||||
- see the names of the users signed in on the controllers.
|
||||
|
||||
They cannot sign in as one of your users without someone at the TV choosing that
|
||||
They cannot sign in as one of your users without someone at the PS4 choosing that
|
||||
user on the PS4's own screen, and they cannot see your screen.
|
||||
|
||||
Run Control4Free on a network you trust. Do not forward port 4264 through your
|
||||
|
||||
@@ -35,8 +35,6 @@
|
||||
[DualShock 4 layout diagram](https://commons.wikimedia.org/wiki/File:Dualshock_4_Layout.svg)
|
||||
by Tokyoship, licensed under CC BY 3.0
|
||||
(https://creativecommons.org/licenses/by/3.0/).
|
||||
- The package settings that file the app under Applications (`CATEGORY`,
|
||||
`ATTRIBUTE`) follow Apollo Save Tool (https://github.com/bucanero/apollo-ps4).
|
||||
|
||||
These license notices are also included inside the package.
|
||||
|
||||
|
||||
@@ -924,6 +924,18 @@
|
||||
.options-menu .actions { display: grid; gap: 10px; margin-top: 16px; }
|
||||
.menu-close { position: absolute; top: max(14px, env(safe-area-inset-top)); right: 14px; }
|
||||
|
||||
/* Invite: the page's address as a QR code, in a panel opened on demand */
|
||||
.invite-qr {
|
||||
width: min(280px, 100%);
|
||||
aspect-ratio: 1;
|
||||
margin: 20px auto 14px;
|
||||
border-radius: 14px;
|
||||
background: #fff;
|
||||
overflow: hidden;
|
||||
}
|
||||
.invite-qr svg { display: block; width: 100%; height: 100%; }
|
||||
.invite-address { text-align: center; font-size: 18px; letter-spacing: .02em; word-break: break-all; user-select: all; }
|
||||
|
||||
/* ---------- Notification ---------- */
|
||||
|
||||
.toast {
|
||||
@@ -987,6 +999,9 @@
|
||||
<span class="conn" id="conn" data-conn="connecting" role="status" aria-live="polite"><span class="dot"></span><span id="connText">Connecting</span><span class="conn-detail" id="connDetail"></span></span>
|
||||
</div>
|
||||
<div class="tb-group">
|
||||
<button class="icon-btn" type="button" id="openInvite" aria-label="Invite someone" title="Invite someone">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="3.5" y="3.5" width="6.5" height="6.5" rx="1.2"/><rect x="14" y="3.5" width="6.5" height="6.5" rx="1.2"/><rect x="3.5" y="14" width="6.5" height="6.5" rx="1.2"/><path d="M14 14h3v3M20.5 14v.01M14 20.5h3M20.5 17.5v3"/></svg>
|
||||
</button>
|
||||
<button class="icon-btn" type="button" id="openSettings" aria-label="Settings">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3.2"/><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z"/></svg>
|
||||
</button>
|
||||
@@ -1146,12 +1161,23 @@
|
||||
<div class="rows" id="menuRows"></div>
|
||||
<div class="actions">
|
||||
<button class="btn" type="button" id="menuLayout">Edit the button layout</button>
|
||||
<button class="btn" type="button" id="menuInvite">Invite someone</button>
|
||||
<button class="btn" type="button" id="menuSettings">All settings</button>
|
||||
<button class="btn danger" type="button" id="leaveBtn">Disconnect controller</button>
|
||||
<button class="btn danger" type="button" id="stopBtn">Stop Control4Free</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<aside class="options-menu" id="inviteSheet" aria-label="Invite someone">
|
||||
<button class="icon-btn menu-close" type="button" id="closeInvite" aria-label="Close">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M6 6l12 12M18 6L6 18"/></svg>
|
||||
</button>
|
||||
<h2>Invite someone</h2>
|
||||
<p class="hint">Scan this with a phone's camera to open the controller page. The phone has to be on the same network as the PS4.</p>
|
||||
<div class="invite-qr" id="inviteQr"></div>
|
||||
<p class="invite-address" id="inviteAddress"></p>
|
||||
</aside>
|
||||
|
||||
<div class="toast" id="toast" role="status" aria-live="polite">
|
||||
<svg viewBox="56 38 888 566" aria-hidden="true"><path fill="#fff" d="M500 78C410 78 330 74 262 62C232 46 188 42 146 62C108 80 86 120 78 170C70 230 58 320 60 400C62 470 80 535 118 568C152 598 196 592 222 560C250 525 272 470 300 430C360 418 430 440 500 440C570 440 640 418 700 430C728 470 750 525 778 560C804 592 848 598 882 568C920 535 938 470 940 400C942 320 930 230 922 170C914 120 892 80 854 62C812 42 768 46 738 62C670 74 590 78 500 78ZM366 98L634 98C648.359 98 660 109.641 660 124L660 226C660 240.359 648.359 252 634 252L366 252C351.641 252 340 240.359 340 226L340 124C340 109.641 351.641 98 366 98ZM173 148L219 148L219 189L260 189L260 235L219 235L219 276L173 276L173 235L132 235L132 189L173 189L173 148ZM804 121C818.912 121 831 133.088 831 148C831 162.912 818.912 175 804 175C789.088 175 777 162.912 777 148C777 133.088 789.088 121 804 121ZM804 249C818.912 249 831 261.088 831 276C831 290.912 818.912 303 804 303C789.088 303 777 290.912 777 276C777 261.088 789.088 249 804 249ZM740 185C754.912 185 767 197.088 767 212C767 226.912 754.912 239 740 239C725.088 239 713 226.912 713 212C713 197.088 725.088 185 740 185ZM868 185C882.912 185 895 197.088 895 212C895 226.912 882.912 239 868 239C853.088 239 841 226.912 841 212C841 197.088 853.088 185 868 185ZM338 276C374.451 276 404 305.549 404 342C404 378.451 374.451 408 338 408C301.549 408 272 378.451 272 342C272 305.549 301.549 276 338 276ZM338 298C313.699 298 294 317.699 294 342C294 366.301 313.699 386 338 386C362.301 386 382 366.301 382 342C382 317.699 362.301 298 338 298ZM662 276C698.451 276 728 305.549 728 342C728 378.451 698.451 408 662 408C625.549 408 596 378.451 596 342C596 305.549 625.549 276 662 276ZM662 298C637.699 298 618 317.699 618 342C618 366.301 637.699 386 662 386C686.301 386 706 366.301 706 342C706 317.699 686.301 298 662 298ZM500 319C509.389 319 517 326.611 517 336C517 345.389 509.389 353 500 353C490.611 353 483 345.389 483 336C483 326.611 490.611 319 500 319Z"/></svg>
|
||||
<span id="toastText"></span>
|
||||
@@ -1195,7 +1221,7 @@
|
||||
// Settings of this device. They stay in this browser.
|
||||
const DEVICE_SETTINGS = [
|
||||
['keepAwake', 'Keep the screen on', 'While this page drives a controller, the screen doesn\'t turn off.', true],
|
||||
['haptics', 'Vibration', 'Buzz this device when you press a button (Android).', 'vibrate' in navigator],
|
||||
['haptics', 'Vibration', 'Buzz this device when you press a button or the game rumbles (Android).', 'vibrate' in navigator],
|
||||
['floating', 'Floating sticks', 'A stick starts wherever your thumb lands inside its area.', false],
|
||||
['tapClick', 'Tap the touchpad to click', 'A quick tap presses the touchpad button. Hold still to keep it pressed.', true],
|
||||
['stickClick', 'Double-tap a stick for L3 / R3', 'Tap a stick, then touch it again right away to press it in. Hold the second touch to keep it pressed. Off: L3 and R3 get their own buttons.', true],
|
||||
@@ -1239,7 +1265,7 @@
|
||||
|
||||
const status = Array.from({ length: PADS }, (_, i) => ({
|
||||
pad: i, open: false, enabled: true, real: false, logged: false, joined: false, connected: false,
|
||||
full: false, shared: false, player: 0, uid: '', cfg: '', name: 'Controller ' + (i + 1),
|
||||
full: false, shared: false, player: 0, uid: '', cfg: '', name: 'Controller ' + (i + 1), user: '',
|
||||
color: FALLBACK_COLORS[i], clients: 0, known: false,
|
||||
}));
|
||||
|
||||
@@ -2336,13 +2362,14 @@
|
||||
head.appendChild(el('span', 'activity'));
|
||||
tile.appendChild(head);
|
||||
const body = el('span');
|
||||
body.appendChild(el('div', 'tile-name', s.known ? s.name : 'Controller ' + (i + 1)));
|
||||
// Who's signed in on it, once the PS4 says; the ring keeps the number.
|
||||
body.appendChild(el('div', 'tile-name', s.known && s.user ? s.user : 'Controller ' + (i + 1)));
|
||||
body.appendChild(el('div', 'tile-meta', padMeta(s) || ''));
|
||||
tile.appendChild(body);
|
||||
const states = el('span', 'tile-state');
|
||||
if (state === 'ready') states.appendChild(stateItem('live', 'Connected'));
|
||||
else if (state === 'connecting') states.appendChild(stateItem('wait', 'Connecting'));
|
||||
else if (state === 'select') states.appendChild(stateItem('wait', 'Choose user on TV'));
|
||||
else if (state === 'select') states.appendChild(stateItem('wait', 'Choose user on PS4'));
|
||||
else if (state === 'paused') states.appendChild(stateItem('wait', 'Paused'));
|
||||
else if (state === 'error') states.appendChild(stateItem('', 'Error'));
|
||||
else if (state === 'free') states.appendChild(stateItem('', 'Free'));
|
||||
@@ -2357,6 +2384,16 @@
|
||||
});
|
||||
}
|
||||
|
||||
// Whether this browser can rumble this gamepad. Chrome and Edge can rumble
|
||||
// Xbox pads, but often not a DualSense; Firefox none. Saying so on the row
|
||||
// beats a gamepad that silently never rumbles.
|
||||
function canRumble(gp) {
|
||||
const a = gp && gp.vibrationActuator;
|
||||
if (!a || typeof a.playEffect !== 'function') return false;
|
||||
if (Array.isArray(a.effects)) return a.effects.includes('dual-rumble');
|
||||
return !a.type || a.type === 'dual-rumble';
|
||||
}
|
||||
|
||||
function renderGamepads() {
|
||||
const list = $('#gamepadList');
|
||||
list.textContent = '';
|
||||
@@ -2377,7 +2414,8 @@
|
||||
const name = (src.gp && src.gp.id || 'Gamepad').replace(/\s*\(.*?\)\s*/g, ' ').trim();
|
||||
text.appendChild(el('div', 'gp-name', name || 'Gamepad'));
|
||||
const mapped = src.gp && src.gp.mapping === 'standard';
|
||||
text.appendChild(el('div', 'gp-sub', 'Gamepad ' + (index + 1) + (mapped ? '' : ' · buttons may be mixed up')));
|
||||
text.appendChild(el('div', 'gp-sub', 'Gamepad ' + (index + 1) + (mapped ? '' : ' · buttons may be mixed up') +
|
||||
(canRumble(src.gp) ? '' : ' · no rumble in this browser')));
|
||||
row.appendChild(text);
|
||||
const seg = el('div', 'seg');
|
||||
seg.setAttribute('role', 'radiogroup');
|
||||
@@ -2389,7 +2427,7 @@
|
||||
b.setAttribute('aria-checked', String(src.pad === pad));
|
||||
if (pad >= 0) {
|
||||
b.style.setProperty('--pc', css(status[pad].color));
|
||||
b.title = 'Controller ' + (pad + 1) + ' · ' + status[pad].name;
|
||||
b.title = 'Controller ' + (pad + 1) + (status[pad].user ? ' · ' + status[pad].user : '');
|
||||
}
|
||||
b.addEventListener('click', () => setGamepadPad(index, pad));
|
||||
seg.appendChild(b);
|
||||
@@ -2416,14 +2454,13 @@
|
||||
const s = status[primaryPad];
|
||||
document.documentElement.style.setProperty('--accent', css(s.color));
|
||||
const label = $('#padLabel');
|
||||
// The console's name for it, when it says more than the number does.
|
||||
const title = 'Controller ' + (primaryPad + 1);
|
||||
label.textContent = title + (s.known && s.name && s.name !== title ? ' · ' + s.name : '');
|
||||
// And who's signed in on it, once the PS4 says.
|
||||
label.textContent = 'Controller ' + (primaryPad + 1) + (s.known && s.user ? ' · ' + s.user : '');
|
||||
const state = padState(s);
|
||||
const extra = conn.state !== 'open' ? $('#connText').textContent + $('#connDetail').textContent
|
||||
: state === 'ready' ? 'Signed in on the PS4'
|
||||
: state === 'connecting' ? 'Connecting to the PS4'
|
||||
: state === 'select' ? 'Choose your user on TV with D-pad and Cross'
|
||||
: state === 'select' ? 'Choose your user on the PS4 with D-pad and Cross'
|
||||
: state === 'noslot' ? 'No free player slot'
|
||||
: state === 'disabled' ? 'Turned off' : '';
|
||||
if (extra) label.appendChild(el('span', 'muted', extra));
|
||||
@@ -2473,7 +2510,7 @@
|
||||
return;
|
||||
} else if (!store.get('padTips', false)) {
|
||||
store.set('padTips', true);
|
||||
toast('Choose your user on the TV with D-pad and Cross. Use the ☰ button for layout and settings.');
|
||||
toast('Choose your user on the PS4 with D-pad and Cross. Use the ☰ button for layout and settings.');
|
||||
}
|
||||
if (primaryPad >= 0 && primaryPad !== pad) {
|
||||
const old = primaryPad;
|
||||
@@ -2562,10 +2599,64 @@
|
||||
}
|
||||
|
||||
function closeMenu() {
|
||||
$('#inviteSheet').classList.remove('open');
|
||||
document.body.classList.remove('menu-open');
|
||||
$('#optionsMenu').classList.remove('open');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- invite
|
||||
|
||||
// The console sends the QR code as a grid, one hex string per row, so the page
|
||||
// needs no QR library: each run of dark modules becomes one rectangle.
|
||||
function qrPath(size, rows, quiet) {
|
||||
const dark = (row, x) => x < size && ((parseInt(row[x >> 2], 16) >> (3 - (x & 3))) & 1) === 1;
|
||||
let d = '';
|
||||
for (let y = 0; y < size; y++) {
|
||||
const row = rows[y] || '';
|
||||
for (let x = 0; x < size;) {
|
||||
if (!dark(row, x)) { x++; continue; }
|
||||
let end = x;
|
||||
while (dark(row, end)) end++;
|
||||
d += 'M' + (x + quiet) + ' ' + (y + quiet) + 'h' + (end - x) + 'v1h-' + (end - x) + 'z';
|
||||
x = end;
|
||||
}
|
||||
}
|
||||
return d;
|
||||
}
|
||||
|
||||
// The address this page reached the console on, which is the one that works
|
||||
// from this network; the console falls back to its own when there's none.
|
||||
function inviteParams() {
|
||||
const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})(?::(\d{1,5}))?$/.exec(conn.address || '');
|
||||
return m ? [+m[1], +m[2], +m[3], +m[4], +(m[5] || 4264)] : [];
|
||||
}
|
||||
|
||||
function openInvite() {
|
||||
closeMenu();
|
||||
document.body.classList.add('menu-open');
|
||||
$('#inviteSheet').classList.add('open');
|
||||
const box = $('#inviteQr'), address = $('#inviteAddress');
|
||||
box.textContent = '';
|
||||
address.textContent = '';
|
||||
call('invite', inviteParams()).then(r => {
|
||||
const quiet = 4, n = r.size + quiet * 2, ns = 'http://www.w3.org/2000/svg';
|
||||
const svg = document.createElementNS(ns, 'svg');
|
||||
svg.setAttribute('viewBox', '0 0 ' + n + ' ' + n);
|
||||
svg.setAttribute('shape-rendering', 'crispEdges');
|
||||
svg.setAttribute('role', 'img');
|
||||
svg.setAttribute('aria-label', 'QR code for ' + r.text);
|
||||
const path = document.createElementNS(ns, 'path');
|
||||
path.setAttribute('d', qrPath(r.size, r.rows, quiet));
|
||||
path.setAttribute('fill', '#000');
|
||||
svg.appendChild(path);
|
||||
box.appendChild(svg);
|
||||
address.textContent = r.text.replace(/\/$/, '');
|
||||
}).catch(e => { address.textContent = e.message || 'Couldn\'t make the QR code'; });
|
||||
}
|
||||
|
||||
$('#openInvite').addEventListener('click', openInvite);
|
||||
$('#menuInvite').addEventListener('click', openInvite);
|
||||
$('#closeInvite').addEventListener('click', closeMenu);
|
||||
$('#padMenuBtn').addEventListener('click', () => { if (!editor.active) openMenu(); });
|
||||
$('#menuLayout').addEventListener('click', () => { closeMenu(); editor.open(); });
|
||||
$('#closeOptions').addEventListener('click', closeMenu);
|
||||
|
||||
@@ -50,7 +50,7 @@ If no line arrives, it stops creating controllers until it's restarted, rather t
|
||||
it can no longer address.
|
||||
|
||||
The device is created for user 1, so it arrives with no user, and the PS4 asks *"Who's using this
|
||||
controller?"*. The service never presses anything itself: the choice is made at the TV. When a user is picked,
|
||||
controller?"*. The service never presses anything itself: the choice is made on the PS4. When a user is picked,
|
||||
the login manager logs `DEVICE_OWNER_CHANGED [DeviceId:…][UserId:…]`, which is how the page learns the
|
||||
controller is signed in.
|
||||
|
||||
@@ -80,6 +80,18 @@ turns it into a `ScePadData` sample and hands it to `scePadVirtualDeviceInsertDa
|
||||
When a page stops sending, its buttons are released at once; after 3 seconds the controller goes neutral, and
|
||||
after 15 it's removed. A page that reconnects within 15 seconds gets its controller back.
|
||||
|
||||
## Rumble and light bar
|
||||
|
||||
A game talks to a virtual controller the way it talks to a real one, and the system keeps what it asked for.
|
||||
`scePadVirtualDeviceGetRemoteSetting` reads it back, in the layout of a DualShock 4's own output report: the
|
||||
two motors, then the light bar's red, green and blue. The service reads it every 16 ms for each controller,
|
||||
because games pulse rumble for only tens of milliseconds:
|
||||
|
||||
- a change in rumble is sent to the controller's owner, whose page buzzes the phone and rumbles any gamepad
|
||||
playing as that controller. Someone who takes a controller over is told its current state at once;
|
||||
- the light bar goes into the status every page sees. The PS4 sets player colours at a quarter of full
|
||||
brightness, so the page brightens them to full and keeps the hue.
|
||||
|
||||
## Rest mode and failures
|
||||
|
||||
The main loop watches both the monotonic clock and the wall clock. A gap of more than 5 seconds means the
|
||||
|
||||
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 535 KiB After Width: | Height: | Size: 537 KiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 840 KiB After Width: | Height: | Size: 840 KiB |
@@ -17,11 +17,17 @@ The home screen shows all four controllers live:
|
||||
|---|---|
|
||||
| **Free** | Nobody is using it. Pick it to play. |
|
||||
| **Connecting** | The PS4 is setting it up. Takes a moment. |
|
||||
| **Choose user on TV** | Waiting for someone to pick a user on the PS4's screen. |
|
||||
| **Connected** | Signed in and ready. |
|
||||
| **Choose user on PS4** | Waiting for someone to pick a user on the PS4's screen. |
|
||||
| **Connected** | Signed in and ready. The tile shows the user's name. |
|
||||
| **Paused** | Its phone stopped sending input, for example because the screen locked. |
|
||||
| **This device** / **+1 device** | Who's driving it: this browser, or other ones. |
|
||||
|
||||
## Inviting someone
|
||||
|
||||
Press the **QR code button** at the top of the home screen, or **☰ → Invite someone** on the controller
|
||||
screen. The page shows its own address as a QR code: a friend scans it with their phone's camera and gets the
|
||||
controller page, as long as they're on the same network as the PS4. Tap outside the panel to close it.
|
||||
|
||||
## Touch controls
|
||||
|
||||
The controller screen is a DualShock 4 laid out for your screen, best held sideways:
|
||||
@@ -77,7 +83,7 @@ way.
|
||||
| Setting | What it does |
|
||||
|---|---|
|
||||
| **Keep the screen on** | The phone's screen doesn't turn off while the page drives a controller. |
|
||||
| **Vibration** | The phone buzzes briefly when you press a button (Android). |
|
||||
| **Vibration** | The phone buzzes when the game rumbles, and briefly when you press a button (Android). |
|
||||
| **Floating sticks** | A stick starts wherever your thumb lands in its area. |
|
||||
| **Tap the touchpad to click** | A quick tap presses the touchpad button. |
|
||||
| **Double-tap a stick for L3 / R3** | Off: L3 and R3 get their own buttons. |
|
||||
@@ -100,6 +106,21 @@ for the PS4 in your router so it doesn't change.
|
||||
**Full screen**: the **⛶** button in the corner. Safari on iPhone doesn't let pages go full screen, so there
|
||||
it explains how to use the home screen instead.
|
||||
|
||||
## Rumble and light bar
|
||||
|
||||
When a game rumbles the controller, the device driving it feels it:
|
||||
|
||||
- **an Android phone** buzzes, with **Vibration** turned on in the settings. Safari on iPhone doesn't let pages
|
||||
vibrate the phone;
|
||||
- **a gamepad** playing as that controller rumbles too, though not every controller supports vibration in
|
||||
the browser. When the browser knows it can't, the gamepad's row says *no rumble in this browser*.
|
||||
|
||||
A gamepad's own light bar stays as it is: browsers don't let pages change it.
|
||||
|
||||
The page also glows in the controller's **light-bar colour**: the player colour the PS4 gives each user, or
|
||||
whatever colour the game sets. Until one is set, a controller shows its own colour (1 blue, 2 red, 3 green,
|
||||
4 pink).
|
||||
|
||||
## Leaving
|
||||
|
||||
- **☰ → Disconnect controller** removes it from the PS4 straight away.
|
||||
|
||||
@@ -40,13 +40,14 @@ Controllers are numbered 0 to 3 in the protocol and 1 to 4 on screen.
|
||||
|
||||
| Method | Params | Reply |
|
||||
|---|---|---|
|
||||
| `info` | `[]` | `{"version": "1.0.0", "protocol": 2, "pads": 4}` |
|
||||
| `info` | `[]` | `{"version": "1.1.0", "protocol": 2, "pads": 4}` |
|
||||
| `status` | `[]` | A status object, below. |
|
||||
| `claim` | the controllers this connection wants, for example `[0]` or `[0, 2]` | A status object, once every controller in the claim exists. |
|
||||
| `u` | input, below | None. |
|
||||
| `leave` | `[pad]` | A status object. |
|
||||
| `stop` | `[]` | None; the service stops and the connection closes. |
|
||||
| `ping` | `[]` | `{}` |
|
||||
| `invite` | `[a, b, c, d, port]`, the address the page reached, or `[]` | `{"text": "http://192.168.1.20:4264/", "size": 29, "rows": […]}` |
|
||||
|
||||
**`claim`** is the whole set this connection wants: controllers missing from it that it owned are removed,
|
||||
and free ones in it are created. A claim that needs a new controller is answered when the controller exists,
|
||||
@@ -89,14 +90,18 @@ a connection claims them again; the page does so when it reconnects.
|
||||
|
||||
**`stop`** is refused with `409` while another connection owns a controller.
|
||||
|
||||
**`invite`** returns a QR code of the page's address: `[]` uses the console's own address. `rows` holds one
|
||||
hex string per row of modules, most significant bit first, `size` modules wide; add a light border of 4
|
||||
modules around it when you draw it.
|
||||
|
||||
### The status object
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"protocol": 2,
|
||||
"pads": [
|
||||
{"pad": 0, "name": "Controller 1", "enabled": true, "open": true, "connected": true, "clients": 1,
|
||||
{"pad": 0, "name": "Controller 1", "user": "Alex", "enabled": true, "open": true, "connected": true, "clients": 1,
|
||||
"mine": true, "state": "ready", "uid": "1a2b3c4d", "color": [32, 96, 255], "reports": 5120, "error": 0}
|
||||
]
|
||||
}
|
||||
@@ -104,19 +109,22 @@ a connection claims them again; the page does so when it reconnects.
|
||||
|
||||
| Field | Meaning |
|
||||
|---|---|
|
||||
| `user` | The name of the PS4 user signed in on it, or `""` (not signed in, or not known yet). |
|
||||
| `open` | The virtual controller exists. |
|
||||
| `connected` | A connection owns it and is sending input. |
|
||||
| `clients` | 1 if a connection owns it, else 0. |
|
||||
| `mine` | This connection owns it. |
|
||||
| `state` | `free`, `connecting`, `select` (waiting for a user on the TV), `ready` (signed in), `paused` (no input lately, or nobody connected), or `error` (the console refused input). |
|
||||
| `state` | `free`, `connecting`, `select` (waiting for a user to be chosen on the PS4), `ready` (signed in), `paused` (no input lately, or nobody connected), or `error` (the console refused input). |
|
||||
| `uid` | The signed-in user's ID in hex, or `unassigned-…` before sign-in. |
|
||||
| `color` | The controller's colour on the page. |
|
||||
| `color` | The light bar's colour, brightened so its strongest channel is 255; the controller's own colour until one is set. |
|
||||
| `reports` | Samples given to the console so far. |
|
||||
| `error` | The console's error code for the last refused sample, or 0. |
|
||||
|
||||
### Messages from the service
|
||||
|
||||
- `{"method": "s", "params": <status>}`: the status, whenever something changes and at least once a second.
|
||||
- `{"method": "v", "params": [pad, large, small]}`: rumble, 0 to 255 for each motor, sent to the controller's
|
||||
owner when it changes and when a connection takes the controller over. `[pad, 0, 0]` stops it.
|
||||
- `{"method": "error", "params": {"message": "…"}}`: a problem that isn't the reply to a request: a controller
|
||||
removed for sitting unused, a request that couldn't be parsed, or an error for a request sent without an
|
||||
`id`.
|
||||
@@ -141,7 +149,7 @@ if they carry an `Origin`, an `Upgrade`, a body or `Transfer-Encoding`. A browse
|
||||
cross-origin after a CORS preflight, which the service never answers, so websites can't use these.
|
||||
|
||||
```
|
||||
GET /api/status → {"application": "Control4Free", "api": 1, "version": "1.0.0", "controllers": 2, "stopping": false}
|
||||
GET /api/status → {"application": "Control4Free", "api": 1, "version": "1.1.0", "controllers": 2, "stopping": false}
|
||||
POST /api/stop → {"application": "Control4Free", "stopping": true}
|
||||
```
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ controller. If it still doesn't, disconnect the controller from the page's menu
|
||||
**The screen went away without a user.** PS cancels it. Pick the controller again and use only the D-pad and
|
||||
Cross until you're signed in.
|
||||
|
||||
**The page keeps saying "Choose user on TV" after signing in.** The page learns about sign-ins from the PS4's
|
||||
**The page keeps saying "Choose user on PS4" after signing in.** The page learns about sign-ins from the PS4's
|
||||
kernel log. If a klog viewer was connected at that moment, it missed it. Play on: the controller works either
|
||||
way.
|
||||
|
||||
@@ -66,8 +66,10 @@ that takes over a signed-in user's controller can stop games from reading Contro
|
||||
**Buttons feel slow.** The number next to the version at the bottom of the home screen is the round trip to
|
||||
the console. Above about 30 ms, your Wi-Fi is the slow part: move closer to the router or use 5 GHz.
|
||||
|
||||
**No vibration from the game.** Rumble isn't passed to the phone or gamepad yet. It's planned for the next
|
||||
version.
|
||||
**No vibration from the game.** Check **Vibration** is on in the settings. Only Android phones can vibrate:
|
||||
Safari on iPhone doesn't let pages do it. Not every controller supports vibration in the browser, and one
|
||||
whose row says *no rumble in this browser* can't be rumbled from the page at all. Keep the page's window in
|
||||
front. And not every game rumbles every player.
|
||||
|
||||
## Gamepads
|
||||
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
|
||||
/* ---- libSceUserService ---- */
|
||||
int32_t sceUserServiceInitialize(void *params);
|
||||
/* The user's name as the PS4 shows it: an online ID, or a local account's name. */
|
||||
int32_t sceUserServiceGetUserName(int32_t userId, char *name, size_t size);
|
||||
|
||||
#define C4F_USER_ID_INVALID (-1)
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
#ifndef C4F_VDA_H
|
||||
#define C4F_VDA_H
|
||||
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
#include "c4f_sce.h"
|
||||
|
||||
@@ -59,6 +60,24 @@ int32_t c4fVirtualPadAdd(int32_t vdaUser);
|
||||
/* Completes a pad from the DeviceId that came out of the log. */
|
||||
void c4fVirtualPadAdopt(C4fVirtualPad *out, int32_t userId, int32_t vdaUser, uint64_t deviceId);
|
||||
|
||||
/* ---- what the game asks of a controller ---- */
|
||||
|
||||
typedef struct {
|
||||
uint8_t large, small; /* rumble motors, 0..255 */
|
||||
uint8_t r, g, b; /* light bar; the PS4 uses 0x40 for full */
|
||||
} C4fPadFeedback;
|
||||
|
||||
/* Reads the buffer scePadVirtualDeviceGetRemoteSetting fills (feedback.c). */
|
||||
void c4fPadFeedbackParse(const uint8_t *buf, size_t size, C4fPadFeedback *out);
|
||||
|
||||
/* The rumble and light bar the system wants on this controller right now.
|
||||
* Returns 0, the call's error code, or -1 if the call isn't exported. */
|
||||
int32_t c4fVirtualPadFeedback(const C4fVirtualPad *pad, C4fPadFeedback *out);
|
||||
|
||||
/* The PS4's name for a user, as it shows it. Returns 0, or the call's error.
|
||||
* Not written to the log: people post logs in bug reports. */
|
||||
int32_t c4fUserName(uint32_t userId, char *out, size_t size);
|
||||
|
||||
/* A neutral sample: sticks centred, identity quaternion, connected. */
|
||||
void c4fPadDataNeutral(ScePadData *data);
|
||||
|
||||
|
||||
@@ -18,9 +18,10 @@ FONTS := ../vendor/roboto/Roboto-Light.ttf ../vendor/roboto/Roboto-Regular.ttf
|
||||
all: package
|
||||
$(OUT):
|
||||
mkdir -p $@
|
||||
$(OUT)/%.o: %.c | $(OUT)
|
||||
# The version is compiled in; a new one must rebuild everything that shows it.
|
||||
$(OUT)/%.o: %.c ../VERSION | $(OUT)
|
||||
$(CC) $(CFLAGS) -std=gnu11 -c $< -o $@
|
||||
$(OUT)/main.o: main.cpp | $(OUT)
|
||||
$(OUT)/main.o: main.cpp ../VERSION | $(OUT)
|
||||
$(CXX) $(CFLAGS) -std=c++17 -fno-exceptions -fno-rtti -c $< -o $@
|
||||
$(OUT)/qrcodegen.o: ../vendor/qrcodegen/qrcodegen.c | $(OUT)
|
||||
$(CC) $(CFLAGS) -std=gnu11 -c $< -o $@
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
/* Control4Free -- what a game asks of a controller: rumble and light bar.
|
||||
*
|
||||
* Pure byte work, with no PS4 calls, so the host tests run the same code the
|
||||
* console does.
|
||||
*/
|
||||
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "c4f_vda.h"
|
||||
|
||||
/* scePadVirtualDeviceGetRemoteSetting fills a buffer laid out like the DualShock
|
||||
* 4's HID output report without its report ID. Read on firmware 10.01
|
||||
* (2026-10-05) while games rumbled and users signed in:
|
||||
*
|
||||
* [0] update flags: bit 0 rumble, bit 1 light bar, as in the DS4 report.
|
||||
* Pulses for a few milliseconds, so it is not relied on here.
|
||||
* [3] right motor, the small fast one: 0xff for ~300 ms on a game's hit.
|
||||
* [4] left motor, the large slow one.
|
||||
* [5..7] light bar red, green, blue: 0x40 at full, as the PS4 sets them
|
||||
* (40 00 00 for player 2, 00 40 00 for player 3).
|
||||
* [16] 1 once a user is assigned.
|
||||
*/
|
||||
void c4fPadFeedbackParse(const uint8_t *buf, size_t size, C4fPadFeedback *out)
|
||||
{
|
||||
(void)memset(out, 0, sizeof(*out));
|
||||
if (size < 8) return;
|
||||
out->small = buf[3];
|
||||
out->large = buf[4];
|
||||
out->r = buf[5];
|
||||
out->g = buf[6];
|
||||
out->b = buf[7];
|
||||
}
|
||||
@@ -147,6 +147,29 @@ void c4fVirtualPadAdopt(C4fVirtualPad *out, int32_t userId, int32_t vdaUser, uin
|
||||
(unsigned long long)deviceId, (uint32_t)out->handle);
|
||||
}
|
||||
|
||||
int32_t c4fVirtualPadFeedback(const C4fVirtualPad *pad, C4fPadFeedback *out)
|
||||
{
|
||||
uint8_t buf[256]; /* the call's size is unknown; it has only ever used 17 */
|
||||
|
||||
(void)memset(out, 0, sizeof(*out));
|
||||
if (!scePadVirtualDeviceGetRemoteSetting) return -1;
|
||||
(void)memset(buf, 0, sizeof(buf));
|
||||
int32_t ret = scePadVirtualDeviceGetRemoteSetting(pad->handle, buf);
|
||||
if (ret == 0) c4fPadFeedbackParse(buf, sizeof(buf), out);
|
||||
return ret;
|
||||
}
|
||||
|
||||
int32_t c4fUserName(uint32_t userId, char *out, size_t size)
|
||||
{
|
||||
char name[64]; /* the system's own limit is 16 characters */
|
||||
|
||||
if (size) out[0] = 0;
|
||||
(void)memset(name, 0, sizeof(name));
|
||||
int32_t ret = sceUserServiceGetUserName((int32_t)userId, name, sizeof(name) - 1);
|
||||
if (ret == 0) snprintf(out, size, "%s", name);
|
||||
return ret;
|
||||
}
|
||||
|
||||
void c4fPadDataNeutral(ScePadData *data)
|
||||
{
|
||||
(void)memset(data, 0, sizeof(*data));
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
#define JSMN_STATIC
|
||||
#define JSMN_STRICT
|
||||
#include "jsmn.h"
|
||||
#include "qrcodegen.h"
|
||||
#include "c4f_log.h"
|
||||
#include "c4f_net.h"
|
||||
#include "c4f_vda.h"
|
||||
@@ -30,6 +31,9 @@
|
||||
#define C4F_RELEASE_MS 15000
|
||||
#endif
|
||||
#define C4F_QUEUE_SIZE 32
|
||||
/* How often each controller's rumble and light bar are read back. Games pulse
|
||||
* rumble for a few tens of milliseconds, so this has to stay well under 100. */
|
||||
#define C4F_FEEDBACK_MS 16
|
||||
|
||||
typedef struct {
|
||||
C4fVirtualPad device;
|
||||
@@ -39,6 +43,15 @@ typedef struct {
|
||||
uint64_t lastInput, lastReport, nextReport, detachedAt, reports;
|
||||
int active, stale, error, assigned;
|
||||
uint32_t userId;
|
||||
/* What the game wants this controller to do, and what its owner was told. */
|
||||
C4fPadFeedback feedback;
|
||||
int feedbackKnown, rumbleSentValid, feedbackFailed;
|
||||
uint8_t rumbleSent[2];
|
||||
uint64_t feedbackAt;
|
||||
/* The signed-in user's name, once the PS4 will tell it. */
|
||||
char userName[72];
|
||||
int nameTries;
|
||||
uint64_t nameAt;
|
||||
} C4fWebPad;
|
||||
|
||||
/* Creating a controller means issuing AddDevice and then finding its DeviceId in
|
||||
@@ -72,7 +85,11 @@ typedef struct {
|
||||
typedef struct {
|
||||
C4fNet net;
|
||||
C4fWebPad pads[C4F_MAX_PADS];
|
||||
int klogFd, stop, changed, creationBlocked;
|
||||
int klogFd, stop, changed, creationBlocked, feedbackMissing;
|
||||
unsigned rumbleChanges; /* since the last heartbeat, to tell a silent game from a silent phone */
|
||||
/* What reading rumble and light bar costs, since the last heartbeat. Every
|
||||
* microsecond here is one an arriving input could have to wait. */
|
||||
uint64_t feedbackCalls, feedbackUs, feedbackMaxUs;
|
||||
char klogLine[1024];
|
||||
size_t klogUsed;
|
||||
C4fAdd add;
|
||||
@@ -143,9 +160,34 @@ static void c4fError(C4fNetClient *c, const C4fRequest *r, int code, const char
|
||||
c4fNetText(c, reply);
|
||||
}
|
||||
|
||||
/* `in` as the inside of a JSON string. Quotes and backslashes are escaped, and
|
||||
* control characters and anything that isn't valid UTF-8 become '?'. A user
|
||||
* name is the one string here we don't write ourselves, and a browser drops the
|
||||
* whole WebSocket on a text frame that isn't valid UTF-8. */
|
||||
static void c4fJsonText(char *out, size_t size, const char *in)
|
||||
{
|
||||
const unsigned char *p = (const unsigned char *)in;
|
||||
size_t o = 0;
|
||||
|
||||
if (!size) return;
|
||||
while (*p && o + 7 < size) {
|
||||
unsigned c = *p;
|
||||
unsigned n = c < 0x80 ? 1 : (c & 0xe0) == 0xc0 ? 2 : (c & 0xf0) == 0xe0 ? 3 : (c & 0xf8) == 0xf0 ? 4 : 0;
|
||||
int valid = n > 0 && !(n == 2 && c < 0xc2) && c <= 0xf4;
|
||||
for (unsigned k = 1; valid && k < n; k++) valid = (p[k] & 0xc0) == 0x80;
|
||||
/* No overlong forms, no UTF-16 surrogates, nothing past U+10FFFF. */
|
||||
if (valid && n == 3 && ((c == 0xe0 && p[1] < 0xa0) || (c == 0xed && p[1] >= 0xa0))) valid = 0;
|
||||
if (valid && n == 4 && ((c == 0xf0 && p[1] < 0x90) || (c == 0xf4 && p[1] >= 0x90))) valid = 0;
|
||||
if (!valid || (n == 1 && (c < 0x20 || c == 0x7f))) { out[o++] = '?'; p++; continue; }
|
||||
if (c == '"' || c == '\\') out[o++] = '\\';
|
||||
memcpy(out + o, p, n); o += n; p += n;
|
||||
}
|
||||
out[o] = 0;
|
||||
}
|
||||
|
||||
static void c4fStatus(C4fWeb *app, C4fNetClient *c, const C4fRequest *request)
|
||||
{
|
||||
char json[3072];
|
||||
char json[4096];
|
||||
size_t pos;
|
||||
if (request && request->hasId) pos = (size_t)snprintf(json, sizeof(json), "{\"id\":%lld,\"result\":", (long long)request->id);
|
||||
else pos = (size_t)snprintf(json, sizeof(json), "{\"method\":\"s\",\"params\":");
|
||||
@@ -154,12 +196,24 @@ static void c4fStatus(C4fWeb *app, C4fNetClient *c, const C4fRequest *request)
|
||||
C4fWebPad *p = &app->pads[i];
|
||||
const char *state = p->error ? "error" : app->add.state && (app->add.pending & (1u << i)) ? "connecting"
|
||||
: !p->active ? "free" : !p->owner || p->stale ? "paused" : p->assigned ? "ready" : "select";
|
||||
unsigned colors[4][3] = {{32,96,255},{255,48,64},{48,200,96},{255,80,180}};
|
||||
static const unsigned colors[4][3] = {{32,96,255},{255,48,64},{48,200,96},{255,80,180}};
|
||||
unsigned rgb[3] = { colors[i][0], colors[i][1], colors[i][2] };
|
||||
if (p->active && p->feedbackKnown) {
|
||||
/* The PS4 sets player colours at a quarter strength (0x40), which on a
|
||||
* screen reads as near black: keep the hue, brighten it to full. An
|
||||
* unlit bar keeps the controller's own colour. */
|
||||
unsigned l[3] = { p->feedback.r, p->feedback.g, p->feedback.b };
|
||||
unsigned top = l[0] > l[1] ? l[0] : l[1];
|
||||
if (l[2] > top) top = l[2];
|
||||
if (top) for (int k = 0; k < 3; k++) rgb[k] = l[k] * 255 / top;
|
||||
}
|
||||
char user[160];
|
||||
c4fJsonText(user, sizeof(user), p->active && p->assigned ? p->userName : "");
|
||||
pos += (size_t)snprintf(json+pos, sizeof(json)-pos,
|
||||
"%s{\"pad\":%d,\"name\":\"Controller %d\",\"enabled\":true,\"open\":%s,\"connected\":%s,\"clients\":%d,\"mine\":%s,\"state\":\"%s\",\"uid\":\"%s%08x\",\"color\":[%u,%u,%u],\"reports\":%llu,\"error\":%d}",
|
||||
i ? "," : "", i, i+1, p->active ? "true" : "false", p->owner && !p->stale ? "true" : "false", p->owner ? 1 : 0,
|
||||
"%s{\"pad\":%d,\"name\":\"Controller %d\",\"user\":\"%s\",\"enabled\":true,\"open\":%s,\"connected\":%s,\"clients\":%d,\"mine\":%s,\"state\":\"%s\",\"uid\":\"%s%08x\",\"color\":[%u,%u,%u],\"reports\":%llu,\"error\":%d}",
|
||||
i ? "," : "", i, i+1, user, p->active ? "true" : "false", p->owner && !p->stale ? "true" : "false", p->owner ? 1 : 0,
|
||||
p->owner == c ? "true" : "false", state, p->assigned ? "" : "unassigned-", p->userId,
|
||||
colors[i][0], colors[i][1], colors[i][2], (unsigned long long)p->reports, p->error);
|
||||
rgb[0], rgb[1], rgb[2], (unsigned long long)p->reports, p->error);
|
||||
}
|
||||
snprintf(json+pos, sizeof(json)-pos, "]}}"); c4fNetText(c, json);
|
||||
}
|
||||
@@ -222,6 +276,81 @@ static void c4fReportPads(C4fWeb *app)
|
||||
}
|
||||
}
|
||||
|
||||
static uint64_t c4fTimeUs(void)
|
||||
{
|
||||
struct timespec t;
|
||||
clock_gettime(CLOCK_MONOTONIC, &t);
|
||||
return (uint64_t)t.tv_sec * 1000000 + (uint64_t)t.tv_nsec / 1000;
|
||||
}
|
||||
|
||||
/* The names of signed-in users. The PS4 may not have finished signing a user in
|
||||
* when it reports the controller assigned, so a failed look-up is retried for a
|
||||
* few seconds. Names are kept out of the log. */
|
||||
static void c4fLookUpNames(C4fWeb *app, uint64_t now)
|
||||
{
|
||||
for (int i = 0; i < C4F_MAX_PADS; i++) {
|
||||
C4fWebPad *p = &app->pads[i];
|
||||
if (!p->active || !p->assigned || p->userName[0] || p->nameTries >= 10 || now < p->nameAt) continue;
|
||||
p->nameTries++;
|
||||
p->nameAt = now + 500;
|
||||
int32_t ret = c4fUserName(p->userId, p->userName, sizeof(p->userName));
|
||||
if (ret == 0 && p->userName[0]) {
|
||||
c4fLog("web controller %d user name found (%u bytes)\n", i + 1, (unsigned)strlen(p->userName));
|
||||
app->changed = 1;
|
||||
} else if (p->nameTries == 10) {
|
||||
p->userName[0] = 0;
|
||||
c4fLog("web controller %d user name unavailable (0x%08x)\n", i + 1, (uint32_t)ret);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Reads back what the game wants from each controller. Rumble goes to the
|
||||
* controller's owner, whose page drives the phone's vibration and any gamepad
|
||||
* playing as it; the light bar goes into the status everyone sees. */
|
||||
static void c4fPollFeedback(C4fWeb *app, uint64_t now)
|
||||
{
|
||||
if (app->feedbackMissing) return;
|
||||
for (int i = 0; i < C4F_MAX_PADS; i++) {
|
||||
C4fWebPad *p = &app->pads[i];
|
||||
C4fPadFeedback f;
|
||||
int32_t ret;
|
||||
|
||||
if (!p->active || now < p->feedbackAt) continue;
|
||||
p->feedbackAt = now + C4F_FEEDBACK_MS;
|
||||
uint64_t started = c4fTimeUs();
|
||||
ret = c4fVirtualPadFeedback(&p->device, &f);
|
||||
uint64_t took = c4fTimeUs() - started;
|
||||
app->feedbackCalls++; app->feedbackUs += took;
|
||||
if (took > app->feedbackMaxUs) app->feedbackMaxUs = took;
|
||||
if (ret == -1) {
|
||||
c4fLog("scePadVirtualDeviceGetRemoteSetting is not exported: no rumble or light bar\n");
|
||||
app->feedbackMissing = 1;
|
||||
return;
|
||||
}
|
||||
if (ret != 0) {
|
||||
if (!p->feedbackFailed) c4fLog("web controller %d GetRemoteSetting = 0x%08x\n", i + 1, (uint32_t)ret);
|
||||
p->feedbackFailed = 1;
|
||||
continue;
|
||||
}
|
||||
if (!p->feedbackKnown || f.r != p->feedback.r || f.g != p->feedback.g || f.b != p->feedback.b) {
|
||||
/* Rare: a sign-in, or a game setting its colour. */
|
||||
c4fLog("web controller %d light bar %02x %02x %02x\n", i + 1, f.r, f.g, f.b);
|
||||
app->changed = 1;
|
||||
}
|
||||
if (p->feedbackKnown && (f.large != p->feedback.large || f.small != p->feedback.small))
|
||||
app->rumbleChanges++;
|
||||
p->feedback = f;
|
||||
p->feedbackKnown = 1;
|
||||
if (p->owner && (!p->rumbleSentValid || f.large != p->rumbleSent[0] || f.small != p->rumbleSent[1])) {
|
||||
char message[64];
|
||||
snprintf(message, sizeof(message), "{\"method\":\"v\",\"params\":[%d,%u,%u]}", i, f.large, f.small);
|
||||
if (c4fNetText(p->owner, message) == 0) {
|
||||
p->rumbleSent[0] = f.large; p->rumbleSent[1] = f.small; p->rumbleSentValid = 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Preserve button/touch edges for at least one report. Coalesce analogue-only
|
||||
* changes so mouse/gamepad updates cannot create seconds of input latency. */
|
||||
static void c4fEnqueue(C4fWebPad *p, const ScePadData *data)
|
||||
@@ -316,6 +445,7 @@ static void c4fKlogLine(C4fWeb *app, const char *line)
|
||||
C4fWebPad *p = &app->pads[i];
|
||||
if (p->active && p->device.deviceId == device) {
|
||||
p->assigned = user != 0xffffffffu; p->userId = user; app->changed = 1;
|
||||
p->userName[0] = 0; p->nameTries = 0; p->nameAt = 0;
|
||||
c4fLog("web controller %d assignment confirmed=%d\n", i+1, p->assigned);
|
||||
}
|
||||
}
|
||||
@@ -371,7 +501,7 @@ static void c4fApplyClaim(C4fWeb *app, C4fNetClient *c, unsigned wanted, C4fRequ
|
||||
for (int i = 0; i < C4F_MAX_PADS; i++) {
|
||||
C4fWebPad *p = &app->pads[i];
|
||||
if (wanted & (1u << i)) {
|
||||
if (p->owner != c) c4fNeutralize(p);
|
||||
if (p->owner != c) { c4fNeutralize(p); p->rumbleSentValid = 0; }
|
||||
p->owner = c; p->detachedAt = 0; p->lastInput = c4fTimeMs(); p->stale = 0;
|
||||
} else if (p->owner == c) c4fRemove(app, p);
|
||||
}
|
||||
@@ -535,6 +665,51 @@ static void c4fClaim(C4fWeb *app, C4fNetClient *c, C4fRequest *r)
|
||||
app->changed = 1;
|
||||
}
|
||||
|
||||
/* A QR code of the page's address, for the page's Invite panel. The page names
|
||||
* the address it reached us on, as [a, b, c, d, port], because that's the one
|
||||
* that works from this network; without it, the console's own address is used.
|
||||
* The answer is the module grid, one hex string per row, most significant bit
|
||||
* first: the page draws it, so it needs no QR library of its own. */
|
||||
static void c4fInvite(C4fNetClient *c, const C4fRequest *r)
|
||||
{
|
||||
char text[64], address[64], reply[1024];
|
||||
uint8_t qr[qrcodegen_BUFFER_LEN_FOR_VERSION(5)], scratch[qrcodegen_BUFFER_LEN_FOR_VERSION(5)];
|
||||
size_t pos;
|
||||
|
||||
if (r->argc == 5) {
|
||||
for (int i = 0; i < 4; i++)
|
||||
if (r->args[i] < 0 || r->args[i] > 255) { c4fError(c, r, 400, "That isn't an IPv4 address"); return; }
|
||||
if (r->args[4] < 1 || r->args[4] > 65535) { c4fError(c, r, 400, "That isn't a port"); return; }
|
||||
snprintf(text, sizeof(text), "http://%d.%d.%d.%d:%d/", (int)r->args[0], (int)r->args[1],
|
||||
(int)r->args[2], (int)r->args[3], (int)r->args[4]);
|
||||
} else if (r->argc == 0) {
|
||||
c4fNetLocalAddress(address, sizeof(address));
|
||||
if (!address[0]) { c4fError(c, r, 503, "Couldn't work out the console's address"); return; }
|
||||
snprintf(text, sizeof(text), "http://%s:%d/", address, C4F_WEB_PORT);
|
||||
} else {
|
||||
c4fError(c, r, 400, "Invalid request"); return;
|
||||
}
|
||||
if (!qrcodegen_encodeText(text, scratch, qr, qrcodegen_Ecc_MEDIUM, 1, 5, qrcodegen_Mask_AUTO, true)) {
|
||||
c4fError(c, r, 500, "Couldn't make the QR code"); return;
|
||||
}
|
||||
int size = qrcodegen_getSize(qr);
|
||||
pos = (size_t)snprintf(reply, sizeof(reply), "{\"id\":%lld,\"result\":{\"text\":\"%s\",\"size\":%d,\"rows\":[",
|
||||
(long long)r->id, text, size);
|
||||
for (int y = 0; y < size && pos < sizeof(reply) - 16; y++) {
|
||||
reply[pos++] = y ? ',' : '"';
|
||||
if (y) reply[pos++] = '"';
|
||||
for (int x = 0; x < size; x += 4) {
|
||||
unsigned nibble = 0;
|
||||
for (int b = 0; b < 4; b++)
|
||||
nibble = nibble << 1 | (x + b < size && qrcodegen_getModule(qr, x + b, y));
|
||||
reply[pos++] = "0123456789abcdef"[nibble];
|
||||
}
|
||||
reply[pos++] = '"';
|
||||
}
|
||||
snprintf(reply + pos, sizeof(reply) - pos, "]}}");
|
||||
c4fNetText(c, reply);
|
||||
}
|
||||
|
||||
static void c4fUpdate(C4fWeb *app, C4fNetClient *c, C4fRequest *r)
|
||||
{
|
||||
if (r->argc < 9 || r->args[0] < 0 || r->args[0] >= C4F_MAX_PADS) { c4fError(c, r, 400, "That input was not in a form Control4Free understands"); return; }
|
||||
@@ -596,6 +771,8 @@ static void c4fWebEvent(C4fNetClient *c, int event, const char *text, size_t len
|
||||
} else if (!strcmp(r.method, "ping")) {
|
||||
char reply[64];
|
||||
if (r.hasId) { snprintf(reply, sizeof(reply), "{\"id\":%lld,\"result\":{}}", (long long)r.id); c4fNetText(c, reply); }
|
||||
} else if (!strcmp(r.method, "invite")) {
|
||||
if (r.hasId) c4fInvite(c, &r);
|
||||
} else if (!strcmp(r.method, "status")) c4fStatus(app, c, &r);
|
||||
else if (!strcmp(r.method, "claim")) c4fClaim(app, c, &r);
|
||||
else if (!strcmp(r.method, "u")) c4fUpdate(app, c, &r);
|
||||
@@ -661,6 +838,8 @@ int c4fWebRun(int klogFd)
|
||||
}
|
||||
long klogBytes = c4fReadKlog(app);
|
||||
c4fReportPads(app);
|
||||
c4fPollFeedback(app, now);
|
||||
c4fLookUpNames(app, now);
|
||||
#ifdef C4F_PROBE_SETTING
|
||||
c4fProbeSetting(app, now);
|
||||
#endif
|
||||
@@ -683,8 +862,14 @@ int c4fWebRun(int klogFd)
|
||||
if (now >= app->heartbeatAt) {
|
||||
int active = 0;
|
||||
for (int i = 0; i < C4F_MAX_PADS; i++) active += app->pads[i].active;
|
||||
c4fLog("heartbeat: listener=%d controllers=%d klog=%d connecting=%d\n",
|
||||
app->net.fd >= 0, active, app->klogFd >= 0, app->add.state);
|
||||
c4fLog("heartbeat: listener=%d controllers=%d klog=%d connecting=%d rumble-changes=%u "
|
||||
"feedback-reads=%llu avg=%lluus max=%lluus\n",
|
||||
app->net.fd >= 0, active, app->klogFd >= 0, app->add.state, app->rumbleChanges,
|
||||
(unsigned long long)app->feedbackCalls,
|
||||
(unsigned long long)(app->feedbackCalls ? app->feedbackUs / app->feedbackCalls : 0),
|
||||
(unsigned long long)app->feedbackMaxUs);
|
||||
app->rumbleChanges = 0;
|
||||
app->feedbackCalls = app->feedbackUs = app->feedbackMaxUs = 0;
|
||||
app->heartbeatAt = now + 60000;
|
||||
}
|
||||
if (app->changed || now >= app->broadcastAt) {
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
/* c4fPadFeedbackParse against buffers read from the console (firmware 10.01). */
|
||||
#include <assert.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include "c4f_vda.h"
|
||||
|
||||
static C4fPadFeedback parse(const uint8_t *bytes, size_t n)
|
||||
{
|
||||
uint8_t buf[256] = {0};
|
||||
C4fPadFeedback f;
|
||||
memcpy(buf, bytes, n);
|
||||
c4fPadFeedbackParse(buf, sizeof(buf), &f);
|
||||
return f;
|
||||
}
|
||||
|
||||
int main(void)
|
||||
{
|
||||
/* A new controller, player 2: update flag, light bar 40 00 00. */
|
||||
static const uint8_t red[] = { 0x01, 0, 0, 0, 0, 0x40, 0, 0 };
|
||||
C4fPadFeedback f = parse(red, sizeof(red));
|
||||
assert(f.r == 0x40 && f.g == 0 && f.b == 0 && f.large == 0 && f.small == 0);
|
||||
|
||||
/* Player 3, signed in: light bar 00 40 00, assigned flag at [16]. */
|
||||
static const uint8_t green[17] = { 0, 0, 0, 0, 0, 0, 0x40, 0, [16] = 1 };
|
||||
f = parse(green, sizeof(green));
|
||||
assert(f.r == 0 && f.g == 0x40 && f.b == 0);
|
||||
|
||||
/* A game's hit: the small motor at full for ~300 ms. */
|
||||
static const uint8_t hit[] = { 0, 0, 0, 0xff, 0, 0, 0x40, 0 };
|
||||
f = parse(hit, sizeof(hit));
|
||||
assert(f.small == 0xff && f.large == 0 && f.g == 0x40);
|
||||
|
||||
/* Both motors, by the DualShock 4 layout: [3] small, [4] large. */
|
||||
static const uint8_t both[] = { 0x01, 0, 0, 0x30, 0xc0, 0x20, 0, 0x20 };
|
||||
f = parse(both, sizeof(both));
|
||||
assert(f.small == 0x30 && f.large == 0xc0 && f.r == 0x20 && f.b == 0x20);
|
||||
|
||||
/* Too short to hold the fields: nothing, rather than a misread. */
|
||||
uint8_t shortBuf[4] = { 1, 2, 3, 4 };
|
||||
c4fPadFeedbackParse(shortBuf, sizeof(shortBuf), &f);
|
||||
assert(!f.large && !f.small && !f.r && !f.g && !f.b);
|
||||
|
||||
puts("PASS rumble and light bar read from the console's buffer layout");
|
||||
}
|
||||
@@ -110,4 +110,27 @@ function padStatus(fields) {
|
||||
fs = await fullScreen({ enabled: false });
|
||||
assert.equal(fs.button.hidden, true);
|
||||
console.log('PASS full screen: real where allowed, a home-screen hint on iPhone, gone when standalone');
|
||||
|
||||
// Which gamepads the page says it can rumble.
|
||||
const rumbleCtx = {};
|
||||
vm.createContext(rumbleCtx);
|
||||
vm.runInContext(extract(' function canRumble(gp)', ' function renderGamepads()'), rumbleCtx);
|
||||
const can = gp => vm.runInContext('canRumble(gp)', Object.assign(rumbleCtx, { gp }));
|
||||
const play = () => Promise.resolve();
|
||||
assert.equal(can({ vibrationActuator: { playEffect: play, effects: ['dual-rumble', 'trigger-rumble'] } }), true);
|
||||
assert.equal(can({ vibrationActuator: { playEffect: play, type: 'dual-rumble' } }), true, 'older Chrome');
|
||||
assert.equal(can({ vibrationActuator: null }), false, 'Chrome with a DualSense');
|
||||
assert.equal(can({}), false, 'Firefox');
|
||||
assert.equal(can({ vibrationActuator: { playEffect: play, effects: [] } }), false);
|
||||
console.log('PASS gamepads that cannot rumble are named as such');
|
||||
|
||||
// The Invite panel draws the console's grid as runs of dark modules.
|
||||
const qrCtx = {};
|
||||
vm.createContext(qrCtx);
|
||||
vm.runInContext(extract(' function qrPath(size, rows, quiet)', ' // The address this page reached'), qrCtx);
|
||||
// 5 wide: row 0 = 11011, row 1 = 00100 -> hex d8, 20.
|
||||
assert.equal(vm.runInContext("qrPath(5, ['d8', '20'], 4)", qrCtx),
|
||||
'M4 4h2v1h-2zM7 4h2v1h-2zM6 5h1v1h-1z');
|
||||
assert.equal(vm.runInContext("qrPath(3, ['0', '0', '0'], 4)", qrCtx), '');
|
||||
console.log('PASS the invite QR is drawn from the console grid');
|
||||
})();
|
||||
@@ -14,6 +14,8 @@ def main():
|
||||
subprocess.run(flags + ['src/klog_line.c', 'tests/device_id_test.c',
|
||||
'-o', 'build/device-id-host-test'], check=True)
|
||||
subprocess.run(['build/device-id-host-test'], check=True, timeout=5)
|
||||
subprocess.run(flags + ['src/feedback.c', 'tests/feedback_test.c', '-o', 'build/feedback-host-test'], check=True)
|
||||
subprocess.run(['build/feedback-host-test'], check=True, timeout=5)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
@@ -21,7 +21,7 @@ def connect_again():
|
||||
|
||||
def main():
|
||||
subprocess.run(['clang-18', '-std=gnu11', '-Wall', '-Wextra', '-Werror', '-g',
|
||||
'-Iinclude', '-Ivendor/jsmn', 'src/net.c', 'src/web.c', 'src/klog_line.c',
|
||||
'-Iinclude', '-Ivendor/jsmn', '-Ivendor/qrcodegen', 'src/net.c', 'src/web.c', 'src/klog_line.c', 'vendor/qrcodegen/qrcodegen.c',
|
||||
'tests/web_stub.c', 'tests/recovery_faults.c', 'build/client.c', 'build/assets.c', '-pthread',
|
||||
'-Wl,--wrap=select,--wrap=accept,--wrap=getsockopt', '-o', str(web.BINARY)], check=True)
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
|
||||
@@ -21,8 +21,8 @@ def build():
|
||||
subprocess.run(['python3', 'tools/embed_file.py', 'build/assets.c',
|
||||
'c4fManifest=client/manifest.webmanifest', 'c4fIcon=client/icon-192.png'], check=True)
|
||||
subprocess.run(['clang-18', '-std=gnu11', '-Wall', '-Wextra', '-Werror', '-g',
|
||||
'-Iinclude', '-Ivendor/jsmn', '-DC4F_STALE_MS=400', '-DC4F_RELEASE_MS=1800',
|
||||
'src/net.c', 'src/web.c', 'src/klog_line.c', 'tests/web_stub.c', 'build/client.c',
|
||||
'-Iinclude', '-Ivendor/jsmn', '-Ivendor/qrcodegen', '-DC4F_STALE_MS=400', '-DC4F_RELEASE_MS=1800',
|
||||
'src/net.c', 'src/web.c', 'src/klog_line.c', 'vendor/qrcodegen/qrcodegen.c', 'tests/web_stub.c', 'build/client.c',
|
||||
'build/assets.c', '-pthread', '-o', str(BINARY)], check=True)
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ class Client:
|
||||
def __init__(self, port=4264):
|
||||
self.sock = socket.create_connection(('127.0.0.1', port), timeout=3)
|
||||
self.buf = b''
|
||||
self.pushed = [] # messages the service sent on its own, oldest first
|
||||
key = 'dGhlIHNhbXBsZSBub25jZQ=='
|
||||
self.sock.sendall((f'GET /ws HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\nOrigin: http://127.0.0.1:{port}\r\n'
|
||||
f'Connection: Upgrade\r\nUpgrade: websocket\r\nSec-WebSocket-Key: {key}\r\nSec-WebSocket-Version: 13\r\n\r\n').encode())
|
||||
@@ -76,6 +77,24 @@ class Client:
|
||||
msg = json.loads(text)
|
||||
if msg.get('id') == ident:
|
||||
return msg
|
||||
if 'id' not in msg:
|
||||
self.pushed.append(msg)
|
||||
|
||||
def wait_for(self, match, timeout=2):
|
||||
"""The first pushed message `match` accepts, seen already or still to come."""
|
||||
end = time.monotonic() + timeout
|
||||
while True:
|
||||
for i, msg in enumerate(self.pushed):
|
||||
if match(msg):
|
||||
del self.pushed[:i + 1]
|
||||
return msg
|
||||
self.pushed.clear()
|
||||
assert time.monotonic() < end, 'the expected message never came'
|
||||
op, text = self.receive()
|
||||
if op == 1:
|
||||
msg = json.loads(text)
|
||||
if 'id' not in msg:
|
||||
self.pushed.append(msg)
|
||||
|
||||
def input(self, pad, buttons=0, lx=128, ly=128, rx=128, ry=128, l2=0, r2=0, touch=()):
|
||||
params = [pad, buttons, lx, ly, rx, ry, l2, r2, len(touch)]
|
||||
@@ -347,6 +366,101 @@ def main():
|
||||
finally:
|
||||
server.close()
|
||||
|
||||
# Rumble goes to the controller's owner; the light bar, brightened, to everyone.
|
||||
def rumble(pad, large, small):
|
||||
return lambda m: m.get('method') == 'v' and m['params'] == [pad, large, small]
|
||||
server = Server()
|
||||
try:
|
||||
c = server.c
|
||||
assert c.request('claim', [0])['result']['pads'][0]['mine']
|
||||
assert c.request('status')['result']['pads'][0]['color'] == [32, 96, 255], 'unlit: its own colour'
|
||||
os.write(server.log, b'C4F-TEST-FEEDBACK 11030d 200 40 64 0 0\n')
|
||||
c.wait_for(rumble(0, 200, 40))
|
||||
time.sleep(.05)
|
||||
assert c.request('status')['result']['pads'][0]['color'] == [255, 0, 0], 'player 2 red, at full'
|
||||
assert 'web controller 1 light bar 40 00 00' in server.rows(), 'the colour is logged for diagnosis'
|
||||
watcher = Client()
|
||||
os.write(server.log, b'C4F-TEST-FEEDBACK 11030d 0 0 0 64 0\n')
|
||||
c.wait_for(rumble(0, 0, 0))
|
||||
time.sleep(.05)
|
||||
assert watcher.request('status')['result']['pads'][0]['color'] == [0, 255, 0], 'everyone sees it'
|
||||
os.write(server.log, b'C4F-TEST-FEEDBACK 11030d 0 0 32 0 32\n')
|
||||
time.sleep(.1)
|
||||
assert c.request('status')['result']['pads'][0]['color'] == [255, 0, 255], 'pink keeps its hue'
|
||||
assert not any(m.get('method') == 'v' for m in watcher.pushed), 'rumble went to a non-owner'
|
||||
# Rumble holds while the game holds it; one message per change, not per poll.
|
||||
os.write(server.log, b'C4F-TEST-FEEDBACK 11030d 90 0 32 0 32\n')
|
||||
c.wait_for(rumble(0, 90, 0))
|
||||
time.sleep(.3)
|
||||
c.request('status')
|
||||
assert not any(m.get('method') == 'v' for m in c.pushed), 'unchanged rumble was sent again'
|
||||
# Whoever takes the controller over is told the current state at once.
|
||||
c.close()
|
||||
server.c = c = Client()
|
||||
assert c.request('claim', [0])['result']['pads'][0]['mine']
|
||||
c.wait_for(rumble(0, 90, 0))
|
||||
# And told when it stops, so a page never keeps buzzing on old news.
|
||||
os.write(server.log, b'C4F-TEST-FEEDBACK 11030d 0 0 0 0 0\n')
|
||||
c.wait_for(rumble(0, 0, 0))
|
||||
time.sleep(.05)
|
||||
assert c.request('status')['result']['pads'][0]['color'] == [32, 96, 255], 'unlit again: its own colour'
|
||||
watcher.close()
|
||||
print('PASS rumble to the owner on change, the light bar to everyone, the state to a new owner', flush=True)
|
||||
finally:
|
||||
server.close()
|
||||
|
||||
# Without the call, controllers work as before.
|
||||
server = Server(C4F_TEST_NO_FEEDBACK='1')
|
||||
try:
|
||||
assert server.c.request('claim', [0, 1])['result']['pads'][1]['mine']
|
||||
time.sleep(.1)
|
||||
assert server.c.request('status')['result']['pads'][0]['color'] == [32, 96, 255]
|
||||
assert len([r for r in server.rows() if 'not exported' in r]) == 1
|
||||
print('PASS no rumble call: logged once, controllers unaffected', flush=True)
|
||||
finally:
|
||||
server.close()
|
||||
|
||||
# Who's signed in on each controller, made safe for JSON and UTF-8, kept out of the log.
|
||||
server = Server()
|
||||
try:
|
||||
c = server.c
|
||||
assert c.request('claim', [0, 1])['result']['pads'][1]['mine']
|
||||
assert c.request('status')['result']['pads'][0]['user'] == ''
|
||||
os.write(server.log, b'<118>#LOGIN MGR# Receive Event : SCE_MBUS_EVENT_DEVICE_OWNER_CHANGED [DeviceId:0x11030d][UserId:0x1a2b3c4d]\n')
|
||||
os.write(server.log, b'<118>#LOGIN MGR# Receive Event : SCE_MBUS_EVENT_DEVICE_OWNER_CHANGED [DeviceId:0x12030d][UserId:0x1a2b3c4e]\n')
|
||||
time.sleep(1.2) # Sam's name only comes on the retry, half a second later
|
||||
pads = c.request('status')['result']['pads']
|
||||
assert pads[0]['user'] == 'Alex', pads[0]
|
||||
assert pads[1]['user'] == 'Sam "S" \\ ? ? \u00e9', repr(pads[1]['user'])
|
||||
assert pads[2]['user'] == '' and pads[3]['user'] == ''
|
||||
assert not any('Alex' in r or 'Sam' in r for r in server.rows()), 'a name reached the log'
|
||||
assert any('user name found (4 bytes)' in r for r in server.rows())
|
||||
print('PASS signed-in names shown, made safe for JSON and UTF-8, kept out of the log', flush=True)
|
||||
finally:
|
||||
server.close()
|
||||
|
||||
# The Invite panel's QR code: a real QR code of the address the page names.
|
||||
server = Server()
|
||||
try:
|
||||
r = server.c.request('invite', [192, 168, 1, 20, 4264])['result']
|
||||
assert r['text'] == 'http://192.168.1.20:4264/', r
|
||||
size = r['size']
|
||||
assert size in (21, 25, 29, 33, 37) and len(r['rows']) == size
|
||||
bits = [[int(row[x >> 2], 16) >> (3 - (x & 3)) & 1 for x in range(size)] for row in r['rows']]
|
||||
assert all(len(row) == (size + 3) // 4 for row in r['rows'])
|
||||
# The three finder squares: a dark ring, a light ring, a dark 3x3 core.
|
||||
finder = [[1,1,1,1,1,1,1], [1,0,0,0,0,0,1], [1,0,1,1,1,0,1], [1,0,1,1,1,0,1],
|
||||
[1,0,1,1,1,0,1], [1,0,0,0,0,0,1], [1,1,1,1,1,1,1]]
|
||||
for ox, oy in ((0, 0), (size - 7, 0), (0, size - 7)):
|
||||
assert [bits[oy + y][ox:ox + 7] for y in range(7)] == finder, (ox, oy)
|
||||
assert server.c.request('invite', [300, 1, 1, 1, 4264])['error']['code'] == 400
|
||||
assert server.c.request('invite', [192, 168, 1, 20, 0])['error']['code'] == 400
|
||||
reply = server.c.request('invite', []) # the console's own address, when it has a route
|
||||
assert reply.get('result', {}).get('text', 'http://').startswith('http://') or reply['error']['code'] == 503
|
||||
print('PASS invite: a real QR code of the page address, bad addresses refused', flush=True)
|
||||
finally:
|
||||
server.close()
|
||||
|
||||
# Two claims cannot create at once: the second is refused, not queued.
|
||||
server = Server(C4F_TEST_ADD_DELAY='600')
|
||||
try:
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
* marker verification and DeviceId matching, and only scePad is absent.
|
||||
*
|
||||
* A test injects its own kernel lines by writing them to the pipe named in
|
||||
* C4F_TEST_KLOG_FD; they are relayed into the log. The line C4F-TEST-CLOSE-KLOG
|
||||
* C4F_TEST_KLOG_FD; they are relayed into the log, except two commands: the line
|
||||
* C4F-TEST-FEEDBACK <handle> <large> <small> <r> <g> <b> sets what the "game"
|
||||
* wants of that controller (handle in hex), and C4F-TEST-CLOSE-KLOG
|
||||
* kills the log instead, as GoldHEN taking the device would.
|
||||
*
|
||||
* Environment switches:
|
||||
@@ -16,6 +18,7 @@
|
||||
* C4F_TEST_ADD_DELAY milliseconds before the device-added line turns up
|
||||
* C4F_FAIL_ADD AddDevice runs but its line never arrives
|
||||
* C4F_FAIL_INPUT InsertData fails while a button is held
|
||||
* C4F_TEST_NO_FEEDBACK the rumble/light-bar call isn't exported
|
||||
* C4F_TEST_SLOW_MS milliseconds each new device takes to adopt, the way
|
||||
* real console work spends time inside one loop iteration
|
||||
*/
|
||||
@@ -32,6 +35,35 @@
|
||||
|
||||
static unsigned nextId = 0x11030d;
|
||||
static int logRead = -1, logWrite = -1;
|
||||
|
||||
/* What the "game" wants of each controller, by handle. */
|
||||
static struct { unsigned handle; C4fPadFeedback feedback; } feedbacks[8];
|
||||
static pthread_mutex_t feedbackLock = PTHREAD_MUTEX_INITIALIZER;
|
||||
|
||||
static void setFeedback(const char *line)
|
||||
{
|
||||
unsigned handle, v[5];
|
||||
if (sscanf(line, "C4F-TEST-FEEDBACK %x %u %u %u %u %u", &handle, &v[0], &v[1], &v[2], &v[3], &v[4]) != 6) return;
|
||||
pthread_mutex_lock(&feedbackLock);
|
||||
for (int i = 0; i < 8; i++) {
|
||||
if (feedbacks[i].handle && feedbacks[i].handle != handle) continue;
|
||||
feedbacks[i].handle = handle;
|
||||
feedbacks[i].feedback = (C4fPadFeedback){ (uint8_t)v[0], (uint8_t)v[1], (uint8_t)v[2], (uint8_t)v[3], (uint8_t)v[4] };
|
||||
break;
|
||||
}
|
||||
pthread_mutex_unlock(&feedbackLock);
|
||||
}
|
||||
|
||||
int32_t c4fVirtualPadFeedback(const C4fVirtualPad *p, C4fPadFeedback *out)
|
||||
{
|
||||
memset(out, 0, sizeof(*out));
|
||||
if (getenv("C4F_TEST_NO_FEEDBACK")) return -1;
|
||||
pthread_mutex_lock(&feedbackLock);
|
||||
for (int i = 0; i < 8; i++)
|
||||
if (feedbacks[i].handle == (unsigned)p->handle) *out = feedbacks[i].feedback;
|
||||
pthread_mutex_unlock(&feedbackLock);
|
||||
return 0;
|
||||
}
|
||||
static pthread_mutex_t logLock = PTHREAD_MUTEX_INITIALIZER;
|
||||
|
||||
/* The write end is non-blocking: a log nobody drains drops lines instead of
|
||||
@@ -69,6 +101,16 @@ static void *relay(void *arg)
|
||||
ssize_t n = read(source, buf, sizeof(buf) - 1);
|
||||
if (n <= 0) return NULL;
|
||||
buf[n] = 0;
|
||||
if (strstr(buf, "C4F-TEST-FEEDBACK")) {
|
||||
char *save = NULL;
|
||||
for (char *line = strtok_r(buf, "\n", &save); line; line = strtok_r(NULL, "\n", &save)) {
|
||||
char copy[sizeof(buf) + 1];
|
||||
if (!strncmp(line, "C4F-TEST-FEEDBACK", 17)) { setFeedback(line); continue; }
|
||||
snprintf(copy, sizeof(copy), "%s\n", line);
|
||||
logLine(copy);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (strstr(buf, "C4F-TEST-CLOSE-KLOG")) {
|
||||
pthread_mutex_lock(&logLock);
|
||||
if (logWrite >= 0) { close(logWrite); logWrite = -1; }
|
||||
@@ -147,6 +189,21 @@ void c4fVirtualPadAdopt(C4fVirtualPad *p, int32_t user, int32_t vdaUser, uint64_
|
||||
printf("ADOPT %x %d\n", (unsigned)p->handle, vdaUser);
|
||||
}
|
||||
|
||||
/* Alex, and a name built to break JSON and UTF-8 that only resolves on a retry,
|
||||
* as a user still signing in does. */
|
||||
int32_t c4fUserName(uint32_t userId, char *out, size_t size)
|
||||
{
|
||||
static int samTries;
|
||||
if (size) out[0] = 0;
|
||||
if (userId == 0x1a2b3c4d) { snprintf(out, size, "Alex"); return 0; }
|
||||
if (userId == 0x1a2b3c4e) {
|
||||
if (++samTries < 2) return (int32_t)0x80960009;
|
||||
snprintf(out, size, "Sam \"S\" \\ \x01 \xff \xc3\xa9");
|
||||
return 0;
|
||||
}
|
||||
return (int32_t)0x80960009;
|
||||
}
|
||||
|
||||
void c4fVirtualPadRemove(C4fVirtualPad *p) { printf("REMOVE %x\n", p->handle); p->owned = 0; }
|
||||
void c4fPadDataNeutral(ScePadData *p)
|
||||
{
|
||||
|
||||