diff --git a/CHANGELOG.md b/CHANGELOG.md index ab35a87..e41e175 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index aefc964..308ad38 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 diff --git a/README.md b/README.md index a29db17..52edc03 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Support on Ko-fi

-**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`. diff --git a/SECURITY.md b/SECURITY.md index ec2738e..06b0ed9 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -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 diff --git a/client/index.html b/client/index.html index ae368be..2f5ebad 100644 --- a/client/index.html +++ b/client/index.html @@ -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); diff --git a/docs/gamepad-mapping.md b/docs/gamepad-mapping.md index 0c1e7bc..18110d9 100644 --- a/docs/gamepad-mapping.md +++ b/docs/gamepad-mapping.md @@ -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. diff --git a/docs/how-it-works.md b/docs/how-it-works.md index f79e991..8430f04 100644 --- a/docs/how-it-works.md +++ b/docs/how-it-works.md @@ -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 diff --git a/docs/images/controller.png b/docs/images/controller.png index a0344d5..f69b8af 100644 Binary files a/docs/images/controller.png and b/docs/images/controller.png differ diff --git a/docs/images/desktop.png b/docs/images/desktop.png index 934f784..ae1c777 100644 Binary files a/docs/images/desktop.png and b/docs/images/desktop.png differ diff --git a/docs/images/home.png b/docs/images/home.png index a278cf0..aa2e835 100644 Binary files a/docs/images/home.png and b/docs/images/home.png differ diff --git a/docs/images/launcher.png b/docs/images/launcher.png index d0be5d1..dd4b6aa 100644 Binary files a/docs/images/launcher.png and b/docs/images/launcher.png differ diff --git a/docs/images/mapping-inputs.png b/docs/images/mapping-inputs.png new file mode 100644 index 0000000..dd98613 Binary files /dev/null and b/docs/images/mapping-inputs.png differ diff --git a/docs/images/mapping.png b/docs/images/mapping.png new file mode 100644 index 0000000..d3c3894 Binary files /dev/null and b/docs/images/mapping.png differ diff --git a/docs/installation.md b/docs/installation.md index ec84d0e..1548b3b 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -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. | diff --git a/docs/playing.md b/docs/playing.md index 8e199e7..f3b2f2c 100644 --- a/docs/playing.md +++ b/docs/playing.md @@ -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 diff --git a/docs/protocol.md b/docs/protocol.md index 47b81e3..f988ed5 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -37,10 +37,12 @@ handled. an `Invalid request` error. Controllers are numbered 0 to 3 in the protocol and 1 to 4 on screen. +`` 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": "", "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": "", "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": }`: 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": "", "controllers": 2, "stopping": false} POST /api/stop → {"application": "Control4Free", "stopping": true} ``` diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 1d030d8..2fb7db9 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -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. diff --git a/include/c4f_sce.h b/include/c4f_sce.h index b3d86d9..68aaddc 100644 --- a/include/c4f_sce.h +++ b/include/c4f_sce.h @@ -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) diff --git a/include/c4f_vda.h b/include/c4f_vda.h index 76a1f57..a41128c 100644 --- a/include/c4f_vda.h +++ b/include/c4f_vda.h @@ -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); diff --git a/src/vda.c b/src/vda.c index 00edd7d..b1a37a2 100644 --- a/src/vda.c +++ b/src/vda.c @@ -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)); diff --git a/src/web.c b/src/web.c index bcca25d..43bc364 100644 --- a/src/web.c +++ b/src/web.c @@ -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); diff --git a/tests/browser_test.cjs b/tests/browser_test.cjs index 86e2d87..81d7532 100644 --- a/tests/browser_test.cjs +++ b/tests/browser_test.cjs @@ -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. diff --git a/tests/fake_gamepads_test.cjs b/tests/fake_gamepads_test.cjs index 2a33303..20a281d 100644 --- a/tests/fake_gamepads_test.cjs +++ b/tests/fake_gamepads_test.cjs @@ -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); diff --git a/tests/test_web.py b/tests/test_web.py index f6202d4..ccc90b6 100644 --- a/tests/test_web.py +++ b/tests/test_web.py @@ -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() diff --git a/tests/web_stub.c b/tests/web_stub.c index 40de79c..31d2a89 100644 --- a/tests/web_stub.c +++ b/tests/web_stub.c @@ -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); }