Finalize Control4Free 1.2.0 fixes and release documentation
No files matched your search
@@ -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,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
|
||||
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||

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

|
||||
|
||||
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.
|
||||
@@ -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
|
||||
|
||||
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 537 KiB After Width: | Height: | Size: 504 KiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 840 KiB After Width: | Height: | Size: 840 KiB |
|
After Width: | Height: | Size: 561 KiB |
|
After Width: | Height: | Size: 495 KiB |
@@ -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. |
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
```
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -16,6 +16,11 @@
|
||||
int32_t sceUserServiceInitialize(void *params);
|
||||
/* The user's name as the PS4 shows it: an online ID, or a local account's name. */
|
||||
int32_t sceUserServiceGetUserName(int32_t userId, char *name, size_t size);
|
||||
typedef struct { int32_t userId[4]; } C4fUserServiceLoginList;
|
||||
typedef struct { int32_t event, userId; } C4fUserServiceEvent;
|
||||
int32_t sceUserServiceGetLoginUserIdList(C4fUserServiceLoginList *list);
|
||||
int32_t sceUserServiceGetEvent(C4fUserServiceEvent *event);
|
||||
#define C4F_USER_EVENT_LOGOUT 1
|
||||
|
||||
#define C4F_USER_ID_INVALID (-1)
|
||||
|
||||
|
||||
@@ -77,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);
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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()
|
||||
@@ -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);
|
||||
}
|
||||
|
||||