Cleanup: drop the diagnostic spike, one version source, public tests

The staged spike that worked out the VDA call order is no longer part of the
build. It is kept at tag spike-final.

- src/main.c is the service only: instance lock, credentials, MBus and pad
  init, then the web service. src/server.c and include/c4f_server.h are gone
  with stage 8, and so are the stage-only helpers in src/vda.c: the MBus bind
  and holds, the assignment and button-map probes, and the GoldHEN klog stream
  path. The service reads /dev/klog, which is what works.
- VERSION holds the only version number. Both Makefiles pass it in, the
  packager reads it for param.sfo and the package name, and the build and
  deploy scripts read it for the file they look for.
- The payload's log is /data/control4free/control4free.log, not spike.log.
- The page drops the protocol 1 branches: the payload has always answered 2.
  A console slot is a "controller" and a pad plugged into the phone or PC is a
  "gamepad", so the two no longer read as the same word.
- The host tests move to tests/, with tests/run.py to run all four suites. They
  build the real sources with the PS4 calls stubbed, so they need no console.
  klog_test now covers the device path only, and asserts that nothing ever
  connects to GoldHEN's klog server.
This commit is contained in:
MoHadiShibli committed 2026-10-05 01:53:11 +03:00
1 parent f008db6e16
commit 4331ec79fd
30 files changed
+991 -1469

No files matched your search

