diff --git a/Makefile b/Makefile index dcc1a52..8b09cdb 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/README.md b/README.md index 423f663..b3db6a4 100644 --- a/README.md +++ b/README.md @@ -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-.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-.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 diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..3eefcb9 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +1.0.0 diff --git a/client/index.html b/client/index.html index 8fc84f9..c75f509 100644 --- a/client/index.html +++ b/client/index.html @@ -957,7 +957,7 @@
- +
@@ -992,7 +992,7 @@
-

Controllers on this device

+

Gamepads on this device

    @@ -1006,7 +1006,7 @@ @@ -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', ''); 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 = ''; diff --git a/include/c4f_log.h b/include/c4f_log.h index df3f444..f8fdd3d 100644 --- a/include/c4f_log.h +++ b/include/c4f_log.h @@ -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); diff --git a/include/c4f_sce.h b/include/c4f_sce.h index b2217ee..be28f0c 100644 --- a/include/c4f_sce.h +++ b/include/c4f_sce.h @@ -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 */ diff --git a/include/c4f_server.h b/include/c4f_server.h deleted file mode 100644 index fe14fb8..0000000 --- a/include/c4f_server.h +++ /dev/null @@ -1,16 +0,0 @@ -/* Control4Free -- the resident command server (see src/server.c). */ - -#ifndef C4F_SERVER_H -#define C4F_SERVER_H - -#include - -/* 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 */ diff --git a/include/c4f_vda.h b/include/c4f_vda.h index f960e71..1745850 100644 --- a/include/c4f_vda.h +++ b/include/c4f_vda.h @@ -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); diff --git a/include/c4f_version.h b/include/c4f_version.h new file mode 100644 index 0000000..f4fe857 --- /dev/null +++ b/include/c4f_version.h @@ -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 diff --git a/launcher/Makefile b/launcher/Makefile index 5b40638..d782581 100644 --- a/launcher/Makefile +++ b/launcher/Makefile @@ -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 diff --git a/launcher/screen.h b/launcher/screen.h index b2e7fa1..548016d 100644 --- a/launcher/screen.h +++ b/launcher/screen.h @@ -1,7 +1,10 @@ #ifndef C4F_LAUNCHER_SCREEN_H #define C4F_LAUNCHER_SCREEN_H #include -#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 { diff --git a/src/log.c b/src/log.c index 781ca3a..27bea55 100644 --- a/src/log.c +++ b/src/log.c @@ -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) diff --git a/src/main.c b/src/main.c index 7394b22..a4236ea 100644 --- a/src/main.c +++ b/src/main.c @@ -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 -#include #include +#include #include #include +#include #include #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); } diff --git a/src/server.c b/src/server.c deleted file mode 100644 index c19e696..0000000 --- a/src/server.c +++ /dev/null @@ -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 [ms] hold those button bits - * stick [ms] hold stick positions (0-255, 128 centre) - * hold neutral for that long (keeps the pad alive) - * sweep [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 -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#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 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 [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 [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; -} diff --git a/src/vda.c b/src/vda.c index 9c1411e..aaf1c50 100644 --- a/src/vda.c +++ b/src/vda.c @@ -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 #include #include #include #include -#include #include #include #include -#include -#include #include -#include #include #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; } diff --git a/src/web.c b/src/web.c index 2f105cf..8e062ac 100644 --- a/src/web.c +++ b/src/web.c @@ -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 diff --git a/tests/include/ps4/kernel.h b/tests/include/ps4/kernel.h new file mode 100644 index 0000000..afa0ff4 --- /dev/null +++ b/tests/include/ps4/kernel.h @@ -0,0 +1,4 @@ +#include +#include +uint64_t kernel_get_ucred_authid(pid_t); +int kernel_set_ucred_authid(pid_t, uint64_t); diff --git a/tests/include/ps4/klog.h b/tests/include/ps4/klog.h new file mode 100644 index 0000000..0f911a5 --- /dev/null +++ b/tests/include/ps4/klog.h @@ -0,0 +1 @@ +int klog_printf(const char *, ...); diff --git a/tests/klog_test.c b/tests/klog_test.c new file mode 100644 index 0000000..21dc290 --- /dev/null +++ b/tests/klog_test.c @@ -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 +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#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); +} diff --git a/tests/launcher_cli.c b/tests/launcher_cli.c new file mode 100644 index 0000000..85e7797 --- /dev/null +++ b/tests/launcher_cli.c @@ -0,0 +1,41 @@ +#include "autorun.h" +#include "service.h" +#include +#include +#include +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 [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; +} diff --git a/tests/log_test.c b/tests/log_test.c new file mode 100644 index 0000000..eba8cc0 --- /dev/null +++ b/tests/log_test.c @@ -0,0 +1,36 @@ +#include +#include +#include +#include +#include +#include +#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"); +} diff --git a/tests/recovery_faults.c b/tests/recovery_faults.c new file mode 100644 index 0000000..7771f05 --- /dev/null +++ b/tests/recovery_faults.c @@ -0,0 +1,37 @@ +/* Link-time syscall fault injection; never linked into the payload. */ +#include +#include +#include +#include +#include +#include +#include + +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); +} diff --git a/tests/run.py b/tests/run.py new file mode 100644 index 0000000..b38ec93 --- /dev/null +++ b/tests/run.py @@ -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()) diff --git a/tests/test_launcher.py b/tests/test_launcher.py new file mode 100644 index 0000000..cf6d554 --- /dev/null +++ b/tests/test_launcher.py @@ -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() diff --git a/tests/test_logging.py b/tests/test_logging.py new file mode 100644 index 0000000..eaf9e08 --- /dev/null +++ b/tests/test_logging.py @@ -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() diff --git a/tests/test_recovery.py b/tests/test_recovery.py new file mode 100644 index 0000000..c25929c --- /dev/null +++ b/tests/test_recovery.py @@ -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() diff --git a/tests/test_web.py b/tests/test_web.py new file mode 100644 index 0000000..2249961 --- /dev/null +++ b/tests/test_web.py @@ -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() diff --git a/tests/web_stub.c b/tests/web_stub.c new file mode 100644 index 0000000..4ef5e9b --- /dev/null +++ b/tests/web_stub.c @@ -0,0 +1,66 @@ +/* Local HTTP/WebSocket integration harness. No console access. */ +#include +#include +#include +#include +#include +#include +#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; +} diff --git a/tools/build-launcher.ps1 b/tools/build-launcher.ps1 index b08d229..e91770e 100644 --- a/tools/build-launcher.ps1 +++ b/tools/build-launcher.ps1 @@ -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 } diff --git a/tools/package_launcher.py b/tools/package_launcher.py index 6fd335e..78db517 100644 --- a/tools/package_launcher.py +++ b/tools/package_launcher.py @@ -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)