19 Commits
Author SHA1 Message Date
MoHadiShibli 017b8e2473 Docs: GoldHEN v2.4b18.10 is the minimum, and a compatibility table
GoldHEN versions before v2.4b18.10 don't run ELF payloads reliably (v2.4b18.8
improved ELF loading, v2.4b18.10 fixed a sceUserServiceInitialize failure in
them) and have no auto-start, and every firmware GoldHEN supports has a
v2.4b18.10 or later. The installation guide now lists each firmware with the
GoldHEN version it needs, marking the ones confirmed on a console (10.01,
13.02, 13.04, 13.52) apart from those that only have GoldHEN and start-up
support so far. The README points to it.
2026-10-07 02:21:39 +03:00
MoHadiShibli 96326c71b0 Docs: tested firmwares, and what to do when updating, restarting or reporting
- Requirements list the firmwares Control4Free is tested on (10.01, 13.02,
  13.04 and 13.52, with GoldHEN v2.4b18.10 and v2.4b18.12); 13.52 needs 1.1.1.
- Updating says you don't need to stop Control4Free before deleting or
  replacing the app. A new "After a restart" section says auto-start brings it
  back by itself and the app doesn't get in the way.
- Uninstalling warns that deleting the app alone leaves Control4Free starting
  with GoldHEN, and how to clean up when the app is already gone.
- Reporting a problem stops the normal version before starting the diagnostic
  one, so the report comes from the diagnostic version, and says how to switch
  back.
- The kernel-log message also names old test builds of the diagnostic app.
2026-10-07 02:03:52 +03:00
MoHadiShibli f57224ee39 Control4Free 1.1.1 2026-10-07 01:39:35 +03:00
MoHadiShibli 9d0c3d6a7d Ship a diagnostic package with every release, and ask for it in bug reports
CI now builds Control4Free-<version>-diag.pkg next to the normal package and
attaches it to each release. The bug report form asks for its screenshots or
report file instead of an optional log, and the troubleshooting guide's
"Reporting a problem" section walks through it: install the diagnostic
package, make the problem happen again, screenshot the 5 pages, then put the
normal package back.
2026-10-07 01:39:35 +03:00
MoHadiShibli b583d149d8 Diagnostic app: never hold the kernel log while Control4Free runs
The app read the kernel log in short windows: when it opened, around a
start, and on Options. The log has one reader, and Control4Free needs it to
sign a controller in. A tester on 13.52 opened the app after a restart and
went back to the home screen while it was reading: the PS4 suspended the app
with the log still open, and no controller could sign in until the app was
closed. Now the app reads the log only while Control4Free is not running,
which is the only time it is useful, and stops as soon as a start succeeds.
2026-10-07 01:39:34 +03:00
MoHadiShibli eceeb7bf46 Diagnostic build: log each connection and each notification
On firmware 13.52 the service now starts and answers the launcher, but a
browser on the network gets its connection closed, and no notification
appears. The diagnostic build now logs, for every connection from another
device, what it asked for, what it was sent, and why it was closed (send or
recv errors with errno, the 10-second timeout with the first byte received),
plus the result of every notification request. The diagnostic screen shows
its build time and, while Control4Free runs, asks for the address to be
opened and page 2 screenshotted. It also reads GoldHEN's SDK version
correctly: GoldHEN returns it through the call's error value.
2026-10-06 02:37:51 +03:00
MoHadiShibli 056a32f6c9 Let Cross retry a failed start instead of asking for a restart
When Control4Free was sent but did not answer, the app locked itself until
the PS4 restarted, so two copies could never run. The payload already takes
care of that: a second copy finds the instance lock and quits at once. So
the app now says to press Cross to try again, and a restart never helped a
payload that cannot start anyway. The lock stays only for the app itself
failing to start ("Reopen the app").
2026-10-06 02:07:00 +03:00
MoHadiShibli 745c16decf Diagnostic build: a package that shows why Control4Free does not start
For consoles it fails on, so a user can send screenshots instead of digging
out log files. `make C4F_DIAG=1` and `make -C launcher C4F_DIAG=1` (or
tools/build-launcher.ps1 -Diag) build Control4Free-<version>-diag.pkg, listed
as "Control4Free (diagnostic)".

Its app shows plain pages in place of the normal screen: a summary with a
verdict (firmware, GoldHEN, sandbox, auto-start files, the service and the
last start), the service log and the previous one, and the kernel log,
filtered and in full. The kernel log is read only in short windows, at
start-up, around a start and on Options, since Control4Free needs it to sign
controllers in. The pages are also saved to
/data/control4free/diag-report.txt, and to a USB stick when one is plugged in.

The payload says on screen that it started, and logs the kernel addresses
the SDK found and the process PayLoader runs it in.
2026-10-06 02:06:53 +03:00
MoHadiShibli 94e4a7ecd7 Build: take the payload's start-up code from a newer SDK, for firmware 13.52
The ps4-payload-sdk v0.9 release has no kernel offsets for firmware 13.52 or
14.00, so on those consoles the payload quit in its start-up code before
main(): PayLoader reported it launched, but no log or service ever appeared.
The SDK added both on master (573b4a0); crt1.o is rebuilt from that commit
with the SDK's own crt/Makefile. It exports the same symbols as v0.9's.
2026-10-06 02:06:36 +03:00
MoHadiShibli 7d3eb00e52 CHANGELOG: date 1.1.0 2026-10-06 01:05:33 +03:00
MoHadiShibli 1b4ee87294 CHANGELOG: state 1.0.0's limits without promising anything 2026-10-06 01:05:33 +03:00
MoHadiShibli 200e2bfc0b Show who's signed in on each controller, and an Invite QR code
When the PS4 reports a user signed in on a controller, the service asks for
the user's name and adds it to the status as `user`. The PS4 may not have
finished signing the user in by then, so a failed look-up is retried every
half second for five seconds. The tile shows the name in place of
"Controller N" (the ring keeps the number), and the controller screen reads
"Controller 1 · Alex". Names are kept out of the log, since people post logs
in bug reports, and they go out cleaned: quotes and backslashes escaped, and
control characters or anything that isn't valid UTF-8 replaced, because a
browser drops the whole WebSocket on a text frame that isn't valid UTF-8.

An Invite panel, opened from a button next to the settings or from the menu,
shows the page's address as a QR code for friends to scan. The console makes
the code with the QR encoder the app already ships, from the address the page
reached it on, and sends the module grid; the page draws it as SVG, so it still
needs no libraries.

The gamepad picker's tooltip had the same "Controller 1 · Controller 1" doubling
the controller screen had; it now names the user too.
2026-10-06 01:05:33 +03:00
MoHadiShibli be950bf7b5 Docs: say not every controller supports vibration in the browser
Rather than a list of which pads rumble in which browser, which would go
stale; a gamepad the browser knows it can't rumble says so on its row.
2026-10-06 01:05:33 +03:00
MoHadiShibli 028cb9f2f4 Name gamepads the browser can't rumble, and time the rumble reads
A gamepad's row now says "no rumble in this browser" when the browser has
no way to rumble it: common for a DualSense, while Chrome and Edge rumble
Xbox pads. Before, it just never rumbled.

The heartbeat now reports how many rumble/light-bar reads ran in the last
minute and what they cost on average and at worst, in microseconds: the
longest an arriving input could wait behind one.
2026-10-06 01:05:33 +03:00
MoHadiShibli 6d9949f88b Build: rebuild everything when VERSION changes
The version is passed in as a compiler flag, and make doesn't notice a
changed flag. After the bump to 1.1.0, only files whose source had changed
were recompiled, so main.c and log.c still logged 1.0.0 and the app could
show it. Objects now depend on VERSION. Builds from a clean checkout, as CI
does, were never affected.
2026-10-06 01:05:33 +03:00
MoHadiShibli 627e2cec63 Log the light bar and count rumble changes
So a console test can tell a game that never rumbled from a phone that
never buzzed: each light-bar change is logged as it happens, and the
heartbeat says how many rumble changes arrived in the last minute.
2026-10-06 01:05:32 +03:00
MoHadiShibli baf36d648e 1.1.0: rumble and the light bar
A game talks to a virtual controller as it would to a real one, and the system
keeps what it asked for. scePadVirtualDeviceGetRemoteSetting reads it back in
the layout of a DualShock 4's output report: the two motors at [3] and [4], the
light bar at [5..7]. The layout was read on the console while games rumbled and
users signed in, and feedback.c is tested against those exact buffers.

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

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

Also: the page says "Choose user on PS4", not "on TV", since not everyone plays
on a TV. The test stub takes C4F-TEST-FEEDBACK lines to set what the "game"
wants, and the test client can wait for messages the service pushes.
2026-10-06 01:05:32 +03:00
MoHadiShibli 183f9dfaee Update user selection instructions for PS4 2026-10-05 21:49:11 +03:00
MoHadiShibli 1cc59c12dc README: shorter credits, and the AI models by name
Credits are down to SplashDown, whose code the virtual-device layer is
ported from; everything else Control4Free uses, with its license, stays
listed in THIRD_PARTY.md. The AI models named are the ones actually used:
Claude Opus 5.5 and Astra GPT-6.
2026-10-05 16:30:44 +03:00
48 changed files with 1721 additions and 141 deletions

No files matched your search