+11 -29
View File
@@ -1,47 +1,29 @@
# Control4Free -- system-wide virtual controllers for the PS4 (GoldHEN payload).
#
# Build:
# make # browser controller: control4free.elf
# make C4F_STAGE=1 # diagnostic probe
# make C4F_STAGE=4 # create a virtual pad and press PS
# make C4F_STAGE=4 C4F_VDA_USER=1 # same, created for user 1 (-> ...-stage4-u1.elf)
# make # build/control4free.elf
# make clean
#
# A build needs the ps4-payload-sdk; dev/scripts/build.ps1 runs this inside Docker.
# A build needs the ps4-payload-sdk; docker/Dockerfile has it, and
# tools/build.ps1 runs this inside the container.
ifdef PS4_PAYLOAD_SDK
include $(PS4_PAYLOAD_SDK)/toolchain/orbis.mk
else
$(error PS4_PAYLOAD_SDK is undefined -- use dev/scripts/build.ps1, or point it at an SDK)
$(error PS4_PAYLOAD_SDK is undefined -- build in Docker, or point it at an SDK)
endif
# Spike stage (see dev/notes/vda.md). Each stage does everything the lower ones do:
# 1 probe only (no scePad call) 2 pad service 3 add a virtual device
# 4 press PS on it 5 raise the selection screen and confirm with Cross
# 6 map the button bits by reading injected samples back
# 7 visual direction test on the selection screen (fallback for 6)
# 8 resident command server on port 4264 (dev/scripts/console.ps1)
C4F_STAGE ?= 0
# Optional: the userId the virtual device is created for. Unset = foreground
# user mapped to a local-user id (0x10000000). Each value gets its own ELF.
C4F_VDA_USER ?=
VERSION := $(shell cat VERSION)
NAME := control4free
TAG := $(if $(filter 0,$(C4F_STAGE)),web,stage$(C4F_STAGE)$(if $(C4F_VDA_USER),-u$(C4F_VDA_USER)))
ELF := build/$(NAME)$(if $(filter 0,$(C4F_STAGE)),,-$(TAG)).elf
OBJDIR := build/$(TAG)
SOURCES := src/main.c src/log.c src/vda.c src/server.c
OBJECTS := $(SOURCES:src/%.c=$(OBJDIR)/%.o)
ifeq ($(C4F_STAGE),0)
SOURCES += src/web.c src/net.c
OBJECTS := $(SOURCES:src/%.c=$(OBJDIR)/%.o) $(OBJDIR)/client.o
endif
ELF := build/$(NAME).elf
OBJDIR := build/payload
SOURCES := src/main.c src/log.c src/vda.c src/web.c src/net.c
OBJECTS := $(SOURCES:src/%.c=$(OBJDIR)/%.o) $(OBJDIR)/client.o
CFLAGS += -std=gnu11 -Wall -Wextra -Wpointer-arith -g -O2
CFLAGS += -MMD -MP
CFLAGS += -Iinclude -Ivendor/jsmn -DC4F_STAGE=$(C4F_STAGE)
CFLAGS += $(if $(C4F_VDA_USER),-DC4F_VDA_USER=$(C4F_VDA_USER))
CFLAGS += -Iinclude -Ivendor/jsmn -DC4F_VERSION='"$(VERSION)"'
LDFLAGS += -lScePad -lSceUserService -ldl -lpthread
.PHONY: all clean
@@ -65,7 +47,7 @@ $(OBJDIR)/client.o: build/client.c | $(OBJDIR)
$(ELF): $(OBJECTS) | build
$(CC) -o $@ $(OBJECTS) $(LDFLAGS)
@echo "built $@"
@echo "built $@ ($(VERSION))"
clean:
rm -rf build
+24 -18
View File
@@ -7,17 +7,17 @@ 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.** Version 0.2.2 reads the kernel log directly again for
sign-in; it still needs its console check. Four virtual slots are exposed; the
console's device limits apply.
development console.** Four virtual slots are exposed; the console's device
limits apply.
## Requirements
- A PS4 running GoldHEN. Development console: firmware 10.01, GoldHEN 2.4b18.10.
Starting Control4Free from GoldHEN itself (LaunchPad or AutoRun) needs 2.4b18.10
or later; the launcher app and PC sending need PayLoader.
- GoldHEN's klog server, which 2.4b18 always runs. Device creation relies on
kernel-log events to recover the virtual device's handle.
- Nothing connected to GoldHEN's klog viewer while you add a controller.
Control4Free reads the kernel log itself to recover a new device's handle, and
the log has a single reader.
- A phone or PC on the same network.
- 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
@@ -25,7 +25,7 @@ console's device limits apply.
## Install
1. Copy `build/Control4Free-0.2.2.pkg` to a USB drive (or to `/data/pkg/` over
1. Copy `build/Control4Free-<version>.pkg` to a USB drive (or to `/data/pkg/` over
GoldHEN's FTP server) and install it with GoldHEN's Package Installer.
2. Open **Control4Free** from the home screen and press **Cross** once. The app
adds Control4Free to GoldHEN's AutoRun (GoldHEN 2.4b18.10 or later), so GoldHEN
@@ -63,7 +63,7 @@ When you update Control4Free this way, replace `/data/payloads/control4free.elf`
controller page's menu, or restart the PS4. That old version has no launcher API,
so the app will not start a second copy over it.
**Rest mode (since 0.2.1):** the service closes stale connections and rebuilds its
**Rest mode:** the service closes stale connections and rebuilds its
listener after socket failures or a long pause. Queued input is discarded, and
disconnected controllers report neutral input. Reopen the controller page and
select your controller after waking; an unused controller is removed after the
@@ -79,9 +79,9 @@ client at a time and, after one leaves, can go minutes without serving the next.
If a klog viewer is connected to GoldHEN when you add a controller, the page
says so; close the viewer and try again.
Diagnostics are saved at `/data/control4free/spike.log`, with elapsed timestamps
Diagnostics are saved at `/data/control4free/control4free.log`, with elapsed timestamps
such as `[c4f] [+00:01:23.456]`; the PS4's calendar setting is not used. Starting
a new instance preserves the last run in `spike.log.previous`. A heartbeat every
a new instance preserves the last run in `control4free.log.previous`. A heartbeat every
minute and explicit network recovery messages help locate any remaining hang.
## Use
@@ -105,7 +105,7 @@ controllers one at a time. Touch and keyboard can also operate the selected slot
The corner menu provides local layout/keyboard preferences, **Disconnect
controller**, and **Stop Control4Free**. PS and Share send actual controller
buttons. The PS4 assigns users; there are no pad or user settings on the page.
buttons. The PS4 assigns users; there are no user settings on the page.
Some browsers restrict Gamepad API access on an HTTP page. If the page reports
that restriction, try opening a saved copy of `client/index.html` locally and
@@ -140,9 +140,8 @@ docker build -t control4free-build docker/
docker run --rm -v "$PWD:/src" -w /src control4free-build make
```
Output: `build/control4free.elf`, with the compressed page embedded. `C4F_STAGE=0`
is the default browser build. Stages 1-8 remain available as diagnostics, with
separate `control4free-stageN.elf` outputs.
Output: `build/control4free.elf`, with the compressed page embedded. The version
comes from the `VERSION` file at the top of the repository.
To build the installable launcher after the payload image is available:
@@ -151,17 +150,24 @@ docker build -t control4free-launcher-build -f docker/Dockerfile.launcher docker
docker run --rm -v "$PWD:/src" -w /src control4free-launcher-build bash -lc 'make && make -C launcher'
```
Output: `build/Control4Free-0.2.2.pkg` (title ID `CFRE00001`). On Windows,
The host test suites build the real sources with the PS4 calls stubbed, so they
need no console:
```sh
docker run --rm --network none -v "$PWD:/src" -w /src control4free-launcher-build python3 -B tests/run.py
```
Output: `build/Control4Free-<version>.pkg` (title ID `CFRE00001`). On Windows,
`tools/build-launcher.ps1` runs both image builds and the package build. It does
not send anything to a console. Packaging uses OpenOrbis and LibOrbisPkg. The
launcher draws its screen and its icon in software, in the controller page's style.
## Current limits
Rumble, lightbar feedback and motion input are not implemented. Rest-mode
recovery and multiple native user sessions are unverified. User-assignment status
comes from kernel-log events and may lag or miss an event; check the TV.
Logs are written to `/data/control4free/spike.log` on the console.
Rumble, lightbar feedback and motion input are not implemented. Multiple native
user sessions are unverified. User-assignment status comes from kernel-log events
and may lag or miss an event; check the TV.
Logs are written to `/data/control4free/control4free.log` on the console.
## Credits and license
+1
View File
@@ -0,0 +1 @@
1.0.0
+29 -31
View File
@@ -957,7 +957,7 @@
<div class="sparkles"></div>
</div>
<!-- Home: pick a pad -->
<!-- Home: pick a controller -->
<div class="home" id="home">
<header class="topbar">
<div class="tb-group">
@@ -992,7 +992,7 @@
</section>
<section class="panel" aria-labelledby="gpTitle" id="gamepadSection">
<h2 id="gpTitle">Controllers on this device</h2>
<h2 id="gpTitle">Gamepads on this device</h2>
<p class="hint" id="gamepadHint"></p>
<ul class="gp-list" id="gamepadList"></ul>
</section>
@@ -1006,7 +1006,7 @@
<!-- Controller: DualShock 4 buttons, each placed from DEFAULT_LAYOUT in the script -->
<div class="pad-view" id="padView" data-conn="connecting" hidden>
<button class="corner-btn left" id="backBtn" type="button" aria-label="Back to the pads">
<button class="corner-btn left" id="backBtn" type="button" aria-label="Back to the controllers">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 18l-6-6 6-6"/></svg>
</button>
<button class="corner-btn right" id="fsBtn" type="button" aria-label="Full screen">
@@ -1107,7 +1107,7 @@
<section data-page="keyboard" hidden>
<h2 class="page-title">Keyboard</h2>
<p class="hint">Click a key to change it, then press the new key. Keys drive the pad opened on this page.</p>
<p class="hint">Click a key to change it, then press the new key. Keys drive the controller opened on this page.</p>
<div class="rows" id="keyRows"></div>
<div class="actions" style="margin-top:14px"><button class="btn" type="button" id="resetKeys">Reset keys</button></div>
</section>
@@ -1174,7 +1174,7 @@
// Settings of this device (the console's settings live on the console)
const DEVICE_SETTINGS = [
['keepAwake', 'Keep the screen on', 'While this page controls a pad, the screen doesn\'t turn off.', true],
['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 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],
@@ -1213,13 +1213,13 @@
// ---------------------------------------------------------------- connection
const conn = {
ws: null, state: 'connecting', address: '', protocol: 1, version: '', nextId: 1,
ws: null, state: 'connecting', address: '', version: '', nextId: 1,
pending: new Map(), retry: 0, retryTimer: 0, everOpened: false,
};
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: 'Pad ' + (i + 1),
full: false, shared: false, player: 0, uid: '', cfg: '', name: 'Controller ' + (i + 1),
color: FALLBACK_COLORS[i], clients: 0, known: false,
}));
@@ -1270,10 +1270,9 @@
$('#addressForm').hidden = servedByConsole;
$('#addressHint').classList.remove('error');
call('info', []).then(info => {
conn.protocol = info.protocol || 1;
conn.version = info.version || '';
$('#version').textContent = 'Control4Free' + (conn.version ? ' ' + conn.version : '');
if (conn.protocol >= 2) sendClaim(true); else renderPads();
sendClaim(true);
}).catch(() => {});
resendAll();
};
@@ -1433,7 +1432,7 @@
let lastClaim = '';
function sendClaim(force) {
keepAwake.update();
if (conn.protocol < 2 || conn.state !== 'open') return;
if (conn.state !== 'open') return;
const pads = claimedPads();
const key = pads.join(',');
if (!force && key === lastClaim) return;
@@ -1475,11 +1474,10 @@
lastSent.fill('');
}
// Leaving a pad: protocol 2 consoles release it when the claim changes (input would claim it again),
// protocol 1 consoles need a neutral state
// The console releases a controller when the claim changes; sending input
// here would only claim it again. Just forget what was last sent.
function releasePad(pad) {
if (pad < 0) return;
if (conn.protocol < 2) send({ method: 'u', params: [pad, 0, 128, 128, 128, 128, 0, 0, 0] });
lastSent[pad] = '';
}
@@ -2126,16 +2124,16 @@
seen.add(gp.index);
let src = gamepadSources.get(gp.index);
if (!src) {
const saved = undefined;
// A new controller drives the pad opened on this page; elsewhere it waits until you pick a pad
const pad = saved !== undefined ? saved : (primaryPad >= 0 && ![...gamepadSources.values()].some(s => s.pad === primaryPad) ? primaryPad : -1);
// A new gamepad drives the controller opened on this page; elsewhere
// it waits until one is picked for it.
const pad = primaryPad >= 0 && ![...gamepadSources.values()].some(s => s.pad === primaryPad) ? primaryPad : -1;
src = blankSource(pad);
src.gp = gp;
src.activeAt = 0;
gamepadSources.set(gp.index, src);
renderGamepads();
sendClaim();
toast(pad >= 0 ? 'Controller ' + (gp.index + 1) + ' plays as Pad ' + (pad + 1) : 'Controller ' + (gp.index + 1) + ' connected. Pick its pad on the home screen.');
toast(pad >= 0 ? 'Gamepad ' + (gp.index + 1) + ' plays as Controller ' + (pad + 1) : 'Gamepad ' + (gp.index + 1) + ' connected. Pick its controller on the home screen.');
}
src.gp = gp;
let buttons = 0;
@@ -2272,8 +2270,8 @@
head.appendChild(el('span', 'activity'));
tile.appendChild(head);
const body = el('span');
body.appendChild(el('div', 'tile-name', s.known ? s.name : 'Pad ' + (i + 1)));
body.appendChild(el('div', 'tile-meta', padMeta(s) || (s.known ? '' : conn.protocol < 2 && conn.version ? 'Waiting for controller status' : '')));
body.appendChild(el('div', 'tile-name', s.known ? s.name : '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'));
@@ -2297,26 +2295,26 @@
list.textContent = '';
if (!gamepadsAvailable) {
$('#gamepadHint').textContent = window.isSecureContext
? 'This browser does not support controllers.'
: 'This browser only allows controllers on secure pages. Save this page to your device, open the saved file, and enter the console address.';
? 'This browser does not support gamepads.'
: 'This browser only allows gamepads on secure pages. Save this page to your device, open the saved file, and enter the console address.';
return;
}
$('#gamepadHint').textContent = gamepadSources.size
? 'Pick the pad each controller plays as. A row lights up while you use its controller. Keep this window in front: browsers only read controllers on the active window.'
: 'Connect controllers to this device and press a button on each one. Each controller can play as a different pad.';
? 'Pick the controller each gamepad plays as. A row lights up while you use it. Keep this window in front: browsers only read gamepads on the active window.'
: 'Connect gamepads to this device and press a button on each one. Each one can play as a different controller.';
for (const [index, src] of gamepadSources) {
const row = el('li', 'gp-row');
row.dataset.index = index;
row.insertAdjacentHTML('afterbegin', '<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M7 7h10a5 5 0 0 1 4.8 6.4l-1 3.4a2.6 2.6 0 0 1-4.4 1.2L14.4 16H9.6l-2 2a2.6 2.6 0 0 1-4.4-1.2l-1-3.4A5 5 0 0 1 7 7z"/></svg>');
const text = el('div');
const name = (src.gp && src.gp.id || 'Controller').replace(/\s*\(.*?\)\s*/g, ' ').trim();
text.appendChild(el('div', 'gp-name', name || 'Controller'));
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', 'Controller ' + (index + 1) + (mapped ? '' : ' · buttons may be mixed up')));
text.appendChild(el('div', 'gp-sub', 'Gamepad ' + (index + 1) + (mapped ? '' : ' · buttons may be mixed up')));
row.appendChild(text);
const seg = el('div', 'seg');
seg.setAttribute('role', 'radiogroup');
seg.setAttribute('aria-label', 'Pad for controller ' + (index + 1));
seg.setAttribute('aria-label', 'Controller for gamepad ' + (index + 1));
for (let pad = -1; pad < PADS; pad++) {
const b = el('button', '', pad < 0 ? 'Off' : String(pad + 1));
b.type = 'button';
@@ -2324,7 +2322,7 @@
b.setAttribute('aria-checked', String(src.pad === pad));
if (pad >= 0) {
b.style.setProperty('--pc', css(status[pad].color));
b.title = 'Pad ' + (pad + 1) + ' · ' + status[pad].name;
b.title = 'Controller ' + (pad + 1) + ' · ' + status[pad].name;
}
b.addEventListener('click', () => setGamepadPad(index, pad));
seg.appendChild(b);
@@ -2351,7 +2349,7 @@
const s = status[primaryPad];
document.documentElement.style.setProperty('--accent', css(s.color));
const label = $('#padLabel');
label.textContent = 'Pad ' + (primaryPad + 1) + (s.known && s.name ? ' · ' + s.name : '');
label.textContent = 'Controller ' + (primaryPad + 1) + (s.known && s.name ? ' · ' + s.name : '');
const state = padState(s);
const extra = conn.state !== 'open' ? $('#connText').textContent
: state === 'ready' ? 'Signed in on the PS4'
@@ -2396,7 +2394,7 @@
function openPad(pad) {
const s = status[pad];
if (s.known && !s.enabled) {
toast('Pad ' + (pad + 1) + ' is turned off in Settings');
toast('Controller ' + (pad + 1) + ' is turned off in Settings');
return;
}
const others = s.clients - (claimedPads().includes(pad) ? 1 : 0);
@@ -2419,7 +2417,7 @@
$('#settingsView').hidden = true;
padView.hidden = false;
document.body.classList.add('in-pad');
document.title = 'Pad ' + (pad + 1) + ' · Control4Free';
document.title = 'Controller ' + (pad + 1) + ' · Control4Free';
history.replaceState(null, '', updateQuery('pad', String(pad + 1)));
shownButtons = -1;
shownStick.L = shownStick.R = '';
+2 -2
View File
@@ -3,7 +3,7 @@
* Everything goes to klog (GoldHEN's klog server, port 3232) and is mirrored to a
* file on the console, so a run can be read back over FTP even if the klog
* connection dropped. Every line has [c4f] and elapsed HH:MM:SS.mmm, independent
* of the console's calendar. The previous run is kept in spike.log.previous.
* of the console's calendar. The previous run is kept in control4free.log.previous.
*
* c4fLogSetKlog(0) turns the klog half off. The /dev/klog scanner needs that:
* while it is reading, its own writes would come back round and bury the kernel
@@ -14,7 +14,7 @@
#define C4F_LOG_H
#define C4F_LOG_DIR "/data/control4free"
#define C4F_LOG_PATH C4F_LOG_DIR "/spike.log"
#define C4F_LOG_PATH C4F_LOG_DIR "/control4free.log"
void c4fLogOpen(void);
void c4fLogClose(void);
+9 -41
View File
@@ -14,18 +14,11 @@
/* ---- libSceUserService ---- */
int32_t sceUserServiceInitialize(void *params);
int32_t sceUserServiceTerminate(void);
int32_t sceUserServiceGetInitialUser(int32_t *outUserId);
int32_t sceUserServiceGetForegroundUser(int32_t *outUserId) __attribute__((weak));
#define C4F_USER_ID_INVALID (-1)
/* ---- libScePad ---- */
int32_t scePadInit(void);
int32_t scePadGetHandle(int32_t userId, int32_t type, int32_t index);
int32_t scePadOpen(int32_t userId, int32_t type, int32_t index, void *param);
int32_t scePadClose(int32_t handle);
int32_t scePadReadState(int32_t handle, void *data);
/* Lets an unsigned process talk to the pad service at all. SplashDown calls this
* with 1 before scePadInit; without it the VDA calls are refused. */
@@ -38,8 +31,7 @@ int32_t scePadVirtualDeviceDeleteDevice(int32_t handle);
int32_t scePadVirtualDeviceInsertData(int32_t handle, const void *padData);
int32_t scePadVirtualDeviceGetRemoteSetting(int32_t handle, void *setting) __attribute__((weak));
/* Device type 3 is what SplashDown uses on both PS4 and PS5. On a PS4 it shows up
* in klog as REMOTEPLAY, type 4, subType 2 -- the path Remote Play itself uses. */
/* Device type 3 is what SplashDown uses on both PS4 and PS5. */
#define C4F_VIRTUAL_DEVICE_TYPE 3
/* AddDevice's parameter block. size must be sizeof the struct (32). The six
@@ -51,14 +43,11 @@ typedef struct {
int32_t pad[6];
} C4fVdaParam;
/* userId values outside the real-user range are rejected, so AddDevice gets a
* value in 0x1000000x. 0x10000000 is the first local user on a PS4. */
#define C4F_VDA_USER_FALLBACK 0x10000000
/* ---- libSceMbus (no SDK stub exists: resolved with dlopen/dlsym) ---- */
typedef int32_t (*C4fMbusBindFn)(uint64_t deviceId, int32_t userId);
typedef int32_t (*C4fMbusDisconnectFn)(uint64_t deviceId);
/* Created for user 1, a device arrives unassigned and the PS4 opens its native
* "Who's using this controller?" screen (confirmed on firmware 10.01). */
#define C4F_VDA_USER_SELECT 1
/* ---- libSceMbus (no SDK stub exists: loaded with dlopen) ---- */
#define C4F_LIBSCEMBUS_PATH "/system/common/lib/libSceMbus.sprx"
/* ---- libkernel ---- */
@@ -98,30 +87,9 @@ typedef struct {
uint8_t unknown[15];
} ScePadData;
/* VDA bits verified on PS4 firmware 10.01. With AddDevice user=1, the native
* selection screen opens automatically: RIGHT=0x20 selects the next user and
* CROSS=0x4000 signs them in. Pressing PS during selection cancels that flow.
* The previously assumed Cross=0x20000 was not correct for this console. */
#define C4F_VDI_BUTTON_PS 0x00010000u /* confirmed on our console: moved the home screen */
#define C4F_VDI_BUTTON_CROSS 0x00004000u /* confirmed: native second-user sign-in */
/* Standard read-side bits. RIGHT and CROSS also verified on the VDA input
* path; the remaining inputs still need hardware checks. */
#define C4F_PAD_L3 0x00000002u
#define C4F_PAD_R3 0x00000004u
#define C4F_PAD_OPTIONS 0x00000008u
#define C4F_PAD_UP 0x00000010u
#define C4F_PAD_RIGHT 0x00000020u
#define C4F_PAD_DOWN 0x00000040u
#define C4F_PAD_LEFT 0x00000080u
#define C4F_PAD_L2 0x00000100u
#define C4F_PAD_R2 0x00000200u
#define C4F_PAD_L1 0x00000400u
#define C4F_PAD_R1 0x00000800u
#define C4F_PAD_TRIANGLE 0x00001000u
#define C4F_PAD_CIRCLE 0x00002000u
#define C4F_PAD_CROSS 0x00004000u
#define C4F_PAD_SQUARE 0x00008000u
#define C4F_PAD_TOUCHPAD 0x00100000u
/* VDA button bits use the standard read-side layout; these were confirmed on
* firmware 10.01. The page sends the full mask (C4F_INPUT_MASK in web.c). */
#define C4F_VDI_BUTTON_PS 0x00010000u /* moved the home screen */
#define C4F_VDI_BUTTON_CROSS 0x00004000u /* native second-user sign-in */
#endif /* C4F_SCE_H */
-16
View File
@@ -1,16 +0,0 @@
/* Control4Free -- the resident command server (see src/server.c). */
#ifndef C4F_SERVER_H
#define C4F_SERVER_H
#include <stdint.h>
/* Its own port, so experiments no longer go through GoldHEN's PayLoader. */
#define C4F_SERVER_PORT 4264
/* Listens, runs commands, and returns when told to quit or when idle too long.
* `klogFd` is the kernel-log reader new pads are found through. Any pad still
* alive is removed before it returns. */
int c4fServerRun(int32_t realUserId, int klogFd);
#endif /* C4F_SERVER_H */
+11 -60
View File
@@ -1,7 +1,7 @@
/* Control4Free -- the virtual device (VDA) lifecycle.
*
* One C4fVirtualPad is one virtual DualShock 4 as the system sees it. The order
* the calls have to go in, and why, is in dev/notes/vda.md.
* One C4fVirtualPad is one virtual DualShock 4 as the system sees it. The call
* order and its reasons came from the spike (tag spike-final, dev notes).
*/
#ifndef C4F_VDA_H
@@ -16,94 +16,45 @@ typedef struct {
int32_t userId; /* the user it is bound to, or C4F_USER_ID_INVALID */
int32_t vdaUserId; /* the userId AddDevice was given */
int owned; /* we created it, so we delete it */
int bound; /* sceMbusBindDeviceWithUserId succeeded */
} C4fVirtualPad;
/* Resolves libSceMbus. Must run before any scePad* call: libScePad imports from
/* Loads libSceMbus. Must run before any scePad* call: libScePad imports from
* libSceMbus and those imports are unresolved in a payload, so calling into
* libScePad first can kill the process with PRX_NOT_RESOLVED_FUNCTION.
* Returns 0 on success. */
int c4fMbusInit(void);
/* Starts the MBus client this process never started. which: 0 calls libScePad's
* scePadMbusInit(), 1 calls sceMbusInit(). Both take no arguments as far as we
* know (no published signature). Returns 0 if the function was found and
* called; its own return value goes in *outRet. */
int c4fMbusClientInit(int which, int32_t *outRet);
/* scePadInit(), then scePadSetProcessPrivilege(1) (it fails with
* NOT_INITIALIZED the other way round). Returns 0 if scePadInit succeeded. */
int c4fPadInit(void);
/* Picks the user to give the virtual pad: the foreground user, else the initial
* user, else C4F_VDA_USER_FALLBACK. */
int32_t c4fPickUserId(void);
/* AddDevice, then capture the DeviceId from /dev/klog. klogFd may be -1, in which
* case only the write-back words of the parameter block are inspected.
* Returns 0 and fills *out on success. */
int c4fVirtualPadAdd(C4fVirtualPad *out, int32_t userId, int klogFd);
/* Same, but AddDevice gets `vdaUser` exactly as given (no mapping). */
/* AddDevice for `vdaUser`, then capture the DeviceId from klogFd (a verified
* /dev/klog reader). Returns 0 and fills *out on success. */
int c4fVirtualPadAddAs(C4fVirtualPad *out, int32_t userId, int32_t vdaUser, int klogFd);
/* Keep existing pads reporting during the bounded klog waits for a new pad.
* The callback must not read klog, create devices, or run the network loop. */
void c4fVdaSetWaitCallback(void (*callback)(void *), void *context);
/* sceMbusBindDeviceWithUserId. Until this runs the device exists but belongs to
* nobody, so input goes nowhere. Returns 0 on success. */
int c4fVirtualPadBind(C4fVirtualPad *pad, int32_t userId);
/* The bind with `authid` switched on for just the call (0 = leave it as is).
* Ghostcontrol uses SceShellCore's 0x4800000000000010 for this on PS4. */
int c4fVirtualPadBindAs(C4fVirtualPad *pad, int32_t userId, uint64_t authid);
#define C4F_AUTHID_SHELLCORE 0x4800000000000010ull
/* A neutral sample: sticks centred, identity quaternion, connected. */
void c4fPadDataNeutral(ScePadData *data);
/* One InsertData call. Returns the raw return value. */
int32_t c4fVirtualPadInsert(const C4fVirtualPad *pad, const ScePadData *data);
/* Holds `buttons` for durationMs, one sample every C4F_FRAME_MS. A real pad
* reports continuously, so a single sample is often missed. */
int32_t c4fVirtualPadHold(const C4fVirtualPad *pad, uint32_t buttons,
int durationMs, const char *label);
/* DeleteDevice, if we own the handle. */
void c4fVirtualPadRemove(C4fVirtualPad *pad);
/* Asks the pad service whether the device has been assigned to a user yet.
* An unassigned virtual pad answers nothing useful; once the system's user
* selection has run, scePadGetHandle starts returning a handle for the user it
* was given to. Logs every probe. Returns that user id, or
* C4F_USER_ID_INVALID while it is still unassigned.
* `realUserId` is the signed-in account to include among the candidates. */
int32_t c4fProbeAssignment(int32_t realUserId);
/* Opens a read handle on the pad for `userId`, so injected samples can be read
* back. Returns the handle, or negative. Close it with scePadClose. */
int32_t c4fOpenReadHandle(int32_t userId);
/* Injects one candidate bit at a time and reads back what the system reports,
* which gives the input-bit -> button map without anyone watching the screen.
* `readHandle` must come from c4fOpenReadHandle. Logs a line per bit. */
void c4fProbeButtonMap(const C4fVirtualPad *pad, int32_t readHandle);
#define C4F_FRAME_MS 33
/* ---- klog capture ---- */
/* GoldHEN's klog server port, read from inside the console when /dev/klog is
* already held by that server. */
#define C4F_KLOG_PORT 3232
/* ---- klog capture ----
* /dev/klog has a single reader. GoldHEN's klog server opens it only while it
* has a client and serves one client at a time; after a client leaves it can
* go minutes without serving the next one (console, 2026-10-04). So the
* service reads the device itself, only while a controller signs in. */
/* /dev/klog only, verified by a fresh marker; -1 if busy or silent. For the
* browser service, which holds it only while a controller signs in. */
/* /dev/klog, verified by a fresh marker; -1 if busy or silent. */
int c4fKlogOpenDevice(void);
/* /dev/klog, else GoldHEN's stream on 3232 (diagnostic stages); verified. */
int c4fKlogOpen(void);
void c4fKlogDrain(int fd);
/* Writes a marker to klog and reads it back. 0 = the capture path works. */
int c4fKlogSelfTest(int fd);
+7
View File
@@ -0,0 +1,7 @@
#ifndef C4F_VERSION_H
#define C4F_VERSION_H
/* Set by the Makefile from the VERSION file; "dev" for test builds. */
#ifndef C4F_VERSION
#define C4F_VERSION "dev"
#endif
#endif
+2 -1
View File
@@ -2,12 +2,13 @@ ifndef OO_PS4_TOOLCHAIN
$(error OO_PS4_TOOLCHAIN is undefined; use the launcher Docker image)
endif
SDK := $(OO_PS4_TOOLCHAIN)
VERSION := $(shell cat ../VERSION)
OUT := ../build/launcher
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
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
LIBS := -lc -lkernel -lSceVideoOut -lScePad -lSceUserService -lSceSystemService -lSceNetCtl
FONTS := ../vendor/roboto/Roboto-Light.ttf ../vendor/roboto/Roboto-Regular.ttf
+4 -1
View File
@@ -1,7 +1,10 @@
#ifndef C4F_LAUNCHER_SCREEN_H
#define C4F_LAUNCHER_SCREEN_H
#include <stdint.h>
#define C4F_LAUNCHER_VERSION "0.2.2"
/* Set by the Makefile from the VERSION file. */
#ifndef C4F_LAUNCHER_VERSION
#define C4F_LAUNCHER_VERSION "dev"
#endif
#define C4F_SCREEN_WIDTH 1920
#define C4F_SCREEN_HEIGHT 1080
typedef struct {
+2 -1
View File
@@ -11,6 +11,7 @@
#include "c4f_log.h"
#include "c4f_sce.h"
#include "c4f_version.h"
static int g_logFd = -1;
static int g_klogEnabled = 1;
@@ -36,7 +37,7 @@ void c4fLogOpen(void)
else
g_logFd = open(C4F_LOG_PATH, O_WRONLY | O_CREAT | O_TRUNC, 0666);
g_startedMs = c4fLogNowMs();
c4fLog("---- Control4Free log (stage %d); timestamps are elapsed time ----\n", C4F_STAGE);
c4fLog("---- Control4Free %s; timestamps are elapsed time ----\n", C4F_VERSION);
}
void c4fLogClose(void)
+25 -305
View File
@@ -1,110 +1,33 @@
/* Control4Free -- system-wide virtual controllers for the PS4.
*
* The browser payload builds on a successful hardware experiment: a GoldHEN
* payload can create a virtual controller that the PS4 menus accept.
* A game plugin cannot (GoldHEN only loads plugins into game processes, and the
* menus live in SceShellUI/SceShellCore), so Control4Free is a payload and uses
* Sony's own virtual-device API, the one Remote Play goes through.
* A GoldHEN payload. A game plugin cannot reach the menus (GoldHEN loads
* plugins into game processes only, and the menus live in
* SceShellUI/SceShellCore), so Control4Free runs as a payload and uses Sony's
* virtual-device API, the one Remote Play goes through. Phones and PCs drive
* the controllers from a page the payload serves on port 4264 (web.c).
*
* The work is split into stages so each call can be checked on klog before the
* next one is let near the console. Pick one at build time:
*
* C4F_STAGE=0 browser controller (default), with explicit creation/sign-in.
* C4F_STAGE=1 probe only: firmware, credentials, users. No scePad call.
* C4F_STAGE=2 plus libSceMbus, scePadSetProcessPrivilege and scePadInit.
* C4F_STAGE=3 plus AddDevice. Holds the device a few seconds so the
* "Who's using this controller?" screen can be watched for,
* then deletes it. No input is injected.
* C4F_STAGE=4 plus one short PS press, then 10 s of neutral input so a user
* selection prompt can show. No MBus bind (it crashed; C4F_BIND=1
* puts it back).
* C4F_STAGE=5 the sign-in attempt: raise the selection screen, press Cross to
* confirm the highlighted user, then check whether the pad was
* really assigned. Build it with C4F_VDA_USER=1.
* C4F_STAGE=6 button mapping: inject one bit at a time and read the pad back,
* so the map comes from the system, not from guessing. Build with
* the default user so the pad is assigned and readable.
* C4F_STAGE=7 visual direction test on the selection screen.
* C4F_STAGE=8 command server: stays resident on port 4264 and takes commands
* from dev\scripts\console.ps1, so the PayLoader is needed once.
*
* Diagnostic stages require an explicit build setting.
* The staged spike that established the call order is in git history, tag
* spike-final.
*/
#include <stdint.h>
#include <unistd.h>
#include <fcntl.h>
#include <stdint.h>
#include <sys/file.h>
#include <sys/stat.h>
#include <unistd.h>
#include <ps4/kernel.h>
#include "c4f_log.h"
#include "c4f_sce.h"
#include "c4f_vda.h"
#include "c4f_server.h"
#include "c4f_version.h"
#include "c4f_web.h"
#ifndef C4F_STAGE
#define C4F_STAGE 0
#endif
/* The authid SplashDown raises to before the VDA calls. */
#define C4F_AUTHID_VDA 0x3800000000010003L
/* Stage 3: how long the virtual device is left in place to be observed. */
#ifndef C4F_STAGE3_DWELL_MS
#define C4F_STAGE3_DWELL_MS 8000
#endif
/* Stage 4 press shape. PS is one of only two bits confirmed on this path. A
* short press: holding PS for over a second opens the quick menu instead. */
#ifndef C4F_PRESS_BUTTONS
#define C4F_PRESS_BUTTONS C4F_VDI_BUTTON_PS
#endif
#ifndef C4F_PRESS_LABEL
#define C4F_PRESS_LABEL "PS"
#endif
#ifndef C4F_PRE_NEUTRAL_MS
#define C4F_PRE_NEUTRAL_MS 300
#endif
#ifndef C4F_PRESS_MS
#define C4F_PRESS_MS 200
#endif
/* Neutral samples keep flowing after the press, like a real pad that is still
* switched on, so a "Who's using this controller?" prompt has time to appear
* before the device is removed (removing it cancels the prompt). */
#ifndef C4F_POST_NEUTRAL_MS
#define C4F_POST_NEUTRAL_MS 10000
#endif
/* Stage 5 timings. The screen takes a moment to come up after device creation, and
* the pad has to keep reporting the whole time or the system drops it. */
#ifndef C4F_S5_SCREEN_WAIT_MS
#define C4F_S5_SCREEN_WAIT_MS 4000
#endif
#ifndef C4F_S5_CROSS_MS
#define C4F_S5_CROSS_MS 120
#endif
#ifndef C4F_S5_SETTLE_MS
#define C4F_S5_SETTLE_MS 3000
#endif
/* How long the pad is kept alive after the attempt, so the result stays on
* screen and a second probe can run. */
#ifndef C4F_S5_ALIVE_MS
#define C4F_S5_ALIVE_MS 15000
#endif
/* sceMbusBindDeviceWithUserId crashed the host process on 2026-10-04 (stage 4,
* ScePartyDaemon, reason 0xb). It is also not what we want: the goal is the
* system's own user selection. Off unless asked for. */
#ifndef C4F_BIND
#define C4F_BIND 0
#endif
/* Reports what privileges this process actually has. On a GoldHEN console the
* payload may already be jailbroken, so the before/after pair tells us whether
* raising them by hand is needed at all. */
/* Logs the host process's privileges, before and after they are raised. */
static void c4fLogCredentials(const char *when, pid_t pid)
{
uint8_t caps[16];
@@ -177,9 +100,8 @@ static void c4fRestoreCredentials(void)
c4fLogCredentials("restored", g_pid);
}
static int c4fFinish(int klogFd, int status)
static int c4fFinish(int status)
{
if (klogFd >= 0) close(klogFd);
c4fRestoreCredentials();
c4fLog("exiting with status %d\n", status);
c4fLogClose();
@@ -190,244 +112,42 @@ static int c4fFinish(int klogFd, int status)
int main(void)
{
pid_t pid = getpid();
uint32_t fw;
int32_t userId;
int klogFd = -1;
int32_t ret;
#if C4F_STAGE >= 3 && C4F_STAGE <= 7
C4fVirtualPad pad;
#endif
/* Keep ownership while reopening the listener after wake. A second load
* must not change the shared host's credentials or truncate the first log.
* flock belongs to this open descriptor, even inside the same process. */
/* One copy at a time. A second load (AutoRun plus the launcher, say) must
* not change the shared host's credentials or rotate the first one's log.
* The lock belongs to this open descriptor, even inside the same process,
* and the kernel drops it with the descriptor. */
(void)mkdir(C4F_LOG_DIR, 0777);
g_instanceFd = open(C4F_LOG_DIR "/instance.lock", O_WRONLY | O_CREAT, 0666);
if (g_instanceFd < 0 || flock(g_instanceFd, LOCK_EX | LOCK_NB)) {
if (g_instanceFd >= 0) close(g_instanceFd);
c4fNotify("Control4Free: another instance owns the service, or its lock is unavailable");
c4fNotify("Control4Free is already running");
return 1;
}
c4fLogOpen();
#if C4F_STAGE != 0
c4fNotify("Control4Free: stage %d", C4F_STAGE);
#endif
fw = kernel_get_fw_version();
c4fLog("pid=%d firmware=0x%08x (10.01 reads as 0x1001xxxx)\n", pid, fw);
c4fLog("Control4Free %s: pid=%d firmware=0x%08x\n", C4F_VERSION, pid, kernel_get_fw_version());
c4fLogCredentials("on entry", pid);
if (c4fRaiseCredentials(pid) != 0) return c4fFinish(klogFd, 1);
if (c4fRaiseCredentials(pid) != 0) return c4fFinish(1);
c4fLogCredentials("after raise", pid);
/* The browser service acquires klog only when a controller needs it. */
#if C4F_STAGE != 0
klogFd = c4fKlogOpen();
c4fKlogDrain(klogFd);
/* Stage 3 depends on reading kernel lines back, so prove that works first. */
c4fKlogSelfTest(klogFd);
#endif
ret = sceUserServiceInitialize(NULL);
c4fLog("sceUserServiceInitialize = 0x%08x\n", (uint32_t)ret);
userId = c4fPickUserId();
c4fLog("chosen userId=0x%08x\n", (uint32_t)userId);
#if C4F_STAGE == 1
c4fLog("stage 1 done: nothing in libScePad was called\n");
c4fNotify("Control4Free: probe done");
#else
/* libScePad has unresolved imports into libSceMbus in a payload, so MBus has
* to be loaded first or the first scePad call can kill the process. */
if (c4fMbusInit() != 0) {
c4fNotify("Control4Free: libSceMbus missing");
return c4fFinish(klogFd, 1);
return c4fFinish(1);
}
if (c4fPadInit() != 0) {
c4fNotify("Control4Free: scePadInit failed");
return c4fFinish(klogFd, 1);
}
c4fLog("pad service ready\n");
#endif
#if C4F_STAGE == 8
/* Resident command server: sent once, then driven over its own port. */
if (c4fServerRun(userId, klogFd) != 0)
return c4fFinish(klogFd, 1);
#endif
#if C4F_STAGE == 0
/* c4fWebRun owns the klog descriptor from here on. */
ret = c4fWebRun(klogFd);
klogFd = -1;
if (ret != 0) {
c4fNotify("Control4Free: could not start; check payload log");
return c4fFinish(klogFd, 1);
}
#endif
#if C4F_STAGE >= 3 && C4F_STAGE <= 7
if (c4fVirtualPadAdd(&pad, userId, klogFd) != 0) {
c4fNotify("Control4Free: no virtual device");
return c4fFinish(klogFd, 1);
}
/* The toast names the user value, so whoever watches the TV can tell runs apart. */
c4fNotify("Control4Free: device 0x%x, user 0x%x",
(uint32_t)pad.handle, (uint32_t)pad.vdaUserId);
#endif
#if C4F_STAGE == 3
c4fLog("holding the device for %d ms -- watch the screen for a controller "
"assignment prompt\n", C4F_STAGE3_DWELL_MS);
usleep(C4F_STAGE3_DWELL_MS * 1000);
c4fVirtualPadRemove(&pad);
c4fNotify("Control4Free: device removed");
#endif
#if C4F_STAGE == 7
/* Visual fallback for the button map, for when reading the pad back does not
* work. It tests one hypothesis: that the VDA path uses the ordinary
* documented layout (PS = 0x10000 fits it, which is why it is likely).
* Build with C4F_VDA_USER=1 so the selection screen is what receives the
* presses -- a d-pad there only moves a highlight.
* Watch the screen and report what moved; nothing is confirmed here. */
{
ScePadData neutral;
int press;
c4fPadDataNeutral(&neutral);
ret = c4fVirtualPadInsert(&pad, &neutral);
c4fLog("first neutral InsertData = 0x%08x\n", (uint32_t)ret);
c4fVirtualPadHold(&pad, 0, 500, "pre-neutral");
/* AddDevice(user=1) opens selection itself. PS would cancel it. */
c4fNotify("C4F: waiting for user selection");
c4fVirtualPadHold(&pad, 0, 4000, "waiting for the screen");
c4fNotify("C4F: 3x DOWN now");
for (press = 0; press < 3; press++) {
c4fVirtualPadHold(&pad, C4F_PAD_DOWN, 120, "DOWN 0x40");
c4fVirtualPadHold(&pad, 0, 900, "gap");
}
c4fVirtualPadHold(&pad, 0, 1500, "pause");
c4fNotify("C4F: 3x UP now");
for (press = 0; press < 3; press++) {
c4fVirtualPadHold(&pad, C4F_PAD_UP, 120, "UP 0x10");
c4fVirtualPadHold(&pad, 0, 900, "gap");
}
c4fVirtualPadHold(&pad, 0, 4000, "staying alive");
c4fVirtualPadRemove(&pad);
c4fNotify("C4F: direction test finished");
}
#endif
#if C4F_STAGE == 6
/* Button mapping, read back from the system instead of watched on screen.
* Build this one with the default user (0x10000000) so the pad is already
* assigned and can be read; an unassigned pad answers nothing. */
{
int32_t readHandle;
ScePadData neutral;
c4fPadDataNeutral(&neutral);
ret = c4fVirtualPadInsert(&pad, &neutral);
c4fLog("first neutral InsertData = 0x%08x\n", (uint32_t)ret);
/* The user the device was created for -- not the signed-in account,
* whose handle would be the real DualShock. */
readHandle = c4fOpenReadHandle(pad.vdaUserId);
if (readHandle < 0) {
c4fLog("no read handle: cannot map buttons this way\n");
c4fNotify("Control4Free: no read handle");
} else {
c4fNotify("Control4Free: mapping buttons");
c4fProbeButtonMap(&pad, readHandle);
(void)scePadClose(readHandle);
}
c4fVirtualPadRemove(&pad);
c4fNotify("Control4Free: probe finished");
}
#endif
#if C4F_STAGE == 5
c4fLog("no MBus bind: the system's own user selection is the point here\n");
{
ScePadData neutral;
int32_t assigned;
c4fPadDataNeutral(&neutral);
ret = c4fVirtualPadInsert(&pad, &neutral);
c4fLog("first neutral InsertData = 0x%08x\n", (uint32_t)ret);
if (ret < 0) {
c4fNotify("Control4Free: InsertData failed 0x%x", (uint32_t)ret);
c4fVirtualPadRemove(&pad);
return c4fFinish(klogFd, 1);
}
c4fLog("before anything: ");
(void)c4fProbeAssignment(userId);
/* AddDevice(user=1) opens selection itself. A PS press here cancels
* selection and disconnects the pad from the sign-in flow. */
c4fVirtualPadHold(&pad, 0, 500, "pre-neutral");
c4fNotify("Control4Free: waiting for user selection");
c4fVirtualPadHold(&pad, 0, C4F_S5_SCREEN_WAIT_MS, "waiting for the screen");
/* Cross confirms whoever is highlighted. RIGHT=0x20 is also verified
* on this path; stage 8 can use it to choose another user first. */
c4fVirtualPadHold(&pad, C4F_VDI_BUTTON_CROSS, C4F_S5_CROSS_MS, "Cross press");
c4fVirtualPadHold(&pad, 0, C4F_S5_SETTLE_MS, "settling");
assigned = c4fProbeAssignment(userId);
if (assigned != C4F_USER_ID_INVALID) {
/* GetHandle may be the physical controller; it cannot prove that
* this virtual DeviceId belongs to the returned user. */
c4fLog("read handle found for user 0x%08x; verify this virtual "
"DeviceId's OWNER_CHANGED event in klog\n", (uint32_t)assigned);
}
c4fNotify("Control4Free: confirmation sent; check system log");
/* Keep reporting so the result stays visible, then look once more. */
c4fVirtualPadHold(&pad, 0, C4F_S5_ALIVE_MS, "staying alive");
c4fLog("final check: ");
(void)c4fProbeAssignment(userId);
c4fVirtualPadRemove(&pad);
c4fNotify("Control4Free: done");
}
#endif
#if C4F_STAGE == 4
#if C4F_BIND
if (c4fVirtualPadBind(&pad, userId) != 0)
c4fLog("bind failed; injecting anyway to see what happens\n");
#else
c4fLog("no MBus bind: leaving user selection to the system\n");
#endif
{
ScePadData neutral;
c4fPadDataNeutral(&neutral);
ret = c4fVirtualPadInsert(&pad, &neutral);
c4fLog("first neutral InsertData = 0x%08x\n", (uint32_t)ret);
if (ret < 0) {
c4fNotify("Control4Free: InsertData failed 0x%x", (uint32_t)ret);
c4fVirtualPadRemove(&pad);
return c4fFinish(klogFd, 1);
}
return c4fFinish(1);
}
c4fNotify("Control4Free: pressing %s", C4F_PRESS_LABEL);
c4fVirtualPadHold(&pad, 0, C4F_PRE_NEUTRAL_MS, "pre-neutral");
c4fVirtualPadHold(&pad, C4F_PRESS_BUTTONS, C4F_PRESS_MS, C4F_PRESS_LABEL " press");
c4fVirtualPadHold(&pad, 0, C4F_POST_NEUTRAL_MS, "post-neutral");
c4fVirtualPadRemove(&pad);
c4fNotify("Control4Free: done");
#endif
return c4fFinish(klogFd, 0);
/* The service takes the kernel log only while a controller signs in. */
ret = c4fWebRun(-1);
if (ret != 0) c4fNotify("Control4Free could not start; see /data/control4free/control4free.log");
return c4fFinish(ret != 0);
}
-594
View File
@@ -1,594 +0,0 @@
/* Control4Free -- a small command server, so experiments stop depending on
* GoldHEN's PayLoader.
*
* The PayLoader stops listening a few minutes after it is started, which meant
* restarting it by hand before every single test. This payload is sent once and
* then stays resident, listening on its own port, so the PC can create virtual
* pads and inject input as often as it likes over one connection. Replies come
* straight back down the socket, so there is no log file to fetch either.
*
* It is deliberately short-lived: an idle client or a long session makes it shut
* down on its own, because it is running inside somebody else's process
* (ScePartyDaemon) and must not sit there forever.
*
* Protocol: one ASCII command per line, one reply line per command.
* add [userhex] create a virtual pad (default 1 = selection screen)
* padmbus call scePadMbusInit() (start the MBus client)
* mbusinit call sceMbusInit()
* bind [userhex] [authid] give the pad to a user (default: signed-in, ShellCore authid)
* del delete it
* press <hexbits> [ms] hold those button bits
* stick <lx> <ly> <rx> <ry> [ms] hold stick positions (0-255, 128 centre)
* hold <ms> neutral for that long (keeps the pad alive)
* sweep <first> <last> [ms] press each single bit from first to last
* status what the server thinks is going on
* klog [lines] recent system log (LOG lines, then OK klog finished)
* quit clean up and exit
*/
#include <errno.h>
#include <fcntl.h>
#include <stdarg.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#include <unistd.h>
#include <sys/select.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include "c4f_log.h"
#include "c4f_sce.h"
#include "c4f_server.h"
#include "c4f_vda.h"
/* Shut down rather than linger inside the host process. */
#ifndef C4F_IDLE_TIMEOUT_S
#define C4F_IDLE_TIMEOUT_S 300
#endif
#ifndef C4F_SESSION_MAX_S
#define C4F_SESSION_MAX_S 1800
#endif
#define C4F_DEFAULT_HOLD_MS 150
#define C4F_MAX_HOLD_MS 30000
#define C4F_KLOG_LINES 128
#define C4F_KLOG_LINE_SIZE 512
static C4fVirtualPad g_pad;
static int g_havePad;
static int g_klogFd = -1; /* how a new pad's handle is found */
static uint64_t g_deadlineMs;
static uint64_t g_nextFrameMs;
static uint64_t g_frames;
static uint64_t g_insertErrors;
static int32_t g_lastInsert;
static int g_replyFailed;
/* A bounded history: never echo these lines through c4fLog, which would feed
* them straight back into the same klog stream. */
static char g_klogLines[C4F_KLOG_LINES][C4F_KLOG_LINE_SIZE];
static char g_klogPartial[C4F_KLOG_LINE_SIZE];
static size_t g_klogUsed;
static unsigned g_klogNext;
static unsigned g_klogCount;
static uint64_t g_klogTotal;
static uint64_t c4fNowMs(void)
{
struct timespec now;
(void)clock_gettime(CLOCK_MONOTONIC, &now);
return (uint64_t)now.tv_sec * 1000u + (uint64_t)now.tv_nsec / 1000000u;
}
static void c4fCaptureKlog(void)
{
char buf[2048];
int batch;
if (g_klogFd < 0) return;
/* Bound the work even when a noisy process keeps writing continuously. */
for (batch = 0; batch < 8; batch++) {
ssize_t n = read(g_klogFd, buf, sizeof(buf));
ssize_t i;
if (n < 0 && errno == EINTR) continue;
if (n < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) break;
if (n <= 0) {
g_klogFd = -1; /* main still owns and closes the original fd */
break;
}
for (i = 0; i < n; i++) {
unsigned char ch = (unsigned char)buf[i];
if (ch == '\n') {
g_klogPartial[g_klogUsed] = '\0';
if (g_klogUsed && !strstr(g_klogPartial, "[c4f]")) {
(void)memcpy(g_klogLines[g_klogNext], g_klogPartial,
g_klogUsed + 1);
g_klogNext = (g_klogNext + 1) % C4F_KLOG_LINES;
if (g_klogCount < C4F_KLOG_LINES) g_klogCount++;
g_klogTotal++;
}
g_klogUsed = 0;
} else if ((ch >= 32 || ch == '\t') &&
g_klogUsed + 1 < sizeof(g_klogPartial)) {
g_klogPartial[g_klogUsed++] = (char)ch;
}
}
}
}
static int32_t c4fReport(const ScePadData *data)
{
int32_t ret = c4fVirtualPadInsert(&g_pad, data);
g_frames++;
if (ret < 0) {
g_insertErrors++;
if (ret != g_lastInsert)
c4fLog("InsertData failed = 0x%08x\n", (uint32_t)ret);
}
g_lastInsert = ret;
g_nextFrameMs = c4fNowMs() + C4F_FRAME_MS;
return ret;
}
static void c4fPump(void)
{
c4fCaptureKlog();
if (g_havePad && c4fNowMs() >= g_nextFrameMs) {
ScePadData neutral;
c4fPadDataNeutral(&neutral);
(void)c4fReport(&neutral);
}
}
/* Wait in frame-sized slices, so silence or a disconnected client never stops
* the pad reporting. All pad calls stay on the same thread. */
static int c4fWaitReadable(int fd, uint64_t untilMs)
{
for (;;) {
struct timeval tv;
fd_set set;
uint64_t now, waitMs;
int ready;
c4fPump();
now = c4fNowMs();
if (now >= untilMs || now >= g_deadlineMs) return 0;
waitMs = untilMs - now;
if (waitMs > g_deadlineMs - now) waitMs = g_deadlineMs - now;
if (waitMs > C4F_FRAME_MS) waitMs = C4F_FRAME_MS;
if (g_havePad && g_nextFrameMs > now && waitMs > g_nextFrameMs - now)
waitMs = g_nextFrameMs - now;
tv.tv_sec = 0;
tv.tv_usec = (int)waitMs * 1000;
FD_ZERO(&set);
FD_SET(fd, &set);
ready = select(fd + 1, &set, NULL, NULL, &tv);
if (ready < 0 && errno == EINTR) continue;
if (ready != 0) return ready;
}
}
static void c4fReply(int fd, const char *text)
{
size_t left = strlen(text);
while (left && !g_replyFailed) {
int flags = 0;
#ifdef MSG_NOSIGNAL
flags = MSG_NOSIGNAL;
#endif
ssize_t sent = send(fd, text, left, flags);
if (sent < 0 && errno == EINTR) continue;
if (sent <= 0) { g_replyFailed = 1; break; }
text += sent;
left -= (size_t)sent;
}
}
static void c4fReplyFmt(int fd, const char *fmt, ...)
{
char line[512];
va_list ap;
int n;
va_start(ap, fmt);
n = vsnprintf(line, sizeof(line), fmt, ap);
va_end(ap);
if (n < 0) return;
c4fReply(fd, line);
c4fLog("> %s", line);
}
/* Holds `buttons` and the given stick positions for durationMs. */
static int32_t c4fHold(uint32_t buttons, uint8_t lx, uint8_t ly,
uint8_t rx, uint8_t ry, int durationMs)
{
ScePadData data;
uint64_t untilMs;
int32_t result = 0;
if (durationMs < C4F_FRAME_MS) durationMs = C4F_FRAME_MS;
if (durationMs > C4F_MAX_HOLD_MS) durationMs = C4F_MAX_HOLD_MS;
untilMs = c4fNowMs() + (unsigned)durationMs;
c4fPadDataNeutral(&data);
data.buttons = buttons;
data.lx = lx;
data.ly = ly;
data.rx = rx;
data.ry = ry;
while (c4fNowMs() < untilMs && c4fNowMs() < g_deadlineMs) {
int32_t ret = c4fReport(&data);
if (ret < 0 && result == 0) result = ret;
c4fCaptureKlog();
usleep(C4F_FRAME_MS * 1000);
}
return result;
}
static int32_t c4fNeutral(int durationMs)
{
return c4fHold(0, 128, 128, 128, 128, durationMs);
}
static int c4fDuration(int fd, const char *arg, int fallback, int *out)
{
char *end;
long value;
if (!arg) { *out = fallback; return 1; }
errno = 0;
value = strtol(arg, &end, 10);
if (errno || end == arg || *end || value < C4F_FRAME_MS ||
value > C4F_MAX_HOLD_MS) {
c4fReplyFmt(fd, "ERR duration must be %d..%d ms\n",
C4F_FRAME_MS, C4F_MAX_HOLD_MS);
return 0;
}
*out = (int)value;
return 1;
}
static int c4fInputResult(int fd, uint64_t errorsBefore)
{
if (c4fNowMs() >= g_deadlineMs) {
c4fReplyFmt(fd, "ERR session deadline reached; input stopped\n");
return 0;
}
if (g_insertErrors == errorsBefore) return 1;
c4fReplyFmt(fd, "ERR InsertData failed; errors=%llu last=0x%08x (see log)\n",
(unsigned long long)g_insertErrors, (uint32_t)g_lastInsert);
return 0;
}
static int c4fNeedPad(int fd)
{
if (g_havePad) return 1;
c4fReplyFmt(fd, "ERR no pad; run add first\n");
return 0;
}
/* Returns 0 to keep going, 1 to shut down. */
static int c4fCommand(int fd, char *line, int32_t realUserId)
{
char *cmd = strtok(line, " \t");
char *a1 = strtok(NULL, " \t");
char *a2 = strtok(NULL, " \t");
char *a3 = strtok(NULL, " \t");
char *a4 = strtok(NULL, " \t");
char *a5 = strtok(NULL, " \t");
uint64_t errorsBefore = g_insertErrors;
if (!cmd || !*cmd) return 0;
c4fLog("< %s %s %s\n", cmd, a1 ? a1 : "", a2 ? a2 : "");
if (strcmp(cmd, "quit") == 0) {
c4fReplyFmt(fd, "OK bye\n");
return 1;
}
if (strcmp(cmd, "status") == 0) {
c4fReplyFmt(fd, "OK pad=%d handle=0x%08x deviceId=0x%llx vdaUser=0x%08x realUser=0x%08x frames=%llu errors=%llu last=0x%08x klog=%d\n",
g_havePad, (uint32_t)g_pad.handle,
(unsigned long long)g_pad.deviceId,
(uint32_t)g_pad.vdaUserId, (uint32_t)realUserId,
(unsigned long long)g_frames, (unsigned long long)g_insertErrors,
(uint32_t)g_lastInsert, g_klogFd >= 0);
return 0;
}
if (strcmp(cmd, "klog") == 0) {
unsigned count = g_klogCount;
unsigned i;
if (a1) {
char *end;
long requested;
errno = 0;
requested = strtol(a1, &end, 10);
if (errno || end == a1 || *end || requested < 1 || requested > C4F_KLOG_LINES) {
c4fReplyFmt(fd, "ERR klog [1..%d]\n", C4F_KLOG_LINES);
return 0;
}
if ((unsigned)requested < count) count = (unsigned)requested;
}
for (i = 0; i < count; i++) {
unsigned index = (g_klogNext + C4F_KLOG_LINES - count + i) % C4F_KLOG_LINES;
c4fReply(fd, "LOG ");
c4fReply(fd, g_klogLines[index]);
c4fReply(fd, "\n");
}
c4fReplyFmt(fd, "OK klog finished lines=%u total=%llu available=%d\n",
count, (unsigned long long)g_klogTotal, g_klogFd >= 0);
return 0;
}
if (strcmp(cmd, "add") == 0) {
int32_t user = a1 ? (int32_t)strtoul(a1, NULL, 0) : 1;
if (g_havePad) {
c4fReplyFmt(fd, "ERR pad already exists; del first\n");
return 0;
}
/* Only lines written after this point may name the new device. */
c4fKlogDrain(g_klogFd);
g_klogUsed = 0; /* drain/scanner consume whole sections of the stream */
/* Exactly the value asked for: the whole point of the argument is to try
* values the default mapping would rewrite (1 became 0x10000000). */
if (c4fVirtualPadAddAs(&g_pad, user, user, g_klogFd) != 0) {
c4fReplyFmt(fd, "ERR AddDevice gave no handle\n");
return 0;
}
g_havePad = 1;
g_frames = g_insertErrors = 0;
g_lastInsert = 0;
errorsBefore = 0;
/* One neutral sample so the device starts reporting like a real pad. */
c4fNeutral(C4F_FRAME_MS * 3);
if (!c4fInputResult(fd, errorsBefore)) return 0;
c4fReplyFmt(fd, "OK handle=0x%08x deviceId=0x%llx vdaUser=0x%08x\n",
(uint32_t)g_pad.handle, (unsigned long long)g_pad.deviceId,
(uint32_t)g_pad.vdaUserId);
return 0;
}
/* padmbus / mbusinit: start the MBus client before a bind. */
if (strcmp(cmd, "padmbus") == 0 || strcmp(cmd, "mbusinit") == 0) {
int which = strcmp(cmd, "padmbus") == 0 ? 0 : 1;
int32_t ret = 0;
if (c4fMbusClientInit(which, &ret) != 0) {
c4fReplyFmt(fd, "ERR %s not found\n", which == 0 ? "scePadMbusInit" : "sceMbusInit");
return 0;
}
c4fReplyFmt(fd, "OK %s() = 0x%08x\n",
which == 0 ? "scePadMbusInit" : "sceMbusInit", (uint32_t)ret);
return 0;
}
/* bind [userhex] [authidhex]: give the pad to a user. Default user is the
* signed-in account; default authid is SceShellCore's, as Ghostcontrol uses
* on PS4. "bind <user> 0" makes the call with our own credentials -- that
* is the form that crashed the host on 2026-10-04. */
if (strcmp(cmd, "bind") == 0) {
int32_t user = a1 ? (int32_t)strtoul(a1, NULL, 0) : realUserId;
uint64_t authid = a2 ? strtoull(a2, NULL, 0) : C4F_AUTHID_SHELLCORE;
if (!c4fNeedPad(fd)) return 0;
if (c4fVirtualPadBindAs(&g_pad, user, authid) != 0) {
c4fReplyFmt(fd, "ERR bind to 0x%08x failed (see log)\n", (uint32_t)user);
return 0;
}
c4fReplyFmt(fd, "OK bound to 0x%08x (authid 0x%llx)\n",
(uint32_t)user, (unsigned long long)authid);
return 0;
}
if (strcmp(cmd, "del") == 0) {
if (!c4fNeedPad(fd)) return 0;
c4fVirtualPadRemove(&g_pad);
g_havePad = 0;
c4fReplyFmt(fd, "OK deleted\n");
return 0;
}
if (strcmp(cmd, "press") == 0) {
uint32_t bits;
int ms;
if (!a1) { c4fReplyFmt(fd, "ERR press <hexbits> [ms]\n"); return 0; }
if (!c4fNeedPad(fd)) return 0;
bits = (uint32_t)strtoul(a1, NULL, 0);
if (!c4fDuration(fd, a2, C4F_DEFAULT_HOLD_MS, &ms)) return 0;
c4fNeutral(C4F_FRAME_MS * 2);
c4fHold(bits, 128, 128, 128, 128, ms);
c4fNeutral(C4F_FRAME_MS * 3);
if (!c4fInputResult(fd, errorsBefore)) return 0;
c4fReplyFmt(fd, "OK pressed 0x%08x for %dms\n", bits, ms);
return 0;
}
if (strcmp(cmd, "stick") == 0) {
int ms;
if (!a4) { c4fReplyFmt(fd, "ERR stick <lx> <ly> <rx> <ry> [ms]\n"); return 0; }
if (!c4fNeedPad(fd)) return 0;
if (!c4fDuration(fd, a5, 1000, &ms)) return 0;
c4fHold(0, (uint8_t)atoi(a1), (uint8_t)atoi(a2),
(uint8_t)atoi(a3), (uint8_t)atoi(a4), ms);
c4fNeutral(C4F_FRAME_MS * 3);
if (!c4fInputResult(fd, errorsBefore)) return 0;
c4fReplyFmt(fd, "OK sticks %s,%s %s,%s for %dms\n", a1, a2, a3, a4, ms);
return 0;
}
if (strcmp(cmd, "hold") == 0) {
int ms;
if (!c4fNeedPad(fd)) return 0;
if (!c4fDuration(fd, a1, 1000, &ms)) return 0;
c4fNeutral(ms);
if (!c4fInputResult(fd, errorsBefore)) return 0;
c4fReplyFmt(fd, "OK held neutral %dms\n", ms);
return 0;
}
/* Presses each single bit in a range, so one command can walk a whole byte
* while somebody watches the screen. Each one is announced on screen. */
if (strcmp(cmd, "sweep") == 0) {
int first = a1 ? atoi(a1) : 0;
int last = a2 ? atoi(a2) : 15;
int ms;
int bit;
if (!c4fNeedPad(fd)) return 0;
if (!c4fDuration(fd, a3, 400, &ms)) return 0;
if (first < 0) first = 0;
if (last > 31) last = 31;
for (bit = first; bit <= last; bit++) {
uint32_t mask = 1u << bit;
if (g_replyFailed || c4fNowMs() >= g_deadlineMs) break;
c4fNotify("C4F: bit %d (0x%x)", bit, mask);
c4fNeutral(400);
c4fHold(mask, 128, 128, 128, 128, ms);
c4fNeutral(600);
if (!c4fInputResult(fd, errorsBefore)) return 0;
c4fReplyFmt(fd, "OK bit %d = 0x%08x\n", bit, mask);
}
c4fReplyFmt(fd, "OK sweep finished %d..%d\n", first, last);
return 0;
}
c4fReplyFmt(fd, "ERR unknown command %s\n", cmd);
return 0;
}
static int c4fAcceptOne(int listenFd, int timeoutS)
{
int ready;
ready = c4fWaitReadable(listenFd, c4fNowMs() + (unsigned)timeoutS * 1000u);
if (ready <= 0) return -1;
return accept(listenFd, NULL, NULL);
}
int c4fServerRun(int32_t realUserId, int klogFd)
{
struct sockaddr_in addr;
int listenFd;
int one = 1;
int sessions = 0;
g_klogFd = klogFd;
g_havePad = 0;
g_klogUsed = g_klogNext = g_klogCount = 0;
g_klogTotal = 0;
g_frames = g_insertErrors = 0;
g_deadlineMs = c4fNowMs() + (uint64_t)C4F_SESSION_MAX_S * 1000u;
/* Never let an unexpected blocking log source freeze controller reports. */
if (g_klogFd >= 0) {
int flags = fcntl(g_klogFd, F_GETFL, 0);
if (flags < 0 || fcntl(g_klogFd, F_SETFL, flags | O_NONBLOCK) < 0) {
c4fLog("klog nonblocking setup failed errno=%d\n", errno);
g_klogFd = -1;
}
}
listenFd = socket(AF_INET, SOCK_STREAM, 0);
if (listenFd < 0) {
c4fLog("socket() failed errno=%d\n", errno);
return -1;
}
(void)setsockopt(listenFd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one));
(void)memset(&addr, 0, sizeof(addr));
#ifdef __FreeBSD__
addr.sin_len = sizeof(addr);
#endif
addr.sin_family = AF_INET;
addr.sin_port = htons(C4F_SERVER_PORT);
addr.sin_addr.s_addr = htonl(INADDR_ANY);
if (bind(listenFd, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
c4fLog("bind(%d) failed errno=%d\n", C4F_SERVER_PORT, errno);
(void)close(listenFd);
return -1;
}
if (listen(listenFd, 1) < 0) {
c4fLog("listen() failed errno=%d\n", errno);
(void)close(listenFd);
return -1;
}
c4fLog("command server listening on port %d\n", C4F_SERVER_PORT);
c4fNotify("Control4Free: server on port %d", C4F_SERVER_PORT);
while (c4fNowMs() < g_deadlineMs) {
char buf[512];
size_t used = 0;
int discarding = 0;
int clientFd = c4fAcceptOne(listenFd, 30);
int done = 0;
uint64_t idleDeadline;
if (clientFd < 0) continue;
#ifdef SO_NOSIGPIPE
(void)setsockopt(clientFd, SOL_SOCKET, SO_NOSIGPIPE, &one, sizeof(one));
#endif
{
struct timeval sendTimeout = { .tv_sec = 1, .tv_usec = 0 };
(void)setsockopt(clientFd, SOL_SOCKET, SO_SNDTIMEO,
&sendTimeout, sizeof(sendTimeout));
}
g_replyFailed = 0;
idleDeadline = c4fNowMs() + (uint64_t)C4F_IDLE_TIMEOUT_S * 1000u;
sessions++;
c4fLog("client %d connected\n", sessions);
c4fReplyFmt(clientFd, "OK Control4Free ready (port %d)\n", C4F_SERVER_PORT);
while (!done && !g_replyFailed) {
char incoming[512];
ssize_t n;
ssize_t i;
if (c4fWaitReadable(clientFd, idleDeadline) <= 0) {
c4fLog("client wait ended (timeout, session limit, or socket error)\n");
break;
}
n = recv(clientFd, incoming, sizeof(incoming), 0);
if (n < 0 && errno == EINTR) continue;
if (n <= 0) break;
idleDeadline = c4fNowMs() + (uint64_t)C4F_IDLE_TIMEOUT_S * 1000u;
for (i = 0; i < n && !done && !g_replyFailed; i++) {
char ch = incoming[i];
if (c4fNowMs() >= g_deadlineMs) break;
if (ch == '\n') {
if (!discarding) {
if (used && buf[used - 1] == '\r') used--;
buf[used] = '\0';
done = c4fCommand(clientFd, buf, realUserId);
}
used = 0;
discarding = 0;
c4fPump();
} else if (!discarding) {
if (used == sizeof(buf) - 1 || ch == '\0') {
c4fReplyFmt(clientFd, "ERR invalid or too long line\n");
discarding = 1; /* discard the entire command, including its tail */
used = 0;
} else {
buf[used++] = ch;
}
}
}
}
(void)close(clientFd);
c4fLog("client disconnected\n");
if (done) break;
}
if (g_havePad) {
c4fLog("cleaning up the pad before exit\n");
c4fVirtualPadRemove(&g_pad);
g_havePad = 0;
}
(void)close(listenFd);
c4fLog("command server stopped\n");
c4fNotify("Control4Free: server stopped");
return 0;
}
+5 -364
View File
@@ -2,23 +2,18 @@
*
* Ported from seregonwar/SplashDown's psbutton.c (GPL-3.0), the only known
* working caller of this API on a PS4. The call order and the klog trick for
* recovering the device handle both come from there; see dev/notes/vda.md.
* recovering the device handle both come from there.
*/
#include <arpa/inet.h>
#include <ctype.h>
#include <dlfcn.h>
#include <errno.h>
#include <fcntl.h>
#include <netinet/in.h>
#include <stdio.h>
#include <string.h>
#include <time.h>
#include <sys/socket.h>
#include <sys/select.h>
#include <unistd.h>
#include <ps4/kernel.h>
#include <ps4/klog.h>
#include "c4f_log.h"
@@ -32,9 +27,6 @@
#define RTLD_GLOBAL 0x100
#endif
static C4fMbusBindFn g_mbusBind;
static C4fMbusDisconnectFn g_mbusDisconnect;
static void *g_mbusHandle;
static void (*g_waitCallback)(void *);
static void *g_waitContext;
@@ -72,50 +64,6 @@ int c4fMbusInit(void)
c4fLog("FATAL: libSceMbus did not load; not touching libScePad\n");
return -1;
}
g_mbusBind = (C4fMbusBindFn)dlsym(handle, "sceMbusBindDeviceWithUserId");
g_mbusDisconnect = (C4fMbusDisconnectFn)dlsym(handle, "sceMbusDisconnectDevice");
c4fLog("sceMbusBindDeviceWithUserId=%p sceMbusDisconnectDevice=%p\n",
(void *)g_mbusBind, (void *)g_mbusDisconnect);
if (!g_mbusBind) {
c4fLog("FATAL: sceMbusBindDeviceWithUserId unresolved\n");
return -1;
}
g_mbusHandle = handle;
return 0;
}
/* libSceMbus is a client of a system service. Our host process (ScePartyDaemon)
* never starts that client, and a bind without it faulted twice on 2026-10-04.
* libScePad has its own wrapper for starting it, scePadMbusInit, which games
* never call; there is also sceMbusInit itself. Neither has a published
* signature, so both are called with no arguments and the return is logged.
* which: 0 = scePadMbusInit, 1 = sceMbusInit. */
typedef int32_t (*C4fNoArgFn)(void);
int c4fMbusClientInit(int which, int32_t *outRet)
{
const char *name = which == 0 ? "scePadMbusInit" : "sceMbusInit";
void *lib;
C4fNoArgFn fn;
if (which == 0) {
lib = dlopen("/system/common/lib/libScePad.sprx", RTLD_NOW | RTLD_GLOBAL);
} else {
lib = g_mbusHandle;
}
if (!lib) {
c4fLog("%s: library handle missing\n", name);
return -1;
}
fn = (C4fNoArgFn)dlsym(lib, name);
c4fLog("%s resolved at %p\n", name, (void *)fn);
if (!fn) return -1;
*outRet = fn();
c4fLog("%s() = 0x%08x\n", name, (uint32_t)*outRet);
return 0;
}
@@ -141,50 +89,6 @@ int c4fPadInit(void)
return 0;
}
static int c4fIsRealUser(int32_t userId)
{
return userId != 0 && userId != C4F_USER_ID_INVALID;
}
int32_t c4fPickUserId(void)
{
int32_t userId = C4F_USER_ID_INVALID;
int32_t ret;
if (sceUserServiceGetForegroundUser) {
ret = sceUserServiceGetForegroundUser(&userId);
c4fLog("GetForegroundUser = 0x%08x id=0x%08x\n", (uint32_t)ret, (uint32_t)userId);
if (ret == 0 && c4fIsRealUser(userId)) return userId;
} else {
c4fLog("GetForegroundUser not present on this firmware\n");
}
userId = C4F_USER_ID_INVALID;
ret = sceUserServiceGetInitialUser(&userId);
c4fLog("GetInitialUser = 0x%08x id=0x%08x\n", (uint32_t)ret, (uint32_t)userId);
if (ret == 0 && c4fIsRealUser(userId)) return userId;
c4fLog("no real user found; using fallback 0x%08x\n", C4F_VDA_USER_FALLBACK);
return C4F_VDA_USER_FALLBACK;
}
/* Which user the device is created for. SplashDown maps any real account id
* (like 1A2B3C4D) onto 0x10000000. On our console a device created that way
* acts as the signed-in user: PS works, but there is no "Who's using this
* controller?" screen (2026-10-04). C4F_VDA_USER overrides it at build time to
* look for a value that makes the device arrive unassigned; Ghostcontrol uses
* 1 as an "anonymous" slot and sees -1 on devices waiting for assignment. */
static int32_t c4fVdaUserId(int32_t userId)
{
#ifdef C4F_VDA_USER
(void)userId;
return (int32_t)(C4F_VDA_USER);
#else
if (userId >= 0x10000000 && userId <= 0x1000000f) return userId;
return C4F_VDA_USER_FALLBACK;
#endif
}
/* ---- /dev/klog scanning ----
*
* AddDevice hands back a status, not a handle. The handle we need is the MBus
@@ -192,51 +96,6 @@ static int32_t c4fVdaUserId(int32_t userId)
* while the device is being added. Ugly, but it is what works.
*/
/* Prefer GoldHEN's stream. Both connection and capture checks are bounded:
* a successful TCP connection alone does not mean this client gets any logs. */
static int c4fKlogConnectLocal(void)
{
struct sockaddr_in addr;
int s = socket(AF_INET, SOCK_STREAM, 0);
if (s < 0) {
c4fLog("klog socket() failed errno=%d\n", errno);
return -1;
}
(void)memset(&addr, 0, sizeof(addr));
#ifdef __FreeBSD__
addr.sin_len = sizeof(addr);
#endif
addr.sin_family = AF_INET;
addr.sin_port = htons(C4F_KLOG_PORT);
addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
if (s >= (int)FD_SETSIZE || fcntl(s, F_SETFL, O_NONBLOCK) < 0) goto fail;
if (connect(s, (struct sockaddr *)&addr, sizeof(addr)) != 0) {
if (errno != EINPROGRESS) goto fail;
uint64_t deadline = c4fVdaNowMs() + 700;
for (;;) {
if (g_waitCallback) g_waitCallback(g_waitContext);
fd_set wr; FD_ZERO(&wr); FD_SET(s, &wr);
struct timeval timeout = {0, 20000};
int ready = select(s + 1, NULL, &wr, NULL, &timeout);
if (ready > 0) {
int error = 0; socklen_t size = sizeof(error);
if (getsockopt(s, SOL_SOCKET, SO_ERROR, &error, &size)) goto fail;
if (error) { errno = error; goto fail; }
break;
}
if (ready < 0 && errno != EINTR) goto fail;
if (c4fVdaNowMs() >= deadline) { errno = ETIMEDOUT; goto fail; }
}
}
return s;
fail:
c4fLog("klog socket unavailable errno=%d\n", errno);
close(s);
return -1;
}
/* Drained and proven by a marker that comes back, or closed. */
static int c4fKlogVerified(int fd, const char *what)
@@ -265,18 +124,6 @@ int c4fKlogOpenDevice(void)
return c4fKlogVerified(fd, "/dev/klog");
}
int c4fKlogOpen(void)
{
int fd = c4fKlogOpenDevice();
if (fd >= 0) return fd;
/* The diagnostic stages run once, usually with the PC's klog viewer
* attached, which holds the device: then the stream is the only way. */
fd = c4fKlogConnectLocal();
if (fd >= 0 && (fd = c4fKlogVerified(fd, "127.0.0.1:3232")) >= 0) return fd;
c4fLog("no verified klog source\n");
return -1;
}
/* Throws away whatever is already buffered so only new lines get scanned. A
* fresh connection to the klog server may replay a backlog, and an old "device
* added" line in it would hand us a stale DeviceId. Keep reading until the
@@ -308,7 +155,7 @@ void c4fKlogDrain(int fd)
}
/* Writes a marker into the kernel log and checks we can read it back. If this
* fails, stage 3 cannot learn the virtual device's handle. */
* fails, a new controller cannot learn its device handle. */
int c4fKlogSelfTest(int fd)
{
static unsigned sequence;
@@ -494,11 +341,6 @@ int c4fKlogFindDeviceId(int fd, uint64_t *outDeviceId, int timeoutMs)
/* ---- device lifecycle ---- */
int c4fVirtualPadAdd(C4fVirtualPad *out, int32_t userId, int klogFd)
{
return c4fVirtualPadAddAs(out, userId, c4fVdaUserId(userId), klogFd);
}
int c4fVirtualPadAddAs(C4fVirtualPad *out, int32_t userId, int32_t vdaUser, int klogFd)
{
C4fVdaParam param;
@@ -556,49 +398,6 @@ int c4fVirtualPadAddAs(C4fVirtualPad *out, int32_t userId, int32_t vdaUser, int
return 0;
}
int c4fVirtualPadBind(C4fVirtualPad *pad, int32_t userId)
{
int32_t ret;
if (!g_mbusBind) {
c4fLog("bind skipped: MBus not resolved\n");
return -1;
}
if (!pad->deviceId) {
c4fLog("bind skipped: no DeviceId\n");
return -1;
}
ret = g_mbusBind(pad->deviceId, userId);
c4fLog("sceMbusBindDeviceWithUserId(0x%llx, 0x%08x) = 0x%08x\n",
(unsigned long long)pad->deviceId, (uint32_t)userId, (uint32_t)ret);
if (ret != 0) return -1;
pad->bound = 1;
pad->userId = userId;
return 0;
}
/* Ghostcontrol's PS4 path makes this call with SceShellCore's authid switched
* on for just the call, then puts the old one back. We called it with
* SplashDown's 0x3800000000010003 and the host process crashed (stage 4,
* 2026-10-04), so the credentials are the first suspect. */
int c4fVirtualPadBindAs(C4fVirtualPad *pad, int32_t userId, uint64_t authid)
{
pid_t pid = getpid();
uint64_t saved = kernel_get_ucred_authid(pid);
int result;
if (authid) {
c4fLog("bind: authid 0x%016llx -> 0x%016llx for the call\n",
(unsigned long long)saved, (unsigned long long)authid);
(void)kernel_set_ucred_authid(pid, authid);
}
result = c4fVirtualPadBind(pad, userId);
if (authid) (void)kernel_set_ucred_authid(pid, saved);
return result;
}
void c4fPadDataNeutral(ScePadData *data)
{
(void)memset(data, 0, sizeof(*data));
@@ -629,177 +428,19 @@ int32_t c4fVirtualPadInsert(const C4fVirtualPad *pad, const ScePadData *data)
return scePadVirtualDeviceInsertData(pad->handle, &stamped);
}
int32_t c4fVirtualPadHold(const C4fVirtualPad *pad, uint32_t buttons,
int durationMs, const char *label)
{
ScePadData data;
int32_t ret = 0;
int frames = 0;
int elapsed;
if (durationMs < C4F_FRAME_MS) durationMs = C4F_FRAME_MS;
c4fPadDataNeutral(&data);
data.buttons = buttons;
/* A real pad reports continuously, so one lone sample is easily missed. */
for (elapsed = 0; elapsed < durationMs; elapsed += C4F_FRAME_MS) {
ret = c4fVirtualPadInsert(pad, &data);
frames++;
usleep(C4F_FRAME_MS * 1000);
}
c4fLog("%s: buttons=0x%08x %dms %d frames last=0x%08x\n",
label ? label : "hold", buttons, durationMs, frames, (uint32_t)ret);
return ret;
}
int32_t c4fProbeAssignment(int32_t realUserId)
{
/* Device type 3 is what we created; 0 is the ordinary "any pad" type that
* the shell uses. Both are worth asking about. */
static const int32_t types[] = { 0, 3 };
int32_t users[3];
size_t u, t;
int32_t idx;
users[0] = realUserId;
users[1] = C4F_VDA_USER_FALLBACK; /* 0x10000000 */
users[2] = 1;
for (u = 0; u < sizeof(users) / sizeof(users[0]); u++) {
for (t = 0; t < sizeof(types) / sizeof(types[0]); t++) {
for (idx = 0; idx < 4; idx++) {
int32_t handle = scePadGetHandle(users[u], types[t], idx);
if (handle >= 0) {
c4fLog("assigned: GetHandle(user=0x%08x type=%d idx=%d) = 0x%08x\n",
(uint32_t)users[u], (int)types[t], (int)idx, (uint32_t)handle);
return users[u];
}
}
}
}
c4fLog("not assigned yet (no GetHandle answered)\n");
return C4F_USER_ID_INVALID;
}
/* Only ever called with the user the virtual device was created for. Asking for
* the signed-in account instead hands back that person's real DualShock, which
* reads as all-zero (nobody is touching it) and is not ours to open and close. */
int32_t c4fOpenReadHandle(int32_t userId)
{
/* Type 3 is what the device was created as, so try it before the generic 0. */
static const int32_t types[] = { C4F_VIRTUAL_DEVICE_TYPE, 0 };
size_t t;
int32_t idx;
for (t = 0; t < sizeof(types) / sizeof(types[0]); t++) {
for (idx = 0; idx < 4; idx++) {
int32_t handle = scePadGetHandle(userId, types[t], idx);
if (handle >= 0) {
c4fLog("read handle from GetHandle(user=0x%08x type=%d idx=%d) = 0x%08x\n",
(uint32_t)userId, (int)types[t], (int)idx, (uint32_t)handle);
return handle;
}
}
}
for (t = 0; t < sizeof(types) / sizeof(types[0]); t++) {
int32_t handle = scePadOpen(userId, types[t], 0, NULL);
c4fLog("scePadOpen(user=0x%08x type=%d) = 0x%08x\n",
(uint32_t)userId, (int)types[t], (uint32_t)handle);
if (handle >= 0) return handle;
}
return -1;
}
/* Candidate input bits. The documented read-side layout first, then the two
* bits SplashDown reported for this path. */
typedef struct {
uint32_t bit;
const char *name;
} C4fBitCandidate;
static const C4fBitCandidate c4fBitCandidates[] = {
{ C4F_PAD_L3, "L3?" },
{ C4F_PAD_R3, "R3?" },
{ C4F_PAD_OPTIONS, "OPTIONS?" },
{ C4F_PAD_UP, "UP?" },
{ C4F_PAD_RIGHT, "RIGHT?" },
{ C4F_PAD_DOWN, "DOWN?" },
{ C4F_PAD_LEFT, "LEFT?" },
{ C4F_PAD_L2, "L2?" },
{ C4F_PAD_R2, "R2?" },
{ C4F_PAD_L1, "L1?" },
{ C4F_PAD_R1, "R1?" },
{ C4F_PAD_TRIANGLE, "TRIANGLE?" },
{ C4F_PAD_CIRCLE, "CIRCLE?" },
{ C4F_PAD_CROSS, "CROSS?" },
{ C4F_PAD_SQUARE, "SQUARE?" },
{ C4F_PAD_TOUCHPAD, "TOUCHPAD?" },
{ 0x00020000u, "SplashDown's Cross bit" },
};
void c4fProbeButtonMap(const C4fVirtualPad *pad, int32_t readHandle)
{
ScePadData sample;
ScePadData readback;
size_t i;
int frame;
int32_t ret;
/* What the pad reports with nothing held: anything set here is noise that
* has to be subtracted from every result below. */
c4fPadDataNeutral(&sample);
for (frame = 0; frame < 5; frame++) {
(void)c4fVirtualPadInsert(pad, &sample);
usleep(C4F_FRAME_MS * 1000);
}
(void)memset(&readback, 0, sizeof(readback));
ret = scePadReadState(readHandle, &readback);
c4fLog("probe baseline: ReadState = 0x%08x buttons=0x%08x connected=%u\n",
(uint32_t)ret, readback.buttons, (unsigned)readback.connected);
if (ret < 0) {
c4fLog("probe aborted: cannot read the pad back\n");
return;
}
for (i = 0; i < sizeof(c4fBitCandidates) / sizeof(c4fBitCandidates[0]); i++) {
uint32_t seen = 0;
c4fPadDataNeutral(&sample);
sample.buttons = c4fBitCandidates[i].bit;
for (frame = 0; frame < 5; frame++) {
(void)c4fVirtualPadInsert(pad, &sample);
usleep(C4F_FRAME_MS * 1000);
(void)memset(&readback, 0, sizeof(readback));
if (scePadReadState(readHandle, &readback) >= 0) seen |= readback.buttons;
}
c4fLog("probe bit 0x%08x (%s) -> reported 0x%08x\n",
c4fBitCandidates[i].bit, c4fBitCandidates[i].name, seen);
/* Release, so presses do not run together. */
c4fPadDataNeutral(&sample);
for (frame = 0; frame < 5; frame++) {
(void)c4fVirtualPadInsert(pad, &sample);
usleep(C4F_FRAME_MS * 1000);
}
}
c4fLog("probe done\n");
}
void c4fVirtualPadRemove(C4fVirtualPad *pad)
{
int32_t ret;
if (!pad->owned || pad->handle < 0) return;
/* DeleteDevice alone, as SplashDown does. sceMbusDisconnectDevice is resolved
* (g_mbusDisconnect) but calling it before the delete has not been tried on
* hardware, so it stays out of the cleanup path until it has. */
/* DeleteDevice alone, as SplashDown does. sceMbusDisconnectDevice exists, but
* calling it before the delete has not been tried on hardware, so it stays
* out of the cleanup path until it has. */
ret = scePadVirtualDeviceDeleteDevice(pad->handle);
c4fLog("DeleteDevice(0x%08x) = 0x%08x\n", (uint32_t)pad->handle, (uint32_t)ret);
pad->owned = 0;
pad->bound = 0;
pad->handle = -1;
pad->deviceId = 0;
}
+1 -1
View File
@@ -13,9 +13,9 @@
#include "c4f_log.h"
#include "c4f_net.h"
#include "c4f_vda.h"
#include "c4f_version.h"
#include "c4f_web.h"
#define C4F_VERSION "0.2.2"
#define C4F_INPUT_MASK 0x0011ffffu
#define C4F_REPORT_MS 16
#ifndef C4F_STALE_MS
+4
View File
@@ -0,0 +1,4 @@
#include <stdint.h>
#include <sys/types.h>
uint64_t kernel_get_ucred_authid(pid_t);
int kernel_set_ucred_authid(pid_t, uint64_t);
+1
View File
@@ -0,0 +1 @@
int klog_printf(const char *, ...);
+82
View File
@@ -0,0 +1,82 @@
/* The real VDA capture functions, with only /dev/klog and the klog writer
* stubbed. Modes: direct (the device is free), busy (another reader has it).
*
* GoldHEN's klog server on port 3232 serves one client at a time and can go
* minutes without serving the next one, so the service must never reach for it.
* A listener on 3232 stays open through both modes and must never see a
* connection. */
#include <assert.h>
#include <errno.h>
#include <fcntl.h>
#include <netinet/in.h>
#include <pthread.h>
#include <stdarg.h>
#include <stdatomic.h>
#include <stdio.h>
#include <string.h>
#include <sys/socket.h>
#include <unistd.h>
#include "c4f_vda.h"
static atomic_int peer = -1;
static int direct[2] = {-1, -1}, enabled = 1, deviceBusy, directOpens, waits;
static void waitCallback(void *unused) { (void)unused; waits++; }
void c4fLog(const char *fmt, ...) { (void)fmt; }
int c4fLogKlogEnabled(void) { return enabled; }
void c4fLogSetKlog(int value) { enabled = value; }
int klog_printf(const char *fmt, ...)
{
char line[1024]; va_list ap;
va_start(ap, fmt); int n = vsnprintf(line, sizeof(line), fmt, ap); va_end(ap);
if (direct[1] >= 0) { assert(write(direct[1], line, n) == n); return n; }
return n;
}
int __real_open(const char *, int, ...);
int __wrap_open(const char *path, int flags, ...)
{
if (strcmp(path, "/dev/klog")) return __real_open(path, flags);
directOpens++;
if (deviceBusy) { errno = EBUSY; return -1; }
assert(pipe(direct) == 0);
assert(fcntl(direct[0], F_SETFL, O_NONBLOCK) == 0);
return direct[0];
}
static void *serve(void *arg)
{
int listener = *(int *)arg;
peer = accept(listener, NULL, NULL);
return NULL;
}
int main(int argc, char **argv)
{
assert(argc == 2);
const char *mode = argv[1];
int listener, fd; pthread_t thread;
deviceBusy = strcmp(mode, "direct") != 0;
listener = socket(AF_INET, SOCK_STREAM, 0);
int one = 1; setsockopt(listener, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one));
struct sockaddr_in addr = {.sin_family = AF_INET, .sin_port = htons(3232), .sin_addr.s_addr = htonl(INADDR_LOOPBACK)};
assert(bind(listener, (struct sockaddr *)&addr, sizeof(addr)) == 0);
assert(listen(listener, 1) == 0);
assert(pthread_create(&thread, NULL, serve, &listener) == 0);
c4fVdaSetWaitCallback(waitCallback, NULL);
fd = c4fKlogOpenDevice();
if (!deviceBusy) {
/* Non-blocking, opened once, and verified by a marker read back, which
* the wait callback keeps existing controllers reporting through. */
assert(fd >= 0 && (fcntl(fd, F_GETFL) & O_NONBLOCK) && directOpens == 1 && waits > 0);
close(fd);
} else {
assert(fd == -1 && directOpens == 1);
}
usleep(50000);
assert(peer < 0); /* GoldHEN's klog stream was never touched */
assert(enabled == 1);
pthread_cancel(thread);
pthread_join(thread, NULL);
close(listener);
printf("PASS real klog acquisition: %s\n", mode);
}
+41
View File
@@ -0,0 +1,41 @@
#include "autorun.h"
#include "service.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int main(int argc, char **argv)
{
char message[160] = {0};
if (argc < 2) return 2;
if (getenv("C4F_TEST_HOST")) c4fLauncherSetHost(getenv("C4F_TEST_HOST"));
if (!strncmp(argv[1], "autorun-", 8)) {
/* autorun-check|autorun-on|autorun-off <root> [bundled.elf] */
if (argc < 3) return 2;
c4fAutorunSetRoot(argv[2]);
if (argc > 3 && c4fAutorunLoadBundled(argv[3])) { printf("unreadable\n"); return 0; }
if (!strcmp(argv[1], "autorun-check")) {
char note[96];
int state = c4fAutorunCheck(note, sizeof(note));
printf("%d|%s\n", state, note);
return 0;
}
int result = !strcmp(argv[1], "autorun-on") ? c4fAutorunEnable(message, sizeof(message)) :
!strcmp(argv[1], "autorun-off") ? c4fAutorunDisable(message, sizeof(message)) : 2;
printf("%d %s\n", result, message);
return result == 2 ? 2 : 0;
}
if (!strcmp(argv[1], "probe")) {
C4fServiceStatus s;
int result = c4fLauncherProbe(&s);
printf("%d %d %d %s|%s\n", result, s.controllers, s.stopping, s.version, c4fLauncherProblem());
return 0;
}
if (!strcmp(argv[1], "start") && argc == 3) {
size_t size = 0;
const unsigned char *payload = c4fAutorunLoadBundled(argv[2]) ? NULL : c4fAutorunBundled(&size);
int result = c4fLauncherStart(payload, size, message, sizeof(message));
printf("%d %s\n", result, message); return 0;
}
int result = c4fLauncherStop(message, sizeof(message));
printf("%d %s\n", result, message); return 0;
}
+36
View File
@@ -0,0 +1,36 @@
#include <assert.h>
#include <stdio.h>
#include <stdarg.h>
#include <string.h>
#include <time.h>
#include <unistd.h>
#include "c4f_log.h"
static unsigned ticks;
int __wrap_clock_gettime(clockid_t id, struct timespec *out)
{
assert(id == CLOCK_MONOTONIC);
out->tv_sec = 4000 + ticks; out->tv_nsec = ticks++ ? 234000000 : 0;
return 0;
}
int klog_printf(const char *fmt, ...) { (void)fmt; return 0; }
int sceKernelSendNotificationRequest(int a, void *b, size_t c, int d)
{ (void)a; (void)b; (void)c; (void)d; return 0; }
int main(void)
{
char data[8192], large[2000];
c4fLogOpen(); c4fLog("first\nsecond\n");
memset(large, 'x', sizeof(large)-1); large[sizeof(large)-1] = 0;
c4fLog("%s", large); c4fLogClose();
FILE *file = fopen(C4F_LOG_PATH, "r"); assert(file);
size_t n = fread(data, 1, sizeof(data)-1, file); data[n] = 0; fclose(file);
assert(strstr(data, "[c4f] [+00:00:02.234] first\n"));
assert(strstr(data, "[c4f] [+00:00:02.234] second\n"));
char *p = data;
while (*p) { assert(strncmp(p, "[c4f] [+", 8) == 0); p = strchr(p, '\n'); assert(p); p++; }
c4fLogOpen(); c4fLog("new run\n"); c4fLogClose();
file = fopen(C4F_LOG_PATH ".previous", "r"); assert(file);
char previous[8192]; size_t m = fread(previous, 1, sizeof(previous), file); fclose(file);
assert(m == n && memcmp(data, previous, n) == 0);
puts("PASS elapsed timestamps, multiline/truncated messages and previous-log preservation");
}
+37
View File
@@ -0,0 +1,37 @@
/* Link-time syscall fault injection; never linked into the payload. */
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/select.h>
#include <sys/socket.h>
#include <unistd.h>
static int fault(const char *wanted)
{
const char *path = getenv("C4F_TEST_FAULT");
char value[32] = {0};
FILE *file = path ? fopen(path, "r") : NULL;
if (!file) return 0;
(void)fgets(value, sizeof(value), file); fclose(file);
if (strcmp(value, wanted)) return 0;
unlink(path); errno = ENETDOWN; return 1;
}
int __real_select(int, fd_set *, fd_set *, fd_set *, struct timeval *);
int __wrap_select(int n, fd_set *r, fd_set *w, fd_set *e, struct timeval *t)
{
if (fault("select")) { errno = EBADF; return -1; }
return __real_select(n, r, w, e, t);
}
int __real_accept(int, struct sockaddr *, socklen_t *);
int __wrap_accept(int fd, struct sockaddr *a, socklen_t *n)
{
if (fault("accept")) return -1;
return __real_accept(fd, a, n);
}
int __real_getsockopt(int, int, int, void *, socklen_t *);
int __wrap_getsockopt(int fd, int level, int opt, void *out, socklen_t *size)
{
if (opt == SO_ERROR && fault("socket")) { *(int *)out = ECONNRESET; return 0; }
return __real_getsockopt(fd, level, opt, out, size);
}
+31
View File
@@ -0,0 +1,31 @@
#!/usr/bin/env python3
"""Run every host test suite. The real sources are built for the host with the
PS4 calls stubbed, so this needs no console.
docker run --rm --network none -v "$PWD:/src" -w /src \
control4free-launcher-build python3 -B tests/run.py
"""
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
SUITES = ['test_logging', 'test_web', 'test_recovery', 'test_launcher']
def main():
failed = []
for name in SUITES:
print(f'=== {name} ===', flush=True)
result = subprocess.run([sys.executable, '-B', f'tests/{name}.py'], cwd=ROOT)
if result.returncode != 0:
failed.append(name)
if failed:
print('FAILED: ' + ', '.join(failed))
return 1
print(f'All {len(SUITES)} suites passed.')
return 0
if __name__ == '__main__':
sys.exit(main())
+176
View File
@@ -0,0 +1,176 @@
"""Loopback-only tests of the real launcher client and payload HTTP management."""
import json
import os
from pathlib import Path
import socket
import subprocess
import sys
import tempfile
import threading
import time
import test_web as web
ROOT = Path(__file__).resolve().parents[1]
CLI = ROOT / 'build/launcher-host-test'
BUNDLED = ROOT / 'build/control4free.elf'
STATUS = b'{"application":"Control4Free","api":1,"version":"0.2.0","controllers":1,"stopping":false}'
def command(*args, **env):
return subprocess.check_output([str(CLI), *args], text=True, timeout=30, env=dict(os.environ, **env)).strip()
def request(method='GET', path='/api/status', extra=b'', marker=True):
return web.http(f'{method} {path} HTTP/1.1\r\nHost: 127.0.0.1:4264\r\n'.encode() +
(b'X-Control4Free-Launcher: 1\r\n' if marker else b'') + extra + b'\r\n')
def one_reply(address, reply, check=None):
"""A listener on address:4264 answering one request with `reply`."""
listener = socket.socket()
listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
listener.bind((address, 4264)); listener.listen()
def serve():
conn, _ = listener.accept()
with conn:
data = conn.recv(4096)
if check:
check(data)
conn.sendall(reply)
listener.close()
thread = threading.Thread(target=serve); thread.start()
return thread
def main():
web.build()
subprocess.run(['clang-18', '-std=gnu11', '-D_DEFAULT_SOURCE', '-Wall', '-Wextra', '-Werror',
'-Ilauncher', '-Ivendor/jsmn', 'launcher/service.c', 'launcher/autorun.c', 'launcher/sandbox.c',
'tests/launcher_cli.c', '-o', str(CLI)], check=True)
assert command('probe').startswith('0 ')
assert 'PayLoader did not answer' in command('start', str(BUNDLED))
assert 'payload is missing' in command('start', 'build/no-such-payload.elf')
print('PASS stopped service and missing PayLoader/file reporting', flush=True)
server = web.Server()
try:
assert command('probe').startswith('1 0 0 ')
assert 'Already running' in command('start', str(BUNDLED))
assert not any(r.startswith('ADD') for r in server.rows())
for extra, marker in [(b'', False), (b'Origin: http://127.0.0.1:4264\r\n', True),
(b'Origin: null\r\n', True), (b'Content-Length: 2\r\n', True),
(b'Transfer-Encoding: chunked\r\n', True)]:
assert b'403' in request('POST', '/api/stop', extra, marker)
assert b'404' in request('GET', '/api/stop')
assert server.p.poll() is None
assert command('probe').startswith('1 0 0 ')
server.c.request('claim', [0, 1])
server.c.input(0, 0x4000)
time.sleep(.04)
assert command('probe').startswith('1 2 0 ')
assert command('stop').startswith('0 Stopped')
assert server.p.wait(timeout=3) == 0
rows = server.rows()
assert len([r for r in rows if r.startswith('REMOVE')]) == 2
for i, row in enumerate(rows):
if row.startswith('REMOVE'):
assert rows[i-1].startswith('FRAME') and rows[i-1].split()[3] == '0'
print('PASS idempotent start, protected management, active-controller cleanup and stop acknowledgment', flush=True)
finally:
server.close()
# An older service (or an unrelated port owner) must block another send,
# and the screen gets the reason.
thread = one_reply('127.0.0.1', b'HTTP/1.1 404 Error\r\nContent-Length: 0\r\nConnection: close\r\n\r\n')
out = command('start', str(BUNDLED))
thread.join(timeout=3)
assert 'Control4Free is not responding (127.0.0.1 HTTP 404)' in out, out
print('PASS older/unrecognized service prevents duplicate loading and is explained', flush=True)
# The app's sandbox may not reach 127.0.0.1: the console's own address is
# the second way in. A refusal on one address alone is not "stopped".
def host_header(data):
assert b'Host: 127.0.0.2:4264' in data and b'X-Control4Free-Launcher: 1' in data, data
thread = one_reply('127.0.0.2', b'HTTP/1.1 200 OK\r\nContent-Length: %d\r\nConnection: close\r\n\r\n%s'
% (len(STATUS), STATUS), host_header)
out = command('probe', C4F_TEST_HOST='127.0.0.2')
thread.join(timeout=3)
assert out.startswith('1 1 0 0.2.0|'), out
thread = one_reply('127.0.0.2', b'HTTP/1.1 403 Error\r\nContent-Length: 0\r\nConnection: close\r\n\r\n')
out = command('probe', C4F_TEST_HOST='127.0.0.2')
thread.join(timeout=3)
assert out.startswith('-1 ') and '127.0.0.1 refused; 127.0.0.2 HTTP 403' in out, out
assert command('probe', C4F_TEST_HOST='127.0.0.2').startswith('0 ')
print('PASS console-address fallback, with the reason when neither path answers', flush=True)
received = bytearray()
child = []
with socket.socket() as loader:
loader.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
loader.bind(('127.0.0.1', 9090)); loader.listen()
def load():
conn, _ = loader.accept()
with conn:
while data := conn.recv(4096):
received.extend(data)
time.sleep(.001)
child.append(web.Server())
thread = threading.Thread(target=load); thread.start()
try:
assert command('start', str(BUNDLED)).startswith('0 Ready')
thread.join(timeout=3)
assert bytes(received) == BUNDLED.read_bytes()
assert not any(r.startswith('ADD') for r in child[0].rows())
assert command('stop').startswith('0 Stopped')
print('PASS complete payload upload, ready confirmation, no automatic controller creation', flush=True)
finally:
for instance in child:
instance.close()
# Auto-start: the files GoldHEN's Payloader LaunchPad uses, in a stand-in /user/data.
root = Path(tempfile.mkdtemp())
(root / 'GoldHEN').mkdir()
ini = root / 'GoldHEN/payloads.ini'
ours = '/user/data/payloads/control4free.elf = 1'
autorun = lambda *args, **env: command(args[0], str(root), *args[1:], **env)
assert autorun('autorun-check', str(BUNDLED)).startswith('0|')
ini.write_bytes(b'[AutoRun]\r\n/user/data/payloads/other.bin = 1\r\n')
assert autorun('autorun-on', str(BUNDLED)).startswith('0 Auto-start is on')
text = ini.read_text()
assert text.count('[AutoRun]') == 1 and '/user/data/payloads/other.bin = 1' in text and ours in text, text
assert (root / 'payloads/control4free.elf').read_bytes() == BUNDLED.read_bytes()
assert autorun('autorun-check', str(BUNDLED)).startswith('1|')
autorun('autorun-on', str(BUNDLED))
assert ini.read_text().count('control4free.elf') == 1
(root / 'payloads/control4free.elf').write_bytes(BUNDLED.read_bytes()[:-1] + b'\x01')
assert autorun('autorun-check', str(BUNDLED)).startswith('2|')
assert autorun('autorun-off').startswith('0 Auto-start is off')
text = ini.read_text()
assert 'control4free.elf' not in text and '/user/data/payloads/other.bin = 1' in text, text
assert autorun('autorun-check', str(BUNDLED)).startswith('0|')
ini.write_text('[Other]\n/user/data/payloads/control4free.elf = 1\n')
assert autorun('autorun-check', str(BUNDLED)).startswith('0|')
ini.unlink()
assert autorun('autorun-on', str(BUNDLED)).startswith('0 ')
assert ini.read_text() == '[AutoRun]\n' + ours + '\n'
assert 'GoldHEN did not let the app' in autorun('autorun-check', str(BUNDLED), C4F_TEST_NO_GOLDHEN='1')
assert autorun('autorun-on', str(BUNDLED), C4F_TEST_NO_GOLDHEN='1').startswith('-1 GoldHEN did not let')
print('PASS auto-start setup: copy, AutoRun entry, other entries kept, older copy detected, turned off', flush=True)
# The shipped drawing code, built for this machine: every screen state and
# the package icon. The PNGs in build/ are for looking at.
sys.path.insert(0, str(ROOT / 'tools'))
import package_launcher as package
package.build_art_tool()
for state in ('setup', 'running', 'stopped', 'outdated', 'confirm', 'busy', 'locked', 'unknown'):
png = ROOT / f'build/launcher-preview-{state}.png'
package.render(['screen', state], png, 1920, 1080)
assert png.stat().st_size > 50000, png
package.render(['icon', '512'], ROOT / 'build/launcher-icon.png', 512, 512)
assert subprocess.run([str(package.ART), 'screen', 'nonsense', '/dev/null']).returncode != 0
print('PASS native screen states and icon rendered from the shipped drawing code', flush=True)
if __name__ == '__main__':
main()
+17
View File
@@ -0,0 +1,17 @@
"""All filesystem/device substitutions stay in this host-only test binary."""
import subprocess
def main():
flags = ['clang-18', '-std=gnu11', '-Wall', '-Wextra', '-Werror', '-Iinclude', '-Itests/include']
subprocess.run(flags + ['-ffunction-sections', '-fdata-sections', 'src/vda.c', 'tests/klog_test.c',
'-Wl,--gc-sections,--wrap=open', '-pthread', '-o', 'build/klog-host-test'], check=True)
for mode in ('direct', 'busy'):
subprocess.run(['build/klog-host-test', mode], check=True, timeout=8)
subprocess.run(flags + ['src/log.c', 'tests/log_test.c',
'-Wl,--wrap=clock_gettime', '-o', 'build/log-host-test'], check=True)
subprocess.run(['build/log-host-test'], check=True, timeout=5)
if __name__ == '__main__':
main()
+107
View File
@@ -0,0 +1,107 @@
"""Suspend and network failure checks against the actual service loop."""
import os
from pathlib import Path
import signal
import socket
import subprocess
import tempfile
import time
import test_web as web
def connect_again():
end = time.monotonic() + 4
while time.monotonic() < end:
try:
return web.Client()
except (OSError, AssertionError):
time.sleep(.05)
raise AssertionError('listener did not recover')
def main():
subprocess.run(['clang-18', '-std=gnu11', '-Wall', '-Wextra', '-Werror', '-g',
'-Iinclude', '-Ivendor/jsmn', 'src/net.c', 'src/web.c',
'tests/web_stub.c', 'tests/recovery_faults.c', 'build/client.c',
'-Wl,--wrap=select,--wrap=accept,--wrap=getsockopt', '-o', str(web.BINARY)], check=True)
with tempfile.TemporaryDirectory() as directory:
fault = Path(directory) / 'fault'
for name in ('select', 'accept', 'socket'):
server = web.Server(C4F_TEST_FAULT=str(fault))
try:
assert server.c.request('claim', [0])['result']['pads'][0]['mine']
server.c.input(0, 0x4000)
time.sleep(.04)
fault.write_text(name)
if name == 'accept':
try:
socket.create_connection(('127.0.0.1', 4264), timeout=1).close()
except OSError:
pass
end = time.monotonic() + 3
while not any('network failed' in row for row in server.rows()):
assert time.monotonic() < end, server.rows()
time.sleep(.02)
server.c.close()
server.c = connect_again()
assert server.c.request('info')['result']['pads'] == 4
assert server.c.request('claim', [0])['result']['pads'][0]['mine']
rows = server.rows()
split = next(i for i, row in enumerate(rows) if 'network failed' in row)
assert all(row.split()[3] == '0' for row in rows[split:] if row.startswith('FRAME'))
assert len([r for r in rows if r.startswith('ADD')]) == 1
assert any('listener recovered' in row for row in rows)
print(f'PASS {name} failure: listener returns, old buttons cleared, no duplicate pad', flush=True)
finally:
server.close()
server = web.Server()
try:
server.c.request('claim', [0])
server.c.input(0, 0x4000)
time.sleep(.04)
os.kill(server.p.pid, signal.SIGSTOP)
time.sleep(6)
before = len(server.rows())
os.kill(server.p.pid, signal.SIGCONT)
time.sleep(.1)
server.c.close(); server.c = connect_again()
assert server.c.request('claim', [0])['result']['pads'][0]['mine']
after = server.rows()[before:]
assert any('service resumed after a gap' in row for row in after)
assert all(row.split()[3] == '0' for row in after if row.startswith('FRAME'))
assert len([r for r in server.rows() if r.startswith('ADD')]) == 1
print('PASS process suspension: fresh connection, neutral input and retained controller', flush=True)
finally:
os.kill(server.p.pid, signal.SIGCONT)
server.close()
server = web.Server(C4F_TEST_KLOG_BUSY='1')
try:
assert server.c.request('claim', [0])['error']['code'] == 503
assert not any(row.startswith('ADD') for row in server.rows())
assert server.c.request('info')['result']['pads'] == 4
print('PASS busy log reader: creation refused before any device is added; service stays usable', flush=True)
finally:
server.close()
server = web.Server()
try:
assert not any(row.startswith('KLOG ') for row in server.rows())
server.c.request('claim', [0])
os.write(server.log, b'DEVICE_OWNER_CHANGED [DeviceId:0x11030d][UserId:0x1a2b3c4d]\n')
time.sleep(.04)
assert server.c.request('status')['result']['pads'][0]['state'] == 'ready'
assert 'released klog reader' in server.rows()
server.c.input(0, 0x4000)
time.sleep(.04)
assert any(r.startswith('FRAME') and r.split()[3] == '16384' for r in server.rows())
server.c.request('claim', [0, 1])
assert len([r for r in server.rows() if r.startswith('KLOG ')]) == 2
print('PASS klog acquired on demand, released after sign-in, gameplay continues, reopened for next pad', flush=True)
finally:
server.close()
if __name__ == '__main__':
main()
+249
View File
@@ -0,0 +1,249 @@
"""Real transport + application tests; VDA calls are replaced by web_stub.c."""
import base64
import gzip
import hashlib
import json
import os
from pathlib import Path
import socket
import struct
import subprocess
import tempfile
import time
ROOT = Path(__file__).resolve().parents[1]
BINARY = ROOT / 'build/web-host-test'
def build():
subprocess.run(['python3', 'tools/embed_client.py', 'client/index.html', 'build/client.c'], 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', 'tests/web_stub.c', 'build/client.c', '-o', str(BINARY)], check=True)
class Client:
def __init__(self, port=4264):
self.sock = socket.create_connection(('127.0.0.1', port), timeout=3)
self.buf = b''
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())
while b'\r\n\r\n' not in self.buf:
self.buf += self.sock.recv(8192)
head, self.buf = self.buf.split(b'\r\n\r\n', 1)
assert b'101 Switching' in head, head
assert b's3pPLMBiTxaQ9kYGzzhZRbK+xOo=' in head, head
self.next_id = 1
def exact(self, size):
while len(self.buf) < size:
chunk = self.sock.recv(8192)
assert chunk, 'unexpected close'
self.buf += chunk
result, self.buf = self.buf[:size], self.buf[size:]
return result
def frame(self, payload, op=1, fin=True, masked=True):
if isinstance(payload, str):
payload = payload.encode()
mask = b'abcd'
head = bytes([(128 if fin else 0) | op, (128 if masked else 0) | (len(payload) if len(payload) < 126 else 126)])
if len(payload) >= 126:
head += struct.pack('!H', len(payload))
self.sock.sendall(head + (mask if masked else b'') + bytes(b ^ (mask[i % 4] if masked else 0) for i, b in enumerate(payload)))
def receive(self):
first, second = self.exact(2)
size = second & 127
if size == 126:
size = struct.unpack('!H', self.exact(2))[0]
assert not second & 128
return first & 15, self.exact(size)
def request(self, method, params=()):
ident = self.next_id
self.next_id += 1
self.frame(json.dumps(dict(id=ident, method=method, params=list(params))))
while True:
op, text = self.receive()
if op != 1:
continue
msg = json.loads(text)
if msg.get('id') == ident:
return 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)]
for t in touch:
params.extend(t)
self.frame(json.dumps(dict(method='u', params=params)))
def close(self):
self.sock.close()
class Server:
def __init__(self, **options):
readfd, self.log = os.pipe()
os.set_blocking(readfd, False)
self.trace = tempfile.TemporaryFile(mode='w+')
env = dict(os.environ, **{'C4F_TEST_KLOG_FD': str(readfd), **options})
self.p = subprocess.Popen([str(BINARY)], env=env, pass_fds=(readfd,), stdout=self.trace)
os.close(readfd)
for _ in range(100):
try:
self.c = Client()
break
except ConnectionRefusedError:
time.sleep(.02)
else:
raise AssertionError('server not listening')
def rows(self):
self.trace.seek(0)
return self.trace.read().splitlines()
def close(self):
self.c.close()
if self.p.poll() is None:
self.p.terminate()
self.p.wait(timeout=3)
if self.log is not None:
os.close(self.log)
self.trace.close()
def http(request):
with socket.create_connection(('127.0.0.1', 4264), timeout=3) as s:
s.sendall(request)
parts = []
while chunk := s.recv(65536):
parts.append(chunk)
return b''.join(parts)
def main():
build()
server = Server()
try:
response = http(b'GET / HTTP/1.1\r\nHost: 127.0.0.1:4264\r\n\r\n')
headers, body = response.split(b'\r\n\r\n', 1)
assert b'200 OK' in headers and b'Content-Security-Policy:' in headers
assert gzip.decompress(body) == Path('client/index.html').read_bytes()
assert not any(r.startswith('ADD') for r in server.rows()), 'page load created a controller'
assert b'403' in http(b'GET / HTTP/1.1\r\nHost: evil.example:4264\r\n\r\n')
assert b'403' in http(b'GET /ws HTTP/1.1\r\nHost: 127.0.0.1:4264\r\nOrigin: https://evil.example\r\nUpgrade: websocket\r\n\r\n')
assert b'404' in http(b'GET /../README.md HTTP/1.1\r\nHost: 127.0.0.1:4264\r\n\r\n')
print('PASS embedded page, RFC handshake, origin/host/path checks, no automatic creation', flush=True)
c = server.c
assert c.request('info')['result']['pads'] == 4
c.frame(b'ping', op=9)
while True:
op, data = c.receive()
if op == 10:
assert data == b'ping'
break
c.frame('{"id":99,"method":', fin=False)
c.frame('"status","params":[]}', op=0)
while True:
_, data = c.receive()
msg = json.loads(data)
if msg.get('id') == 99:
assert msg['result']['pads'][0]['state'] == 'free'
break
assert 'error' in c.request('claim', [4])
assert c.request('claim', [0])['result']['pads'][0]['state'] == 'select'
assert all(row.split()[3] == '0' for row in server.rows() if row.startswith('FRAME')), 'creation injected a button'
other = Client()
assert other.request('claim', [0])['error']['code'] == 409
assert c.request('claim', [0, 1, 2, 3])['result']['pads'][3]['mine']
print('PASS ping, fragmented messages, explicit creation, four slots, exclusive ownership', flush=True)
# Exact device event determines assignment; no generic physical-pad read handle.
os.write(server.log, b'<118>DEVICE_OWNER_CHANGED [DeviceId:0x11030d][UserId:0x1a2b3c4d]\n')
time.sleep(.04)
state = c.request('status')['result']['pads']
assert state[0]['state'] == 'ready' and state[1]['state'] == 'select'
c.input(0, 0x4000, lx=1, ry=254, l2=12, r2=230, touch=[(3, 1000, 500)])
c.input(0)
c.input(1, 0x10000)
time.sleep(.10)
frames = [r.split() for r in server.rows() if r.startswith('FRAME')]
assert any(r[2:11] == ['11030d', '16384', '1', '128', '128', '254', '12', '230', '1'] for r in frames)
assert any(r[2] == '12030d' and r[3] == '65536' for r in frames)
assert not any(r[2] in ['13030d', '14030d'] and r[3] != '0' for r in frames)
assert any(r[2] == '11030d' and r[3] == '0' for r in frames[-30:])
print('PASS device-specific assignment, PS/Cross, axes/triggers/touch, button edges, pad isolation', flush=True)
time.sleep(.5)
assert c.request('status')['result']['pads'][1]['state'] == 'paused'
frames = [r.split() for r in server.rows() if r.startswith('FRAME')]
assert all(r[3] == '0' for r in frames[-12:]), 'stale input stayed pressed'
c.input(1, 0x20)
time.sleep(.04)
assert c.request('status')['result']['pads'][1]['state'] == 'select'
assert other.request('stop')['error']['code'] == 409
other.close()
assert not c.request('claim', [])['result']['pads'][0]['open']
assert len([r for r in server.rows() if r.startswith('REMOVE')]) == 4
print('PASS idle neutralization, recovery, release and stopping protection', flush=True)
assert c.request('claim', [0])['result']['pads'][0]['open']
c.input(0, 0x4000)
time.sleep(.03)
c.close()
time.sleep(.08)
c = server.c = Client()
assert c.request('status')['result']['pads'][0]['state'] == 'paused'
assert c.request('claim', [0])['result']['pads'][0]['mine']
time.sleep(1.9)
assert c.request('status')['result']['pads'][0]['state'] == 'free'
c.frame(json.dumps(dict(method='stop', params=[])))
assert server.p.wait(timeout=3) == 0
print('PASS disconnect releases buttons, reconnect grace and inactive-device cleanup', flush=True)
finally:
server.close()
server = Server(C4F_FAIL_ADD='1')
try:
assert server.c.request('claim', [0])['error']['code'] == 503
assert server.c.request('claim', [0])['error']['code'] == 503
assert server.c.request('status')['result']['pads'][0]['state'] == 'free'
server.c.frame(json.dumps(dict(method='stop', params=[])))
assert server.p.wait(timeout=3) == 0
print('PASS failed device creation is visible and retries do not accumulate orphan devices', flush=True)
finally:
server.close()
# AutoRun can start the payload before GoldHEN's klog server listens.
server = Server(C4F_TEST_KLOG_FD='-1')
try:
assert server.c.request('claim', [0])['error']['code'] == 503
assert 'KLOG none' in server.rows() and not any(r.startswith('ADD') for r in server.rows())
finally:
server.close()
server = Server(C4F_TEST_KLOG_FD='-1', C4F_TEST_KLOG_LATE='1')
try:
assert server.c.request('claim', [0])['result']['pads'][0]['mine']
assert any(r.startswith('KLOG ') and r != 'KLOG none' for r in server.rows())
assert any(r.startswith('ADD') for r in server.rows())
finally:
server.close()
# A klog source that closes is reopened for the next new controller.
server = Server(C4F_TEST_KLOG_LATE='1')
try:
os.close(server.log)
server.log = None
time.sleep(.1)
assert server.c.request('claim', [1])['result']['pads'][1]['mine']
rows = server.rows()
assert any(r.startswith('KLOG ') and r != 'KLOG none' for r in rows) and any(r.startswith('ADD') for r in rows)
print('PASS klog missing at start or closed later: reconnected on the next controller request', flush=True)
finally:
server.close()
if __name__ == '__main__':
main()
+66
View File
@@ -0,0 +1,66 @@
/* Local HTTP/WebSocket integration harness. No console access. */
#include <stdio.h>
#include <stdarg.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
#include "c4f_net.h"
#include "c4f_vda.h"
#include "c4f_web.h"
static unsigned nextId = 0x11030d;
static void (*waitFn)(void *);
static void *waitContext;
static int klogSource = -1;
void c4fLog(const char *fmt, ...) { va_list ap; va_start(ap, fmt); vprintf(fmt, ap); va_end(ap); }
void c4fNotify(const char *fmt, ...) { (void)fmt; }
void c4fVdaSetWaitCallback(void (*fn)(void *), void *ctx) { waitFn = fn; waitContext = ctx; }
void c4fKlogDrain(int fd) { (void)fd; }
int c4fKlogSelfTest(int fd) { (void)fd; return getenv("C4F_TEST_KLOG_BUSY") ? -1 : 0; }
/* A klog source that only turns up later (AutoRun before GoldHEN's klog server). */
int c4fKlogOpenDevice(void)
{
int fds[2];
if (getenv("C4F_TEST_KLOG_BUSY")) { printf("KLOG none\n"); return -1; }
if (klogSource >= 0) { int fd = dup(klogSource); printf("KLOG %d\n", fd); return fd; }
if (!getenv("C4F_TEST_KLOG_LATE") || pipe(fds)) { printf("KLOG none\n"); return -1; }
fcntl(fds[0], F_SETFL, O_NONBLOCK);
printf("KLOG %d\n", fds[0]);
return fds[0];
}
int c4fVirtualPadAddAs(C4fVirtualPad *p, int32_t user, int32_t vdaUser, int fd)
{
(void)fd;
for (int i = 0; i < 3; i++) { if (waitFn) waitFn(waitContext); usleep(20000); }
if (getenv("C4F_FAIL_ADD")) return -1;
memset(p, 0, sizeof(*p));
p->handle = nextId; p->deviceId = nextId; nextId += 0x10000;
p->userId = user; p->vdaUserId = vdaUser; p->owned = 1;
printf("ADD %x %d\n", p->handle, vdaUser);
return 0;
}
void c4fVirtualPadRemove(C4fVirtualPad *p) { printf("REMOVE %x\n", p->handle); p->owned = 0; }
void c4fPadDataNeutral(ScePadData *p)
{
memset(p, 0, sizeof(*p)); p->lx=p->ly=p->rx=p->ry=128; p->connected=1; p->quat[3]=1;
}
int32_t c4fVirtualPadInsert(const C4fVirtualPad *p, const ScePadData *d)
{
printf("FRAME %llu %x %u %u %u %u %u %u %u %u\n", (unsigned long long)c4fTimeMs(),
p->handle, d->buttons, d->lx, d->ly, d->rx, d->ry, d->l2, d->r2, d->touchData.fingers);
return getenv("C4F_FAIL_INPUT") && d->buttons ? -5 : 0;
}
int main(void)
{
const char *fd = getenv("C4F_TEST_KLOG_FD");
int logs[2];
setvbuf(stdout, NULL, _IONBF, 0);
if (fd) { klogSource = atoi(fd); return c4fWebRun(-1) != 0; }
if (pipe(logs)) return 1;
fcntl(logs[0], F_SETFL, O_NONBLOCK);
klogSource = logs[0];
int result = c4fWebRun(-1);
close(logs[1]);
return result != 0;
}
+2 -1
View File
@@ -9,7 +9,8 @@ try {
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'
if ($LASTEXITCODE -ne 0) { throw 'Launcher package build failed.' }
Get-Item -LiteralPath (Join-Path $projectRoot 'build/Control4Free-0.2.2.pkg') | Select-Object FullName,Length
$version = (Get-Content -LiteralPath (Join-Path $projectRoot 'VERSION') -Raw).Trim()
Get-Item -LiteralPath (Join-Path $projectRoot "build/Control4Free-$version.pkg") | Select-Object FullName,Length
} finally {
Pop-Location
}
+9 -4
View File
@@ -16,6 +16,10 @@ FONTS = OUT / 'fonts.c'
TITLE_ID = 'CFRE00001' # 4 letters + 5 digits, the usual title ID format
CONTENT_ID = f'IV0000-{TITLE_ID}_00-CONTROL4FREE0000'
assert len(CONTENT_ID) == 36
VERSION = (ROOT / 'VERSION').read_text().strip()
# param.sfo wants MM.mm; the patch level is not part of an app version.
major, minor = VERSION.split('.')[:2]
SFO_VERSION = f'{int(major):02d}.{int(minor):02d}'
def embed_fonts():
@@ -31,7 +35,8 @@ def build_art_tool():
"""Host build of the launcher's drawing code, for the icon and previews."""
embed_fonts()
flags = ['clang-18', '-std=gnu11', '-O2', '-D_DEFAULT_SOURCE', f'-I{ROOT}/launcher',
f'-I{ROOT}/vendor/stb', f'-I{ROOT}/vendor/qrcodegen']
f'-I{ROOT}/vendor/stb', f'-I{ROOT}/vendor/qrcodegen',
f'-DC4F_LAUNCHER_VERSION="{VERSION}"']
stb = OUT / 'stb_truetype-host.o'
subprocess.run([*flags, '-w', '-c', str(ROOT / 'launcher/stb_truetype.c'), '-o', str(stb)], check=True)
subprocess.run([*flags, '-Wall', '-Wextra', '-Werror', str(ROOT / 'tools/launcher_art.c'),
@@ -93,11 +98,11 @@ def main():
sfo = 'sce_sys/param.sfo'
run(pkgtool, 'sfo_new', sfo)
entries = {
'APP_TYPE': ('Integer', 4, '1'), 'APP_VER': ('Utf8', 8, '00.22'),
'APP_TYPE': ('Integer', 4, '1'), 'APP_VER': ('Utf8', 8, SFO_VERSION),
'ATTRIBUTE': ('Integer', 4, '0'), 'CATEGORY': ('Utf8', 4, 'gd'),
'CONTENT_ID': ('Utf8', 48, CONTENT_ID), 'DOWNLOAD_DATA_SIZE': ('Integer', 4, '0'),
'SYSTEM_VER': ('Integer', 4, '0'), 'TITLE': ('Utf8', 128, 'Control4Free'),
'TITLE_ID': ('Utf8', 12, TITLE_ID), 'VERSION': ('Utf8', 8, '00.22'),
'TITLE_ID': ('Utf8', 12, TITLE_ID), 'VERSION': ('Utf8', 8, SFO_VERSION),
}
for key, (kind, size, value) in entries.items():
run(pkgtool, 'sfo_setentry', sfo, key, '--type', kind, '--maxsize', size, '--value', value)
@@ -123,7 +128,7 @@ def main():
shutil.copyfile(project, OUT / 'launcher.gp4')
run(pkgtool, 'pkg_build', project, OUT)
generated = OUT / (CONTENT_ID + '.pkg')
target = ROOT / 'build/Control4Free-0.2.2.pkg'
target = ROOT / f'build/Control4Free-{VERSION}.pkg'
shutil.copyfile(generated, target)
print(f'Built {target} ({target.stat().st_size:,} bytes)', flush=True)