Release scaffolding: changelog, CI, and a research build for rumble

CHANGELOG.md says what 1.0.0 is. The CI workflow builds both toolchain images,
the payload and the package, runs every host test, and on a vX.Y.Z tag checks the
tag against VERSION before publishing the ELF, the package and their SHA256 sums.

`make C4F_PROBE=1` adds a poll of scePadVirtualDeviceGetRemoteSetting for every
live controller, logging the return code and only the bytes that change. That is
the one call that looks like it could carry a game's rumble and light-bar state
back to a virtual pad, and nothing is known about its buffer, so this is how to
find out on hardware. It builds to control4free-probe.elf and is never in a
release.
This commit is contained in:
MoHadiShibli committed 2026-10-05 02:36:26 +03:00
1 parent 243f5efa68
commit de0c0a3eea
5 files changed
+173 -6

No files matched your search

+59
View File
@@ -0,0 +1,59 @@
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.
on:
push:
branches: [main, next]
tags: ['v*']
pull_request:
workflow_dispatch:
permissions:
contents: write # the release step needs it; nothing else writes
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build the toolchain images
run: |
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
run: |
docker run --rm --network none -v "$PWD:/src" -w /src \
control4free-launcher-build bash -lc 'make && make -C launcher'
- name: Host tests
run: |
docker run --rm --network none -v "$PWD:/src" -w /src \
control4free-launcher-build python3 -B tests/run.py
- name: Collect what was built
run: |
version=$(cat VERSION)
mkdir -p artifacts
cp build/control4free.elf "build/Control4Free-$version.pkg" artifacts/
cd artifacts && sha256sum * > SHA256SUMS && cat SHA256SUMS
- uses: actions/upload-artifact@v4
with:
name: control4free
path: artifacts/
if-no-files-found: error
- name: Check the tag matches VERSION
if: startsWith(github.ref, 'refs/tags/v')
run: |
test "v$(cat VERSION)" = "${GITHUB_REF#refs/tags/}" \
|| { echo "tag ${GITHUB_REF#refs/tags/} does not match VERSION $(cat VERSION)"; exit 1; }
- name: Publish the release
if: startsWith(github.ref, 'refs/tags/v')
env:
GH_TOKEN: ${{ github.token }}
run: gh release create "${GITHUB_REF#refs/tags/}" artifacts/* --verify-tag --notes-file CHANGELOG.md
+32
View File
@@ -0,0 +1,32 @@
# Changelog
Dates are the day the work landed. 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.0.0 — unreleased
First release.
Control4Free is a GoldHEN payload that turns a phone or PC into a PS4 controller
that works everywhere: the home screen, the sign-in screen and games. Up to four
of them, through the PS4's own virtual-device API, with the PS4's own
"Who's using this controller?" screen deciding who each one is.
- A controller page the payload serves itself on port 4264. Touch controls shaped
like a DualShock 4, keyboard keys, or any gamepad the browser exposes. A layout
editor, key remapping and a per-controller picker, saved per device.
- An installable app for the PS4's home screen that starts and stops Control4Free,
shows its address and a QR code, and sets up GoldHEN's AutoRun so the console
starts it by itself after every restart.
- The page can be kept on a phone's home screen.
- Input is reported the moment it arrives, at a DualShock 4's own 4 ms cadence
while a player is doing something, and at a slow keepalive while nobody is.
- Connecting a controller never stops the other players being served.
- Recovers by itself after rest mode or a network failure: controllers have to be
picked again, which is what rest mode does to real ones too.
- 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 2.4b18.10. Rumble, light bar and motion are
not implemented.
+12 -2
View File
@@ -1,7 +1,8 @@
# Control4Free -- system-wide virtual controllers for the PS4 (GoldHEN payload).
#
# Build:
# make # build/control4free.elf
# make # build/control4free.elf
# make C4F_PROBE=1 # plus the rumble/light-bar research probe (dev only)
# make clean
#
# A build needs the ps4-payload-sdk; docker/Dockerfile has it, and
@@ -19,11 +20,20 @@ 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
# Research build: logs what scePadVirtualDeviceGetRemoteSetting returns. Its ELF
# gets its own name, so it can never be mistaken for a release.
ifdef C4F_PROBE
EXTRA_CFLAGS := -DC4F_PROBE_SETTING
ELF := build/$(NAME)-probe.elf
OBJDIR := build/probe
endif
OBJECTS := $(SOURCES:src/%.c=$(OBJDIR)/%.o) $(OBJDIR)/client.o $(OBJDIR)/assets.o
CFLAGS += -std=gnu11 -Wall -Wextra -Wpointer-arith -g -O2
CFLAGS += -MMD -MP
CFLAGS += -Iinclude -Ivendor/jsmn -DC4F_VERSION='"$(VERSION)"'
CFLAGS += -Iinclude -Ivendor/jsmn -DC4F_VERSION='"$(VERSION)"' $(EXTRA_CFLAGS)
LDFLAGS += -lScePad -lSceUserService -ldl -lpthread
.PHONY: all clean
+11 -4
View File
@@ -5,10 +5,13 @@ keyboard, or an Xbox/other controller exposed by the browser's Gamepad API.
Virtual controllers use the PS4's native user-selection screen, so they work on
the home screen, at sign-in and in games.
**Status: browser control, native user sign-in, gameplay, the launcher,
GoldHEN AutoRun and recovery after rest mode have been confirmed on the
development console.** Four virtual slots are exposed; the console's device
limits apply.
Browser control, native user sign-in, gameplay, the launcher app, GoldHEN
AutoRun and recovery after rest mode are all confirmed on the development
console. Four controllers at a time; the console's own device limits apply.
See [CHANGELOG.md](CHANGELOG.md) for what is in a release, and
[SECURITY.md](SECURITY.md) for what being open on your network does and does not
mean.
## Requirements
@@ -173,6 +176,10 @@ readable over GoldHEN's FTP server, with the previous run kept beside it.
## Build
Everything builds in Docker, so nothing has to be installed on the machine. CI
(`.github/workflows/build.yml`) runs exactly these steps and attaches the payload,
the package and their SHA256 sums to each tagged release.
The [ps4-payload-sdk](https://github.com/ps4-payload-dev/sdk) toolchain is pinned
in the Docker image:
+59
View File
@@ -225,6 +225,62 @@ static void c4fEnqueue(C4fWebPad *p, const ScePadData *data)
p->queue[(p->head+p->count)%C4F_QUEUE_SIZE] = *data; p->count++;
}
#ifdef C4F_PROBE_SETTING
/* Research only, built with `make C4F_PROBE=1`, never in a release.
*
* A real DualShock 4 is told to rumble and to change its light bar by the game.
* For a virtual pad that has to arrive through the same API the Remote Play path
* uses, and scePadVirtualDeviceGetRemoteSetting is the only call that looks like
* it carries anything back. Nothing is known about its buffer, so this polls it
* for every live controller and logs the return code once and then only what
* changes. Play something that rumbles, set a light bar, and read the log.
*
* The page already knows how to act on both ('v' and 'l' messages), so if the
* fields turn up here, driving them is a small step. */
static void c4fProbeSetting(C4fWeb *app, uint64_t now)
{
static uint64_t nextAt;
static unsigned char previous[C4F_MAX_PADS][256];
static int32_t lastRet[C4F_MAX_PADS];
static int seen[C4F_MAX_PADS];
unsigned char current[256];
if (now < nextAt) return;
nextAt = now + 100;
for (int i = 0; i < C4F_MAX_PADS; i++) {
C4fWebPad *p = &app->pads[i];
int32_t ret;
int first, from = -1, to = -1;
if (!p->active) { seen[i] = 0; continue; }
if (!scePadVirtualDeviceGetRemoteSetting) {
if (!seen[i]) c4fLog("probe: scePadVirtualDeviceGetRemoteSetting is not exported\n");
seen[i] = 1;
continue;
}
memset(current, 0, sizeof(current));
ret = scePadVirtualDeviceGetRemoteSetting(p->device.handle, current);
first = !seen[i];
seen[i] = 1;
if (first || ret != lastRet[i]) {
c4fLog("probe: controller %d GetRemoteSetting = 0x%08x\n", i + 1, (uint32_t)ret);
lastRet[i] = ret;
}
if (ret != 0) continue;
for (int b = 0; b < (int)sizeof(current); b++)
if (first || current[b] != previous[i][b]) { if (from < 0) from = b; to = b; }
if (from >= 0) {
char hex[3 * 256 + 1];
int used = 0;
for (int b = from; b <= to && used + 3 < (int)sizeof(hex); b++)
used += snprintf(hex + used, sizeof(hex) - (size_t)used, "%02x ", current[b]);
c4fLog("probe: controller %d bytes %d-%d: %s\n", i + 1, from, to, hex);
memcpy(previous[i], current, sizeof(current));
}
}
}
#endif
/* One kernel-log line. Our own mirrored output is skipped: c4fLog writes to klog
* too, so a line that reacted to a line would feed itself for ever. The marker
* c4fKlogMark writes carries no [c4f], which is how it gets through. */
@@ -594,6 +650,9 @@ int c4fWebRun(int klogFd)
}
long klogBytes = c4fReadKlog(app);
c4fReportPads(app);
#ifdef C4F_PROBE_SETTING
c4fProbeSetting(app, now);
#endif
c4fAdvanceAdd(app, now, klogBytes);
for (int i = 0; i < C4F_MAX_PADS; i++) {
C4fWebPad *p = &app->pads[i];