Finalize Control4Free 1.2.0 fixes and release documentation

This commit is contained in:
MoHadiShibli committed 2026-10-09 21:13:37 +03:00
1 parent 41c476b2e2
commit 252e26d2b7
25 files changed
+627 -65

No files matched your search

+18 -13
View File
@@ -3,33 +3,38 @@
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.2.0 — 2026-10-08
## 1.2.0 — 2026-10-09
Gamepad mapping.
- Map a gamepad on a picture of a DualShock 4: each control shows what drives it and lights up when pressed.
Click a control, then press the gamepad button for it. It works for gamepads the browser doesn't know, and
for wheels, guitars, pedals and arcade sticks, as DualShock 4 buttons, sticks and triggers. Sticks and
triggers show their position with a dead zone and a curve. **Everything it sends** shows every button and
- Map a gamepad on a DualShock 4: each control shows what drives it and lights up when pressed.
Click a control, then press the gamepad button for it. Map browser-exposed inputs from gamepads, wheels,
guitars, pedals and arcade sticks to DualShock 4 buttons, sticks and triggers. Wheel and guitar model
compatibility remains unverified, and native accessory identity is not provided. Sticks and
triggers show their position with a dead zone and a curve. **Gamepad Controls** shows every button and
axis live, and sets an axis's range a step at a time. See [Gamepad mapping](docs/gamepad-mapping.md).
- Keyboard keys are chosen on the same picture.
- Mappings are saved as profiles in the browser, used for every gamepad of the same kind, and can be exported
to a file and imported on another device.
- Keyboard keys are chosen on the same screen.
- Mappings are saved as named profiles in the browser. Save updates every connected gamepad using that
profile; Save as new gives the selected gamepad a separate profile. Profiles can be exported to a file
and imported on another device for review before saving.
- Several gamepads can play as the same controller, for example a wheel and its pedals.
- A button still held when a gamepad starts playing does nothing until it's let go.
- A phone that drops off the network no longer keeps a connection to the PS4 open for ever. Before, enough of
those could stop new phones and computers from opening the page.
- When two people pick controllers at the same moment, one is asked to try again, instead of one taking the
other's controller.
- A controller the console refused input for recovers once it accepts input again.
- The page only asks for the PS4's address when it was saved to the device and opened from there.
- The address box takes a full address, such as `http://192.168.1.20:4264/`, and adds `:4264` when it's
missing.
- Signing out of a PS4 user disconnects their virtual controllers and clears their names and browser
selections. Other signed-in players stay connected.
- The mapping header aligns its controls, and gamepad rows group the device name, controller choice and
Mapping button. The PS button uses the PlayStation mark and shows press feedback.
- Documented a Windows GameInput workaround for Xbox buttons crossing gamepads in affected Chrome/Edge
builds. A two-controller Chrome/Windows 11 test confirmed it; it is not a general requirement. See
[Gamepad troubleshooting](docs/troubleshooting.md#gamepads).
- A touch on the touchpad that the browser cancels no longer clicks it.
- Keys with Ctrl, Alt or the Windows key can't be chosen any more: they never worked in play.
- The app on the PS4 says when the Control4Free running is a different version from the app, and how to switch.
It tells a missing auto-start copy apart from auto-start being off.
- The service's log stays under 2 MB, however long it runs.
- Active service logs are bounded to two 1 MiB files. The last segment of the previous run is kept separately.
- The diagnostic app says whether it managed to save its report.
## 1.1.1 — 2026-10-07
+11 -5
View File
@@ -11,7 +11,7 @@ details:
- the phone or computer and browser you used;
- what you did and what happened;
- the service's log, if you can get it: `/data/control4free/control4free.log` on the PS4, over GoldHEN's FTP
server (port 2121). The previous run is kept in `control4free.log.previous`.
server (port 2121). The last segment of the previous run is kept in `control4free.log.previous`.
The log contains your console's network address. Remove it before posting if you prefer.
@@ -78,10 +78,16 @@ Build without new warnings; the app is built with `-Werror`.
## Releasing
1. Update `VERSION`, and add a section to `CHANGELOG.md`.
2. Commit, tag `vX.Y.Z`, and push the tag.
3. CI builds both images, runs the tests, and publishes the release with the ELF, the package and
`SHA256SUMS`. It refuses a tag that doesn't match `VERSION`.
1. Update `VERSION` and the changelog's release date and notes. Review mapping, compatibility and
diagnostic documentation; distinguish hardware reports from emulated-input tests.
2. Run all five Docker host suites and the browser/editor and emulated-gamepad checks. Build normal and
diagnostic packages without new warnings. Inspect launcher previews and browser layouts.
3. Validate standard controllers on real hardware: simultaneous devices, source mixing, rumble,
reconnect, logout and rest/wake. Recheck firmware 10.01 and a modern tested firmware, and record the
results. Wheel/guitar model compatibility remains unverified until device reports establish it.
4. Commit, tag `vX.Y.Z`, and push the tag when the release gates pass.
5. CI checks the tag against `VERSION` and publishes `control4free.elf`, the normal package, the diagnostic
package and `SHA256SUMS`. Release-note documentation links point to that tag.
## Reviewing AI-written code
+12 -6
View File
@@ -9,7 +9,7 @@
<a href="https://ko-fi.com/mohadishibli"><img src="https://img.shields.io/badge/Ko--fi-support%20this%20project-FF5E5B?logo=ko-fi&logoColor=white" alt="Support on Ko-fi"></a>
</p>
**Use your phone or PC as a PS4 controller everywhere: on the home screen, at sign-in, and in every game.**
**Use your phone or PC as a PS4 controller: on the home screen, at sign-in, and in games.**
Control4Free adds up to four extra DualShock 4 controllers to a jailbroken PS4. Open its page in a browser on
your network, pick a controller, and choose who's playing on the PS4's own *"Who's using this controller?"*
@@ -22,11 +22,11 @@ others work through the phone or PC. There's nothing to install on the phone or
Controller plugins live inside games, so they can't reach the home screen or the sign-in screen. Control4Free
is a GoldHEN payload that goes through Sony's own virtual-device system, the one Remote Play uses. The PS4
sees a real controller, so it works wherever a DualShock does:
sees a virtual DualShock 4, which provides:
- **on the home screen and in every menu**, PS button included;
- **at sign-in**, through the PS4's own user picker, for real users and guests alike;
- **in every game**, with no per-game setup.
- **in games**, with DS4 input and no per-game plugin setup. See the limits below for motion and accessories.
## Features
@@ -40,7 +40,8 @@ sees a real controller, so it works wherever a DualShock does:
can run all four.
- **A keyboard**, with keys you choose on the same picture.
- **Your own gamepad mapping.** Map any gamepad on a picture of a DualShock 4: click a control, then press the
button you want. Works for gamepads the browser doesn't know, and for wheels, guitars and pedals too. See
button you want. Map browser-exposed buttons and axes from gamepads, wheels, guitars and pedals to DS4
controls; wheel and guitar model compatibility remains unverified. See
[Gamepad mapping](docs/gamepad-mapping.md).
- **Rumble and light bar.** When a game rumbles, your phone buzzes (Android), and so does your gamepad,
though not every controller supports vibration in the browser. The page glows in the controller's
@@ -76,6 +77,7 @@ New to GoldHEN packages? The **[installation guide](docs/installation.md)** walk
- **[Installation](docs/installation.md)**: requirements, installing, auto-start, updating, uninstalling.
- **[Playing](docs/playing.md)**: signing in, touch, keyboard and gamepads, the layout editor, settings.
- **[Gamepad mapping](docs/gamepad-mapping.md)**: profiles, calibration and using several input devices together.
- **[Troubleshooting](docs/troubleshooting.md)**: the messages you might see and what to do about each.
- **[How it works](docs/how-it-works.md)**: the virtual-device API, sign-in, and what runs where.
- **[Protocol](docs/protocol.md)**: the WebSocket protocol between the page and the console.
@@ -95,8 +97,12 @@ New to GoldHEN packages? The **[installation guide](docs/installation.md)** walk
## Limitations
- **The PS4 always sees a DualShock 4.** A wheel plays as sticks and triggers, so games that need a real
wheel or guitar don't accept it, and there's no force feedback.
- **The PS4 always sees a DualShock 4.** Mapping does not provide native wheel or guitar identity, or wheel
force feedback. Games requiring native accessories are outside this release; native accessory emulation
remains a separate, unproven research question.
- **Browser input limits apply.** Hidden buttons and lost input identity cannot be recovered by mapping.
With several Xbox controllers, some Chrome/Edge Windows backends combine their Xbox buttons; see the
[GameInput workaround](docs/troubleshooting.md#gamepads).
- **Six pages at a time.** Up to six phones or computers can have the page open at once.
- **No motion controls.** Browsers only give phone motion sensors to secure (`https`) pages, and the
console's page is plain `http`.
+5 -4
View File
@@ -19,8 +19,9 @@ can reach your PS4 over the network can:
- 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 PS4 choosing that
user on the PS4's own screen, and they cannot see your screen.
Controller input can operate the PS4's user-selection screen, so someone on your
network can select an available user. The PS4's own login restrictions still apply.
Control4Free does not stream the screen.
Run Control4Free on a network you trust. Do not forward port 4264 through your
router, and do not run it on open or guest Wi-Fi. When you are finished, stop it
@@ -51,8 +52,8 @@ for a controller page opened from a saved file, and the saved file is how gamepa
work in browsers that only allow the Gamepad API on secure pages. A website can
produce the same `Origin: null` from a sandboxed frame, so a page you have open
while on your home network could take a free controller and press buttons, as
anyone on your network can. It cannot take a controller someone is using, sign in
as a user, or see your screen.
anyone on your network can. It cannot take a controller someone is using or stream
your screen, but its input can operate the user-selection screen.
**One controller has one owner.** A slot is claimed by one WebSocket connection.
Another device asking for the same slot is refused (409), and input never claims a
+24 -6
View File
@@ -1704,6 +1704,7 @@
function applyStatus(s) {
if (!s || !Array.isArray(s.pads)) return;
let closePrimary = false;
if (s.version && s.version !== conn.version) {
conn.version = s.version;
showVersion();
@@ -1715,13 +1716,17 @@
const wasMine = st.mine;
Object.assign(st, p, { known: true });
if (wasMine && !st.mine) {
for (const src of gamepadSources.values()) if (src.pad === p.pad) src.pad = -1;
if (primaryPad === p.pad) closePad();
onRumble(p.pad, 0, 0);
for (const src of gamepadSources.values()) if (src.pad === p.pad) { src.pad = -1; clearDevice(src); }
if (primaryPad === p.pad) closePrimary = true;
lastClaim = '';
}
// The game just opened this pad: make sure it gets our current state right away
if (!wasOpen && st.open) lastSent[p.pad] = '';
}
// Closing sends the remaining claim. Clear every lost source first so a
// logout affecting several controllers cannot recreate a later slot.
if (closePrimary) closePad();
renderPads();
renderPadLabel();
if (gamepadSources.size) renderGamepads();
@@ -2885,6 +2890,12 @@
src() { return this.index === null ? null : gamepadSources.get(this.index); },
keyboard() { return this.source === 'keyboard'; },
status(text) { $('#mapStatus').textContent = text || ''; },
guideStatus() {
if (this.keyboard()) return 'Click a DS4 control to change its key.';
return (this.tab === 'inputs'
? 'Select a button or axis to inspect or change it.'
: 'Click a DS4 control, then choose the gamepad input for it.') + ' This gamepad stops playing until you leave.';
},
// Changed since saved, or a different profile chosen for this gamepad.
dirty() {
if (!this.draft) return false;
@@ -2913,8 +2924,7 @@
document.body.classList.remove('in-pad');
mapView.hidden = false;
this.render();
this.status(this.keyboard() ? 'Click a control to change its key.' :
'Click a control, then press the gamepad button for it. This gamepad stops playing until you leave.');
this.status(this.guideStatus());
},
// Leaves without saving. `quiet` when another view is about to take over.
@@ -3098,7 +3108,13 @@
if (this.pick) return this.renderPick(panel);
if (this.input) return this.renderInputPanel(panel);
if (!this.target) {
panel.appendChild(el('h3', 'map-panel-title', this.keyboard() ? 'Keyboard' : 'Mapping'));
if (!this.keyboard() && this.tab === 'inputs') {
panel.appendChild(el('h3', 'map-panel-title', 'Gamepad Controls'));
panel.appendChild(el('p', 'hint', 'See the buttons and axes your browser detects, their live readings, and what they control on the DS4.'));
panel.appendChild(el('p', 'hint', 'Press a button or move a stick to identify it. Select its tile or bar to change its mapping. Axes also have response and range settings.'));
return;
}
panel.appendChild(el('h3', 'map-panel-title', this.keyboard() ? 'Keyboard' : 'DS4 Mapping'));
panel.appendChild(el('p', 'hint', this.keyboard()
? 'Click a control on the controller to choose its key. Press keys to see what they do.'
: 'Click a control on the controller to choose what drives it. Press anything on the gamepad to see what it does.' +
@@ -3830,7 +3846,9 @@
mapper.importProfile(file);
});
for (const tab of $$('#mapTabs button')) tab.addEventListener('click', () => {
mapper.stopListening(); mapper.tab = tab.dataset.tab; mapper.pick = null; mapper.render();
mapper.stopListening(); mapper.tab = tab.dataset.tab;
mapper.target = ''; mapper.input = null; mapper.wizard = null; mapper.pick = null;
mapper.render(); mapper.status(mapper.guideStatus());
});
$('#mapStage').addEventListener('click', e => mapper.clickPicture(e));
window.addEventListener('keydown', e => { if (!mapView.hidden && mapper.keyboard()) mapper.onKey(e); }, true);
+69 -11
View File
@@ -1,9 +1,16 @@
# Gamepad mapping
Any gamepad, wheel, guitar or other game controller the browser can see can play as one of the four
controllers, with every button and stick doing what you choose. You map it on a picture of a DualShock 4. The
Map the buttons and axes a browser exposes from gamepads, wheels, guitars and other input devices to one of
the four virtual DualShock 4 controllers. Wheel and guitar model compatibility remains unverified; these
are mapping capabilities, not native accessory emulation. You map on a picture of a DualShock 4. The
keyboard is mapped the same way. Mappings are kept in the browser, like your layout.
With several gamepads on Chrome or Edge for Windows, Xbox/Guide presses can be reported on the wrong
gamepad by the browser. Check **Gamepad Controls** first; see the
[Xbox-button workaround](troubleshooting.md#gamepads). Mapping cannot restore an input's missing source.
![DS4 Mapping with an emulated example gamepad](images/mapping.png)
## Change a button
1. Connect the gamepad to the phone or computer and press a button on it, so it shows up under **Gamepads on
@@ -15,9 +22,10 @@ keyboard is mapped the same way. Mappings are kept in the browser, like your lay
4. Click a control, press **Change**, then press the gamepad button you want for it.
5. Press **Save**.
**Add another** gives a control a second button, for example a back paddle that also presses Cross.
**Remove** takes one away. A gamepad button drives one control, as a keyboard key does: give it to Circle, and
Cross lets go of it.
**Add another** gives a DS4 control another input, for example a back paddle that also presses Cross.
Cross stays pressed until both inputs are released. **Remove** takes a binding away. Choosing **Change**
moves the selected input from its previous control. The engine also accepts profiles with one input
driving multiple outputs; see [Advanced profiles](#advanced-profiles).
While you map a gamepad, it stops playing. Its controller stays connected, and everyone else keeps playing.
When you leave, a button you're still holding does nothing until you let go of it, so nothing gets pressed by
@@ -54,6 +62,12 @@ A D-pad that the browser reports as a single axis (a "hat") works the same way:
## Gamepad Controls
The **Gamepad Controls** tab shows every button and axis on the gamepad, live, and what each one drives.
Press a button or move a stick to identify its tile or bar. This view starts with the physical input;
**DS4 Mapping** starts with the DS4 control you want to change. The guide panel follows the selected tab.
The screenshots use an emulated gamepad to illustrate the editor.
![Gamepad Controls showing the emulated gamepad's raw inputs](images/mapping-inputs.png)
Click one to:
- **Make it drive…** another control: click that control on the picture. For a stick, choose the direction.
@@ -63,19 +77,56 @@ Click one to:
(let go, then all the way one way, then the other), a pedal or trigger (let go, then all the way), or a D-pad
hat (let go, then up, right, down and left).
The hat wizard infers diagonal values from the four directions. Test every diagonal afterward. A hat with
different encoding needs its actual neutral and eight direction values in an advanced profile.
## Profiles
A mapping is saved as a profile, named in the **Name** box. It's used for every gamepad of the same kind in this
browser, now and when you connect one again. **Save** changes the profile for every connected gamepad using it.
The **Profile** list switches between your profiles and the **Standard layout**, which can't be changed.
A mapping is saved as a profile, named in the **Name** box. Choose that profile for each connected gamepad
that should use it. **Save** updates every connected gamepad already using that profile; the editor lists
the others affected. **Save as a new profile** branches a profile for only the selected gamepad.
The browser remembers the last saved profile choice for matching device IDs, mapping types and input
counts when a gamepad reconnects. The **Profile** list switches between your profiles and the
**Standard layout**, which is immutable: editing and saving it creates a named profile.
The **⋯** menu has **Save as a new profile**, **New empty profile**, **Back to the standard layout**, **Export to a
file**, **Import from a file**, and **Delete this profile**, which asks you to press it twice. A profile made for a
different gamepad says so: press **Fit it to this gamepad** to drop the inputs this one doesn't have.
Imports open as drafts and do not overwrite an existing profile. Review bindings and calibration before
**Save**, especially after fitting a different device. Missing inputs must be removed or corrected.
Profiles use browser storage under `c4f.gamepadProfiles`; export them as a backup. Invalid data falls back
safely, and a storage warning means changes will last only for this session.
Several gamepads can play as the same controller, for example a wheel and a separate set of pedals: pick the
same controller on both rows.
## Advanced profiles
Export a profile, edit its JSON, then import it for review. The file has
`format: "control4free-controller-profile"`, `schema: 1`, and a `profile` containing its ID, name,
device signature, bindings and calibration. Unknown file versions are rejected. Unknown versions already
in browser storage are preserved, with changes limited to the session.
Bindings can use a digital or pressure-sensitive button, a full axis, its positive or negative half,
a calibrated pedal, or a hat direction. Outputs are DS4 buttons, stick axes or directions, and L2/R2
pressure. This permits button-to-stick, button-to-trigger, axis-to-button and axis-to-trigger mapping.
Duplicate a binding with a different `output` to make one input drive multiple actions.
Each binding includes `invert`, `deadzone`, `saturation`, `exponent`, `activate` and `release`.
The response curve is a power curve with exponent 1 for linear response. Analog-to-button bindings default
to 50% activation and 40% release to avoid jitter. Default stick bindings retain the previous transfer
behavior through `legacy`; remove that property when opting into tuning.
Centered-axis calibration stores `min`, `center` and `max`, including asymmetric ranges. Pedal calibration
stores `released` and `full`, which may be reversed. For combined pedals, use opposite halves of the axis.
Hat calibration stores `values` in the order neutral, up, up-right, right, down-right, down, down-left,
left, up-left, plus a `tolerance` that separates those values.
Inputs sharing a controller are merged: buttons use OR, triggers use maximum pressure, opposing digital
stick directions cancel, and analog sticks use the strongest displacement. Equal opposing analog
contributions cancel. Values are converted to DS4 bytes after merging.
## The keyboard
**Keyboard keys** at the bottom of the home screen, or **All settings → Keyboard → Choose keys**, opens the same
@@ -87,7 +138,14 @@ Windows key can't be used.
- Only what the browser can see can be mapped. Some extra buttons, like paddles, either aren't shown to
browsers or send the same button as another one.
- The PS4 always sees a DualShock 4: a wheel plays as sticks and triggers, so games that need a real wheel or
guitar don't accept it, and there is no force feedback.
- Pedals combined into one axis by the device can't be split back into two.
- The PS4 always sees a DualShock 4. Native wheel/guitar identity and wheel force feedback are not provided.
Native accessory emulation is unproven and outside this release; existing supported gamepad rumble remains.
- A combined pedal axis can drive separate outputs, but simultaneous pedal positions lost by the device
cannot be recovered.
- The page reads gamepads about 60 times a second, so a press shorter than that can be missed.
- DS4 output uses 8-bit stick and trigger values; extra precision in a physical device is reduced.
- Macros, turbo, toggles, chords and shifted layers are outside this release.
When reporting compatibility, include the controller model, browser and OS versions, connection mode,
exposed button/axis counts, working mappings and remaining limitations. Emulated-input tests are not
real-device compatibility reports.
+8 -2
View File
@@ -54,6 +54,12 @@ controller?"*. The service never presses anything itself: the choice is made on
the login manager logs `DEVICE_OWNER_CHANGED [DeviceId:…][UserId:…]`, which is how the page learns the
controller is signed in.
UserService logout events and the current local login list are checked independently of the assignment
log. When a bound user signs out, their controllers are neutralized and removed, rumble is stopped, and
status clears the user and ownership. A failed login-list read leaves controllers alone; a newly assigned
user is first observed in the list before absence counts as logout. The browser clears affected gamepad
selections and returns to its home screen, requiring an explicit choice to reconnect.
None of this blocks. Creating a controller is a small state machine the main loop advances: drain the log's
backlog, write a marker and wait for it to come back (proof that the reader really delivers), call
`AddDevice`, wait for the line. The other players keep playing and keep being answered meanwhile.
@@ -77,8 +83,8 @@ turns it into a `ScePadData` sample and hands it to `scePadVirtualDeviceInsertDa
- a short queue keeps every button press and release, so a quick tap can't fall between two reports, while
stick movement only ever keeps the latest position.
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 forgets which controllers it had; picking the same one again
When a connection closes, its buttons are released at once. With no new input for 3 seconds the controller
goes neutral, and after 15 it's removed. A page that reconnects forgets which controllers it had; picking the same one again
within those 15 seconds gets it back, still signed in.
Gamepad mapping happens on the page, before anything is sent. Each gamepad's inputs go through its profile's
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: 537 KiB

After

Width:  |  Height:  |  Size: 504 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 840 KiB

After

Width:  |  Height:  |  Size: 840 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 561 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 495 KiB

+3 -2
View File
@@ -126,6 +126,7 @@ or remove the `control4free.elf` line from `/data/GoldHEN/payloads.ini` by hand
|---|---|
| `/data/payloads/control4free.elf` | The copy of the service that AutoRun starts. |
| `/data/GoldHEN/payloads.ini` | GoldHEN's AutoRun list. The app adds or removes one line. |
| `/data/control4free/control4free.log` | The service's log, with the previous run in `control4free.log.previous`. |
| `/data/control4free/control4free.log.1` | The older part of the log, once it passes 1 MB. |
| `/data/control4free/control4free.log` | The current service log, bounded to 1 MiB. |
| `/data/control4free/control4free.log.1` | One older 1 MiB segment from the current run. |
| `/data/control4free/control4free.log.previous` | The last segment of the previous run. |
| `/data/control4free/instance.lock` | Keeps a second copy from starting next to the first. |
+9 -1
View File
@@ -11,6 +11,10 @@
Connect one controller at a time, and finish the user screen before the next person picks theirs. **Don't
press PS while that screen is up**: it cancels the choice. PS works normally once you're signed in.
Signing out of that user on the PS4 disconnects their virtual controllers. The page clears the user's name,
returns to the home screen, and switches gamepads assigned to those controllers to **Off**. Pick a
controller again to reconnect. Other signed-in players keep playing.
The home screen shows all four controllers live:
| State | Meaning |
@@ -66,7 +70,7 @@ the bottom of the home screen: click a control on the picture of the controller,
Connect an Xbox, DualSense, Switch Pro or other gamepad to the phone or computer and press a button on it. It
appears under **Gamepads on this device**:
- a gamepad that turns up while a controller is open on the page drives that controller;
- if a controller is open and has no gamepad assigned yet, the first new gamepad drives it;
- otherwise, pick the controller each gamepad plays as. One computer can run all four controllers this way;
- a row lights up while you use its gamepad, so you can tell which is which;
- several gamepads can play as the same controller, a wheel and its pedals for example;
@@ -74,6 +78,10 @@ appears under **Gamepads on this device**:
Keep the page's window in front: browsers only read gamepads for the active window.
With several Xbox controllers on Chrome or Edge for Windows, check that each Xbox button appears on its
own gamepad in **Gamepad Controls**. Some browser backends combine those presses; see the
[GameInput workaround](troubleshooting.md#gamepads). This is not a general Control4Free requirement.
**"This browser only allows gamepads on secure pages."** Some browsers only offer gamepads on `https`
pages, and the console's page is plain `http`. Save the page to the device (**Save page as** on a computer),
open the saved file, and enter the console's address in its connection box. Touch and the keyboard work either
+10 -4
View File
@@ -37,10 +37,12 @@ handled.
an `Invalid request` error.
Controllers are numbered 0 to 3 in the protocol and 1 to 4 on screen.
`<version>` in the examples is a placeholder for the service's version, read from `VERSION`.
The control protocol remains version 2 and the launcher API remains version 1.
| Method | Params | Reply |
|---|---|---|
| `info` | `[]` | `{"version": "1.1.0", "protocol": 2, "pads": 4}` |
| `info` | `[]` | `{"version": "<version>", "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. |
@@ -107,7 +109,7 @@ modules around it when you draw it.
```json
{
"version": "1.1.0",
"version": "<version>",
"protocol": 2,
"pads": [
{"pad": 0, "name": "Controller 1", "user": "Alex", "enabled": true, "open": true, "connected": true, "clients": 1,
@@ -131,11 +133,15 @@ modules around it when you draw it.
### Messages from the service
PS4 user logout removes that user's virtual controllers. Their status becomes `free`, with `open`,
`connected` and `mine` false, `clients: 0`, and an empty `user`. Owners receive `[pad, 0, 0]` to stop rumble
and a disconnection message. Pages clear the affected selections instead of automatically claiming them.
- `{"method": "s", "params": <status>}`: 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
removed for sitting unused or PS4 user logout, a request that couldn't be parsed, or an error for a request sent without an
`id`.
Errors in reply to a request:
@@ -158,7 +164,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.1.0", "controllers": 2, "stopping": false}
GET /api/status → {"application": "Control4Free", "api": 1, "version": "<version>", "controllers": 2, "stopping": false}
POST /api/stop → {"application": "Control4Free", "stopping": true}
```
+44 -4
View File
@@ -86,6 +86,44 @@ of Bluetooth, or the other way round), can also help.
**A gamepad button does nothing after changing its mapping.** Let go of it and press it again: a button held
down while a gamepad starts playing waits until it's released.
**The Xbox button presses PS for another controller in Chrome or Edge on Windows.** The browser's
Windows.Gaming.Input backend combines Xbox/Guide presses from several controllers and reports them on one
gamepad. Other buttons can still belong to the correct gamepad. Check **Mapping → Gamepad Controls** on
each device: if the wrong device's **B16** lights up, the browser has already lost the physical source.
Control4Free cannot recover that source from the reported input. See the
[Chromium implementation](https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/wgi_data_fetcher_win.cc#419).
On Windows 11, newer Chrome builds offer an experimental **Windows GameInput** backend with
per-device Guide-button callbacks. If your browser offers it, open
`chrome://flags/#enable-windows-gameinput-data-fetcher` (or search `edge://flags` in Edge), set it to
**Enabled**, and relaunch. Reconnect the gamepads and repeat the **Gamepad Controls** check: each Xbox
button should light **B16** only on its own gamepad. Confirm both PS buttons after assigning controllers.
This resolved combined Xbox/Guide reporting in a two-controller user test on Chrome 154 and Windows 11.
Controller models and connection modes were not recorded, and other input and haptic capabilities were
not revalidated. The option remains experimental; input counts and haptics may change. Compare
**Gamepad Controls**, including any extra paddles, and review saved profiles after changing the backend.
If gamepads stop appearing, return the flag to **Default**.
See Chromium's [backend selection](https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/gamepad_platform_data_fetcher.h)
and [per-device Guide handling](https://chromium.googlesource.com/chromium/src/+/refs/heads/main/device/gamepad/gameinput_data_fetcher.cc).
GameInput is a workaround for this browser's multi-controller Guide reporting, not a general
Control4Free requirement. The controller page cannot select a Windows input backend or change browser
flags through the [Gamepad API](https://w3c.github.io/gamepad/). A permanent correction to this browser
path requires a browser fix; a future PC app could choose its own per-device reader. The existing mapper
can also use an independently reported button for PS.
In older browser builds that offer **Enable Windows.Gaming.Input**, open
`chrome://flags/#enable-windows-gaming-input-data-fetcher` (or search `edge://flags` in Edge), set it to
**Disabled**, and relaunch the browser. Reconnect the gamepads, select their controllers again, and check
both Xbox buttons independently. This switches those builds to per-device XInput; extra paddles, input
counts and haptic capabilities may change, so review saved mappings. The flag is unavailable in some
newer builds.
If these options are unavailable or the raw Guide input still crosses devices, try Firefox on Windows
or map an independently reported button to **PS button**
using **DS4 Mapping → PS button → Change**. That replaces the Xbox/Guide binding. Remapping or swapping
controller numbers alone cannot separate the combined Xbox-button signal.
**The gamepad does nothing.** Keep the page's window in front, press a button on the gamepad after the page
has loaded, and check it has a controller under **Gamepads on this device**.
@@ -133,8 +171,9 @@ log files. Please use it before you open an issue:
5. [Open an issue](https://github.com/MoHadiShibli/Control4Free/issues/new/choose) and attach the
screenshots.
The app also saves the same report as text: `control4free-diag.txt` on a USB stick that was plugged in, and
`/data/control4free/diag-report.txt` on the PS4. You can attach that instead of the screenshots. The report
The app also attempts to save the same report as text: `control4free-diag.txt` on a plugged-in USB stick, and
`/data/control4free/diag-report.txt` on the PS4. Its first page reports which saves succeeded. You can attach
a saved report instead of the screenshots. The report
shows your console's local network address. It only works inside your home network, but you can blur it.
When you're done, install the normal package again over the diagnostic one, open it, press **Square**, then
@@ -142,6 +181,7 @@ When you're done, install the normal package again over the diagnostic one, open
one back on auto-start.
The service's own log is `/data/control4free/control4free.log` on the PS4, readable over GoldHEN's FTP
server (port 2121). When it reaches 1 MB it moves to `control4free.log.1` and a new one starts; the previous
run is in `control4free.log.previous`. The diagnostic app shows the end of each, and its first page says
server (port 2121). Each active segment is bounded to 1 MiB: rollover moves it to `control4free.log.1` and
starts a new one, replacing the older segment. The last segment of the previous run is in
`control4free.log.previous`. The diagnostic app shows the end of each, and its first page says
whether it managed to save the report.
+5
View File
@@ -16,6 +16,11 @@
int32_t sceUserServiceInitialize(void *params);
/* The user's name as the PS4 shows it: an online ID, or a local account's name. */
int32_t sceUserServiceGetUserName(int32_t userId, char *name, size_t size);
typedef struct { int32_t userId[4]; } C4fUserServiceLoginList;
typedef struct { int32_t event, userId; } C4fUserServiceEvent;
int32_t sceUserServiceGetLoginUserIdList(C4fUserServiceLoginList *list);
int32_t sceUserServiceGetEvent(C4fUserServiceEvent *event);
#define C4F_USER_EVENT_LOGOUT 1
#define C4F_USER_ID_INVALID (-1)
+4
View File
@@ -77,6 +77,10 @@ int32_t c4fVirtualPadFeedback(const C4fVirtualPad *pad, C4fPadFeedback *out);
/* The PS4's name for a user, as it shows it. Returns 0, or the call's error.
* Not written to the log: people post logs in bug reports. */
int32_t c4fUserName(uint32_t userId, char *out, size_t size);
/* Current local login sessions, independently of cached names or pad assignment.
* Failed reads never mean a user logged out. Events are nonblocking. */
int32_t c4fLoginUsers(int32_t userIds[4]);
int32_t c4fUserEvent(int32_t *type, uint32_t *userId);
/* A neutral sample: sticks centred, identity quaternion, connected. */
void c4fPadDataNeutral(ScePadData *data);
+17
View File
@@ -170,6 +170,23 @@ int32_t c4fUserName(uint32_t userId, char *out, size_t size)
return ret;
}
int32_t c4fLoginUsers(int32_t userIds[4])
{
C4fUserServiceLoginList list = {{-1, -1, -1, -1}};
for (int i = 0; i < 4; i++) userIds[i] = C4F_USER_ID_INVALID;
int32_t ret = sceUserServiceGetLoginUserIdList(&list);
if (ret == 0) memcpy(userIds, list.userId, sizeof(list.userId));
return ret;
}
int32_t c4fUserEvent(int32_t *type, uint32_t *userId)
{
C4fUserServiceEvent event = {-1, C4F_USER_ID_INVALID};
int32_t ret = sceUserServiceGetEvent(&event);
if (ret == 0) { *type = event.event; *userId = (uint32_t)event.userId; }
return ret;
}
void c4fPadDataNeutral(ScePadData *data)
{
(void)memset(data, 0, sizeof(*data));
+61 -2
View File
@@ -34,6 +34,7 @@
/* 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
#define C4F_USER_POLL_MS 250
typedef struct {
C4fVirtualPad device;
@@ -41,7 +42,7 @@ typedef struct {
ScePadData current, queue[C4F_QUEUE_SIZE];
unsigned head, count;
uint64_t lastInput, lastReport, nextReport, detachedAt, reports;
int active, stale, error, assigned;
int active, stale, error, assigned, loginSeen;
uint32_t userId;
/* What the game wants this controller to do, and what its owner was told. */
C4fPadFeedback feedback;
@@ -95,7 +96,8 @@ typedef struct {
char klogLine[1024];
size_t klogUsed;
C4fAdd add;
uint64_t broadcastAt, stopAt, heartbeatAt;
uint64_t broadcastAt, stopAt, heartbeatAt, usersAt;
int usersFailed;
} C4fWeb;
typedef struct {
@@ -247,6 +249,60 @@ static void c4fRemove(C4fWeb *app, C4fWebPad *p)
memset(p, 0, sizeof(*p)); app->changed = 1;
}
static void c4fLogoutPad(C4fWeb *app, C4fWebPad *p, int index)
{
C4fNetClient *owner = p->owner;
if (owner) {
char message[64];
snprintf(message, sizeof(message), "{\"method\":\"v\",\"params\":[%d,0,0]}", index);
c4fNetText(owner, message);
}
c4fLog("web controller %d disconnected after PS4 user logout\n", index + 1);
c4fRemove(app, p);
if (owner) c4fError(owner, NULL, 410, "The PS4 user signed out. Controller disconnected. Select it again to play.");
}
/* Assignment and name caches are not login state. UserService keeps working
* after we release /dev/klog. Logout events cover quick sign-in/sign-out;
* snapshots also recover a missed event. Never treat a failed snapshot or a
* user still entering the login list as logout. Foreground-user changes are
* irrelevant: several local users may play at once. */
static void c4fPollUsers(C4fWeb *app, uint64_t now)
{
if (now < app->usersAt) return;
app->usersAt = now + C4F_USER_POLL_MS;
for (int n = 0; n < 16; n++) {
int32_t type;
uint32_t user;
if (c4fUserEvent(&type, &user) != 0) break;
if (type != C4F_USER_EVENT_LOGOUT || user == 0xffffffffu) continue;
for (int i = 0; i < C4F_MAX_PADS; i++) {
C4fWebPad *p = &app->pads[i];
if (p->active && p->assigned && p->userId == user) c4fLogoutPad(app, p, i);
}
}
int assigned = 0;
for (int i = 0; i < C4F_MAX_PADS; i++) assigned |= app->pads[i].active && app->pads[i].assigned;
if (!assigned) return;
int32_t users[4];
int32_t ret = c4fLoginUsers(users);
if (ret != 0) {
if (!app->usersFailed) c4fLog("login session read failed (0x%08x); retaining controller assignments\n", (uint32_t)ret);
app->usersFailed = 1;
return;
}
if (app->usersFailed) c4fLog("login session reads recovered\n");
app->usersFailed = 0;
for (int i = 0; i < C4F_MAX_PADS; i++) {
C4fWebPad *p = &app->pads[i];
if (!p->active || !p->assigned) continue;
int present = 0;
for (int j = 0; j < 4; j++) if ((uint32_t)users[j] == p->userId) present = 1;
if (present) p->loginSeen = 1;
else if (p->loginSeen) c4fLogoutPad(app, p, i);
}
}
/* One report for one controller, taking the next queued sample if there is one. */
static void c4fReportPad(C4fWeb *app, C4fWebPad *p, int index, uint64_t now)
{
@@ -451,6 +507,8 @@ static void c4fKlogLine(C4fWeb *app, const char *line)
for (int i = 0; i < C4F_MAX_PADS; i++) {
C4fWebPad *p = &app->pads[i];
if (p->active && p->device.deviceId == device) {
if (user == 0xffffffffu && p->assigned) { c4fLogoutPad(app, p, i); continue; }
if (user != p->userId) p->loginSeen = 0;
p->assigned = user != 0xffffffffu; p->userId = user; app->changed = 1;
p->userName[0] = 0; p->nameTries = 0; p->nameAt = 0;
c4fLog("web controller %d assignment confirmed=%d\n", i+1, p->assigned);
@@ -864,6 +922,7 @@ int c4fWebRun(int klogFd)
} else c4fLog("listener recovered on port %d\n", C4F_WEB_PORT);
}
long klogBytes = c4fReadKlog(app);
c4fPollUsers(app, now);
c4fReportPads(app);
c4fPollFeedback(app, now);
c4fLookUpNames(app, now);
+157 -3
View File
@@ -2,10 +2,10 @@
// Test instrumentation is inserted into the page in memory, never shipped.
const { chromium } = require('playwright');
const fs = require('node:fs'), assert = require('node:assert/strict'), { spawn } = require('node:child_process');
const hook = '\nwindow.c4fTest = { ControllerMapping, mapper, profileStore, gamepadSources, pollGamepads, setGamepadPad, mergedState, renderGamepads, renderPads, openSettings, closeSettings, openPad, profileFor, loadProfiles, persistProfiles, clearDevice, getKeys: () => keymap };\n';
const hook = '\nwindow.c4fTest = { ControllerMapping, mapper, profileStore, gamepadSources, pollGamepads, setGamepadPad, mergedState, renderGamepads, renderPads, openSettings, closeSettings, openPad, profileFor, loadProfiles, persistProfiles, clearDevice, applyStatus, status, getKeys: () => keymap };\n';
const html = fs.readFileSync('/src/client/index.html', 'utf8').replace('\n })();', hook + ' })();');
fs.copyFileSync('/src/build/web-host-test', '/tmp/c4f-browser-service');
const server = spawn('/tmp/c4f-browser-service', [], { stdio: ['ignore', 'pipe', 'pipe'] });
const server = spawn('/tmp/c4f-browser-service', [], { env: { ...process.env, C4F_TEST_KLOG_FD: '0' }, stdio: ['pipe', 'pipe', 'pipe'] });
server.on('error', error => console.error('Service harness:', error));
server.stderr.on('data', data => console.error(data.toString()));
server.stdout.on('data', data => { const rows = data.toString().split('\n').filter(line => !line.startsWith('FRAME') && line); if (rows.length) console.log(rows.join('\n')); });
@@ -25,6 +25,17 @@ const bindings = (page, output) => page.evaluate(o => c4fTest.mapper.draft.bindi
// its local test service explicitly, as a user would allow local access.
await context.grantPermissions(['local-network-access']);
await context.addInitScript(() => {
window.testClaims = [];
window.testInputs = [];
const socketSend = WebSocket.prototype.send;
WebSocket.prototype.send = function(data) {
try {
const message = JSON.parse(data);
if (message.method === 'claim') testClaims.push(message.params);
if (message.method === 'u') testInputs.push(message.params);
} catch {}
return socketSend.call(this, data);
};
window.testPads = [0, 1, 2].map(index => ({ index, id: index < 2 ? 'Synthetic standard controller' : 'Synthetic wheel / pedals',
mapping: index < 2 ? 'standard' : '', connected: true, buttons: Array.from({ length: 24 }, () => ({ value: 0, pressed: false })), axes: Array(6).fill(0) }));
Object.defineProperty(navigator, 'getGamepads', { value: () => { if (window.failPolling) throw Error('SecurityError'); return window.testPads; } });
@@ -37,8 +48,113 @@ const bindings = (page, output) => page.evaluate(o => c4fTest.mapper.draft.bindi
await page.waitForFunction(() => window.c4fTest?.gamepadSources.size === 3);
await page.waitForFunction(() => document.querySelector('#conn').dataset.conn === 'open');
assert.equal(await page.locator('#addressForm').isHidden(), true, 'a page served by the PS4 never asks for its address');
await page.evaluate(() => { const h = c4fTest; h.setGamepadPad(0, 0); h.setGamepadPad(1, 1); h.setGamepadPad(2, 0); });
// Sign the first player in, open/sign in the second, and press the first
// gamepad's PS before, during and after that change. Assignment status and
// the currently open controller must not move one button to another slot.
await page.evaluate(() => { c4fTest.setGamepadPad(0, 0); c4fTest.openPad(0); });
await page.waitForFunction(() => c4fTest.status[0].mine && c4fTest.status[0].state === 'select');
server.stdin.write('DEVICE_OWNER_CHANGED [DeviceId:0x11030d][UserId:0x1a2b3c4d]\n');
await page.waitForFunction(() => c4fTest.status[0].state === 'ready' && c4fTest.status[0].user === 'Alex');
await page.waitForFunction(() => !c4fTest.gamepadSources.get(0).waitNeutral);
await page.evaluate(() => { testPads[0].buttons[16].value = 1; });
await page.waitForFunction(() => document.querySelector('#psBtn').classList.contains('on'));
await page.evaluate(() => { testPads[0].buttons[16].value = 0; });
await page.waitForFunction(() => !(c4fTest.mergedState(0).buttons & 0x10000));
await page.evaluate(() => { c4fTest.setGamepadPad(1, 1); c4fTest.openPad(1); });
await page.waitForFunction(() => c4fTest.status[1].mine && c4fTest.status[1].state === 'select');
await page.evaluate(() => { testPads[0].buttons[16].value = 1; });
await page.waitForFunction(() => !!(c4fTest.mergedState(0).buttons & 0x10000));
assert.equal(await page.evaluate(() => c4fTest.mergedState(1).buttons & 0x10000), 0, 'PS moved while the second controller was choosing a user');
server.stdin.write('DEVICE_OWNER_CHANGED [DeviceId:0x12030d][UserId:0x1a2b3c4e]\n');
await page.waitForFunction(() => c4fTest.status[1].state === 'ready' && c4fTest.status[1].user.startsWith('Sam'));
assert.equal(await page.evaluate(() => c4fTest.gamepadSources.get(0).pad), 0, 'signing in another user reassigned the first source');
assert.equal(await page.evaluate(() => c4fTest.mergedState(1).buttons & 0x10000), 0, 'PS moved after the second controller signed in');
assert.equal(await page.locator('#psBtn').evaluate(e => e.classList.contains('on')), false, 'the second controller picture displayed the first controller PS');
await page.waitForFunction(() => !!(testInputs.findLast(p => p[0] === 0)?.[1] & 0x10000));
assert.equal(await page.evaluate(() => testInputs.findLast(p => p[0] === 1)?.[1] & 0x10000), 0, 'the first signed-in gamepad sent PS to the second slot');
await page.evaluate(() => { testPads[0].buttons[16].value = 0; });
await page.waitForFunction(() => !(c4fTest.mergedState(0).buttons & 0x10000));
await page.click('#backBtn');
await page.evaluate(() => c4fTest.setGamepadPad(2, 0));
console.log('PASS real browser: first PS stays with its signed-in player before, during and after the second sign-in');
// Identical gamepads still have independent PS inputs. The open controller
// view and physical enumeration order must not choose where a button goes.
await page.waitForFunction(() => c4fTest.status[0].mine && c4fTest.status[1].mine);
await page.evaluate(() => { c4fTest.setGamepadPad(2, -1); c4fTest.openPad(1); });
for (const targets of [[0, 1], [1, 0]]) {
for (const index of [0, 1]) {
const selection = await page.evaluate(({ index, pad }) => {
c4fTest.setGamepadPad(index, pad);
return [...new Set([1, ...[...c4fTest.gamepadSources.values()].map(s => s.pad).filter(p => p >= 0)])];
}, { index, pad: targets[index] });
await page.waitForFunction(selection => c4fTest.status.every(p => p.mine === selection.includes(p.pad)), selection);
}
await page.waitForFunction(() => c4fTest.status[0].mine && c4fTest.status[1].mine &&
!c4fTest.gamepadSources.get(0).waitNeutral && !c4fTest.gamepadSources.get(1).waitNeutral);
for (const index of [0, 1]) {
const target = targets[index], other = 1 - target;
await page.evaluate(index => { testPads[index].buttons[16].value = 1; }, index);
await page.waitForFunction(target => !!(c4fTest.mergedState(target).buttons & 0x10000), target);
assert.equal(await page.evaluate(other => c4fTest.mergedState(other).buttons & 0x10000, other), 0, 'PS leaked into the other virtual controller');
await page.waitForFunction(target => document.querySelector('#psBtn').classList.contains('on') === (target === 1), target);
await page.waitForFunction(target => !!(testInputs.findLast(p => p[0] === target)?.[1] & 0x10000), target);
assert.equal(await page.evaluate(other => testInputs.findLast(p => p[0] === other)?.[1] & 0x10000, other), 0, 'PS went to the wrong WebSocket slot');
await page.evaluate(index => { testPads[index].buttons[16].value = 0; }, index);
await page.waitForFunction(() => !(c4fTest.mergedState(0).buttons & 0x10000) && !(c4fTest.mergedState(1).buttons & 0x10000));
await page.waitForFunction(() => !document.querySelector('#psBtn').classList.contains('on'));
}
for (const firstReleased of [0, 1]) {
await page.evaluate(() => { testPads[0].buttons[16].value = testPads[1].buttons[16].value = 1; });
await page.waitForFunction(() => (c4fTest.mergedState(0).buttons & 0x10000) && (c4fTest.mergedState(1).buttons & 0x10000));
await page.evaluate(index => { testPads[index].buttons[16].value = 0; }, firstReleased);
await page.waitForFunction(targets => !(c4fTest.mergedState(targets[0]).buttons & 0x10000) && !!(c4fTest.mergedState(targets[1]).buttons & 0x10000),
[targets[firstReleased], targets[1 - firstReleased]]);
await page.evaluate(() => { testPads[0].buttons[16].value = testPads[1].buttons[16].value = 0; });
await page.waitForFunction(() => !(c4fTest.mergedState(0).buttons & 0x10000) && !(c4fTest.mergedState(1).buttons & 0x10000));
}
}
await page.evaluate(() => { c4fTest.setGamepadPad(0, 0); c4fTest.setGamepadPad(1, 1); c4fTest.setGamepadPad(2, 0); });
await page.click('#backBtn');
console.log('PASS real browser: independent PS buttons on identical gamepads, reversed assignments, simultaneous holds, both release orders, display and WebSocket routing');
// The two mapping tabs must read only the selected physical source, even
// when identical pads arrive in a different enumeration order. Guide can
// be exposed through pressed alone, rather than a positive analog value.
await page.evaluate(() => { window.testPads = [testPads[1], null, testPads[0], testPads[2]]; });
for (const selected of [0, 1]) {
await page.evaluate(index => c4fTest.mapper.open(index), selected);
await page.click('#mapTabs [data-tab="inputs"]');
for (const physical of [0, 1]) {
for (const value of [1, 0]) {
const changedAt = await page.evaluate(({ physical, value }) => {
const button = testPads.find(p => p?.index === physical).buttons[16];
button.value = value; button.pressed = true;
return performance.now();
}, { physical, value });
await page.waitForFunction(({ physical, changedAt }) =>
c4fTest.gamepadSources.get(physical).snapshot.buttons[16].pressed &&
c4fTest.mapper.renderedAt > changedAt, { physical, changedAt });
const expected = physical === selected;
assert.equal(await page.locator('#mapInputs [data-input="button:16"]').evaluate(e => e.classList.contains('on')), expected,
`raw Guide from gamepad ${physical} appeared on gamepad ${selected}`);
await page.click('#mapTabs [data-tab="pad"]');
await page.waitForFunction(() => c4fTest.mapper.renderedAt > performance.now() - 50);
assert.equal(await page.locator('.map-area [data-btn="PS"]').evaluate(e => e.classList.contains('on')), expected,
`mapped Guide from gamepad ${physical} appeared on gamepad ${selected}`);
await page.evaluate(physical => {
const button = testPads.find(p => p?.index === physical).buttons[16];
button.value = 0; button.pressed = false;
}, physical);
await page.waitForFunction(() => !document.querySelector('.map-area [data-btn="PS"]').classList.contains('on'));
await page.click('#mapTabs [data-tab="inputs"]');
await page.waitForFunction(() => !document.querySelector('#mapInputs [data-input="button:16"]').classList.contains('on'));
}
}
await page.click('#mapBack');
}
await page.evaluate(() => { window.testPads = testPads.filter(Boolean).sort((a, b) => a.index - b.index); });
console.log('PASS real browser: both mapping tabs isolate each PS input, including pressed-only Guide and sparse/reordered device enumeration');
// The gamepad's row opens its mapping. While it's mapped it doesn't play;
// the other gamepads do.
@@ -104,6 +220,22 @@ const bindings = (page, output) => page.evaluate(o => c4fTest.mapper.draft.bindi
// its range set a step at a time; a pedal; a D-pad hat.
await page.evaluate(() => c4fTest.mapper.open(2));
await page.click('#mapTabs [data-tab="inputs"]');
assert.equal(await page.textContent('#mapPanel .map-panel-title'), 'Gamepad Controls');
assert.match(await page.textContent('#mapPanel'), /buttons and axes your browser detects/);
assert.match(await page.textContent('#mapStatus'), /Select a button or axis/);
await page.click('.in-axis[data-input="axis:4"]');
await page.click('#mapTabs [data-tab="pad"]');
assert.equal(await page.textContent('#mapPanel .map-panel-title'), 'DS4 Mapping', 'switching tabs clears the previous selection');
await control(page, 'CROSS');
await page.click('#mapTabs [data-tab="inputs"]');
assert.equal(await page.textContent('#mapPanel .map-panel-title'), 'Gamepad Controls', 'a selected DS4 control cannot hide the input guide');
await page.waitForFunction(() => getComputedStyle(document.querySelector('#mapTabs [data-tab="inputs"]')).backgroundColor === 'rgb(255, 255, 255)');
await page.screenshot({ path: '/src/build/gamepad-controls-guide.png' });
await page.setViewportSize({ width: 390, height: 844 });
await page.locator('#mapPanel').scrollIntoViewIfNeeded();
assert(await page.evaluate(() => document.querySelector('#mapView').scrollWidth <= innerWidth), 'input guide overflows on mobile');
await page.screenshot({ path: '/src/build/gamepad-controls-guide-mobile.png' });
await page.setViewportSize({ width: 1440, height: 1000 });
await page.click('.in-axis[data-input="axis:4"]');
await page.click('#mapPanel button:has-text("Make it drive")');
await control(page, 'RS');
@@ -270,6 +402,28 @@ const bindings = (page, output) => page.evaluate(o => c4fTest.mapper.draft.bindi
await page.waitForFunction(() => new DOMMatrix(getComputedStyle(document.querySelector('.map-area .ps-btn')).transform).a < .95);
await page.evaluate(() => { testPads[0].buttons[16].value = 0; c4fTest.mapper.back(); });
console.log('PASS real browser: PS press/release feedback from pointer, keyboard, gamepad and mapping tester');
// Earlier capture/index-reuse checks release the second source. Connect it
// again so ownership loss is tested with another player actually playing.
await page.evaluate(() => { c4fTest.setGamepadPad(1, 1); c4fTest.setGamepadPad(2, 2); });
await page.waitForFunction(() => c4fTest.status.slice(0, 3).every(p => p.mine) && !c4fTest.gamepadSources.get(1).waitNeutral);
await page.evaluate(() => { testPads[0].buttons[16].value = 1; testPads[1].buttons[0].value = 1; });
await page.waitForFunction(() => c4fTest.gamepadSources.get(0).buttons && c4fTest.gamepadSources.get(1).buttons);
const otherButtons = await page.evaluate(() => c4fTest.gamepadSources.get(1).buttons);
await page.evaluate(() => {
const h = c4fTest;
testClaims.length = 0;
h.applyStatus({ pads: h.status.map((p, i) => i === 0 || i === 2 ? { ...p, state: 'free', mine: false, open: false, connected: false, clients: 0, user: '', uid: 'unassigned-00000000' } : { ...p }) });
});
assert.equal(await page.locator('#home').isVisible(), true, 'logout closes the controller screen');
assert.equal(await page.evaluate(() => c4fTest.gamepadSources.get(0).pad), -1, 'logout clears the physical source selection');
assert.equal(await page.evaluate(() => c4fTest.gamepadSources.get(0).buttons), 0, 'logout clears held input');
assert.equal(await page.evaluate(() => c4fTest.gamepadSources.get(2).pad), -1, 'logout clears another affected controller');
assert.deepEqual(await page.evaluate(() => testClaims), [[1]], 'the remaining claim must not recreate a later logged-out slot');
assert.equal(await page.getAttribute('#gamepadList .gp-row[data-index="0"] [data-focus="pad--1"]', 'aria-checked'), 'true');
assert.equal(await page.evaluate(() => c4fTest.gamepadSources.get(1).pad), 1, 'another player stays selected');
assert.equal(await page.evaluate(() => c4fTest.gamepadSources.get(1).buttons), otherButtons, 'another player keeps their held input');
await page.evaluate(() => { testPads[0].buttons[16].value = 0; testPads[1].buttons[0].value = 0; });
console.log('PASS real browser: logout/ownership loss returns home and switches affected gamepads Off without dropping another player');
assert.deepEqual(errors, []);
// Actual served HTTP document works without instrumentation or grants.
+1 -1
View File
@@ -223,7 +223,7 @@ async function runProfile(browser, profile) {
await checkApi(page, profile);
const row = page.locator('.gp-row[data-index="0"]');
assert.equal(await row.locator('.gp-name').textContent(), profile.gamepad.id.value.replace(/\s*\(.*?\)\s*/g, ' ').trim());
assert.equal((await row.textContent()).includes('buttons may be mixed up'), profile.gamepad.mapping.value === '');
assert.equal(/buttons may be mixed up/i.test(await row.textContent()), profile.gamepad.mapping.value === '');
assert((await row.textContent()).includes('Standard layout'));
await row.locator('[data-focus="pad-0"]').click();
await poll(page);
+115
View File
@@ -490,6 +490,120 @@ def main():
server.close()
def logout_regressions():
def users(server, result=0, ids=(0x1a2b3c4d, 0x1a2b3c4e)):
ids = list(ids) + [0xffffffff] * (4 - len(ids))
os.write(server.log, ('C4F-TEST-USERS ' + str(result) + ' ' + ' '.join(f'{i:x}' for i in ids) + '\n').encode())
def bind(server, handle, user):
os.write(server.log, f'DEVICE_OWNER_CHANGED [DeviceId:0x{handle:x}][UserId:0x{user:x}]\n'.encode())
def wait(c, predicate, timeout=2):
end = time.monotonic() + timeout
while time.monotonic() < end:
pads = c.request('status')['result']['pads']
for p in pads:
if p['mine']: c.input(p['pad'])
if predicate(pads): return pads
time.sleep(.03)
raise AssertionError(pads)
server = Server()
try:
c = server.c
c.request('claim', [0, 1])
bind(server, 0x11030d, 0x1a2b3c4d); bind(server, 0x12030d, 0x1a2b3c4e)
wait(c, lambda p: p[0]['user'] == 'Alex' and p[1]['state'] == 'ready')
time.sleep(.3)
assert 'released klog reader' in server.rows(), 'logout must work without a klog reader'
users(server, -5, ())
time.sleep(.3)
pads = c.request('status')['result']['pads']
assert pads[0]['mine'] and pads[0]['user'] == 'Alex', 'failed lookup is not logout'
# List order/foreground changes do not disconnect another logged-in player.
users(server, ids=(0x1a2b3c4e, 0x1a2b3c4d))
c.input(0); c.input(1); time.sleep(.3)
assert all(p['mine'] for p in c.request('status')['result']['pads'][:2])
os.write(server.log, b'C4F-TEST-FEEDBACK 11030d 80 30 64 0 0\n')
c.wait_for(lambda m: m.get('method') == 'v' and m['params'] == [0, 80, 30])
c.input(0, 0x10000); c.input(1, 0x4000)
adds = len([r for r in server.rows() if r.startswith('ADD ')])
users(server, ids=(0x1a2b3c4e,))
pads = wait(c, lambda p: not p[0]['open'])
assert pads[0]['state'] == 'free' and not pads[0]['mine'] and pads[0]['clients'] == 0 and pads[0]['user'] == ''
assert pads[0]['uid'].startswith('unassigned-') and pads[1]['mine'] and pads[1]['state'] == 'ready'
c.wait_for(lambda m: m.get('method') == 'v' and m['params'] == [0, 0, 0])
rows = server.rows(); removed = rows.index('REMOVE 11030d')
assert rows[removed - 1].split()[2:10] == ['11030d', '0', '128', '128', '128', '128', '0', '0'], rows[removed - 1]
c.input(0, 0x10000); c.input(1)
time.sleep(.1)
assert not c.request('status')['result']['pads'][0]['open']
assert len([r for r in server.rows() if r.startswith('ADD ')]) == adds, 'late input recreated a logged-out controller'
assert len([r for r in server.rows() if 'login session read failed' in r]) == 1
print('PASS logout snapshot after klog release: neutral/delete, clear name/ownership, stop rumble, preserve other player; failed reads and foreground changes safe', flush=True)
finally:
server.close()
server = Server()
try:
c = server.c
c.request('claim', [0, 1, 2])
bind(server, 0x11030d, 0x1a2b3c4d); bind(server, 0x12030d, 0x1a2b3c4d); bind(server, 0x13030d, 0x1a2b3c4e)
wait(c, lambda p: all(s['state'] == 'ready' for s in p[:3]))
os.write(server.log, b'C4F-TEST-USER-EVENT 1 1a2b3c4d\n')
pads = wait(c, lambda p: not p[0]['open'] and not p[1]['open'])
assert pads[2]['mine'] and pads[2]['state'] == 'ready'
assert all(pads[i]['user'] == '' for i in (0, 1))
print('PASS explicit logout removes every controller for that user while a different user stays connected', flush=True)
finally:
server.close()
server = Server()
try:
c = server.c
users(server, ids=())
c.request('claim', [0, 1])
bind(server, 0x11030d, 0x1a2b3c4d)
wait(c, lambda p: p[0]['state'] == 'ready')
time.sleep(.3)
assert c.request('status')['result']['pads'][0]['mine'], 'sign-in race treated as logout'
os.write(server.log, b'C4F-TEST-USER-EVENT 1 1a2b3c4d\n')
pads = wait(c, lambda p: not p[0]['open'])
assert pads[1]['mine'] and pads[1]['state'] == 'select'
# A known assigned controller losing its device owner is also removed.
bind(server, 0x12030d, 0x1a2b3c4e)
wait(c, lambda p: p[1]['state'] == 'ready')
# Keep a third unassigned pad so the assignment log reader remains open.
c.request('claim', [1, 2])
bind(server, 0x12030d, 0xffffffff)
wait(c, lambda p: not p[1]['open'])
print('PASS delayed initial login listing, fast logout event and explicit unassignment', flush=True)
finally:
server.close()
server = Server(C4F_TEST_ADD_DELAY='650')
try:
c = server.c
c.request('claim', [0]); bind(server, 0x11030d, 0x1a2b3c4d)
wait(c, lambda p: p[0]['state'] == 'ready')
replies = []
worker = threading.Thread(target=lambda: replies.append(c.request('claim', [0, 1])))
worker.start()
end = time.monotonic() + 2
while not any(r.startswith('ADD 12030d') for r in server.rows()):
assert time.monotonic() < end
time.sleep(.02)
os.write(server.log, b'C4F-TEST-USER-EVENT 1 1a2b3c4d\n')
worker.join(timeout=4)
assert replies and replies[0]['error']['code'] == 409, replies
pads = c.request('status')['result']['pads']
assert not pads[0]['open'] and not pads[1]['open'] and not pads[0]['mine']
assert 'REMOVE 12030d' in server.rows(), 'issued creation leaked after logout invalidated its retained slot'
print('PASS logout during pending claim: revalidation rejects it and cleans up only its new devices', flush=True)
finally:
server.close()
def reliability():
# Reserve retained and newly-created slots throughout a multi-device claim.
server = Server(C4F_TEST_ADD_DELAY='1250')
@@ -580,4 +694,5 @@ def reliability():
if __name__ == '__main__':
main()
logout_regressions()
reliability()
+54 -1
View File
@@ -35,6 +35,57 @@
static unsigned nextId = 0x11030d;
static int logRead = -1, logWrite = -1;
static pthread_mutex_t userLock = PTHREAD_MUTEX_INITIALIZER;
static int32_t loginUsers[4] = {0x1a2b3c4d, 0x1a2b3c4e, -1, -1}, loginResult;
static struct { int32_t type; uint32_t user; } userEvents[32];
static unsigned userHead, userCount;
static void setUsers(const char *line)
{
int result;
unsigned ids[4];
if (sscanf(line, "C4F-TEST-USERS %d %x %x %x %x", &result, &ids[0], &ids[1], &ids[2], &ids[3]) != 5) return;
pthread_mutex_lock(&userLock);
loginResult = result;
for (int i = 0; i < 4; i++) loginUsers[i] = (int32_t)ids[i];
pthread_mutex_unlock(&userLock);
printf("USERS %d\n", result);
}
static void addUserEvent(const char *line)
{
int type;
unsigned user;
if (sscanf(line, "C4F-TEST-USER-EVENT %d %x", &type, &user) != 2) return;
pthread_mutex_lock(&userLock);
if (userCount < 32) {
unsigned pos = (userHead + userCount++) % 32;
userEvents[pos].type = type; userEvents[pos].user = user;
}
pthread_mutex_unlock(&userLock);
printf("USER-EVENT %d\n", type);
}
int32_t c4fLoginUsers(int32_t out[4])
{
pthread_mutex_lock(&userLock);
int32_t ret = loginResult;
for (int i = 0; i < 4; i++) out[i] = ret == 0 ? loginUsers[i] : -1;
pthread_mutex_unlock(&userLock);
return ret;
}
int32_t c4fUserEvent(int32_t *type, uint32_t *user)
{
pthread_mutex_lock(&userLock);
int32_t ret = userCount ? 0 : -1;
if (userCount) {
*type = userEvents[userHead].type; *user = userEvents[userHead].user;
userHead = (userHead + 1) % 32; userCount--;
}
pthread_mutex_unlock(&userLock);
return ret;
}
/* What the "game" wants of each controller, by handle. */
static struct { unsigned handle; C4fPadFeedback feedback; } feedbacks[8];
@@ -101,11 +152,13 @@ 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")) {
if (strstr(buf, "C4F-TEST-FEEDBACK") || strstr(buf, "C4F-TEST-USERS") || strstr(buf, "C4F-TEST-USER-EVENT")) {
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; }
if (!strncmp(line, "C4F-TEST-USERS", 14)) { setUsers(line); continue; }
if (!strncmp(line, "C4F-TEST-USER-EVENT", 19)) { addUserEvent(line); continue; }
snprintf(copy, sizeof(copy), "%s\n", line);
logLine(copy);
}