1.1.0: rumble and the light bar

A game talks to a virtual controller as it would to a real one, and the system
keeps what it asked for. scePadVirtualDeviceGetRemoteSetting reads it back in
the layout of a DualShock 4's output report: the two motors at [3] and [4], the
light bar at [5..7]. The layout was read on the console while games rumbled and
users signed in, and feedback.c is tested against those exact buffers.

The service reads it every 16 ms for each controller; games pulse rumble for
only tens of milliseconds, which a 100 ms poll missed.

- Rumble goes to the controller's owner as {"method":"v","params":[pad,large,
  small]}, once per change, and to whoever takes a controller over, so a page
  never keeps buzzing on old news. The page already drove an Android phone's
  vibration and a gamepad's actuators from it.
- The light bar goes into the status everyone sees. The PS4 sets player colours
  at a quarter strength, so the colour is brightened to full, keeping its hue;
  an unlit bar keeps the controller's own colour.
- Without the call, controllers work as before and it is logged once.

Also: the page says "Choose user on PS4", not "on TV", since not everyone plays
on a TV. The test stub takes C4F-TEST-FEEDBACK lines to set what the "game"
wants, and the test client can wait for messages the service pushes.
This commit is contained in:
MoHadiShibli committed 2026-10-06 01:05:32 +03:00
1 parent 183f9dfaee
commit baf36d648e
23 files changed
+350 -27

No files matched your search

+13
View File
@@ -3,6 +3,19 @@
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-05
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 in browsers that support it, such as Chrome and Edge. Safari on iPhone
doesn't let pages vibrate the phone.
- 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.
- The page says "Choose user on PS4" rather than "on TV": not everyone plays on a TV.
- The status reports the light bar's colour in `color`, and the service sends rumble as
`{"method": "v", "params": [pad, large, small]}`. See [docs/protocol.md](docs/protocol.md).
## 1.0.0 — 2026-10-05
First release.
+1 -1
View File
@@ -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;
+1 -1
View File
@@ -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.
+7 -3
View File
@@ -36,6 +36,8 @@ sees a real controller, so it works wherever a DualShock does:
- **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 your gamepad rumbles. 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,7 +49,7 @@ 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
@@ -85,8 +87,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
+1 -1
View File
@@ -18,7 +18,7 @@ can reach your PS4 over the network can:
- press PS and Share, which reach the system menus;
- stop Control4Free, which disconnects every controller.
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
+1 -1
View File
@@ -1 +1 @@
1.0.0
1.1.0
+4 -4
View File
@@ -1195,7 +1195,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],
@@ -2342,7 +2342,7 @@
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'));
@@ -2423,7 +2423,7 @@
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 +2473,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;
+13 -1
View File
@@ -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
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 535 KiB

After

Width:  |  Height:  |  Size: 535 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 840 KiB

After

Width:  |  Height:  |  Size: 840 KiB

+15 -2
View File
@@ -17,7 +17,7 @@ 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. |
| **Choose user on PS4** | Waiting for someone to pick a user on the PS4's screen. |
| **Connected** | Signed in and ready. |
| **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. |
@@ -77,7 +77,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 +100,19 @@ 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, in browsers that support it (Chrome and Edge do, with
Xbox and DualSense pads).
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.
+7 -5
View File
@@ -40,7 +40,7 @@ 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. |
@@ -93,7 +93,7 @@ a connection claims them again; the page does so when it reconnects.
```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,
@@ -108,15 +108,17 @@ a connection claims them again; the page does so when it reconnects.
| `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 +143,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}
```
+4 -3
View File
@@ -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,9 @@ 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. For a gamepad, use a browser that supports controller rumble, such as
Chrome or Edge, and keep the page's window in front. Not every game rumbles.
## Gamepads
+15
View File
@@ -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,20 @@ 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);
/* A neutral sample: sticks centred, identity quaternion, connected. */
void c4fPadDataNeutral(ScePadData *data);
+34
View File
@@ -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];
}
+12
View File
@@ -147,6 +147,18 @@ 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;
}
void c4fPadDataNeutral(ScePadData *data)
{
(void)memset(data, 0, sizeof(*data));
+61 -4
View File
@@ -30,6 +30,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 +42,11 @@ 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;
} C4fWebPad;
/* Creating a controller means issuing AddDevice and then finding its DeviceId in
@@ -72,7 +80,7 @@ typedef struct {
typedef struct {
C4fNet net;
C4fWebPad pads[C4F_MAX_PADS];
int klogFd, stop, changed, creationBlocked;
int klogFd, stop, changed, creationBlocked, feedbackMissing;
char klogLine[1024];
size_t klogUsed;
C4fAdd add;
@@ -154,12 +162,22 @@ 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;
}
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,
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 +240,44 @@ static void c4fReportPads(C4fWeb *app)
}
}
/* 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;
ret = c4fVirtualPadFeedback(&p->device, &f);
if (ret == -1) {
c4fLog("scePadVirtualDeviceGetRemoteSetting is not exported: no rumble or light bar\n");
app->feedbackMissing = 1;
return;
}
if (ret != 0) {
if (!p->feedbackFailed) c4fLog("web controller %d GetRemoteSetting = 0x%08x\n", i + 1, (uint32_t)ret);
p->feedbackFailed = 1;
continue;
}
if (!p->feedbackKnown || f.r != p->feedback.r || f.g != p->feedback.g || f.b != p->feedback.b)
app->changed = 1;
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)
@@ -371,7 +427,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);
}
@@ -661,6 +717,7 @@ int c4fWebRun(int klogFd)
}
long klogBytes = c4fReadKlog(app);
c4fReportPads(app);
c4fPollFeedback(app, now);
#ifdef C4F_PROBE_SETTING
c4fProbeSetting(app, now);
#endif
+44
View File
@@ -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");
}
+2
View File
@@ -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__':
+72
View File
@@ -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,59 @@ 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'
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()
# Two claims cannot create at once: the second is refused, not queued.
server = Server(C4F_TEST_ADD_DELAY='600')
try:
+43 -1
View File
@@ -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; }