+6 -5
View File
@@ -40,10 +40,11 @@ body:
validations:
required: true
- type: textarea
id: log
id: diagnostics
attributes:
label: Log (optional)
label: Diagnostic report
description: |
`/data/control4free/control4free.log` on the PS4, over GoldHEN's FTP server (port 2121). If the problem was before a restart, it's in `control4free.log.previous`.
The log contains your console's network address: remove it if you prefer.
render: text
Install the diagnostic package, `Control4Free-<version>-diag.pkg` from the [latest release](https://github.com/MoHadiShibli/Control4Free/releases/latest), over the app. Open **Control4Free (diagnostic)**, make the problem happen again, then attach a screenshot of each of its 5 pages here (**R1** changes the page). Or attach the report it saves: `control4free-diag.txt` on a USB stick that was plugged in, or `/data/control4free/diag-report.txt` over GoldHEN's FTP server. [Step by step](https://github.com/MoHadiShibli/Control4Free/blob/main/docs/troubleshooting.md#reporting-a-problem).
The report shows your console's local network address. It only works inside your home network, but you can blur it.
validations:
required: true
+6 -5
View File
@@ -1,7 +1,8 @@
name: build
# Builds both toolchain images, the payload, the installable package, and runs
# every host test. On a vX.Y.Z tag it also publishes the release.
# Builds both toolchain images, the payload, the installable package and its
# diagnostic version, and runs every host test. On a vX.Y.Z tag it also
# publishes the release.
on:
push:
branches: [main, next]
@@ -23,10 +24,10 @@ jobs:
docker build -t control4free-build -f docker/Dockerfile docker
docker build -t control4free-launcher-build -f docker/Dockerfile.launcher docker
- name: Build the payload and the package
- name: Build the payload, the package and the diagnostic package
run: |
docker run --rm --network none -v "$PWD:/src" -w /src \
control4free-launcher-build bash -lc 'make && make -C launcher'
control4free-launcher-build bash -lc 'make && make -C launcher && make C4F_DIAG=1 && make -C launcher C4F_DIAG=1'
- name: Host tests
run: |
@@ -37,7 +38,7 @@ jobs:
run: |
version=$(cat VERSION)
mkdir -p artifacts
cp build/control4free.elf "build/Control4Free-$version.pkg" artifacts/
cp build/control4free.elf "build/Control4Free-$version.pkg" "build/Control4Free-$version-diag.pkg" artifacts/
cd artifacts && sha256sum * > SHA256SUMS && cat SHA256SUMS
- uses: actions/upload-artifact@v4
+31 -1
View File
@@ -3,6 +3,36 @@
Versions follow [Semantic Versioning](https://semver.org): PATCH for fixes, MINOR for features that break
nothing, MAJOR for anything people rely on that changes.
## 1.1.1 — 2026-10-07
Fixes.
- Control4Free starts on firmware 13.52. Before, it stopped before doing anything on that firmware:
GoldHEN said the payload launched, but the app said it never answered. The payload's start-up code now
comes from a newer ps4-payload-sdk that knows 13.52's kernel.
- When a start fails, press **Cross** to try again. The app no longer asks you to restart the PS4: a second
copy of Control4Free quits on its own, so two can't end up running.
- Each release now includes a diagnostic package, `Control4Free-<version>-diag.pkg`. Its app shows what the
PS4 reports (firmware, GoldHEN, the logs, each connection from a phone or PC) on pages you can screenshot
for a bug report. See [Reporting a problem](docs/troubleshooting.md#reporting-a-problem).
## 1.1.0 — 2026-10-06
Rumble and the light bar.
- When a game rumbles a controller, the device driving it feels it: an Android phone buzzes, and a gamepad
playing as that controller rumbles too, though not every controller supports vibration in the browser. A
gamepad that can't be rumbled says so on its row.
- The page glows in the controller's light-bar colour, the player colour the PS4 gives each user or whatever
the game sets, brightened from the quarter strength the PS4 uses.
- Each controller shows the name of the PS4 user signed in on it.
- An **Invite** button shows the page's address as a QR code, so friends can scan it and join. The console
makes the code, so the page still needs no libraries.
- The page says "Choose user on PS4" rather than "on TV": not everyone plays on a TV.
- Protocol: the status reports the light bar's colour in `color` and the signed-in user in `user`; the
service sends rumble as `{"method": "v", "params": [pad, large, small]}`; a new `invite` method returns the
QR code. See [docs/protocol.md](docs/protocol.md).
## 1.0.0 — 2026-10-05
First release.
@@ -26,4 +56,4 @@ the PS4's own *"Who's using this controller?"* screen deciding who each one is.
- Open on the local network by design, with no pairing. What that does and does not protect is written down in
[SECURITY.md](SECURITY.md).
Tested on firmware 10.01 with GoldHEN v2.4b18.10. Rumble, light bar and motion are not passed on yet.
Tested on firmware 10.01 with GoldHEN v2.4b18.10. Rumble, light bar and motion are not passed on.
+3 -3
View File
@@ -65,7 +65,7 @@ address, or add `?host=<ps4-ip>:4264` to the page's URL.
Match the code around your change:
- C with GNU extensions (gnu11), 4-space indent, `camelCase` functions with a `c4f` prefix, `C4f` types and
`C4F_` macros;
- log with `c4fLog`. `c4fNotify` shows a notification on the TV: keep those rare;
- log with `c4fLog`. `c4fNotify` shows a notification on the PS4's screen: keep those rare;
- comments explain *why*, not *what*;
- only button bits confirmed on hardware go in `include/c4f_sce.h`;
- the page is one file with no build step and no libraries: keep it that way;
@@ -83,8 +83,8 @@ Build without new warnings; the app is built with `-Werror`.
## Reviewing AI-written code
Much of Control4Free was written with the help of AI models (Claude by Anthropic and Codex by OpenAI). It has
been tested, on the console as well, but AI-written code can look right and still be wrong. Please read it
Much of Control4Free was written with the help of AI models (Claude Opus 5.5 by Anthropic and Astra GPT-6 by
OpenAI). It has been tested, on the console as well, but AI-written code can look right and still be wrong. Please read it
critically: if you find dead code, wrong assumptions or needless complexity,
[open an issue](https://github.com/MoHadiShibli/Control4Free/issues) or send a pull request.
+20 -4
View File
@@ -3,6 +3,7 @@
# Build:
# make # build/control4free.elf
# make C4F_PROBE=1 # plus the rumble/light-bar research probe (dev only)
# make C4F_DIAG=1 # diagnostic build for a console it fails on
# make clean
#
# A build needs the ps4-payload-sdk; docker/Dockerfile has it, and
@@ -19,7 +20,7 @@ VERSION := $(shell cat VERSION)
NAME := control4free
ELF := build/$(NAME).elf
OBJDIR := build/payload
SOURCES := src/main.c src/log.c src/vda.c src/klog_line.c src/web.c src/net.c
SOURCES := src/main.c src/log.c src/vda.c src/klog_line.c src/feedback.c src/web.c src/net.c
# Research build: logs what scePadVirtualDeviceGetRemoteSetting returns. Its ELF
# gets its own name, so it can never be mistaken for a release.
@@ -29,11 +30,20 @@ ifdef C4F_PROBE
OBJDIR := build/probe
endif
OBJECTS := $(SOURCES:src/%.c=$(OBJDIR)/%.o) $(OBJDIR)/client.o $(OBJDIR)/assets.o
# Diagnostic build: logs what the SDK found in the kernel and the host process,
# and says on screen that it started. The launcher's C4F_DIAG build carries it.
ifdef C4F_DIAG
EXTRA_CFLAGS += -DC4F_DIAG
VERSION := $(VERSION)-diag
ELF := build/$(NAME)-diag.elf
OBJDIR := build/diag
endif
OBJECTS := $(SOURCES:src/%.c=$(OBJDIR)/%.o) $(OBJDIR)/client.o $(OBJDIR)/assets.o $(OBJDIR)/qrcodegen.o
CFLAGS += -std=gnu11 -Wall -Wextra -Wpointer-arith -g -O2
CFLAGS += -MMD -MP
CFLAGS += -Iinclude -Ivendor/jsmn -DC4F_VERSION='"$(VERSION)"' $(EXTRA_CFLAGS)
CFLAGS += -Iinclude -Ivendor/jsmn -Ivendor/qrcodegen -DC4F_VERSION='"$(VERSION)"' $(EXTRA_CFLAGS)
LDFLAGS += -lScePad -lSceUserService -ldl -lpthread
.PHONY: all clean
@@ -46,12 +56,18 @@ all: $(ELF)
$(OBJDIR) build:
mkdir -p $@
$(OBJDIR)/%.o: src/%.c | $(OBJDIR)
# VERSION is compiled into the objects, and make doesn't notice a changed flag,
# so a new version has to rebuild them.
$(OBJDIR)/%.o: src/%.c VERSION | $(OBJDIR)
$(CC) $(CFLAGS) -c $< -o $@
build/client.c: client/index.html tools/embed_client.py | build
python3 tools/embed_client.py $< $@
# The page's Invite QR code (MIT, the same copy the app uses).
$(OBJDIR)/qrcodegen.o: vendor/qrcodegen/qrcodegen.c | $(OBJDIR)
$(CC) $(CFLAGS) -c $< -o $@
$(OBJDIR)/client.o: build/client.c | $(OBJDIR)
$(CC) $(CFLAGS) -c $< -o $@
+26 -29
View File
@@ -31,11 +31,17 @@ sees a real controller, so it works wherever a DualShock does:
## Features
- **Up to four controllers**, from as many phones, tablets or computers as you like.
- **See who's playing.** Each controller shows the name of the PS4 user signed in on it.
- **Invite friends with a QR code.** One button on the page shows its address as a QR code: friends scan it
and they're in.
- **A full DualShock 4 on the screen**: analog sticks, a two-finger touchpad, L3/R3, PS, Share and Options.
Move, resize or hide any button with the layout editor; each device keeps its own layout.
- **The gamepads you already have.** Connect one to the phone or PC and give it a controller. One computer
can run all four.
- **A keyboard**, with keys you can change.
- **Rumble and light bar.** When a game rumbles, your phone buzzes (Android), and so does your gamepad,
though not every controller supports vibration in the browser. The page glows in the controller's
light-bar colour, the one the PS4 or the game sets.
- **Fast.** Input reaches the console the moment it arrives, at a DualShock 4's own rate while you play.
- **An app on the PS4** that shows the address and a QR code, how many controllers are connected, and starts
or stops Control4Free.
@@ -47,13 +53,13 @@ sees a real controller, so it works wherever a DualShock does:
## Quick start
1. Download `Control4Free-1.0.0.pkg` from the [latest release](https://github.com/MoHadiShibli/Control4Free/releases/latest).
1. Download `Control4Free-<version>.pkg` from the [latest release](https://github.com/MoHadiShibli/Control4Free/releases/latest).
2. Install it with GoldHEN's Package Installer: **Settings → Debug Settings → Game → Package Installer**, from a
USB drive or `/data/pkg/`.
3. Open **Control4Free** from **Library → Applications** and press **Cross**. It sets up auto-start and
starts Control4Free.
4. On your phone or PC, scan the QR code or open the address shown, for example `http://192.168.1.20:4264`.
Pick a controller, then choose your user on the TV with the D-pad and Cross.
Pick a controller, then choose your user on the PS4.
New to GoldHEN packages? The **[installation guide](docs/installation.md)** walks through every step.
@@ -76,8 +82,9 @@ New to GoldHEN packages? The **[installation guide](docs/installation.md)** walk
## Requirements and compatibility
- A PS4 with GoldHEN. Tested on firmware 10.01 with GoldHEN v2.4b18.10. Auto-start and starting Control4Free
from GoldHEN's menu need v2.4b18.10 or later.
- A PS4 with GoldHEN v2.4b18.10 or later. Tested on firmware 10.01, 13.02, 13.04 and 13.52; the
[compatibility table](docs/installation.md#compatibility) lists every firmware and the GoldHEN version it
needs.
- A phone, tablet or computer with a browser, on the same network as the PS4.
- Turn off other GoldHEN controller plugins in games you play with Control4Free. A plugin that takes over a
signed-in user's controller can stop games from reading the virtual ones.
@@ -85,8 +92,10 @@ New to GoldHEN packages? The **[installation guide](docs/installation.md)** walk
## Limitations
- **No rumble, light bar or motion yet.** The PS4 does send rumble and light-bar changes to the virtual
controllers; passing them to the phone is planned for 1.1.
- **No motion controls.** Browsers only give phone motion sensors to secure (`https`) pages, and the
console's page is plain `http`.
- **iPhones don't vibrate.** Safari doesn't let pages use the phone's vibration. A gamepad connected to the
iPhone can still rumble, if the browser supports it.
- **One controller is added at a time.** If two people pick at once, the second is asked to try again a
moment later.
- **Rest mode signs everyone out**, so controllers have to be picked again after the PS4 wakes up, just like
@@ -96,36 +105,24 @@ New to GoldHEN packages? The **[installation guide](docs/installation.md)** walk
## Support
Found a bug? [Open an issue](https://github.com/MoHadiShibli/Control4Free/issues/new/choose); I maintain this
project and read every report. If Control4Free is useful to you, you can help keep it going on
Found a bug? Install the diagnostic package from the latest release first, then
[open an issue](https://github.com/MoHadiShibli/Control4Free/issues/new/choose) with its screenshots (see
[Reporting a problem](docs/troubleshooting.md#reporting-a-problem)). I maintain this project and read every
report. If Control4Free is useful to you, you can help keep it going on
[Ko-fi](https://ko-fi.com/mohadishibli).
## Credits
- [seregonwar/SplashDown](https://github.com/seregonwar/SplashDown): Control4Free's virtual-device code is
ported from its `psbutton.c`, the first working use of this API on a PS4.
- [GoldHEN](https://github.com/GoldHEN/GoldHEN), its PayLoader and AutoRun, and the
[GoldHEN Plugins SDK](https://github.com/GoldHEN/GoldHEN_Plugins_SDK).
- [ps4-payload-sdk](https://github.com/ps4-payload-dev/sdk) by John Törnblom builds the payload, and the
[OpenOrbis PS4 Toolchain](https://github.com/OpenOrbis/OpenOrbis-PS4-Toolchain) builds the app and its package.
- [Ghostcontrol](https://github.com/srbraboo/Ghostcontrol-PS5-USB-Controller-Patcher) was a research reference.
- [Apollo Save Tool](https://github.com/bucanero/apollo-ps4) showed the package settings that file an app under
Applications.
- [jsmn](https://github.com/zserge/jsmn), Project Nayuki's
[QR Code generator](https://github.com/nayuki/QR-Code-generator),
[stb_truetype](https://github.com/nothings/stb) and the [Roboto](https://github.com/googlefonts/roboto) font.
- The on-screen buttons are drawn after the
[DualShock 4 layout diagram](https://commons.wikimedia.org/wiki/File:Dualshock_4_Layout.svg) by Tokyoship
(CC BY 3.0).
Full licence details are in [THIRD_PARTY.md](THIRD_PARTY.md).
Control4Free's virtual-device code is ported from [SplashDown](https://github.com/seregonwar/SplashDown) by
seregonwar. The other code, fonts and artwork it uses, and their licenses, are listed in
[THIRD_PARTY.md](THIRD_PARTY.md).
### Built with AI help
Control4Free was built with the help of AI models, Claude by Anthropic and Codex by OpenAI, which worked on the
code, tests and documentation with me. AI-written code can look right and still be wrong. Reviews are very
welcome: if you spot AI slop (dead code, wrong assumptions, needless complexity), please
[open an issue](https://github.com/MoHadiShibli/Control4Free/issues) or send a pull request.
Control4Free was built with the help of AI models, **Claude Opus 5.5** by Anthropic and **Astra GPT-6** by
OpenAI, which worked on the code, tests and documentation with me. AI-written code can look right and still be
wrong. Reviews are very welcome: if you spot AI slop (dead code, wrong assumptions, needless complexity),
please [open an issue](https://github.com/MoHadiShibli/Control4Free/issues) or send a pull request.
## License
+3 -2
View File
@@ -16,9 +16,10 @@ can reach your PS4 over the network can:
- take a free controller and play, on the home screen, at sign-in and in games;
- press PS and Share, which reach the system menus;
- stop Control4Free, which disconnects every controller.
- stop Control4Free, which disconnects every controller;
- see the names of the users signed in on the controllers.
They cannot sign in as one of your users without someone at the TV choosing that
They cannot sign in as one of your users without someone at the PS4 choosing that
user on the PS4's own screen, and they cannot see your screen.
Run Control4Free on a network you trust. Do not forward port 4264 through your
-2
View File
@@ -35,8 +35,6 @@
[DualShock 4 layout diagram](https://commons.wikimedia.org/wiki/File:Dualshock_4_Layout.svg)
by Tokyoship, licensed under CC BY 3.0
(https://creativecommons.org/licenses/by/3.0/).
- The package settings that file the app under Applications (`CATEGORY`,
`ATTRIBUTE`) follow Apollo Save Tool (https://github.com/bucanero/apollo-ps4).
These license notices are also included inside the package.
+1 -1
View File
@@ -1 +1 @@
1.0.0
1.1.1
+102 -11
View File
@@ -924,6 +924,18 @@
.options-menu .actions { display: grid; gap: 10px; margin-top: 16px; }
.menu-close { position: absolute; top: max(14px, env(safe-area-inset-top)); right: 14px; }
/* Invite: the page's address as a QR code, in a panel opened on demand */
.invite-qr {
width: min(280px, 100%);
aspect-ratio: 1;
margin: 20px auto 14px;
border-radius: 14px;
background: #fff;
overflow: hidden;
}
.invite-qr svg { display: block; width: 100%; height: 100%; }
.invite-address { text-align: center; font-size: 18px; letter-spacing: .02em; word-break: break-all; user-select: all; }
/* ---------- Notification ---------- */
.toast {
@@ -987,6 +999,9 @@
<span class="conn" id="conn" data-conn="connecting" role="status" aria-live="polite"><span class="dot"></span><span id="connText">Connecting</span><span class="conn-detail" id="connDetail"></span></span>
</div>
<div class="tb-group">
<button class="icon-btn" type="button" id="openInvite" aria-label="Invite someone" title="Invite someone">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="3.5" y="3.5" width="6.5" height="6.5" rx="1.2"/><rect x="14" y="3.5" width="6.5" height="6.5" rx="1.2"/><rect x="3.5" y="14" width="6.5" height="6.5" rx="1.2"/><path d="M14 14h3v3M20.5 14v.01M14 20.5h3M20.5 17.5v3"/></svg>
</button>
<button class="icon-btn" type="button" id="openSettings" aria-label="Settings">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3.2"/><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z"/></svg>
</button>
@@ -1146,12 +1161,23 @@
<div class="rows" id="menuRows"></div>
<div class="actions">
<button class="btn" type="button" id="menuLayout">Edit the button layout</button>
<button class="btn" type="button" id="menuInvite">Invite someone</button>
<button class="btn" type="button" id="menuSettings">All settings</button>
<button class="btn danger" type="button" id="leaveBtn">Disconnect controller</button>
<button class="btn danger" type="button" id="stopBtn">Stop Control4Free</button>
</div>
</aside>
<aside class="options-menu" id="inviteSheet" aria-label="Invite someone">
<button class="icon-btn menu-close" type="button" id="closeInvite" aria-label="Close">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M6 6l12 12M18 6L6 18"/></svg>
</button>
<h2>Invite someone</h2>
<p class="hint">Scan this with a phone's camera to open the controller page. The phone has to be on the same network as the PS4.</p>
<div class="invite-qr" id="inviteQr"></div>
<p class="invite-address" id="inviteAddress"></p>
</aside>
<div class="toast" id="toast" role="status" aria-live="polite">
<svg viewBox="56 38 888 566" aria-hidden="true"><path fill="#fff" d="M500 78C410 78 330 74 262 62C232 46 188 42 146 62C108 80 86 120 78 170C70 230 58 320 60 400C62 470 80 535 118 568C152 598 196 592 222 560C250 525 272 470 300 430C360 418 430 440 500 440C570 440 640 418 700 430C728 470 750 525 778 560C804 592 848 598 882 568C920 535 938 470 940 400C942 320 930 230 922 170C914 120 892 80 854 62C812 42 768 46 738 62C670 74 590 78 500 78ZM366 98L634 98C648.359 98 660 109.641 660 124L660 226C660 240.359 648.359 252 634 252L366 252C351.641 252 340 240.359 340 226L340 124C340 109.641 351.641 98 366 98ZM173 148L219 148L219 189L260 189L260 235L219 235L219 276L173 276L173 235L132 235L132 189L173 189L173 148ZM804 121C818.912 121 831 133.088 831 148C831 162.912 818.912 175 804 175C789.088 175 777 162.912 777 148C777 133.088 789.088 121 804 121ZM804 249C818.912 249 831 261.088 831 276C831 290.912 818.912 303 804 303C789.088 303 777 290.912 777 276C777 261.088 789.088 249 804 249ZM740 185C754.912 185 767 197.088 767 212C767 226.912 754.912 239 740 239C725.088 239 713 226.912 713 212C713 197.088 725.088 185 740 185ZM868 185C882.912 185 895 197.088 895 212C895 226.912 882.912 239 868 239C853.088 239 841 226.912 841 212C841 197.088 853.088 185 868 185ZM338 276C374.451 276 404 305.549 404 342C404 378.451 374.451 408 338 408C301.549 408 272 378.451 272 342C272 305.549 301.549 276 338 276ZM338 298C313.699 298 294 317.699 294 342C294 366.301 313.699 386 338 386C362.301 386 382 366.301 382 342C382 317.699 362.301 298 338 298ZM662 276C698.451 276 728 305.549 728 342C728 378.451 698.451 408 662 408C625.549 408 596 378.451 596 342C596 305.549 625.549 276 662 276ZM662 298C637.699 298 618 317.699 618 342C618 366.301 637.699 386 662 386C686.301 386 706 366.301 706 342C706 317.699 686.301 298 662 298ZM500 319C509.389 319 517 326.611 517 336C517 345.389 509.389 353 500 353C490.611 353 483 345.389 483 336C483 326.611 490.611 319 500 319Z"/></svg>
<span id="toastText"></span>
@@ -1195,7 +1221,7 @@
// Settings of this device. They stay in this browser.
const DEVICE_SETTINGS = [
['keepAwake', 'Keep the screen on', 'While this page drives a controller, the screen doesn\'t turn off.', true],
['haptics', 'Vibration', 'Buzz this device when you press a button (Android).', 'vibrate' in navigator],
['haptics', 'Vibration', 'Buzz this device when you press a button or the game rumbles (Android).', 'vibrate' in navigator],
['floating', 'Floating sticks', 'A stick starts wherever your thumb lands inside its area.', false],
['tapClick', 'Tap the touchpad to click', 'A quick tap presses the touchpad button. Hold still to keep it pressed.', true],
['stickClick', 'Double-tap a stick for L3 / R3', 'Tap a stick, then touch it again right away to press it in. Hold the second touch to keep it pressed. Off: L3 and R3 get their own buttons.', true],
@@ -1239,7 +1265,7 @@
const status = Array.from({ length: PADS }, (_, i) => ({
pad: i, open: false, enabled: true, real: false, logged: false, joined: false, connected: false,
full: false, shared: false, player: 0, uid: '', cfg: '', name: 'Controller ' + (i + 1),
full: false, shared: false, player: 0, uid: '', cfg: '', name: 'Controller ' + (i + 1), user: '',
color: FALLBACK_COLORS[i], clients: 0, known: false,
}));
@@ -2336,13 +2362,14 @@
head.appendChild(el('span', 'activity'));
tile.appendChild(head);
const body = el('span');
body.appendChild(el('div', 'tile-name', s.known ? s.name : 'Controller ' + (i + 1)));
// Who's signed in on it, once the PS4 says; the ring keeps the number.
body.appendChild(el('div', 'tile-name', s.known && s.user ? s.user : 'Controller ' + (i + 1)));
body.appendChild(el('div', 'tile-meta', padMeta(s) || ''));
tile.appendChild(body);
const states = el('span', 'tile-state');
if (state === 'ready') states.appendChild(stateItem('live', 'Connected'));
else if (state === 'connecting') states.appendChild(stateItem('wait', 'Connecting'));
else if (state === 'select') states.appendChild(stateItem('wait', 'Choose user on TV'));
else if (state === 'select') states.appendChild(stateItem('wait', 'Choose user on PS4'));
else if (state === 'paused') states.appendChild(stateItem('wait', 'Paused'));
else if (state === 'error') states.appendChild(stateItem('', 'Error'));
else if (state === 'free') states.appendChild(stateItem('', 'Free'));
@@ -2357,6 +2384,16 @@
});
}
// Whether this browser can rumble this gamepad. Chrome and Edge can rumble
// Xbox pads, but often not a DualSense; Firefox none. Saying so on the row
// beats a gamepad that silently never rumbles.
function canRumble(gp) {
const a = gp && gp.vibrationActuator;
if (!a || typeof a.playEffect !== 'function') return false;
if (Array.isArray(a.effects)) return a.effects.includes('dual-rumble');
return !a.type || a.type === 'dual-rumble';
}
function renderGamepads() {
const list = $('#gamepadList');
list.textContent = '';
@@ -2377,7 +2414,8 @@
const name = (src.gp && src.gp.id || 'Gamepad').replace(/\s*\(.*?\)\s*/g, ' ').trim();
text.appendChild(el('div', 'gp-name', name || 'Gamepad'));
const mapped = src.gp && src.gp.mapping === 'standard';
text.appendChild(el('div', 'gp-sub', 'Gamepad ' + (index + 1) + (mapped ? '' : ' · buttons may be mixed up')));
text.appendChild(el('div', 'gp-sub', 'Gamepad ' + (index + 1) + (mapped ? '' : ' · buttons may be mixed up') +
(canRumble(src.gp) ? '' : ' · no rumble in this browser')));
row.appendChild(text);
const seg = el('div', 'seg');
seg.setAttribute('role', 'radiogroup');
@@ -2389,7 +2427,7 @@
b.setAttribute('aria-checked', String(src.pad === pad));
if (pad >= 0) {
b.style.setProperty('--pc', css(status[pad].color));
b.title = 'Controller ' + (pad + 1) + ' · ' + status[pad].name;
b.title = 'Controller ' + (pad + 1) + (status[pad].user ? ' · ' + status[pad].user : '');
}
b.addEventListener('click', () => setGamepadPad(index, pad));
seg.appendChild(b);
@@ -2416,14 +2454,13 @@
const s = status[primaryPad];
document.documentElement.style.setProperty('--accent', css(s.color));
const label = $('#padLabel');
// The console's name for it, when it says more than the number does.
const title = 'Controller ' + (primaryPad + 1);
label.textContent = title + (s.known && s.name && s.name !== title ? ' · ' + s.name : '');
// And who's signed in on it, once the PS4 says.
label.textContent = 'Controller ' + (primaryPad + 1) + (s.known && s.user ? ' · ' + s.user : '');
const state = padState(s);
const extra = conn.state !== 'open' ? $('#connText').textContent + $('#connDetail').textContent
: state === 'ready' ? 'Signed in on the PS4'
: state === 'connecting' ? 'Connecting to the PS4'
: state === 'select' ? 'Choose your user on TV with D-pad and Cross'
: state === 'select' ? 'Choose your user on the PS4 with D-pad and Cross'
: state === 'noslot' ? 'No free player slot'
: state === 'disabled' ? 'Turned off' : '';
if (extra) label.appendChild(el('span', 'muted', extra));
@@ -2473,7 +2510,7 @@
return;
} else if (!store.get('padTips', false)) {
store.set('padTips', true);
toast('Choose your user on the TV with D-pad and Cross. Use the ☰ button for layout and settings.');
toast('Choose your user on the PS4 with D-pad and Cross. Use the ☰ button for layout and settings.');
}
if (primaryPad >= 0 && primaryPad !== pad) {
const old = primaryPad;
@@ -2562,10 +2599,64 @@
}
function closeMenu() {
$('#inviteSheet').classList.remove('open');
document.body.classList.remove('menu-open');
$('#optionsMenu').classList.remove('open');
}
// ---------------------------------------------------------------- invite
// The console sends the QR code as a grid, one hex string per row, so the page
// needs no QR library: each run of dark modules becomes one rectangle.
function qrPath(size, rows, quiet) {
const dark = (row, x) => x < size && ((parseInt(row[x >> 2], 16) >> (3 - (x & 3))) & 1) === 1;
let d = '';
for (let y = 0; y < size; y++) {
const row = rows[y] || '';
for (let x = 0; x < size;) {
if (!dark(row, x)) { x++; continue; }
let end = x;
while (dark(row, end)) end++;
d += 'M' + (x + quiet) + ' ' + (y + quiet) + 'h' + (end - x) + 'v1h-' + (end - x) + 'z';
x = end;
}
}
return d;
}
// The address this page reached the console on, which is the one that works
// from this network; the console falls back to its own when there's none.
function inviteParams() {
const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})(?::(\d{1,5}))?$/.exec(conn.address || '');
return m ? [+m[1], +m[2], +m[3], +m[4], +(m[5] || 4264)] : [];
}
function openInvite() {
closeMenu();
document.body.classList.add('menu-open');
$('#inviteSheet').classList.add('open');
const box = $('#inviteQr'), address = $('#inviteAddress');
box.textContent = '';
address.textContent = '';
call('invite', inviteParams()).then(r => {
const quiet = 4, n = r.size + quiet * 2, ns = 'http://www.w3.org/2000/svg';
const svg = document.createElementNS(ns, 'svg');
svg.setAttribute('viewBox', '0 0 ' + n + ' ' + n);
svg.setAttribute('shape-rendering', 'crispEdges');
svg.setAttribute('role', 'img');
svg.setAttribute('aria-label', 'QR code for ' + r.text);
const path = document.createElementNS(ns, 'path');
path.setAttribute('d', qrPath(r.size, r.rows, quiet));
path.setAttribute('fill', '#000');
svg.appendChild(path);
box.appendChild(svg);
address.textContent = r.text.replace(/\/$/, '');
}).catch(e => { address.textContent = e.message || 'Couldn\'t make the QR code'; });
}
$('#openInvite').addEventListener('click', openInvite);
$('#menuInvite').addEventListener('click', openInvite);
$('#closeInvite').addEventListener('click', closeMenu);
$('#padMenuBtn').addEventListener('click', () => { if (!editor.active) openMenu(); });
$('#menuLayout').addEventListener('click', () => { closeMenu(); editor.open(); });
$('#closeOptions').addEventListener('click', closeMenu);
+11
View File
@@ -26,6 +26,17 @@ RUN curl -fsSL -o /tmp/sdk.zip \
&& rm /tmp/sdk.zip \
&& chmod -R +x /opt/ps4-payload-sdk/bin
# The payload's start-up code (crt1.o) comes from a later SDK commit than the
# release: v0.9 has no kernel offsets for firmware 13.52 or 14.00, so a payload
# built with it quits before main() on those consoles. Same exports, built with
# the SDK's own crt/Makefile.
ARG SDK_CRT_COMMIT=573b4a0d0d5fa8695cfeee5f5e2f0447c550035d
RUN mkdir /tmp/sdk-src \
&& curl -fsSL "https://github.com/ps4-payload-dev/sdk/archive/${SDK_CRT_COMMIT}.tar.gz" \
| tar xz -C /tmp/sdk-src --strip-components=1 \
&& make -C /tmp/sdk-src/crt LLVM_CONFIG=llvm-config-18 DESTDIR=/opt/ps4-payload-sdk install \
&& rm -rf /tmp/sdk-src
# orbis-clang execs $SCRIPT_DIR/clang, and orbis-ld execs $SCRIPT_DIR/ld.lld.
RUN ln -sf /usr/bin/clang-18 /opt/ps4-payload-sdk/bin/clang \
&& ln -sf /usr/bin/clang++-18 /opt/ps4-payload-sdk/bin/clang++ \
+13 -1
View File
@@ -50,7 +50,7 @@ If no line arrives, it stops creating controllers until it's restarted, rather t
it can no longer address.
The device is created for user 1, so it arrives with no user, and the PS4 asks *"Who's using this
controller?"*. The service never presses anything itself: the choice is made at the TV. When a user is picked,
controller?"*. The service never presses anything itself: the choice is made on the PS4. When a user is picked,
the login manager logs `DEVICE_OWNER_CHANGED [DeviceId:…][UserId:…]`, which is how the page learns the
controller is signed in.
@@ -80,6 +80,18 @@ turns it into a `ScePadData` sample and hands it to `scePadVirtualDeviceInsertDa
When a page stops sending, its buttons are released at once; after 3 seconds the controller goes neutral, and
after 15 it's removed. A page that reconnects within 15 seconds gets its controller back.
## Rumble and light bar
A game talks to a virtual controller the way it talks to a real one, and the system keeps what it asked for.
`scePadVirtualDeviceGetRemoteSetting` reads it back, in the layout of a DualShock 4's own output report: the
two motors, then the light bar's red, green and blue. The service reads it every 16 ms for each controller,
because games pulse rumble for only tens of milliseconds:
- a change in rumble is sent to the controller's owner, whose page buzzes the phone and rumbles any gamepad
playing as that controller. Someone who takes a controller over is told its current state at once;
- the light bar goes into the status every page sees. The PS4 sets player colours at a quarter of full
brightness, so the page brightens them to full and keeps the hue.
## Rest mode and failures
The main loop watches both the monotonic clock and the wall clock. A gap of more than 5 seconds means the
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 535 KiB

After

Width:  |  Height:  |  Size: 537 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 840 KiB

After

Width:  |  Height:  |  Size: 840 KiB

+40 -6
View File
@@ -10,11 +10,32 @@ Control4Free comes in two pieces:
## Requirements
- A PS4 with [GoldHEN](https://github.com/GoldHEN/GoldHEN). Tested on firmware 10.01 with GoldHEN v2.4b18.10.
Auto-start and GoldHEN's Payloader LaunchPad need v2.4b18.10 or later.
- A PS4 with [GoldHEN](https://github.com/GoldHEN/GoldHEN) v2.4b18.10 or later. Older GoldHEN versions don't
run ELF payloads like Control4Free reliably, and have no auto-start. See [Compatibility](#compatibility) for
each firmware.
- **Debug Settings** turned on in GoldHEN, for the Package Installer.
- A phone, tablet or computer with a modern browser, on the same network as the PS4.
## Compatibility
Every firmware GoldHEN supports has a GoldHEN version of v2.4b18.10 or later, so updating GoldHEN is all it
takes. **Tested** means someone confirmed it on a console; the other rows have GoldHEN support and support in
Control4Free's start-up code, but nobody has tried them yet.
| Firmware | GoldHEN | Status |
|---|---|---|
| 10.01 | v2.4b18.10 or later | **Tested** (v2.4b18.10) |
| 13.02, 13.04 | v2.4b18.12 or later | **Tested** (v2.4b18.12) |
| 13.52 | v2.4b18.11 or later | **Tested** (v2.4b18.12), with Control4Free 1.1.1 or later |
| 13.50 | v2.4b18.12 or later | Not tested |
| 13.00 | v2.4b18.10 or later | Not tested |
| 12.00, 12.02, 12.50, 12.52 | v2.4b18.10 or later | Not tested |
| 5.05, 6.71, 6.72, 7.0x, 7.5x, 8.0x, 8.5x, 9.0x, 9.5x, 9.60, 10.00, 10.50, 10.70, 10.71, 11.0x, 11.5x | v2.4b18.10 or later | Not tested |
The older a firmware, the less certain it is that the PS4's virtual-controller API behaves as on the tested
ones. If you try an untested firmware, an issue saying whether it worked helps others, with a
[diagnostic report](troubleshooting.md#reporting-a-problem) if it didn't.
## Install the app
1. Download `Control4Free-<version>.pkg` from the
@@ -34,19 +55,25 @@ Open **Control4Free** and press **Cross**. The app:
1. copies the service to `/data/payloads/control4free.elf`;
2. adds it to GoldHEN's AutoRun list, `/data/GoldHEN/payloads.ini`, so GoldHEN starts it every time it
loads;
3. starts it straight away if GoldHEN's **PayLoader** is on. If it isn't, restart the PS4 and run the
jailbreak: AutoRun starts Control4Free from then on.
3. starts it straight away if GoldHEN's **PayLoader** is on. If it isn't, turn PayLoader on in GoldHEN's
settings and press **Cross** again. AutoRun also starts Control4Free each time GoldHEN loads.
Once it's running, the app shows the address to open, for example `http://192.168.1.20:4264`, and a QR code
for your phone's camera. The PS4 also shows the address in a notification when the service starts.
AutoRun keeps other entries in `payloads.ini` as they are.
## After a restart
Run the GoldHEN jailbreak as usual. With auto-start on, Control4Free starts by itself and shows its address in
a notification, so you don't need to open the app. If you do open it and press **PS** to go back to the home
screen, that's fine too: the app doesn't get in the way of Control4Free.
## The app's buttons
| Button | What it does |
|---|---|
| **Cross** | Starts Control4Free when it isn't running. Until auto-start is on, it sets that up first. |
| **Cross** | Starts Control4Free when it isn't running. Until auto-start is on with this app's version, it sets that up first. |
| **Triangle** | Turns auto-start on or off. After you install a newer package, it updates the auto-start copy. |
| **Square**, then **Cross** | Stops Control4Free and disconnects every controller. |
| **Circle** | Closes the app. Control4Free keeps running without it. |
@@ -54,10 +81,13 @@ AutoRun keeps other entries in `payloads.ini` as they are.
## Updating
1. Install the new package over the old one. Delete the old app first if the installer refuses.
You don't need to stop Control4Free first: deleting or replacing the app leaves the running service and
its auto-start copy alone.
2. Open Control4Free. It says the auto-start copy is older than the one in the app: press **Triangle** to
update it.
3. The old version is still the one running. Press **Square**, then **Cross** to stop it, and **Cross**
again to start the new one (this needs GoldHEN's PayLoader). Or restart the PS4 and run the jailbreak.
again to start the new one (this needs GoldHEN's PayLoader). Or restart the PS4 and run the jailbreak:
auto-start then starts the new one.
Your controller layouts and keys live in each phone's browser, so they survive updates.
@@ -86,6 +116,10 @@ It runs until the PS4 restarts.
2. Delete the app from the Library.
3. Delete `/data/payloads/control4free.elf`, and `/data/control4free/` if you want the logs gone too.
Do step 1 before deleting the app. Deleting the app alone doesn't stop the service, and GoldHEN keeps
starting it after every restart. If the app is already gone, either install it again and start from step 1,
or remove the `control4free.elf` line from `/data/GoldHEN/payloads.ini` by hand and restart the PS4.
## Files on the console
| Path | What it is |
+24 -3
View File
@@ -17,11 +17,17 @@ The home screen shows all four controllers live:
|---|---|
| **Free** | Nobody is using it. Pick it to play. |
| **Connecting** | The PS4 is setting it up. Takes a moment. |
| **Choose user on TV** | Waiting for someone to pick a user on the PS4's screen. |
| **Connected** | Signed in and ready. |
| **Choose user on PS4** | Waiting for someone to pick a user on the PS4's screen. |
| **Connected** | Signed in and ready. The tile shows the user's name. |
| **Paused** | Its phone stopped sending input, for example because the screen locked. |
| **This device** / **+1 device** | Who's driving it: this browser, or other ones. |
## Inviting someone
Press the **QR code button** at the top of the home screen, or **☰ → Invite someone** on the controller
screen. The page shows its own address as a QR code: a friend scans it with their phone's camera and gets the
controller page, as long as they're on the same network as the PS4. Tap outside the panel to close it.
## Touch controls
The controller screen is a DualShock 4 laid out for your screen, best held sideways:
@@ -77,7 +83,7 @@ way.
| Setting | What it does |
|---|---|
| **Keep the screen on** | The phone's screen doesn't turn off while the page drives a controller. |
| **Vibration** | The phone buzzes briefly when you press a button (Android). |
| **Vibration** | The phone buzzes when the game rumbles, and briefly when you press a button (Android). |
| **Floating sticks** | A stick starts wherever your thumb lands in its area. |
| **Tap the touchpad to click** | A quick tap presses the touchpad button. |
| **Double-tap a stick for L3 / R3** | Off: L3 and R3 get their own buttons. |
@@ -100,6 +106,21 @@ for the PS4 in your router so it doesn't change.
**Full screen**: the **⛶** button in the corner. Safari on iPhone doesn't let pages go full screen, so there
it explains how to use the home screen instead.
## Rumble and light bar
When a game rumbles the controller, the device driving it feels it:
- **an Android phone** buzzes, with **Vibration** turned on in the settings. Safari on iPhone doesn't let pages
vibrate the phone;
- **a gamepad** playing as that controller rumbles too, though not every controller supports vibration in
the browser. When the browser knows it can't, the gamepad's row says *no rumble in this browser*.
A gamepad's own light bar stays as it is: browsers don't let pages change it.
The page also glows in the controller's **light-bar colour**: the player colour the PS4 gives each user, or
whatever colour the game sets. Until one is set, a controller shows its own colour (1 blue, 2 red, 3 green,
4 pink).
## Leaving
- **☰ → Disconnect controller** removes it from the PS4 straight away.
+14 -6
View File
@@ -40,13 +40,14 @@ Controllers are numbered 0 to 3 in the protocol and 1 to 4 on screen.
| Method | Params | Reply |
|---|---|---|
| `info` | `[]` | `{"version": "1.0.0", "protocol": 2, "pads": 4}` |
| `info` | `[]` | `{"version": "1.1.0", "protocol": 2, "pads": 4}` |
| `status` | `[]` | A status object, below. |
| `claim` | the controllers this connection wants, for example `[0]` or `[0, 2]` | A status object, once every controller in the claim exists. |
| `u` | input, below | None. |
| `leave` | `[pad]` | A status object. |
| `stop` | `[]` | None; the service stops and the connection closes. |
| `ping` | `[]` | `{}` |
| `invite` | `[a, b, c, d, port]`, the address the page reached, or `[]` | `{"text": "http://192.168.1.20:4264/", "size": 29, "rows": […]}` |
**`claim`** is the whole set this connection wants: controllers missing from it that it owned are removed,
and free ones in it are created. A claim that needs a new controller is answered when the controller exists,
@@ -89,14 +90,18 @@ a connection claims them again; the page does so when it reconnects.
**`stop`** is refused with `409` while another connection owns a controller.
**`invite`** returns a QR code of the page's address: `[]` uses the console's own address. `rows` holds one
hex string per row of modules, most significant bit first, `size` modules wide; add a light border of 4
modules around it when you draw it.
### The status object
```json
{
"version": "1.0.0",
"version": "1.1.0",
"protocol": 2,
"pads": [
{"pad": 0, "name": "Controller 1", "enabled": true, "open": true, "connected": true, "clients": 1,
{"pad": 0, "name": "Controller 1", "user": "Alex", "enabled": true, "open": true, "connected": true, "clients": 1,
"mine": true, "state": "ready", "uid": "1a2b3c4d", "color": [32, 96, 255], "reports": 5120, "error": 0}
]
}
@@ -104,19 +109,22 @@ a connection claims them again; the page does so when it reconnects.
| Field | Meaning |
|---|---|
| `user` | The name of the PS4 user signed in on it, or `""` (not signed in, or not known yet). |
| `open` | The virtual controller exists. |
| `connected` | A connection owns it and is sending input. |
| `clients` | 1 if a connection owns it, else 0. |
| `mine` | This connection owns it. |
| `state` | `free`, `connecting`, `select` (waiting for a user on the TV), `ready` (signed in), `paused` (no input lately, or nobody connected), or `error` (the console refused input). |
| `state` | `free`, `connecting`, `select` (waiting for a user to be chosen on the PS4), `ready` (signed in), `paused` (no input lately, or nobody connected), or `error` (the console refused input). |
| `uid` | The signed-in user's ID in hex, or `unassigned-…` before sign-in. |
| `color` | The controller's colour on the page. |
| `color` | The light bar's colour, brightened so its strongest channel is 255; the controller's own colour until one is set. |
| `reports` | Samples given to the console so far. |
| `error` | The console's error code for the last refused sample, or 0. |
### Messages from the service
- `{"method": "s", "params": <status>}`: the status, whenever something changes and at least once a second.
- `{"method": "v", "params": [pad, large, small]}`: rumble, 0 to 255 for each motor, sent to the controller's
owner when it changes and when a connection takes the controller over. `[pad, 0, 0]` stops it.
- `{"method": "error", "params": {"message": "…"}}`: a problem that isn't the reply to a request: a controller
removed for sitting unused, a request that couldn't be parsed, or an error for a request sent without an
`id`.
@@ -141,7 +149,7 @@ if they carry an `Origin`, an `Upgrade`, a body or `Transfer-Encoding`. A browse
cross-origin after a CORS preflight, which the service never answers, so websites can't use these.
```
GET /api/status → {"application": "Control4Free", "api": 1, "version": "1.0.0", "controllers": 2, "stopping": false}
GET /api/status → {"application": "Control4Free", "api": 1, "version": "1.1.0", "controllers": 2, "stopping": false}
POST /api/stop → {"application": "Control4Free", "stopping": true}
```
+42 -14
View File
@@ -1,14 +1,16 @@
# Troubleshooting
Find the message you see, or the symptom, below. If nothing here helps,
[open an issue](https://github.com/MoHadiShibli/Control4Free/issues/new/choose) with the log described at the
end of this page.
[open an issue](https://github.com/MoHadiShibli/Control4Free/issues/new/choose) with a diagnostic report, as
described at the [end of this page](#reporting-a-problem).
## On the controller page
**"Signing a controller in needs the PS4's kernel log, and something else has it."**
A klog viewer is connected to GoldHEN's log server (port 3232). Close it and pick the controller again. Only
signing a controller in needs the log, so this never interrupts play.
A klog viewer is connected to GoldHEN's log server (port 3232). Close it and pick the controller again. A
diagnostic app from a test build before 1.1.1 can also hold the log while it sits in the background: close it
(press **PS**, highlight the app, press **OPTIONS**, choose **Close Application**) and update. Only signing a
controller in needs the log, so this never interrupts play.
**"Another controller is being connected; try again in a moment."**
Controllers are added one at a time. Wait a second and pick yours again. Everyone already playing carries on
@@ -54,7 +56,7 @@ controller. If it still doesn't, disconnect the controller from the page's menu
**The screen went away without a user.** PS cancels it. Pick the controller again and use only the D-pad and
Cross until you're signed in.
**The page keeps saying "Choose user on TV" after signing in.** The page learns about sign-ins from the PS4's
**The page keeps saying "Choose user on PS4" after signing in.** The page learns about sign-ins from the PS4's
kernel log. If a klog viewer was connected at that moment, it missed it. Play on: the controller works either
way.
@@ -66,8 +68,10 @@ that takes over a signed-in user's controller can stop games from reading Contro
**Buttons feel slow.** The number next to the version at the bottom of the home screen is the round trip to
the console. Above about 30 ms, your Wi-Fi is the slow part: move closer to the router or use 5 GHz.
**No vibration from the game.** Rumble isn't passed to the phone or gamepad yet. It's planned for the next
version.
**No vibration from the game.** Check **Vibration** is on in the settings. Only Android phones can vibrate:
Safari on iPhone doesn't let pages do it. Not every controller supports vibration in the browser, and one
whose row says *no rumble in this browser* can't be rumbled from the page at all. Keep the page's window in
front. And not every game rumbles every player.
## Gamepads
@@ -89,8 +93,9 @@ run the jailbreak: with auto-start on, Control4Free starts by itself.
**"Control4Free is not responding."** Wait a few seconds after the PS4 wakes from rest mode, then press
**Cross** again. If it stays stuck, restart the PS4 and run the jailbreak.
**"Sent, but Control4Free never answered."** or **"The transfer to PayLoader broke off."** Restart the PS4
before trying again. The app won't send a second copy until then, so two can't end up running.
**"Sent, but Control4Free did not start."** or **"The transfer to PayLoader broke off."** Press **Cross** to
try again. Two copies can't end up running: a second one quits at once. If it never starts, open an issue
with your firmware and GoldHEN versions.
**"Something answers on port 4264 but not the way this app expects."** or **"No answer the app
understands."** Something else is answering on port 4264, often an older version of Control4Free. Stop it
@@ -104,10 +109,33 @@ the app and open it again; if that doesn't help, restart the PS4 and run the jai
**Control4Free is listed under Games instead of Applications.** That's a package from before 1.0.0. Delete the
app and install the current package.
## The log
## Reporting a problem
The service writes a log to `/data/control4free/control4free.log` on the PS4, and keeps the previous run in
`control4free.log.previous`. Turn on GoldHEN's FTP server and download them from port 2121, or connect to
GoldHEN's log server on port 3232 and look for lines starting with `[c4f]`.
Every release comes with a diagnostic package, `Control4Free-<version>-diag.pkg`. It's the same Control4Free,
except that its app shows on screen what the PS4 reports, so you can send screenshots instead of digging out
log files. Please use it before you open an issue:
The log contains your console's network address. Remove it before posting the log if you prefer.
1. Download `Control4Free-<version>-diag.pkg` from the
[latest release](https://github.com/MoHadiShibli/Control4Free/releases/latest) and install it over the app.
It replaces the app and shows up as **Control4Free (diagnostic)**.
2. Open it. If it says RUNNING, the normal version is still the one running: press **Square**, then **Cross**
to stop it. Then press **Cross** to start the diagnostic version, and wait until the top stops saying
"WORKING" (up to 30 seconds).
3. Make the problem happen again, for example open the page on your phone or pick a controller. Then go back
to the app.
4. Press **R1** to go through its 5 pages, and take a screenshot of each one: press **SHARE**, then
**Triangle**. To copy them to a USB stick, open **Capture Gallery**, press **OPTIONS** and choose **Copy
to USB Storage Device**.
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
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
**Cross** to stop the diagnostic version, and **Cross** to start the normal one. That also puts the normal
one back on auto-start.
The service's own log is `/data/control4free/control4free.log` on the PS4, with the previous run in
`control4free.log.previous`, readable over GoldHEN's FTP server (port 2121). The diagnostic app shows both.
+3
View File
@@ -15,6 +15,9 @@ typedef struct C4fNetClient {
const unsigned char *body;
size_t bodySize, bodySent;
void *user;
#ifdef C4F_DIAG
char peer[16]; /* the other end's address, for the diagnostic log */
#endif
} C4fNetClient;
enum { C4F_NET_OPEN, C4F_NET_MESSAGE_EVENT, C4F_NET_CLOSE,
+2
View File
@@ -14,6 +14,8 @@
/* ---- libSceUserService ---- */
int32_t sceUserServiceInitialize(void *params);
/* The user's name as the PS4 shows it: an online ID, or a local account's name. */
int32_t sceUserServiceGetUserName(int32_t userId, char *name, size_t size);
#define C4F_USER_ID_INVALID (-1)
+19
View File
@@ -11,6 +11,7 @@
#ifndef C4F_VDA_H
#define C4F_VDA_H
#include <stddef.h>
#include <stdint.h>
#include "c4f_sce.h"
@@ -59,6 +60,24 @@ int32_t c4fVirtualPadAdd(int32_t vdaUser);
/* Completes a pad from the DeviceId that came out of the log. */
void c4fVirtualPadAdopt(C4fVirtualPad *out, int32_t userId, int32_t vdaUser, uint64_t deviceId);
/* ---- what the game asks of a controller ---- */
typedef struct {
uint8_t large, small; /* rumble motors, 0..255 */
uint8_t r, g, b; /* light bar; the PS4 uses 0x40 for full */
} C4fPadFeedback;
/* Reads the buffer scePadVirtualDeviceGetRemoteSetting fills (feedback.c). */
void c4fPadFeedbackParse(const uint8_t *buf, size_t size, C4fPadFeedback *out);
/* The rumble and light bar the system wants on this controller right now.
* Returns 0, the call's error code, or -1 if the call isn't exported. */
int32_t c4fVirtualPadFeedback(const C4fVirtualPad *pad, C4fPadFeedback *out);
/* The PS4's name for a user, as it shows it. Returns 0, or the call's error.
* Not written to the log: people post logs in bug reports. */
int32_t c4fUserName(uint32_t userId, char *out, size_t size);
/* A neutral sample: sticks centred, identity quaternion, connected. */
void c4fPadDataNeutral(ScePadData *data);
+18 -6
View File
@@ -4,12 +4,23 @@ endif
SDK := $(OO_PS4_TOOLCHAIN)
VERSION := $(shell cat ../VERSION)
OUT := ../build/launcher
PAYLOAD := ../build/control4free.elf
OBJECTS :=
# Diagnostic build: pages of everything that says why Control4Free does not
# start, in place of the normal screen, with the diagnostic payload inside.
ifdef C4F_DIAG
VERSION := $(VERSION)-diag
OUT := ../build/launcher-diag
PAYLOAD := ../build/control4free-diag.elf
DIAG_FLAGS := -DC4F_DIAG
OBJECTS += $(OUT)/diag.o
endif
PKGDIR := $(OUT)/pkg
CC := clang-18
CXX := clang++-18
LD := ld.lld-18
CFLAGS := --target=x86_64-pc-freebsd12-elf -fPIC -funwind-tables -O2 -Wall -Wextra -Werror -MMD -MP -D_DEFAULT_SOURCE -isysroot $(SDK) -isystem $(SDK)/include -I../vendor/jsmn -I../vendor/qrcodegen -I../vendor/stb -DC4F_LAUNCHER_VERSION='"$(VERSION)"'
OBJECTS := $(OUT)/main.o $(OUT)/screen.o $(OUT)/draw.o $(OUT)/service.o $(OUT)/autorun.o $(OUT)/sandbox.o $(OUT)/logo.o $(OUT)/qrcodegen.o $(OUT)/stb_truetype.o $(OUT)/fonts.o
CFLAGS := --target=x86_64-pc-freebsd12-elf -fPIC -funwind-tables -O2 -Wall -Wextra -Werror -MMD -MP -D_DEFAULT_SOURCE -isysroot $(SDK) -isystem $(SDK)/include -I../vendor/jsmn -I../vendor/qrcodegen -I../vendor/stb -DC4F_LAUNCHER_VERSION='"$(VERSION)"' $(DIAG_FLAGS)
OBJECTS += $(OUT)/main.o $(OUT)/screen.o $(OUT)/draw.o $(OUT)/service.o $(OUT)/autorun.o $(OUT)/sandbox.o $(OUT)/logo.o $(OUT)/qrcodegen.o $(OUT)/stb_truetype.o $(OUT)/fonts.o
LIBS := -lc -lkernel -lSceVideoOut -lScePad -lSceUserService -lSceSystemService -lSceNetCtl
FONTS := ../vendor/roboto/Roboto-Light.ttf ../vendor/roboto/Roboto-Regular.ttf
.PHONY: all package
@@ -18,9 +29,10 @@ FONTS := ../vendor/roboto/Roboto-Light.ttf ../vendor/roboto/Roboto-Regular.ttf
all: package
$(OUT):
mkdir -p $@
$(OUT)/%.o: %.c | $(OUT)
# The version is compiled in; a new one must rebuild everything that shows it.
$(OUT)/%.o: %.c ../VERSION | $(OUT)
$(CC) $(CFLAGS) -std=gnu11 -c $< -o $@
$(OUT)/main.o: main.cpp | $(OUT)
$(OUT)/main.o: main.cpp ../VERSION | $(OUT)
$(CXX) $(CFLAGS) -std=c++17 -fno-exceptions -fno-rtti -c $< -o $@
$(OUT)/qrcodegen.o: ../vendor/qrcodegen/qrcodegen.c | $(OUT)
$(CC) $(CFLAGS) -std=gnu11 -c $< -o $@
@@ -33,5 +45,5 @@ $(OUT)/fonts.o: $(OUT)/fonts.c
$(CC) $(CFLAGS) -std=gnu11 -c $< -o $@
$(OUT)/launcher.elf: $(OBJECTS)
$(LD) -m elf_x86_64 -pie --script $(SDK)/link.x --eh-frame-hdr -o $@ $(OBJECTS) -L$(SDK)/lib $(LIBS) $(SDK)/lib/crt1.o
package: $(OUT)/launcher.elf ../build/control4free.elf
python3 ../tools/package_launcher.py --sdk $(SDK)
package: $(OUT)/launcher.elf $(PAYLOAD)
python3 ../tools/package_launcher.py --sdk $(SDK) $(if $(C4F_DIAG),--diag)
+616
View File
@@ -0,0 +1,616 @@
/* Diagnostic build of the launcher: see diag.h.
*
* Everything is shown on pages a user can screenshot, and also saved to
* /data/control4free/diag-report.txt (and a USB stick when one is plugged in).
* The kernel log has a single reader, and Control4Free needs it to sign a
* controller in. So the app reads it only in short windows, and never while
* Control4Free runs: an app suspended in the background keeps its descriptor
* open, and every controller would then fail to sign in (seen on 13.52). */
#include "diag.h"
#include <errno.h>
#include <fcntl.h>
#include <pthread.h>
#include <stdarg.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/select.h>
#include <unistd.h>
#include <orbis/libkernel.h>
#include "autorun.h"
#include "draw.h"
#include "sandbox.h"
#include "service.h"
#define C4F_DIAG_DIR "/user/data/control4free"
#define C4F_DIAG_LOG C4F_DIAG_DIR "/control4free.log"
#define C4F_DIAG_REPORT C4F_DIAG_DIR "/diag-report.txt"
#define C4F_DIAG_KLOG C4F_DIAG_DIR "/diag-klog.txt"
#define C4F_DIAG_PAYLOAD "/user/data/payloads/control4free.elf"
#define C4F_DIAG_INI "/user/data/GoldHEN/payloads.ini"
#define C4F_LOG_TAIL (64 * 1024)
#define C4F_KLOG_KEEP 1000
#define C4F_KLOG_WIDTH 192
#define C4F_KLOG_FILE_MAX (4u << 20)
#define C4F_D_BG C4F_RGBA(6, 14, 34, 1)
#define C4F_D_TEXT C4F_RGBA(235, 240, 255, 1)
#define C4F_D_FAINT C4F_RGBA(160, 175, 205, 1)
#define C4F_D_ACCENT C4F_RGBA(90, 160, 255, 1)
#define C4F_D_WARN C4F_RGBA(255, 208, 77, 1)
#define C4F_D_LINE C4F_RGBA(255, 255, 255, .2f)
#define C4F_D_SIZE 20.0f
#define C4F_D_TITLE 28.0f
#define C4F_D_ROW 24
#define C4F_D_LEFT 40
#define C4F_D_TOP 186
#define C4F_D_BOTTOM 1052
static const char *const c4fPageNames[C4F_DIAG_PAGES] = {
"Summary", "Control4Free log", "Previous Control4Free log", "Kernel log (filtered)", "Kernel log (everything)",
};
static pthread_mutex_t c4fLock = PTHREAD_MUTEX_INITIALIZER;
static pthread_t c4fKlogReader;
static int c4fReaderStarted, c4fQuit;
static uint64_t c4fBootMs, c4fCaptureUntil;
static int c4fServiceUp, c4fRefreshed;
/* Pages, rebuilt by the launcher's worker and drawn by its main loop. */
static char *c4fPages[C4F_DIAG_PAGES];
static unsigned c4fGeneration = 1;
static int c4fMaxScroll[C4F_DIAG_PAGES];
/* What start-up found. */
static char c4fFirmware[96], c4fGoldHen[96], c4fSandbox[96], c4fBundled[96];
/* The kernel log: the newest lines, and how reading it went. */
static char c4fKlog[C4F_KLOG_KEEP][C4F_KLOG_WIDTH];
static unsigned c4fKlogTotal, c4fKlogOpens;
static int c4fKlogError;
static unsigned long long c4fKlogBytes;
/* The last start. */
static int c4fStarted, c4fStartResult, c4fLogChanged;
static uint64_t c4fStartedAt, c4fStartTook;
static char c4fStartMessage[192];
static char *c4fLogBefore;
static uint64_t c4fLastReport;
static char c4fReportNote[160];
/* ---- helpers ---- */
typedef struct { char *data; size_t used, size; } C4fText;
static void c4fAdd(C4fText *t, const char *fmt, ...) __attribute__((format(printf, 2, 3)));
static void c4fAdd(C4fText *t, const char *fmt, ...)
{
for (;;) {
size_t room = t->data ? t->size - t->used : 0;
va_list ap;
va_start(ap, fmt);
int n = vsnprintf(t->data ? t->data + t->used : NULL, room, fmt, ap);
va_end(ap);
if (n < 0) return;
if (t->data && (size_t)n < room) { t->used += (size_t)n; return; }
size_t grown = t->size ? t->size * 2 : 4096;
while (grown < t->used + (size_t)n + 1) grown *= 2;
char *next = realloc(t->data, grown);
if (!next) return;
t->data = next; t->size = grown;
}
}
static char *c4fTake(C4fText *t) { return t->data ? t->data : strdup(""); }
/* The last `limit` bytes of a file, NUL-terminated, NULs shown as spaces;
* NULL with errno when it cannot be read. */
static char *c4fReadTail(const char *path, size_t limit, long long *total)
{
int fd = open(path, O_RDONLY);
if (fd < 0) return NULL;
off_t size = lseek(fd, 0, SEEK_END);
off_t start = size > (off_t)limit ? size - (off_t)limit : 0;
if (size < 0 || lseek(fd, start, SEEK_SET) < 0) { int saved = errno; close(fd); errno = saved; return NULL; }
size_t want = (size_t)(size - start), used = 0;
char *data = malloc(want + 1);
if (!data) { close(fd); errno = ENOMEM; return NULL; }
while (used < want) {
ssize_t n = read(fd, data + used, want - used);
if (n < 0 && errno == EINTR) continue;
if (n <= 0) break;
used += (size_t)n;
}
close(fd);
for (size_t i = 0; i < used; i++) if (!data[i]) data[i] = ' ';
data[used] = 0;
if (total) *total = (long long)size;
return data;
}
static uint32_t c4fCrc32Update(uint32_t crc, const unsigned char *data, size_t size)
{
crc = ~crc;
for (size_t i = 0; i < size; i++) {
crc ^= data[i];
for (int bit = 0; bit < 8; bit++) crc = (crc >> 1) ^ (0xedb88320u & (0u - (crc & 1)));
}
return ~crc;
}
static uint32_t c4fCrc32(const unsigned char *data, size_t size) { return c4fCrc32Update(0, data, size); }
/* A file's size and CRC-32; -1 with errno when it cannot be read. */
static int c4fCrcFile(const char *path, size_t *size, uint32_t *crc)
{
int fd = open(path, O_RDONLY);
if (fd < 0) return -1;
unsigned char buf[16384];
*size = 0; *crc = 0;
for (;;) {
ssize_t n = read(fd, buf, sizeof(buf));
if (n < 0 && errno == EINTR) continue;
if (n < 0) { int saved = errno; close(fd); errno = saved; return -1; }
if (n == 0) break;
*crc = c4fCrc32Update(*crc, buf, (size_t)n);
*size += (size_t)n;
}
close(fd);
return 0;
}
static const char *c4fLastLine(const char *text, char *out, size_t size)
{
const char *end = text + strlen(text);
while (end > text && (end[-1] == '\n' || end[-1] == '\r' || end[-1] == ' ')) end--;
const char *start = end;
while (start > text && start[-1] != '\n') start--;
snprintf(out, size, "%.*s", (int)(end - start), start);
return out;
}
static int c4fContains(const char *haystack, const char *needle)
{
size_t n = strlen(needle);
for (; *haystack; haystack++) {
size_t i = 0;
while (i < n && haystack[i] && (haystack[i] | 0x20) == (needle[i] | 0x20)) i++;
if (i == n) return 1;
}
return 0;
}
static void c4fWriteAll(const char *path, const char *text, int append)
{
int fd = open(path, O_WRONLY | O_CREAT | (append ? O_APPEND : O_TRUNC), 0666);
if (fd < 0) return;
size_t size = strlen(text);
while (size) {
ssize_t n = write(fd, text, size);
if (n < 0 && errno == EINTR) continue;
if (n <= 0) break;
text += n; size -= (size_t)n;
}
close(fd);
}
/* ---- the kernel log ---- */
/* One line, without terminal colour codes (GoldHEN's lines carry them). */
static void c4fKlogAdd(const char *line)
{
char clean[C4F_KLOG_WIDTH];
size_t used = 0;
for (const char *p = line; *p && used + 1 < sizeof(clean); p++) {
if (*p == '\033') {
if (p[1] == '[') { p += 2; while (*p && !(*p >= '@' && *p <= '~')) p++; if (!*p) break; }
continue;
}
if (*p != '\r') clean[used++] = *p;
}
clean[used] = 0;
pthread_mutex_lock(&c4fLock);
snprintf(c4fKlog[c4fKlogTotal % C4F_KLOG_KEEP], C4F_KLOG_WIDTH, "%s", clean);
c4fKlogTotal++;
pthread_mutex_unlock(&c4fLock);
}
static void *c4fKlogThread(void *unused)
{
(void)unused;
int fd = -1;
size_t used = 0, written = 0;
char line[C4F_KLOG_WIDTH * 2];
for (;;) {
pthread_mutex_lock(&c4fLock);
int quit = c4fQuit, want = !c4fServiceUp && c4fLauncherTimeMs() < c4fCaptureUntil;
pthread_mutex_unlock(&c4fLock);
if (quit) break;
if (want && fd < 0) {
fd = open("/dev/klog", O_RDONLY | O_NONBLOCK);
int error = fd < 0 ? errno : 0;
if (fd >= (int)FD_SETSIZE) { close(fd); fd = -1; error = EMFILE; }
pthread_mutex_lock(&c4fLock);
c4fKlogError = error;
if (fd < 0) c4fCaptureUntil = 0; else c4fKlogOpens++;
pthread_mutex_unlock(&c4fLock);
continue;
}
if (!want && fd >= 0) {
close(fd); fd = -1;
if (used) { line[used] = 0; c4fKlogAdd(line); used = 0; }
}
if (fd < 0) { usleep(100000); continue; }
fd_set set; FD_ZERO(&set); FD_SET(fd, &set);
struct timeval tv = { 0, 100000 };
if (select(fd + 1, &set, NULL, NULL, &tv) <= 0) continue;
char buf[4096];
ssize_t n = read(fd, buf, sizeof(buf) - 1);
if (n < 0 && (errno == EINTR || errno == EAGAIN || errno == EWOULDBLOCK)) continue;
if (n <= 0) {
pthread_mutex_lock(&c4fLock);
c4fKlogError = n < 0 ? errno : EPIPE; c4fCaptureUntil = 0;
pthread_mutex_unlock(&c4fLock);
close(fd); fd = -1;
continue;
}
buf[n] = 0;
if (written < C4F_KLOG_FILE_MAX) { c4fWriteAll(C4F_DIAG_KLOG, buf, 1); written += (size_t)n; }
pthread_mutex_lock(&c4fLock);
c4fKlogBytes += (unsigned long long)n;
pthread_mutex_unlock(&c4fLock);
for (ssize_t i = 0; i < n; i++) {
if (buf[i] == '\n' || used + 1 >= sizeof(line)) {
line[used] = 0; c4fKlogAdd(line); used = 0;
if (buf[i] == '\n') continue;
}
line[used++] = buf[i];
}
}
if (fd >= 0) close(fd);
return NULL;
}
void c4fDiagCapture(unsigned milliseconds)
{
pthread_mutex_lock(&c4fLock);
uint64_t until = c4fLauncherTimeMs() + milliseconds;
if (until > c4fCaptureUntil) c4fCaptureUntil = until;
pthread_mutex_unlock(&c4fLock);
}
/* ---- start-up and starts ---- */
void c4fDiagStart(int sandboxResult, int sandboxError)
{
c4fBootMs = c4fLauncherTimeMs();
OrbisKernelSwVersion version;
memset(&version, 0, sizeof(version));
version.Size = sizeof(version);
int ret = sceKernelGetSystemSwVersion(&version);
version.VersionString[sizeof(version.VersionString) - 1] = 0;
snprintf(c4fFirmware, sizeof(c4fFirmware), "\"%s\" = 0x%08x (call returned 0x%08x)",
version.VersionString, version.Version, (uint32_t)ret);
/* GoldHEN returns the version as the call's error value (seen on 13.52:
* 256 = SDK 1.00), so only ENOSYS (78) means there is no GoldHEN call. */
long goldHen = c4fGoldHenCommand(0, NULL);
if (goldHen == -78) snprintf(c4fGoldHen, sizeof(c4fGoldHen), "no answer (errno 78: no GoldHEN SDK call)");
else snprintf(c4fGoldHen, sizeof(c4fGoldHen), "answers, SDK version 0x%lx", goldHen < 0 ? -goldHen : goldHen);
if (sandboxResult == 0) snprintf(c4fSandbox, sizeof(c4fSandbox), "left through GoldHEN");
else snprintf(c4fSandbox, sizeof(c4fSandbox), "still inside, errno %d", sandboxError);
size_t size = 0;
const unsigned char *payload = c4fAutorunBundled(&size);
if (payload) snprintf(c4fBundled, sizeof(c4fBundled), "%zu bytes, crc32 %08x", size, c4fCrc32(payload, size));
else snprintf(c4fBundled, sizeof(c4fBundled), "could not be read");
c4fWriteAll(C4F_DIAG_KLOG, "", 0);
c4fReaderStarted = pthread_create(&c4fKlogReader, NULL, c4fKlogThread, NULL) == 0;
/* The backlog (GoldHEN, AutoRun, earlier runs) is read at the first
* refresh, once it is known whether Control4Free runs. */
}
void c4fDiagStop(void)
{
pthread_mutex_lock(&c4fLock);
c4fQuit = 1;
pthread_mutex_unlock(&c4fLock);
if (c4fReaderStarted) pthread_join(c4fKlogReader, NULL);
}
void c4fDiagBeforeStart(void)
{
free(c4fLogBefore);
c4fLogBefore = c4fReadTail(C4F_DIAG_LOG, C4F_LOG_TAIL, NULL);
c4fStartedAt = c4fLauncherTimeMs();
c4fDiagCapture(30000);
}
void c4fDiagAfterStart(int result, const char *message)
{
char *after = c4fReadTail(C4F_DIAG_LOG, C4F_LOG_TAIL, NULL);
c4fLogChanged = after && (!c4fLogBefore || strcmp(after, c4fLogBefore));
free(after);
c4fStarted = 1;
c4fStartResult = result;
c4fStartTook = c4fLauncherTimeMs() - c4fStartedAt;
snprintf(c4fStartMessage, sizeof(c4fStartMessage), "%s", message);
c4fLastReport = 0; /* save the report straight away */
if (result) c4fDiagCapture(3000);
else { pthread_mutex_lock(&c4fLock); c4fCaptureUntil = 0; pthread_mutex_unlock(&c4fLock); }
}
/* ---- pages ---- */
static void c4fFileSummary(C4fText *t, const char *label, const char *path)
{
long long size = 0;
char *text = c4fReadTail(path, C4F_LOG_TAIL, &size);
if (!text) { c4fAdd(t, " %s: not there (errno %d)\n", label, errno); return; }
char last[200];
const char *firstEnd = strchr(text, '\n');
c4fAdd(t, " %s: %lld bytes\n first: %.*s\n last: %s\n", label, size,
(int)(firstEnd ? firstEnd - text : (long)strlen(text)), text, c4fLastLine(text, last, sizeof(last)));
free(text);
}
static char *c4fSummary(const C4fDiagState *s)
{
C4fText t = {0};
char last[200] = "";
char *log = c4fReadTail(C4F_DIAG_LOG, C4F_LOG_TAIL, NULL);
if (log) c4fLastLine(log, last, sizeof(last));
if (s->running == 1)
c4fAdd(&t, ">> Control4Free is RUNNING (%s). Now open %s on your phone or PC. Whatever happens, come back\n"
">> here and take screenshots of pages 1 and 2: page 2 lists every connection it saw.\n",
s->version, s->address[0] ? s->address : "the PS4's address with :4264");
else if (c4fStarted && c4fContains(c4fStartMessage, "PayLoader did not answer"))
c4fAdd(&t, ">> GoldHEN's PayLoader (port 9090) did not take the payload. Turn PayLoader on in GoldHEN's\n"
">> settings, then press Cross again.\n");
else if (c4fStarted && c4fStartResult == -2 && !c4fLogChanged)
c4fAdd(&t, ">> The payload was handed to PayLoader, but Control4Free's own code never ran (it wrote no log).\n"
">> PayLoader did not run it, or it stopped while starting up on this firmware. The kernel log\n"
">> pages (4 and 5) show what the PS4 said meanwhile.\n");
else if (c4fStarted && c4fLogChanged)
c4fAdd(&t, ">> Control4Free started but does not answer. Its log (page 2) shows how far it got. Last line:\n"
">> %s\n", last);
else if (c4fStarted)
c4fAdd(&t, ">> The start did not work: %s\n", c4fStartMessage);
else
c4fAdd(&t, ">> Not running. Press Cross to start it, wait for the result (up to 30 seconds), then take a\n"
">> screenshot of every page.\n");
free(log);
c4fAdd(&t, "\nConsole\n");
c4fAdd(&t, " system software: %s\n", c4fFirmware);
c4fAdd(&t, " GoldHEN SDK call: %s\n", c4fGoldHen);
c4fAdd(&t, " app sandbox: %s\n", c4fSandbox);
c4fAdd(&t, " network address: %s\n", s->address[0] ? s->address : "(none)");
c4fAdd(&t, "\nThis app\n");
c4fAdd(&t, " version %s, bundled payload %s, open for %llu s\n", C4F_LAUNCHER_VERSION, c4fBundled,
(unsigned long long)((c4fLauncherTimeMs() - c4fBootMs) / 1000));
c4fAdd(&t, "\nService (port 4264)\n");
if (s->running == 1) c4fAdd(&t, " running, version %s, %d controller(s)\n", s->version, s->controllers);
else if (s->running == 0) c4fAdd(&t, " not running (nothing listens)\n");
else c4fAdd(&t, " unclear: %s\n", s->problem[0] ? s->problem : "(no detail)");
c4fAdd(&t, " screen message: %s%s\n", s->message, s->locked ? " [app error: reopen the app]" : "");
c4fAdd(&t, "\nLast start from this app\n");
if (!c4fStarted) c4fAdd(&t, " none yet\n");
else c4fAdd(&t, " result %d after %llu ms; service log %s\n message: %s\n", c4fStartResult,
(unsigned long long)c4fStartTook, c4fLogChanged ? "CHANGED (it ran)" : "unchanged (it did not run)",
c4fStartMessage);
c4fAdd(&t, "\nAuto-start\n");
static const char *const states[] = { "unknown", "off", "on", "on, but an older copy" };
int state = s->autorun + 1;
c4fAdd(&t, " state: %s%s%s\n", state >= 0 && state < 4 ? states[state] : "?", s->autorunNote[0] ? "; " : "",
s->autorunNote);
size_t installedSize = 0;
uint32_t installedCrc = 0;
if (!c4fCrcFile(C4F_DIAG_PAYLOAD, &installedSize, &installedCrc))
c4fAdd(&t, " /data/payloads/control4free.elf: %zu bytes, crc32 %08x\n", installedSize, installedCrc);
else c4fAdd(&t, " /data/payloads/control4free.elf: not there (errno %d)\n", errno);
char *ini = c4fReadTail(C4F_DIAG_INI, 2048, NULL);
if (ini) {
c4fAdd(&t, " /data/GoldHEN/payloads.ini:\n");
for (char *line = strtok(ini, "\n"); line; line = strtok(NULL, "\n")) c4fAdd(&t, " %s\n", line);
free(ini);
} else c4fAdd(&t, " /data/GoldHEN/payloads.ini: not there (errno %d)\n", errno);
c4fAdd(&t, "\nLogs\n");
c4fFileSummary(&t, "control4free.log", C4F_DIAG_LOG);
c4fFileSummary(&t, "control4free.log.previous", C4F_DIAG_LOG ".previous");
pthread_mutex_lock(&c4fLock);
unsigned total = c4fKlogTotal, opens = c4fKlogOpens;
int error = c4fKlogError;
unsigned long long bytes = c4fKlogBytes;
pthread_mutex_unlock(&c4fLock);
c4fAdd(&t, " kernel log: read %u time(s), %llu bytes, %u lines", opens, bytes, total);
if (s->running == 1) c4fAdd(&t, "; not read while Control4Free runs, which needs it to sign controllers in\n");
else if (error == 16) c4fAdd(&t, "; BUSY: a klog viewer is connected to GoldHEN (port 3232). Close it, press Options.\n");
else if (error) c4fAdd(&t, "; last error errno %d\n", error);
else c4fAdd(&t, "\n");
c4fAdd(&t, " report: %s\n", c4fReportNote[0] ? c4fReportNote : "not saved yet");
return c4fTake(&t);
}
static char *c4fLogPage(const char *path)
{
long long size = 0;
char *text = c4fReadTail(path, C4F_LOG_TAIL, &size);
if (!text) {
C4fText t = {0};
c4fAdd(&t, "%s is not there (errno %d).\n", path, errno);
return c4fTake(&t);
}
return text;
}
/* Lines that tend to say why a payload did not run. */
static int c4fInteresting(const char *line)
{
static const char *const words[] = {
"c4f", "control4free", "unsupported", "firmware", "kexec", "payload", "elf", "goldhen", "signal",
"crash", "fault", "panic", "trap", "segv", "party", "rtld", "dlsym", "unable", "fail", "error",
"denied", "9090", "4264",
};
for (size_t i = 0; i < sizeof(words) / sizeof(words[0]); i++) if (c4fContains(line, words[i])) return 1;
return 0;
}
static char *c4fKlogPage(int filtered)
{
C4fText t = {0};
pthread_mutex_lock(&c4fLock);
unsigned total = c4fKlogTotal, first = total > C4F_KLOG_KEEP ? total - C4F_KLOG_KEEP : 0;
if (!total) c4fAdd(&t, "Nothing read from the kernel log yet (see the summary page). Options reads it again.\n");
for (unsigned i = first; i < total; i++) {
const char *line = c4fKlog[i % C4F_KLOG_KEEP];
if (!filtered || c4fInteresting(line)) c4fAdd(&t, "%s\n", line);
}
pthread_mutex_unlock(&c4fLock);
return c4fTake(&t);
}
static void c4fSaveReport(void)
{
C4fText t = {0};
pthread_mutex_lock(&c4fLock);
for (int i = 0; i < C4F_DIAG_PAGES; i++)
c4fAdd(&t, "===== %s =====\n%s\n", c4fPageNames[i], c4fPages[i] ? c4fPages[i] : "");
pthread_mutex_unlock(&c4fLock);
char *text = c4fTake(&t);
c4fWriteAll(C4F_DIAG_REPORT, text, 0);
snprintf(c4fReportNote, sizeof(c4fReportNote), "/data/control4free/diag-report.txt");
static const char *const usb[] = { "/mnt/usb0", "/mnt/usb1" };
for (int i = 0; i < 2; i++) {
char path[64];
snprintf(path, sizeof(path), "%s/control4free-diag.txt", usb[i]);
int fd = open(path, O_WRONLY | O_CREAT | O_TRUNC, 0666);
if (fd < 0) continue;
close(fd);
c4fWriteAll(path, text, 0);
size_t used = strlen(c4fReportNote);
snprintf(c4fReportNote + used, sizeof(c4fReportNote) - used, " and %s", path);
}
free(text);
}
unsigned c4fDiagRefresh(const C4fDiagState *state)
{
pthread_mutex_lock(&c4fLock);
c4fServiceUp = state->running == 1;
if (c4fServiceUp) c4fCaptureUntil = 0;
pthread_mutex_unlock(&c4fLock);
if (!c4fRefreshed++ && state->running != 1) c4fDiagCapture(4000);
char *pages[C4F_DIAG_PAGES] = {
c4fSummary(state), c4fLogPage(C4F_DIAG_LOG), c4fLogPage(C4F_DIAG_LOG ".previous"),
c4fKlogPage(1), c4fKlogPage(0),
};
int changed = 0;
pthread_mutex_lock(&c4fLock);
for (int i = 0; i < C4F_DIAG_PAGES; i++) {
if (c4fPages[i] && !strcmp(c4fPages[i], pages[i])) { free(pages[i]); continue; }
free(c4fPages[i]);
c4fPages[i] = pages[i];
changed = 1;
}
if (changed) c4fGeneration++;
unsigned generation = c4fGeneration;
pthread_mutex_unlock(&c4fLock);
uint64_t now = c4fLauncherTimeMs();
if (changed && (!c4fLastReport || now - c4fLastReport >= 10000)) { c4fSaveReport(); c4fLastReport = now; }
return generation;
}
/* ---- drawing ---- */
typedef struct { const char *text; int length, highlight; } C4fRow;
/* How many characters of a line fit in the width. */
static int c4fFit(const char *text, int length, float width)
{
char line[400];
int n = length < (int)sizeof(line) - 1 ? length : (int)sizeof(line) - 1;
memcpy(line, text, (size_t)n); line[n] = 0;
float measured = c4fTextWidth(C4F_FONT_REGULAR, C4F_D_SIZE, line);
if (measured <= width) return n;
n = (int)(n * width / measured);
if (n < 1) n = 1;
line[n] = 0;
while (n > 1 && c4fTextWidth(C4F_FONT_REGULAR, C4F_D_SIZE, line) > width) line[--n] = 0;
return n;
}
void c4fDrawDiag(uint32_t *pixels, const C4fLauncherScreen *s)
{
C4fCanvas c = { pixels, C4F_SCREEN_WIDTH, C4F_SCREEN_HEIGHT };
const float width = C4F_SCREEN_WIDTH - 2 * C4F_D_LEFT;
int page = s->diagPage >= 0 && s->diagPage < C4F_DIAG_PAGES ? s->diagPage : 0;
char line[320];
c4fFillRect(&c, 0, 0, c.w, c.h, C4F_D_BG);
/* The build time tells one diagnostic build from the next in a screenshot. */
snprintf(line, sizeof(line), "Control4Free DIAGNOSTIC BUILD %s (%s %s) page %d of %d: %s", C4F_LAUNCHER_VERSION,
__DATE__, __TIME__, page + 1, C4F_DIAG_PAGES, c4fPageNames[page]);
c4fText(&c, C4F_FONT_REGULAR, C4F_D_TITLE, C4F_D_LEFT, 48, C4F_D_ACCENT, line);
c4fText(&c, C4F_FONT_REGULAR, C4F_D_SIZE, C4F_D_LEFT, 84, C4F_D_FAINT,
"L1 / R1: change page Up / Down: scroll Options: read the kernel log again "
"Cross: start Triangle: auto-start on/off Square: stop Circle: close");
c4fText(&c, C4F_FONT_REGULAR, C4F_D_SIZE, C4F_D_LEFT, 114, C4F_D_WARN,
"Please take a screenshot of EVERY page (hold SHARE, or press SHARE then Triangle) and send them all.");
if (s->confirmStop) snprintf(line, sizeof(line), "Press Cross to stop Control4Free, or Circle to cancel.");
else snprintf(line, sizeof(line), "%s%s", s->busy ? "WORKING: " : "Now: ", s->message);
c4fText(&c, C4F_FONT_REGULAR, C4F_D_SIZE, C4F_D_LEFT, 146, C4F_D_TEXT, line);
c4fFillRect(&c, C4F_D_LEFT, 160, (int)width, 1, C4F_D_LINE);
pthread_mutex_lock(&c4fLock);
char *text = strdup(c4fPages[page] ? c4fPages[page] : "(not read yet)");
pthread_mutex_unlock(&c4fLock);
if (!text) return;
/* Wrap every line, then show the window the scroll position picks. */
int count = 0, capacity = 256;
C4fRow *rows = malloc(sizeof(*rows) * (size_t)capacity);
for (char *p = text; rows && *p; ) {
char *end = strchr(p, '\n');
int length = end ? (int)(end - p) : (int)strlen(p);
int highlight = !strncmp(p, ">> ", 3);
if (highlight) { p += 3; length -= 3; }
do {
int fit = length ? c4fFit(p, length, width) : 0;
if (count == capacity) {
C4fRow *grown = realloc(rows, sizeof(*rows) * (size_t)(capacity *= 2));
if (!grown) break;
rows = grown;
}
rows[count++] = (C4fRow){ p, fit, highlight };
p += fit; length -= fit;
} while (length > 0);
p = end ? end + 1 : p + strlen(p);
}
int visible = (C4F_D_BOTTOM - C4F_D_TOP) / C4F_D_ROW;
int maxScroll = count > visible ? count - visible : 0;
c4fMaxScroll[page] = maxScroll;
int first = s->diagScroll[page] >= C4F_DIAG_END ? maxScroll : s->diagScroll[page];
if (first > maxScroll) first = maxScroll;
if (first < 0) first = 0;
for (int i = 0; rows && i < visible && first + i < count; i++) {
const C4fRow *row = &rows[first + i];
c4fTextN(&c, C4F_FONT_REGULAR, C4F_D_SIZE, C4F_D_LEFT, C4F_D_TOP + i * C4F_D_ROW,
row->highlight ? C4F_D_WARN : C4F_D_TEXT, row->text, row->length);
}
snprintf(line, sizeof(line), "lines %d-%d of %d", count ? first + 1 : 0, first + visible < count ? first + visible : count, count);
c4fText(&c, C4F_FONT_REGULAR, C4F_D_SIZE, C4F_D_LEFT, 1072, C4F_D_FAINT, line);
free(rows);
free(text);
}
int c4fDiagMaxScroll(int page)
{
return page >= 0 && page < C4F_DIAG_PAGES ? c4fMaxScroll[page] : 0;
}
+38
View File
@@ -0,0 +1,38 @@
#ifndef C4F_LAUNCHER_DIAG_H
#define C4F_LAUNCHER_DIAG_H
/* Diagnostic build (make -C launcher C4F_DIAG=1): plain pages of everything
* that says why Control4Free does not start on a console, in place of the
* normal screen, so a user can send screenshots instead of log files. */
#include <stdint.h>
#include "screen.h"
#ifdef __cplusplus
extern "C" {
#endif
#define C4F_DIAG_PAGES 5
/* A page's scroll position meaning "show the end". */
#define C4F_DIAG_END 1000000
typedef struct {
int running, controllers, autorun, locked, busy, confirmStop;
const char *version, *problem, *message, *address, *autorunNote;
} C4fDiagState;
/* Records what is known at start-up and reads the kernel log's backlog. */
void c4fDiagStart(int sandboxResult, int sandboxError);
void c4fDiagStop(void);
/* Holds the kernel log open for this long, reading it. */
void c4fDiagCapture(unsigned milliseconds);
/* Around a start: what the service log looked like before, and the result. */
void c4fDiagBeforeStart(void);
void c4fDiagAfterStart(int result, const char *message);
/* Rebuilds the pages; returns their generation, which changes with them. */
unsigned c4fDiagRefresh(const C4fDiagState *state);
void c4fDrawDiag(uint32_t *pixels, const C4fLauncherScreen *screen);
/* The largest useful scroll position of a page, as of its last drawing. */
int c4fDiagMaxScroll(int page);
#ifdef __cplusplus
}
#endif
#endif
+45 -3
View File
@@ -16,6 +16,9 @@
#include "sandbox.h"
#include "screen.h"
#include "service.h"
#ifdef C4F_DIAG
#include "diag.h"
#endif
#define C4F_BUNDLED_PAYLOAD "/app0/assets/control4free.elf"
@@ -54,7 +57,7 @@ static void *c4fWorker(void *)
if (quit) return NULL;
if (command || c4fLauncherTimeMs() >= nextCheck) {
char message[160] = {0}, address[64] = {0};
int result = 0;
[[maybe_unused]] int result = 0; /* read by the diagnostic build */
/* The console's own address: shown for the phone or PC, and the
* app's second way to reach Control4Free from its sandbox. */
OrbisNetCtlInfo info;
@@ -66,6 +69,9 @@ static void *c4fWorker(void *)
} else {
c4fLauncherSetHost(NULL);
}
#ifdef C4F_DIAG
if (command == C4F_DO_START || command == C4F_DO_SET_UP) c4fDiagBeforeStart();
#endif
if (command == C4F_DO_START) {
size_t payloadSize;
const unsigned char *payload = c4fAutorunBundled(&payloadSize);
@@ -73,6 +79,9 @@ static void *c4fWorker(void *)
}
if (command == C4F_DO_STOP) result = c4fLauncherStop(message, sizeof(message));
if (command == C4F_DO_SET_UP) result = c4fSetUp(message, sizeof(message));
#ifdef C4F_DIAG
if (command == C4F_DO_START || command == C4F_DO_SET_UP) c4fDiagAfterStart(result, message);
#endif
if (command == C4F_DO_AUTORUN_ON) result = c4fAutorunEnable(message, sizeof(message));
if (command == C4F_DO_AUTORUN_OFF) result = c4fAutorunDisable(message, sizeof(message));
if (first || command >= C4F_DO_SET_UP) autorun = c4fAutorunCheck(autorunNote, sizeof(autorunNote));
@@ -80,7 +89,6 @@ static void *c4fWorker(void *)
int running = c4fLauncherProbe(&status);
const char *problem = c4fLauncherProblem();
pthread_mutex_lock(&c4fMutex);
if (result == -2) c4fScreen.locked = 1;
if (command) {
snprintf(c4fScreen.message, sizeof(c4fScreen.message), "%s", message);
} else if (!c4fScreen.locked &&
@@ -98,6 +106,15 @@ static void *c4fWorker(void *)
snprintf(c4fScreen.address, sizeof(c4fScreen.address), "%s", address);
/* A queued click while a periodic probe was in flight remains busy. */
if (!c4fCommand) c4fScreen.busy = 0;
#ifdef C4F_DIAG
C4fLauncherScreen copy = c4fScreen;
pthread_mutex_unlock(&c4fMutex);
C4fDiagState diag = { running, status.controllers, autorun, copy.locked, copy.busy, copy.confirmStop,
status.version, problem, copy.message, copy.address, copy.autorunNote };
unsigned generation = c4fDiagRefresh(&diag);
pthread_mutex_lock(&c4fMutex);
c4fScreen.diagGeneration = generation;
#endif
pthread_mutex_unlock(&c4fMutex);
first = 0; nextCheck = c4fLauncherTimeMs() + 2000;
}
@@ -175,7 +192,12 @@ int main(void)
if (c4fAutorunLoadBundled(C4F_BUNDLED_PAYLOAD)) printf("[c4f-launcher] bundled payload unreadable\n");
/* The sandbox refuses connections to the console itself (EACCES), so the
* app could reach neither Control4Free nor PayLoader from inside it. */
if (c4fSandboxLeave()) printf("[c4f-launcher] could not leave the sandbox, errno %d\n", errno);
int sandbox = c4fSandboxLeave(), sandboxError = errno;
if (sandbox) printf("[c4f-launcher] could not leave the sandbox, errno %d\n", sandboxError);
#ifdef C4F_DIAG
c4fDiagStart(sandbox, sandboxError);
for (int i = 0; i < C4F_DIAG_PAGES; i++) c4fScreen.diagScroll[i] = i ? C4F_DIAG_END : 0;
#endif
pthread_t worker;
int workerStarted = pthread_create(&worker, NULL, c4fWorker, NULL) == 0;
if (!workerStarted || pad < 0) {
@@ -220,11 +242,28 @@ int main(void)
off ? "Turning auto-start off..." : "Turning auto-start on...");
} else if ((pressed & ORBIS_PAD_BUTTON_SQUARE) && c4fScreen.running == 1) c4fScreen.confirmStop = 1;
}
#ifdef C4F_DIAG
/* Pages and scrolling work while busy too. */
int page = c4fScreen.diagPage;
if (pressed & (ORBIS_PAD_BUTTON_R1 | ORBIS_PAD_BUTTON_RIGHT)) c4fScreen.diagPage = (page + 1) % C4F_DIAG_PAGES;
if (pressed & (ORBIS_PAD_BUTTON_L1 | ORBIS_PAD_BUTTON_LEFT)) c4fScreen.diagPage = (page + C4F_DIAG_PAGES - 1) % C4F_DIAG_PAGES;
if (pressed & (ORBIS_PAD_BUTTON_UP | ORBIS_PAD_BUTTON_DOWN)) {
int max = c4fDiagMaxScroll(page), at = c4fScreen.diagScroll[page];
if (at >= C4F_DIAG_END) at = max;
at += pressed & ORBIS_PAD_BUTTON_UP ? -10 : 10;
c4fScreen.diagScroll[page] = at >= max ? C4F_DIAG_END : at < 0 ? 0 : at;
}
if (pressed & ORBIS_PAD_BUTTON_OPTIONS) c4fDiagCapture(4000);
#endif
C4fLauncherScreen snapshot = c4fScreen;
pthread_mutex_unlock(&c4fMutex);
/* Redraw only when something changed, then copy it to each buffer once. */
if (!drawn || memcmp(&snapshot, &drawnState, sizeof(snapshot))) {
#ifdef C4F_DIAG
c4fDrawDiag(canvas, &snapshot);
#else
c4fDrawLauncher(canvas, &snapshot);
#endif
drawnState = snapshot; drawn = 1; shown[0] = shown[1] = 0;
}
if (!shown[buffer]) { memcpy(c4fBuffers[buffer], canvas, frameBytes); shown[buffer] = 1; }
@@ -243,6 +282,9 @@ int main(void)
}
pthread_mutex_lock(&c4fMutex); c4fQuit = 1; pthread_mutex_unlock(&c4fMutex);
if (workerStarted) pthread_join(worker, NULL);
#ifdef C4F_DIAG
c4fDiagStop();
#endif
if (pad >= 0) scePadClose(pad);
sceNetCtlTerm();
c4fCloseVideo();
+16 -5
View File
@@ -30,21 +30,32 @@ static long c4fGoldHen(uint64_t command, void *data)
return failed ? -ret : ret;
}
long c4fGoldHenCommand(unsigned long command, void *data)
{
/* Without GoldHEN the call does not exist: get an error, not SIGSYS. The
* kernel's SIGSYS is FreeBSD's 12; OpenOrbis's header has Linux's 31. */
signal(12, SIG_IGN);
return c4fGoldHen(command, data);
}
int c4fSandboxLeave(void)
{
static C4fJailbreak backup;
if (c4fOut) return 0;
/* Without GoldHEN the call does not exist: get an error, not SIGSYS. The
* kernel's SIGSYS is FreeBSD's 12; OpenOrbis's header has Linux's 31. */
signal(12, SIG_IGN);
memset(&backup, 0, sizeof(backup));
long ret = c4fGoldHen(2, &backup);
long ret = c4fGoldHenCommand(2, &backup);
if (ret != 0) { errno = ret < 0 ? (int)-ret : EPERM; return -1; }
c4fOut = 1;
return 0;
}
#else
/* Host tests: there is no sandbox. */
/* Host tests: there is no sandbox, and no GoldHEN. */
long c4fGoldHenCommand(unsigned long command, void *data)
{
(void)command; (void)data;
return -ENOSYS;
}
int c4fSandboxLeave(void)
{
if (getenv("C4F_TEST_NO_GOLDHEN")) { errno = ENOSYS; return -1; }
+3
View File
@@ -10,6 +10,9 @@ extern "C" {
/* 0 when out of the sandbox (again: no-op); -1 with errno otherwise. */
int c4fSandboxLeave(void);
int c4fSandboxIsOut(void);
/* GoldHEN's SDK call (syscall 500): its result, or -errno when the call
* failed or does not exist. */
long c4fGoldHenCommand(unsigned long command, void *data);
#ifdef __cplusplus
}
+4 -4
View File
@@ -428,10 +428,10 @@ static C4fLook c4fLookFor(const C4fLauncherScreen *s, char *meta, size_t metaSiz
"is running, stop it on its controller page or restart the PS4.",
"Not responding", C4F_BAD, C4F_BAD, 0 };
if (s->locked) {
look.pill = look.meta = "Restart needed";
look.title = "Restart the PS4";
look.subtitle = "Control4Free was sent but never answered. Restart the console before you try again, so "
"two copies never run at once.";
look.pill = look.meta = "App error";
look.title = "Reopen the app";
look.subtitle = "The app could not start properly. Close it with the PS button and open it again. "
"Control4Free itself is not affected.";
} else if (s->busy) {
look = (C4fLook){ "Working", "Please wait", s->message, "Checking", C4F_WARN, C4F_ACCENT, 0 };
} else if (s->running == 1) {
+4
View File
@@ -11,6 +11,10 @@ typedef struct {
int running, busy, controllers, confirmStop, locked;
int autorun; /* C4F_AUTORUN_* from autorun.h */
char address[64], message[160], autorunNote[96];
#ifdef C4F_DIAG
unsigned diagGeneration; /* bumped when a diagnostic page changes */
int diagPage, diagScroll[8];
#endif
} C4fLauncherScreen;
#ifdef __cplusplus
extern "C" {
+5 -3
View File
@@ -242,7 +242,7 @@ int c4fLauncherStart(const unsigned char *payload, size_t payloadSize, char *mes
if (fd < 0) { snprintf(message, size, "PayLoader did not answer (errno %d). Turn it on in GoldHEN, then press Cross again.", errno); return -1; }
int failed = c4fSendAll(fd, payload, payloadSize, c4fLauncherTimeMs() + 10000);
shutdown(fd, SHUT_WR); close(fd);
if (failed) { snprintf(message, size, "The transfer to PayLoader broke off. Restart the PS4 before you try again."); return -2; }
if (failed) { snprintf(message, size, "The transfer to PayLoader broke off. Press Cross to try again."); return -2; }
uint64_t deadline;
deadline = c4fLauncherTimeMs() + 20000;
while (c4fLauncherTimeMs() < deadline) {
@@ -251,8 +251,10 @@ int c4fLauncherStart(const unsigned char *payload, size_t payloadSize, char *mes
}
usleep(250000);
}
snprintf(message, size, "Sent, but Control4Free never answered (%s). Restart the PS4 before you try again.", c4fProblem);
return -2; /* Caller locks further sends until restart, avoiding duplicates. */
snprintf(message, size, "Sent, but Control4Free did not start (%s). Press Cross to try again.", c4fProblem);
/* Sending again is safe: a copy that did start holds the payload's
* instance lock, so a second one quits at once. */
return -2;
}
int c4fLauncherStop(char *message, size_t size)
+1
View File
@@ -21,6 +21,7 @@ int c4fLauncherProbe(C4fServiceStatus *status);
/* Whether the last probe connected anywhere, so "no answer" can be told apart
* from "cannot reach". */
int c4fLauncherReached(void);
/* 0 running, -1 not sent, -2 sent but Control4Free did not answer. */
int c4fLauncherStart(const unsigned char *payload, size_t payloadSize, char *message, size_t size);
int c4fLauncherStop(char *message, size_t size);
#ifdef __cplusplus
+34
View File
@@ -0,0 +1,34 @@
/* Control4Free -- what a game asks of a controller: rumble and light bar.
*
* Pure byte work, with no PS4 calls, so the host tests run the same code the
* console does.
*/
#include <stddef.h>
#include <stdint.h>
#include <string.h>
#include "c4f_vda.h"
/* scePadVirtualDeviceGetRemoteSetting fills a buffer laid out like the DualShock
* 4's HID output report without its report ID. Read on firmware 10.01
* (2026-10-05) while games rumbled and users signed in:
*
* [0] update flags: bit 0 rumble, bit 1 light bar, as in the DS4 report.
* Pulses for a few milliseconds, so it is not relied on here.
* [3] right motor, the small fast one: 0xff for ~300 ms on a game's hit.
* [4] left motor, the large slow one.
* [5..7] light bar red, green, blue: 0x40 at full, as the PS4 sets them
* (40 00 00 for player 2, 00 40 00 for player 3).
* [16] 1 once a user is assigned.
*/
void c4fPadFeedbackParse(const uint8_t *buf, size_t size, C4fPadFeedback *out)
{
(void)memset(out, 0, sizeof(*out));
if (size < 8) return;
out->small = buf[3];
out->large = buf[4];
out->r = buf[5];
out->g = buf[6];
out->b = buf[7];
}
+5
View File
@@ -102,5 +102,10 @@ void c4fNotify(const char *fmt, ...)
va_start(ap, fmt);
(void)vsnprintf(req.message, sizeof(req.message), fmt, ap);
va_end(ap);
#ifdef C4F_DIAG
int ret = sceKernelSendNotificationRequest(0, &req, sizeof(req), 0);
c4fLog("notification = 0x%08x: %s\n", (uint32_t)ret, req.message);
#else
(void)sceKernelSendNotificationRequest(0, &req, sizeof(req), 0);
#endif
}
+43
View File
@@ -100,6 +100,45 @@ static void c4fRestoreCredentials(void)
c4fLogCredentials("restored", g_pid);
}
#ifdef C4F_DIAG
#include <signal.h>
#include <string.h>
/* Diagnostic build: what the SDK's start-up code found in the kernel, and
* which process PayLoader runs us in, from GoldHEN's SDK call (syscall 500,
* command 4; struct proc_info of the GoldHEN Plugins SDK). */
typedef struct {
int pid;
char name[40], path[64], titleId[16], contentId[64], version[6];
uint64_t baseAddress;
} __attribute__((packed)) C4fProcInfo;
static void c4fDiagHost(pid_t pid)
{
C4fProcInfo info;
struct sigaction ignore, previous;
c4fLog("diag: built %s %s\n", __DATE__, __TIME__);
c4fLog("diag: kernel image base=0x%lx size=0x%lx allproc=0x%lx prison0=0x%lx rootvnode=0x%lx targetid=0x%lx\n",
(unsigned long)KERNEL_ADDRESS_IMAGE_BASE, (unsigned long)KERNEL_IMAGE_SIZE,
(unsigned long)KERNEL_ADDRESS_ALLPROC, (unsigned long)KERNEL_ADDRESS_PRISON0,
(unsigned long)KERNEL_ADDRESS_ROOTVNODE, (unsigned long)KERNEL_ADDRESS_TARGETID);
c4fLog("diag: proc=0x%lx ucred=0x%lx\n", (unsigned long)kernel_get_proc(pid),
(unsigned long)kernel_get_proc_ucred(pid));
/* Without GoldHEN the call does not exist: get an error, not SIGSYS. */
memset(&info, 0, sizeof(info));
memset(&ignore, 0, sizeof(ignore));
ignore.sa_handler = SIG_IGN;
sigaction(SIGSYS, &ignore, &previous);
int ret = syscall(500, 4, &info);
sigaction(SIGSYS, &previous, NULL);
info.name[sizeof(info.name) - 1] = info.path[sizeof(info.path) - 1] = info.titleId[sizeof(info.titleId) - 1] = 0;
c4fLog("diag: GoldHEN process info = %d: pid=%d name=%s path=%s title=%s\n",
ret, info.pid, info.name, info.path, info.titleId);
}
#endif
static int c4fFinish(int status)
{
c4fRestoreCredentials();
@@ -127,6 +166,10 @@ int main(void)
}
c4fLogOpen();
c4fLog("Control4Free %s: pid=%d firmware=0x%08x\n", C4F_VERSION, pid, kernel_get_fw_version());
#ifdef C4F_DIAG
c4fNotify("Control4Free diagnostic build started (firmware %08x)", kernel_get_fw_version());
c4fDiagHost(pid);
#endif
c4fLogCredentials("on entry", pid);
if (c4fRaiseCredentials(pid) != 0) return c4fFinish(1);
+41 -6
View File
@@ -14,6 +14,16 @@
#include <unistd.h>
#include "c4f_net.h"
#ifdef C4F_DIAG
#include "c4f_log.h"
/* Diagnostic build: each connection from another device, step by step. The
* launcher's status checks over loopback come every 2 seconds and stay out. */
#define C4F_NET_LOG(c, fmt, ...) do { if (strcmp((c)->peer, "127.0.0.1")) \
c4fLog("net: %s " fmt "\n", (c)->peer, __VA_ARGS__); } while (0)
#else
#define C4F_NET_LOG(c, fmt, ...) do { } while (0)
#endif
/* Which interface would carry traffic out, without sending anything: a connected
* UDP socket picks the route, and getsockname then names the local end. */
void c4fNetLocalAddress(char *out, size_t size)
@@ -152,6 +162,7 @@ static void c4fHttpError(C4fNetClient *c, int code, const char *message)
{
char buf[512];
int n = snprintf(buf, sizeof(buf), "HTTP/1.1 %d Error\r\nConnection: close\r\nContent-Type: text/plain\r\nContent-Length: %zu\r\n\r\n%s", code, strlen(message), message);
C4F_NET_LOG(c, "answered %d %s", code, message);
c4fQueue(c, buf, (size_t)n); c->closing = 2;
}
@@ -165,6 +176,7 @@ static void c4fHttp(C4fNet *net, C4fNetClient *c)
if (!end) { if (c->rxUsed == sizeof(c->rx)) c4fHttpError(c, 431, "Headers too large"); return; }
used = (size_t)(end-request) + 4;
request[used] = 0;
C4F_NET_LOG(c, "asked: %.*s", (int)strcspn(request, "\r\n") > 100 ? 100 : (int)strcspn(request, "\r\n"), request);
int statusRequest = !strncmp(request, "GET /api/status HTTP/1.1\r\n", 26);
int stopRequest = !strncmp(request, "POST /api/stop HTTP/1.1\r\n", 25);
/* The two files a phone needs to keep this page on its home screen. */
@@ -231,17 +243,20 @@ static void c4fHttp(C4fNet *net, C4fNetClient *c)
if (!strstr(connection, "upgrade")) { c4fHttpError(c, 400, "Upgrade required"); return; }
c4fWebSocketAccept(key, accept);
n = snprintf(reply, sizeof(reply), "HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: %s\r\n\r\n", accept);
C4F_NET_LOG(c, "websocket open%s", "");
c4fQueue(c, reply, (size_t)n); c->websocket = 1;
memmove(c->rx, c->rx+used, c->rxUsed-used); c->rxUsed -= used;
net->handler(c, C4F_NET_OPEN, NULL, 0, net->context);
} else if (asset) {
char reply[256];
int n = snprintf(reply, sizeof(reply), "HTTP/1.1 200 OK\r\nContent-Type: %s\r\nContent-Length: %zu\r\nConnection: close\r\nCache-Control: max-age=86400\r\nX-Content-Type-Options: nosniff\r\n\r\n", assetType, assetSize);
C4F_NET_LOG(c, "sending %s, %zu bytes", assetType, assetSize);
c4fQueue(c, reply, (size_t)n);
c->body = asset; c->bodySize = assetSize; c->rxUsed = 0;
} else {
char reply[768];
int n = snprintf(reply, sizeof(reply), "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Encoding: gzip\r\nContent-Length: %zu\r\nConnection: close\r\nCache-Control: no-store\r\nX-Content-Type-Options: nosniff\r\nReferrer-Policy: no-referrer\r\nContent-Security-Policy: default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src 'self' data:; media-src data:; connect-src ws:; manifest-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'\r\n\r\n", c4fPageSize);
C4F_NET_LOG(c, "sending the page, %zu bytes", c4fPageSize);
c4fQueue(c, reply, (size_t)n);
c->body = c4fPage; c->bodySize = c4fPageSize; c->rxUsed = 0;
}
@@ -343,6 +358,12 @@ int c4fNetPoll(C4fNet *net, int timeoutMs)
return -1;
for (index = 0; index < C4F_NET_CLIENTS && net->clients[index].fd >= 0; index++) {}
if (fd >= 0) {
#ifdef C4F_DIAG
char peerName[16] = "?";
inet_ntop(AF_INET, &peer.sin_addr, peerName, sizeof(peerName));
if (index == C4F_NET_CLIENTS || fd >= (int)FD_SETSIZE)
c4fLog("net: %s turned away: fd %d, %s\n", peerName, fd, index == C4F_NET_CLIENTS ? "all slots taken" : "fd too high");
#endif
if (index == C4F_NET_CLIENTS || fd >= (int)FD_SETSIZE || fcntl(fd, F_SETFL, O_NONBLOCK)) close(fd);
else {
int one = 1;
@@ -351,6 +372,10 @@ int c4fNetPoll(C4fNet *net, int timeoutMs)
setsockopt(fd, SOL_SOCKET, SO_NOSIGPIPE, &one, sizeof(one));
#endif
net->clients[index].fd = fd; net->clients[index].openedMs = c4fTimeMs();
#ifdef C4F_DIAG
memcpy(net->clients[index].peer, peerName, sizeof(peerName));
C4F_NET_LOG(&net->clients[index], "connected: slot %d, fd %d", index, fd);
#endif
}
}
}
@@ -359,8 +384,10 @@ int c4fNetPoll(C4fNet *net, int timeoutMs)
if (c->fd < 0) continue;
if (FD_ISSET(c->fd, &rd)) {
ssize_t n = recv(c->fd, c->rx+c->rxUsed, sizeof(c->rx)-c->rxUsed, 0);
if (n == 0 || (n < 0 && errno != EAGAIN && errno != EWOULDBLOCK && errno != EINTR)) c->closing = 1;
else if (n > 0) {
if (n == 0 || (n < 0 && errno != EAGAIN && errno != EWOULDBLOCK && errno != EINTR)) {
C4F_NET_LOG(c, "recv = %zd, errno %d: it closed its end", n, n < 0 ? errno : 0);
c->closing = 1;
} else if (n > 0) {
c->rxUsed += (size_t)n;
if (!c->websocket) c4fHttp(net, c);
if (c->websocket) c4fWs(net, c);
@@ -376,15 +403,23 @@ int c4fNetPoll(C4fNet *net, int timeoutMs)
flags = MSG_NOSIGNAL;
#endif
ssize_t n = send(c->fd, data, left, flags);
if (n < 0 && errno != EAGAIN && errno != EWOULDBLOCK && errno != EINTR) c->closing = 1;
else if (n > 0) {
if (n < 0 && errno != EAGAIN && errno != EWOULDBLOCK && errno != EINTR) {
C4F_NET_LOG(c, "send of %zu bytes failed, errno %d (%s, %zu sent)", left, errno,
body ? "body" : "header", body ? c->bodySent : c->txSent);
c->closing = 1;
} else if (n > 0) {
if (body) {
c->bodySent += (size_t)n;
if (c->bodySent == c->bodySize) { c->body = NULL; c->closing = 1; }
if (c->bodySent == c->bodySize) { C4F_NET_LOG(c, "sent all %zu bytes", c->bodySize); c->body = NULL; c->closing = 1; }
} else { c->txSent += (size_t)n; if (c->txSent == c->txUsed) c->txSent = c->txUsed = 0; }
}
}
if (!c->websocket && c4fTimeMs()-c->openedMs > 10000) c->closing = 1;
if (!c->websocket && c4fTimeMs()-c->openedMs > 10000) {
/* A first byte of 0x16 is a TLS handshake: the browser tried https. */
C4F_NET_LOG(c, "timed out after 10 s with %zu bytes unanswered, first byte 0x%02x", c->rxUsed,
c->rxUsed ? c->rx[0] : 0);
c->closing = 1;
}
if (c->closing == 1 || (c->closing == 2 && c->txSent == c->txUsed)) c4fDrop(net, c);
}
return 0;
+23
View File
@@ -147,6 +147,29 @@ void c4fVirtualPadAdopt(C4fVirtualPad *out, int32_t userId, int32_t vdaUser, uin
(unsigned long long)deviceId, (uint32_t)out->handle);
}
int32_t c4fVirtualPadFeedback(const C4fVirtualPad *pad, C4fPadFeedback *out)
{
uint8_t buf[256]; /* the call's size is unknown; it has only ever used 17 */
(void)memset(out, 0, sizeof(*out));
if (!scePadVirtualDeviceGetRemoteSetting) return -1;
(void)memset(buf, 0, sizeof(buf));
int32_t ret = scePadVirtualDeviceGetRemoteSetting(pad->handle, buf);
if (ret == 0) c4fPadFeedbackParse(buf, sizeof(buf), out);
return ret;
}
int32_t c4fUserName(uint32_t userId, char *out, size_t size)
{
char name[64]; /* the system's own limit is 16 characters */
if (size) out[0] = 0;
(void)memset(name, 0, sizeof(name));
int32_t ret = sceUserServiceGetUserName((int32_t)userId, name, sizeof(name) - 1);
if (ret == 0) snprintf(out, size, "%s", name);
return ret;
}
void c4fPadDataNeutral(ScePadData *data)
{
(void)memset(data, 0, sizeof(*data));
+194 -9
View File
@@ -10,6 +10,7 @@
#define JSMN_STATIC
#define JSMN_STRICT
#include "jsmn.h"
#include "qrcodegen.h"
#include "c4f_log.h"
#include "c4f_net.h"
#include "c4f_vda.h"
@@ -30,6 +31,9 @@
#define C4F_RELEASE_MS 15000
#endif
#define C4F_QUEUE_SIZE 32
/* How often each controller's rumble and light bar are read back. Games pulse
* rumble for a few tens of milliseconds, so this has to stay well under 100. */
#define C4F_FEEDBACK_MS 16
typedef struct {
C4fVirtualPad device;
@@ -39,6 +43,15 @@ typedef struct {
uint64_t lastInput, lastReport, nextReport, detachedAt, reports;
int active, stale, error, assigned;
uint32_t userId;
/* What the game wants this controller to do, and what its owner was told. */
C4fPadFeedback feedback;
int feedbackKnown, rumbleSentValid, feedbackFailed;
uint8_t rumbleSent[2];
uint64_t feedbackAt;
/* The signed-in user's name, once the PS4 will tell it. */
char userName[72];
int nameTries;
uint64_t nameAt;
} C4fWebPad;
/* Creating a controller means issuing AddDevice and then finding its DeviceId in
@@ -72,7 +85,11 @@ typedef struct {
typedef struct {
C4fNet net;
C4fWebPad pads[C4F_MAX_PADS];
int klogFd, stop, changed, creationBlocked;
int klogFd, stop, changed, creationBlocked, feedbackMissing;
unsigned rumbleChanges; /* since the last heartbeat, to tell a silent game from a silent phone */
/* What reading rumble and light bar costs, since the last heartbeat. Every
* microsecond here is one an arriving input could have to wait. */
uint64_t feedbackCalls, feedbackUs, feedbackMaxUs;
char klogLine[1024];
size_t klogUsed;
C4fAdd add;
@@ -143,9 +160,34 @@ static void c4fError(C4fNetClient *c, const C4fRequest *r, int code, const char
c4fNetText(c, reply);
}
/* `in` as the inside of a JSON string. Quotes and backslashes are escaped, and
* control characters and anything that isn't valid UTF-8 become '?'. A user
* name is the one string here we don't write ourselves, and a browser drops the
* whole WebSocket on a text frame that isn't valid UTF-8. */
static void c4fJsonText(char *out, size_t size, const char *in)
{
const unsigned char *p = (const unsigned char *)in;
size_t o = 0;
if (!size) return;
while (*p && o + 7 < size) {
unsigned c = *p;
unsigned n = c < 0x80 ? 1 : (c & 0xe0) == 0xc0 ? 2 : (c & 0xf0) == 0xe0 ? 3 : (c & 0xf8) == 0xf0 ? 4 : 0;
int valid = n > 0 && !(n == 2 && c < 0xc2) && c <= 0xf4;
for (unsigned k = 1; valid && k < n; k++) valid = (p[k] & 0xc0) == 0x80;
/* No overlong forms, no UTF-16 surrogates, nothing past U+10FFFF. */
if (valid && n == 3 && ((c == 0xe0 && p[1] < 0xa0) || (c == 0xed && p[1] >= 0xa0))) valid = 0;
if (valid && n == 4 && ((c == 0xf0 && p[1] < 0x90) || (c == 0xf4 && p[1] >= 0x90))) valid = 0;
if (!valid || (n == 1 && (c < 0x20 || c == 0x7f))) { out[o++] = '?'; p++; continue; }
if (c == '"' || c == '\\') out[o++] = '\\';
memcpy(out + o, p, n); o += n; p += n;
}
out[o] = 0;
}
static void c4fStatus(C4fWeb *app, C4fNetClient *c, const C4fRequest *request)
{
char json[3072];
char json[4096];
size_t pos;
if (request && request->hasId) pos = (size_t)snprintf(json, sizeof(json), "{\"id\":%lld,\"result\":", (long long)request->id);
else pos = (size_t)snprintf(json, sizeof(json), "{\"method\":\"s\",\"params\":");
@@ -154,12 +196,24 @@ static void c4fStatus(C4fWeb *app, C4fNetClient *c, const C4fRequest *request)
C4fWebPad *p = &app->pads[i];
const char *state = p->error ? "error" : app->add.state && (app->add.pending & (1u << i)) ? "connecting"
: !p->active ? "free" : !p->owner || p->stale ? "paused" : p->assigned ? "ready" : "select";
unsigned colors[4][3] = {{32,96,255},{255,48,64},{48,200,96},{255,80,180}};
static const unsigned colors[4][3] = {{32,96,255},{255,48,64},{48,200,96},{255,80,180}};
unsigned rgb[3] = { colors[i][0], colors[i][1], colors[i][2] };
if (p->active && p->feedbackKnown) {
/* The PS4 sets player colours at a quarter strength (0x40), which on a
* screen reads as near black: keep the hue, brighten it to full. An
* unlit bar keeps the controller's own colour. */
unsigned l[3] = { p->feedback.r, p->feedback.g, p->feedback.b };
unsigned top = l[0] > l[1] ? l[0] : l[1];
if (l[2] > top) top = l[2];
if (top) for (int k = 0; k < 3; k++) rgb[k] = l[k] * 255 / top;
}
char user[160];
c4fJsonText(user, sizeof(user), p->active && p->assigned ? p->userName : "");
pos += (size_t)snprintf(json+pos, sizeof(json)-pos,
"%s{\"pad\":%d,\"name\":\"Controller %d\",\"enabled\":true,\"open\":%s,\"connected\":%s,\"clients\":%d,\"mine\":%s,\"state\":\"%s\",\"uid\":\"%s%08x\",\"color\":[%u,%u,%u],\"reports\":%llu,\"error\":%d}",
i ? "," : "", i, i+1, p->active ? "true" : "false", p->owner && !p->stale ? "true" : "false", p->owner ? 1 : 0,
"%s{\"pad\":%d,\"name\":\"Controller %d\",\"user\":\"%s\",\"enabled\":true,\"open\":%s,\"connected\":%s,\"clients\":%d,\"mine\":%s,\"state\":\"%s\",\"uid\":\"%s%08x\",\"color\":[%u,%u,%u],\"reports\":%llu,\"error\":%d}",
i ? "," : "", i, i+1, user, p->active ? "true" : "false", p->owner && !p->stale ? "true" : "false", p->owner ? 1 : 0,
p->owner == c ? "true" : "false", state, p->assigned ? "" : "unassigned-", p->userId,
colors[i][0], colors[i][1], colors[i][2], (unsigned long long)p->reports, p->error);
rgb[0], rgb[1], rgb[2], (unsigned long long)p->reports, p->error);
}
snprintf(json+pos, sizeof(json)-pos, "]}}"); c4fNetText(c, json);
}
@@ -222,6 +276,81 @@ static void c4fReportPads(C4fWeb *app)
}
}
static uint64_t c4fTimeUs(void)
{
struct timespec t;
clock_gettime(CLOCK_MONOTONIC, &t);
return (uint64_t)t.tv_sec * 1000000 + (uint64_t)t.tv_nsec / 1000;
}
/* The names of signed-in users. The PS4 may not have finished signing a user in
* when it reports the controller assigned, so a failed look-up is retried for a
* few seconds. Names are kept out of the log. */
static void c4fLookUpNames(C4fWeb *app, uint64_t now)
{
for (int i = 0; i < C4F_MAX_PADS; i++) {
C4fWebPad *p = &app->pads[i];
if (!p->active || !p->assigned || p->userName[0] || p->nameTries >= 10 || now < p->nameAt) continue;
p->nameTries++;
p->nameAt = now + 500;
int32_t ret = c4fUserName(p->userId, p->userName, sizeof(p->userName));
if (ret == 0 && p->userName[0]) {
c4fLog("web controller %d user name found (%u bytes)\n", i + 1, (unsigned)strlen(p->userName));
app->changed = 1;
} else if (p->nameTries == 10) {
p->userName[0] = 0;
c4fLog("web controller %d user name unavailable (0x%08x)\n", i + 1, (uint32_t)ret);
}
}
}
/* Reads back what the game wants from each controller. Rumble goes to the
* controller's owner, whose page drives the phone's vibration and any gamepad
* playing as it; the light bar goes into the status everyone sees. */
static void c4fPollFeedback(C4fWeb *app, uint64_t now)
{
if (app->feedbackMissing) return;
for (int i = 0; i < C4F_MAX_PADS; i++) {
C4fWebPad *p = &app->pads[i];
C4fPadFeedback f;
int32_t ret;
if (!p->active || now < p->feedbackAt) continue;
p->feedbackAt = now + C4F_FEEDBACK_MS;
uint64_t started = c4fTimeUs();
ret = c4fVirtualPadFeedback(&p->device, &f);
uint64_t took = c4fTimeUs() - started;
app->feedbackCalls++; app->feedbackUs += took;
if (took > app->feedbackMaxUs) app->feedbackMaxUs = took;
if (ret == -1) {
c4fLog("scePadVirtualDeviceGetRemoteSetting is not exported: no rumble or light bar\n");
app->feedbackMissing = 1;
return;
}
if (ret != 0) {
if (!p->feedbackFailed) c4fLog("web controller %d GetRemoteSetting = 0x%08x\n", i + 1, (uint32_t)ret);
p->feedbackFailed = 1;
continue;
}
if (!p->feedbackKnown || f.r != p->feedback.r || f.g != p->feedback.g || f.b != p->feedback.b) {
/* Rare: a sign-in, or a game setting its colour. */
c4fLog("web controller %d light bar %02x %02x %02x\n", i + 1, f.r, f.g, f.b);
app->changed = 1;
}
if (p->feedbackKnown && (f.large != p->feedback.large || f.small != p->feedback.small))
app->rumbleChanges++;
p->feedback = f;
p->feedbackKnown = 1;
if (p->owner && (!p->rumbleSentValid || f.large != p->rumbleSent[0] || f.small != p->rumbleSent[1])) {
char message[64];
snprintf(message, sizeof(message), "{\"method\":\"v\",\"params\":[%d,%u,%u]}", i, f.large, f.small);
if (c4fNetText(p->owner, message) == 0) {
p->rumbleSent[0] = f.large; p->rumbleSent[1] = f.small; p->rumbleSentValid = 1;
}
}
}
}
/* Preserve button/touch edges for at least one report. Coalesce analogue-only
* changes so mouse/gamepad updates cannot create seconds of input latency. */
static void c4fEnqueue(C4fWebPad *p, const ScePadData *data)
@@ -316,6 +445,7 @@ static void c4fKlogLine(C4fWeb *app, const char *line)
C4fWebPad *p = &app->pads[i];
if (p->active && p->device.deviceId == device) {
p->assigned = user != 0xffffffffu; p->userId = user; app->changed = 1;
p->userName[0] = 0; p->nameTries = 0; p->nameAt = 0;
c4fLog("web controller %d assignment confirmed=%d\n", i+1, p->assigned);
}
}
@@ -371,7 +501,7 @@ static void c4fApplyClaim(C4fWeb *app, C4fNetClient *c, unsigned wanted, C4fRequ
for (int i = 0; i < C4F_MAX_PADS; i++) {
C4fWebPad *p = &app->pads[i];
if (wanted & (1u << i)) {
if (p->owner != c) c4fNeutralize(p);
if (p->owner != c) { c4fNeutralize(p); p->rumbleSentValid = 0; }
p->owner = c; p->detachedAt = 0; p->lastInput = c4fTimeMs(); p->stale = 0;
} else if (p->owner == c) c4fRemove(app, p);
}
@@ -535,6 +665,51 @@ static void c4fClaim(C4fWeb *app, C4fNetClient *c, C4fRequest *r)
app->changed = 1;
}
/* A QR code of the page's address, for the page's Invite panel. The page names
* the address it reached us on, as [a, b, c, d, port], because that's the one
* that works from this network; without it, the console's own address is used.
* The answer is the module grid, one hex string per row, most significant bit
* first: the page draws it, so it needs no QR library of its own. */
static void c4fInvite(C4fNetClient *c, const C4fRequest *r)
{
char text[64], address[64], reply[1024];
uint8_t qr[qrcodegen_BUFFER_LEN_FOR_VERSION(5)], scratch[qrcodegen_BUFFER_LEN_FOR_VERSION(5)];
size_t pos;
if (r->argc == 5) {
for (int i = 0; i < 4; i++)
if (r->args[i] < 0 || r->args[i] > 255) { c4fError(c, r, 400, "That isn't an IPv4 address"); return; }
if (r->args[4] < 1 || r->args[4] > 65535) { c4fError(c, r, 400, "That isn't a port"); return; }
snprintf(text, sizeof(text), "http://%d.%d.%d.%d:%d/", (int)r->args[0], (int)r->args[1],
(int)r->args[2], (int)r->args[3], (int)r->args[4]);
} else if (r->argc == 0) {
c4fNetLocalAddress(address, sizeof(address));
if (!address[0]) { c4fError(c, r, 503, "Couldn't work out the console's address"); return; }
snprintf(text, sizeof(text), "http://%s:%d/", address, C4F_WEB_PORT);
} else {
c4fError(c, r, 400, "Invalid request"); return;
}
if (!qrcodegen_encodeText(text, scratch, qr, qrcodegen_Ecc_MEDIUM, 1, 5, qrcodegen_Mask_AUTO, true)) {
c4fError(c, r, 500, "Couldn't make the QR code"); return;
}
int size = qrcodegen_getSize(qr);
pos = (size_t)snprintf(reply, sizeof(reply), "{\"id\":%lld,\"result\":{\"text\":\"%s\",\"size\":%d,\"rows\":[",
(long long)r->id, text, size);
for (int y = 0; y < size && pos < sizeof(reply) - 16; y++) {
reply[pos++] = y ? ',' : '"';
if (y) reply[pos++] = '"';
for (int x = 0; x < size; x += 4) {
unsigned nibble = 0;
for (int b = 0; b < 4; b++)
nibble = nibble << 1 | (x + b < size && qrcodegen_getModule(qr, x + b, y));
reply[pos++] = "0123456789abcdef"[nibble];
}
reply[pos++] = '"';
}
snprintf(reply + pos, sizeof(reply) - pos, "]}}");
c4fNetText(c, reply);
}
static void c4fUpdate(C4fWeb *app, C4fNetClient *c, C4fRequest *r)
{
if (r->argc < 9 || r->args[0] < 0 || r->args[0] >= C4F_MAX_PADS) { c4fError(c, r, 400, "That input was not in a form Control4Free understands"); return; }
@@ -596,6 +771,8 @@ static void c4fWebEvent(C4fNetClient *c, int event, const char *text, size_t len
} else if (!strcmp(r.method, "ping")) {
char reply[64];
if (r.hasId) { snprintf(reply, sizeof(reply), "{\"id\":%lld,\"result\":{}}", (long long)r.id); c4fNetText(c, reply); }
} else if (!strcmp(r.method, "invite")) {
if (r.hasId) c4fInvite(c, &r);
} else if (!strcmp(r.method, "status")) c4fStatus(app, c, &r);
else if (!strcmp(r.method, "claim")) c4fClaim(app, c, &r);
else if (!strcmp(r.method, "u")) c4fUpdate(app, c, &r);
@@ -661,6 +838,8 @@ int c4fWebRun(int klogFd)
}
long klogBytes = c4fReadKlog(app);
c4fReportPads(app);
c4fPollFeedback(app, now);
c4fLookUpNames(app, now);
#ifdef C4F_PROBE_SETTING
c4fProbeSetting(app, now);
#endif
@@ -683,8 +862,14 @@ int c4fWebRun(int klogFd)
if (now >= app->heartbeatAt) {
int active = 0;
for (int i = 0; i < C4F_MAX_PADS; i++) active += app->pads[i].active;
c4fLog("heartbeat: listener=%d controllers=%d klog=%d connecting=%d\n",
app->net.fd >= 0, active, app->klogFd >= 0, app->add.state);
c4fLog("heartbeat: listener=%d controllers=%d klog=%d connecting=%d rumble-changes=%u "
"feedback-reads=%llu avg=%lluus max=%lluus\n",
app->net.fd >= 0, active, app->klogFd >= 0, app->add.state, app->rumbleChanges,
(unsigned long long)app->feedbackCalls,
(unsigned long long)(app->feedbackCalls ? app->feedbackUs / app->feedbackCalls : 0),
(unsigned long long)app->feedbackMaxUs);
app->rumbleChanges = 0;
app->feedbackCalls = app->feedbackUs = app->feedbackMaxUs = 0;
app->heartbeatAt = now + 60000;
}
if (app->changed || now >= app->broadcastAt) {
+44
View File
@@ -0,0 +1,44 @@
/* c4fPadFeedbackParse against buffers read from the console (firmware 10.01). */
#include <assert.h>
#include <stdio.h>
#include <string.h>
#include "c4f_vda.h"
static C4fPadFeedback parse(const uint8_t *bytes, size_t n)
{
uint8_t buf[256] = {0};
C4fPadFeedback f;
memcpy(buf, bytes, n);
c4fPadFeedbackParse(buf, sizeof(buf), &f);
return f;
}
int main(void)
{
/* A new controller, player 2: update flag, light bar 40 00 00. */
static const uint8_t red[] = { 0x01, 0, 0, 0, 0, 0x40, 0, 0 };
C4fPadFeedback f = parse(red, sizeof(red));
assert(f.r == 0x40 && f.g == 0 && f.b == 0 && f.large == 0 && f.small == 0);
/* Player 3, signed in: light bar 00 40 00, assigned flag at [16]. */
static const uint8_t green[17] = { 0, 0, 0, 0, 0, 0, 0x40, 0, [16] = 1 };
f = parse(green, sizeof(green));
assert(f.r == 0 && f.g == 0x40 && f.b == 0);
/* A game's hit: the small motor at full for ~300 ms. */
static const uint8_t hit[] = { 0, 0, 0, 0xff, 0, 0, 0x40, 0 };
f = parse(hit, sizeof(hit));
assert(f.small == 0xff && f.large == 0 && f.g == 0x40);
/* Both motors, by the DualShock 4 layout: [3] small, [4] large. */
static const uint8_t both[] = { 0x01, 0, 0, 0x30, 0xc0, 0x20, 0, 0x20 };
f = parse(both, sizeof(both));
assert(f.small == 0x30 && f.large == 0xc0 && f.r == 0x20 && f.b == 0x20);
/* Too short to hold the fields: nothing, rather than a misread. */
uint8_t shortBuf[4] = { 1, 2, 3, 4 };
c4fPadFeedbackParse(shortBuf, sizeof(shortBuf), &f);
assert(!f.large && !f.small && !f.r && !f.g && !f.b);
puts("PASS rumble and light bar read from the console's buffer layout");
}
+23
View File
@@ -110,4 +110,27 @@ function padStatus(fields) {
fs = await fullScreen({ enabled: false });
assert.equal(fs.button.hidden, true);
console.log('PASS full screen: real where allowed, a home-screen hint on iPhone, gone when standalone');
// Which gamepads the page says it can rumble.
const rumbleCtx = {};
vm.createContext(rumbleCtx);
vm.runInContext(extract(' function canRumble(gp)', ' function renderGamepads()'), rumbleCtx);
const can = gp => vm.runInContext('canRumble(gp)', Object.assign(rumbleCtx, { gp }));
const play = () => Promise.resolve();
assert.equal(can({ vibrationActuator: { playEffect: play, effects: ['dual-rumble', 'trigger-rumble'] } }), true);
assert.equal(can({ vibrationActuator: { playEffect: play, type: 'dual-rumble' } }), true, 'older Chrome');
assert.equal(can({ vibrationActuator: null }), false, 'Chrome with a DualSense');
assert.equal(can({}), false, 'Firefox');
assert.equal(can({ vibrationActuator: { playEffect: play, effects: [] } }), false);
console.log('PASS gamepads that cannot rumble are named as such');
// The Invite panel draws the console's grid as runs of dark modules.
const qrCtx = {};
vm.createContext(qrCtx);
vm.runInContext(extract(' function qrPath(size, rows, quiet)', ' // The address this page reached'), qrCtx);
// 5 wide: row 0 = 11011, row 1 = 00100 -> hex d8, 20.
assert.equal(vm.runInContext("qrPath(5, ['d8', '20'], 4)", qrCtx),
'M4 4h2v1h-2zM7 4h2v1h-2zM6 5h1v1h-1z');
assert.equal(vm.runInContext("qrPath(3, ['0', '0', '0'], 4)", qrCtx), '');
console.log('PASS the invite QR is drawn from the console grid');
})();
+2
View File
@@ -14,6 +14,8 @@ def main():
subprocess.run(flags + ['src/klog_line.c', 'tests/device_id_test.c',
'-o', 'build/device-id-host-test'], check=True)
subprocess.run(['build/device-id-host-test'], check=True, timeout=5)
subprocess.run(flags + ['src/feedback.c', 'tests/feedback_test.c', '-o', 'build/feedback-host-test'], check=True)
subprocess.run(['build/feedback-host-test'], check=True, timeout=5)
if __name__ == '__main__':
+1 -1
View File
@@ -21,7 +21,7 @@ def connect_again():
def main():
subprocess.run(['clang-18', '-std=gnu11', '-Wall', '-Wextra', '-Werror', '-g',
'-Iinclude', '-Ivendor/jsmn', 'src/net.c', 'src/web.c', 'src/klog_line.c',
'-Iinclude', '-Ivendor/jsmn', '-Ivendor/qrcodegen', 'src/net.c', 'src/web.c', 'src/klog_line.c', 'vendor/qrcodegen/qrcodegen.c',
'tests/web_stub.c', 'tests/recovery_faults.c', 'build/client.c', 'build/assets.c', '-pthread',
'-Wl,--wrap=select,--wrap=accept,--wrap=getsockopt', '-o', str(web.BINARY)], check=True)
with tempfile.TemporaryDirectory() as directory:
+116 -2
View File
@@ -21,8 +21,8 @@ def build():
subprocess.run(['python3', 'tools/embed_file.py', 'build/assets.c',
'c4fManifest=client/manifest.webmanifest', 'c4fIcon=client/icon-192.png'], check=True)
subprocess.run(['clang-18', '-std=gnu11', '-Wall', '-Wextra', '-Werror', '-g',
'-Iinclude', '-Ivendor/jsmn', '-DC4F_STALE_MS=400', '-DC4F_RELEASE_MS=1800',
'src/net.c', 'src/web.c', 'src/klog_line.c', 'tests/web_stub.c', 'build/client.c',
'-Iinclude', '-Ivendor/jsmn', '-Ivendor/qrcodegen', '-DC4F_STALE_MS=400', '-DC4F_RELEASE_MS=1800',
'src/net.c', 'src/web.c', 'src/klog_line.c', 'vendor/qrcodegen/qrcodegen.c', 'tests/web_stub.c', 'build/client.c',
'build/assets.c', '-pthread', '-o', str(BINARY)], check=True)
@@ -30,6 +30,7 @@ class Client:
def __init__(self, port=4264):
self.sock = socket.create_connection(('127.0.0.1', port), timeout=3)
self.buf = b''
self.pushed = [] # messages the service sent on its own, oldest first
key = 'dGhlIHNhbXBsZSBub25jZQ=='
self.sock.sendall((f'GET /ws HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\nOrigin: http://127.0.0.1:{port}\r\n'
f'Connection: Upgrade\r\nUpgrade: websocket\r\nSec-WebSocket-Key: {key}\r\nSec-WebSocket-Version: 13\r\n\r\n').encode())
@@ -76,6 +77,24 @@ class Client:
msg = json.loads(text)
if msg.get('id') == ident:
return msg
if 'id' not in msg:
self.pushed.append(msg)
def wait_for(self, match, timeout=2):
"""The first pushed message `match` accepts, seen already or still to come."""
end = time.monotonic() + timeout
while True:
for i, msg in enumerate(self.pushed):
if match(msg):
del self.pushed[:i + 1]
return msg
self.pushed.clear()
assert time.monotonic() < end, 'the expected message never came'
op, text = self.receive()
if op == 1:
msg = json.loads(text)
if 'id' not in msg:
self.pushed.append(msg)
def input(self, pad, buttons=0, lx=128, ly=128, rx=128, ry=128, l2=0, r2=0, touch=()):
params = [pad, buttons, lx, ly, rx, ry, l2, r2, len(touch)]
@@ -347,6 +366,101 @@ def main():
finally:
server.close()
# Rumble goes to the controller's owner; the light bar, brightened, to everyone.
def rumble(pad, large, small):
return lambda m: m.get('method') == 'v' and m['params'] == [pad, large, small]
server = Server()
try:
c = server.c
assert c.request('claim', [0])['result']['pads'][0]['mine']
assert c.request('status')['result']['pads'][0]['color'] == [32, 96, 255], 'unlit: its own colour'
os.write(server.log, b'C4F-TEST-FEEDBACK 11030d 200 40 64 0 0\n')
c.wait_for(rumble(0, 200, 40))
time.sleep(.05)
assert c.request('status')['result']['pads'][0]['color'] == [255, 0, 0], 'player 2 red, at full'
assert 'web controller 1 light bar 40 00 00' in server.rows(), 'the colour is logged for diagnosis'
watcher = Client()
os.write(server.log, b'C4F-TEST-FEEDBACK 11030d 0 0 0 64 0\n')
c.wait_for(rumble(0, 0, 0))
time.sleep(.05)
assert watcher.request('status')['result']['pads'][0]['color'] == [0, 255, 0], 'everyone sees it'
os.write(server.log, b'C4F-TEST-FEEDBACK 11030d 0 0 32 0 32\n')
time.sleep(.1)
assert c.request('status')['result']['pads'][0]['color'] == [255, 0, 255], 'pink keeps its hue'
assert not any(m.get('method') == 'v' for m in watcher.pushed), 'rumble went to a non-owner'
# Rumble holds while the game holds it; one message per change, not per poll.
os.write(server.log, b'C4F-TEST-FEEDBACK 11030d 90 0 32 0 32\n')
c.wait_for(rumble(0, 90, 0))
time.sleep(.3)
c.request('status')
assert not any(m.get('method') == 'v' for m in c.pushed), 'unchanged rumble was sent again'
# Whoever takes the controller over is told the current state at once.
c.close()
server.c = c = Client()
assert c.request('claim', [0])['result']['pads'][0]['mine']
c.wait_for(rumble(0, 90, 0))
# And told when it stops, so a page never keeps buzzing on old news.
os.write(server.log, b'C4F-TEST-FEEDBACK 11030d 0 0 0 0 0\n')
c.wait_for(rumble(0, 0, 0))
time.sleep(.05)
assert c.request('status')['result']['pads'][0]['color'] == [32, 96, 255], 'unlit again: its own colour'
watcher.close()
print('PASS rumble to the owner on change, the light bar to everyone, the state to a new owner', flush=True)
finally:
server.close()
# Without the call, controllers work as before.
server = Server(C4F_TEST_NO_FEEDBACK='1')
try:
assert server.c.request('claim', [0, 1])['result']['pads'][1]['mine']
time.sleep(.1)
assert server.c.request('status')['result']['pads'][0]['color'] == [32, 96, 255]
assert len([r for r in server.rows() if 'not exported' in r]) == 1
print('PASS no rumble call: logged once, controllers unaffected', flush=True)
finally:
server.close()
# Who's signed in on each controller, made safe for JSON and UTF-8, kept out of the log.
server = Server()
try:
c = server.c
assert c.request('claim', [0, 1])['result']['pads'][1]['mine']
assert c.request('status')['result']['pads'][0]['user'] == ''
os.write(server.log, b'<118>#LOGIN MGR# Receive Event : SCE_MBUS_EVENT_DEVICE_OWNER_CHANGED [DeviceId:0x11030d][UserId:0x1a2b3c4d]\n')
os.write(server.log, b'<118>#LOGIN MGR# Receive Event : SCE_MBUS_EVENT_DEVICE_OWNER_CHANGED [DeviceId:0x12030d][UserId:0x1a2b3c4e]\n')
time.sleep(1.2) # Sam's name only comes on the retry, half a second later
pads = c.request('status')['result']['pads']
assert pads[0]['user'] == 'Alex', pads[0]
assert pads[1]['user'] == 'Sam "S" \\ ? ? \u00e9', repr(pads[1]['user'])
assert pads[2]['user'] == '' and pads[3]['user'] == ''
assert not any('Alex' in r or 'Sam' in r for r in server.rows()), 'a name reached the log'
assert any('user name found (4 bytes)' in r for r in server.rows())
print('PASS signed-in names shown, made safe for JSON and UTF-8, kept out of the log', flush=True)
finally:
server.close()
# The Invite panel's QR code: a real QR code of the address the page names.
server = Server()
try:
r = server.c.request('invite', [192, 168, 1, 20, 4264])['result']
assert r['text'] == 'http://192.168.1.20:4264/', r
size = r['size']
assert size in (21, 25, 29, 33, 37) and len(r['rows']) == size
bits = [[int(row[x >> 2], 16) >> (3 - (x & 3)) & 1 for x in range(size)] for row in r['rows']]
assert all(len(row) == (size + 3) // 4 for row in r['rows'])
# The three finder squares: a dark ring, a light ring, a dark 3x3 core.
finder = [[1,1,1,1,1,1,1], [1,0,0,0,0,0,1], [1,0,1,1,1,0,1], [1,0,1,1,1,0,1],
[1,0,1,1,1,0,1], [1,0,0,0,0,0,1], [1,1,1,1,1,1,1]]
for ox, oy in ((0, 0), (size - 7, 0), (0, size - 7)):
assert [bits[oy + y][ox:ox + 7] for y in range(7)] == finder, (ox, oy)
assert server.c.request('invite', [300, 1, 1, 1, 4264])['error']['code'] == 400
assert server.c.request('invite', [192, 168, 1, 20, 0])['error']['code'] == 400
reply = server.c.request('invite', []) # the console's own address, when it has a route
assert reply.get('result', {}).get('text', 'http://').startswith('http://') or reply['error']['code'] == 503
print('PASS invite: a real QR code of the page address, bad addresses refused', flush=True)
finally:
server.close()
# Two claims cannot create at once: the second is refused, not queued.
server = Server(C4F_TEST_ADD_DELAY='600')
try:
+58 -1
View File
@@ -6,7 +6,9 @@
* marker verification and DeviceId matching, and only scePad is absent.
*
* A test injects its own kernel lines by writing them to the pipe named in
* C4F_TEST_KLOG_FD; they are relayed into the log. The line C4F-TEST-CLOSE-KLOG
* C4F_TEST_KLOG_FD; they are relayed into the log, except two commands: the line
* C4F-TEST-FEEDBACK <handle> <large> <small> <r> <g> <b> sets what the "game"
* wants of that controller (handle in hex), and C4F-TEST-CLOSE-KLOG
* kills the log instead, as GoldHEN taking the device would.
*
* Environment switches:
@@ -16,6 +18,7 @@
* C4F_TEST_ADD_DELAY milliseconds before the device-added line turns up
* C4F_FAIL_ADD AddDevice runs but its line never arrives
* C4F_FAIL_INPUT InsertData fails while a button is held
* C4F_TEST_NO_FEEDBACK the rumble/light-bar call isn't exported
* C4F_TEST_SLOW_MS milliseconds each new device takes to adopt, the way
* real console work spends time inside one loop iteration
*/
@@ -32,6 +35,35 @@
static unsigned nextId = 0x11030d;
static int logRead = -1, logWrite = -1;
/* What the "game" wants of each controller, by handle. */
static struct { unsigned handle; C4fPadFeedback feedback; } feedbacks[8];
static pthread_mutex_t feedbackLock = PTHREAD_MUTEX_INITIALIZER;
static void setFeedback(const char *line)
{
unsigned handle, v[5];
if (sscanf(line, "C4F-TEST-FEEDBACK %x %u %u %u %u %u", &handle, &v[0], &v[1], &v[2], &v[3], &v[4]) != 6) return;
pthread_mutex_lock(&feedbackLock);
for (int i = 0; i < 8; i++) {
if (feedbacks[i].handle && feedbacks[i].handle != handle) continue;
feedbacks[i].handle = handle;
feedbacks[i].feedback = (C4fPadFeedback){ (uint8_t)v[0], (uint8_t)v[1], (uint8_t)v[2], (uint8_t)v[3], (uint8_t)v[4] };
break;
}
pthread_mutex_unlock(&feedbackLock);
}
int32_t c4fVirtualPadFeedback(const C4fVirtualPad *p, C4fPadFeedback *out)
{
memset(out, 0, sizeof(*out));
if (getenv("C4F_TEST_NO_FEEDBACK")) return -1;
pthread_mutex_lock(&feedbackLock);
for (int i = 0; i < 8; i++)
if (feedbacks[i].handle == (unsigned)p->handle) *out = feedbacks[i].feedback;
pthread_mutex_unlock(&feedbackLock);
return 0;
}
static pthread_mutex_t logLock = PTHREAD_MUTEX_INITIALIZER;
/* The write end is non-blocking: a log nobody drains drops lines instead of
@@ -69,6 +101,16 @@ static void *relay(void *arg)
ssize_t n = read(source, buf, sizeof(buf) - 1);
if (n <= 0) return NULL;
buf[n] = 0;
if (strstr(buf, "C4F-TEST-FEEDBACK")) {
char *save = NULL;
for (char *line = strtok_r(buf, "\n", &save); line; line = strtok_r(NULL, "\n", &save)) {
char copy[sizeof(buf) + 1];
if (!strncmp(line, "C4F-TEST-FEEDBACK", 17)) { setFeedback(line); continue; }
snprintf(copy, sizeof(copy), "%s\n", line);
logLine(copy);
}
continue;
}
if (strstr(buf, "C4F-TEST-CLOSE-KLOG")) {
pthread_mutex_lock(&logLock);
if (logWrite >= 0) { close(logWrite); logWrite = -1; }
@@ -147,6 +189,21 @@ void c4fVirtualPadAdopt(C4fVirtualPad *p, int32_t user, int32_t vdaUser, uint64_
printf("ADOPT %x %d\n", (unsigned)p->handle, vdaUser);
}
/* Alex, and a name built to break JSON and UTF-8 that only resolves on a retry,
* as a user still signing in does. */
int32_t c4fUserName(uint32_t userId, char *out, size_t size)
{
static int samTries;
if (size) out[0] = 0;
if (userId == 0x1a2b3c4d) { snprintf(out, size, "Alex"); return 0; }
if (userId == 0x1a2b3c4e) {
if (++samTries < 2) return (int32_t)0x80960009;
snprintf(out, size, "Sam \"S\" \\ \x01 \xff \xc3\xa9");
return 0;
}
return (int32_t)0x80960009;
}
void c4fVirtualPadRemove(C4fVirtualPad *p) { printf("REMOVE %x\n", p->handle); p->owned = 0; }
void c4fPadDataNeutral(ScePadData *p)
{
+7 -3
View File
@@ -1,16 +1,20 @@
<# Build the payload and installable PS4 launcher locally. Requires Docker. #>
<# Build the payload and installable PS4 launcher locally. Requires Docker.
-Diag builds the diagnostic package instead (Control4Free-<version>-diag.pkg). #>
param([switch]$Diag)
$ErrorActionPreference = 'Stop'
$projectRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
$flag = if ($Diag) { 'C4F_DIAG=1' } else { '' }
$suffix = if ($Diag) { '-diag' } else { '' }
Push-Location $projectRoot
try {
docker build -t control4free-build -f docker/Dockerfile docker
if ($LASTEXITCODE -ne 0) { throw 'Payload toolchain build failed.' }
docker build -t control4free-launcher-build -f docker/Dockerfile.launcher docker
if ($LASTEXITCODE -ne 0) { throw 'Launcher toolchain build failed.' }
docker run --rm --network none -v "${projectRoot}:/src" -w /src control4free-launcher-build bash -lc 'make && make -C launcher'
docker run --rm --network none -v "${projectRoot}:/src" -w /src control4free-launcher-build bash -lc "make $flag && make -C launcher $flag"
if ($LASTEXITCODE -ne 0) { throw 'Launcher package build failed.' }
$version = (Get-Content -LiteralPath (Join-Path $projectRoot 'VERSION') -Raw).Trim()
Get-Item -LiteralPath (Join-Path $projectRoot "build/Control4Free-$version.pkg") | Select-Object FullName,Length
Get-Item -LiteralPath (Join-Path $projectRoot "build/Control4Free-$version$suffix.pkg") | Select-Object FullName,Length
} finally {
Pop-Location
}
+1 -1
View File
@@ -66,7 +66,7 @@ int main(int argc, char **argv)
s.running = -1; s.busy = 1; s.autorun = C4F_AUTORUN_UNKNOWN; message = "Setting up Control4Free. Please wait...";
} else if (!strcmp(state, "locked")) {
s.running = -1; s.locked = 1;
message = "Sent, but Control4Free never answered (127.0.0.1 no reply). Restart the PS4 before you try again.";
message = "Could not initialize launcher. Close with PS and retry.";
} else if (!strcmp(state, "unknown")) {
s.running = -1; s.autorun = C4F_AUTORUN_UNKNOWN;
snprintf(s.autorunNote, sizeof(s.autorunNote), "GoldHEN did not let the app check (errno 78)");
+13 -4
View File
@@ -95,9 +95,17 @@ def render(args, path, width, height):
def main():
global OUT, STAGE, ART, FONTS
parser = argparse.ArgumentParser()
parser.add_argument('--sdk', required=True, type=Path)
sdk = parser.parse_args().sdk
# The diagnostic build (make -C launcher C4F_DIAG=1): its own folder, payload,
# title and file name, so it can never be mistaken for a release.
parser.add_argument('--diag', action='store_true')
args = parser.parse_args()
sdk = args.sdk
suffix = '-diag' if args.diag else ''
OUT = ROOT / f'build/launcher{suffix}'
STAGE, ART, FONTS = OUT / 'pkg', OUT / 'launcher-art', OUT / 'fonts.c'
binary = sdk / 'bin/linux'
# Start clean, so nothing left over from an older build gets packaged.
shutil.rmtree(STAGE, ignore_errors=True)
@@ -106,7 +114,7 @@ def main():
run = lambda *args: subprocess.run([str(arg) for arg in args], cwd=STAGE, check=True)
run(binary / 'create-fself', '-in=' + str(OUT / 'launcher.elf'), '-out=' + str(OUT / 'launcher.oelf'),
'--eboot', 'eboot.bin', '--paid', '0x3800000000000011')
shutil.copyfile(ROOT / 'build/control4free.elf', STAGE / 'assets/control4free.elf')
shutil.copyfile(ROOT / f'build/control4free{suffix}.elf', STAGE / 'assets/control4free.elf')
shutil.copyfile(sdk / 'samples/graphics/sce_sys/about/right.sprx', STAGE / 'sce_sys/about/right.sprx')
# OpenOrbis's stub modules. Its CHANGELOG (v0.5) says homebrew packages
# should carry them in sce_module/, and every sample does.
@@ -136,7 +144,8 @@ def main():
'APP_TYPE': ('Integer', 4, '1'), 'APP_VER': ('Utf8', 8, SFO_VERSION),
'ATTRIBUTE': ('Integer', 4, '32'), 'CATEGORY': ('Utf8', 4, 'gde'),
'CONTENT_ID': ('Utf8', 48, CONTENT_ID), 'DOWNLOAD_DATA_SIZE': ('Integer', 4, '0'),
'SYSTEM_VER': ('Integer', 4, '0'), 'TITLE': ('Utf8', 128, 'Control4Free'),
'SYSTEM_VER': ('Integer', 4, '0'),
'TITLE': ('Utf8', 128, 'Control4Free (diagnostic)' if args.diag else 'Control4Free'),
'TITLE_ID': ('Utf8', 12, TITLE_ID), 'VERSION': ('Utf8', 8, SFO_VERSION),
}
for key, (kind, size, value) in entries.items():
@@ -163,7 +172,7 @@ def main():
shutil.copyfile(project, OUT / 'launcher.gp4')
run(pkgtool, 'pkg_build', project, OUT)
generated = OUT / (CONTENT_ID + '.pkg')
target = ROOT / f'build/Control4Free-{VERSION}.pkg'
target = ROOT / f'build/Control4Free-{VERSION}{suffix}.pkg'
shutil.copyfile(generated, target)
print(f'Built {target} ({target.stat().st_size:,} bytes)', flush=True)