mirror of
https://github.com/MoHadiShibli/Control4Free.git
synced 2026-10-06 08:00:26 +02:00
Cleanup: drop the diagnostic spike, one version source, public tests
The staged spike that worked out the VDA call order is no longer part of the build. It is kept at tag spike-final. - src/main.c is the service only: instance lock, credentials, MBus and pad init, then the web service. src/server.c and include/c4f_server.h are gone with stage 8, and so are the stage-only helpers in src/vda.c: the MBus bind and holds, the assignment and button-map probes, and the GoldHEN klog stream path. The service reads /dev/klog, which is what works. - VERSION holds the only version number. Both Makefiles pass it in, the packager reads it for param.sfo and the package name, and the build and deploy scripts read it for the file they look for. - The payload's log is /data/control4free/control4free.log, not spike.log. - The page drops the protocol 1 branches: the payload has always answered 2. A console slot is a "controller" and a pad plugged into the phone or PC is a "gamepad", so the two no longer read as the same word. - The host tests move to tests/, with tests/run.py to run all four suites. They build the real sources with the PS4 calls stubbed, so they need no console. klog_test now covers the device path only, and asserts that nothing ever connects to GoldHEN's klog server.
This commit is contained in:
1 parent
f008db6e16
commit
4331ec79fd
30 files changed
+991
-1469
No files matched your search
@@ -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
|
||||
@@ -7,17 +7,17 @@ the home screen, at sign-in and in games.
|
||||
|
||||
**Status: browser control, native user sign-in, gameplay, the launcher,
|
||||
GoldHEN AutoRun and recovery after rest mode have been confirmed on the
|
||||
development console.** Version 0.2.2 reads the kernel log directly again for
|
||||
sign-in; it still needs its console check. Four virtual slots are exposed; the
|
||||
console's device limits apply.
|
||||
development console.** Four virtual slots are exposed; the console's device
|
||||
limits apply.
|
||||
|
||||
## Requirements
|
||||
|
||||
- A PS4 running GoldHEN. Development console: firmware 10.01, GoldHEN 2.4b18.10.
|
||||
Starting Control4Free from GoldHEN itself (LaunchPad or AutoRun) needs 2.4b18.10
|
||||
or later; the launcher app and PC sending need PayLoader.
|
||||
- GoldHEN's klog server, which 2.4b18 always runs. Device creation relies on
|
||||
kernel-log events to recover the virtual device's handle.
|
||||
- Nothing connected to GoldHEN's klog viewer while you add a controller.
|
||||
Control4Free reads the kernel log itself to recover a new device's handle, and
|
||||
the log has a single reader.
|
||||
- A phone or PC on the same network.
|
||||
- Turn off other GoldHEN controller plugins in games you play with Control4Free. A
|
||||
plugin that takes over a signed-in user's controller can stop games from reading
|
||||
@@ -25,7 +25,7 @@ console's device limits apply.
|
||||
|
||||
## Install
|
||||
|
||||
1. Copy `build/Control4Free-0.2.2.pkg` to a USB drive (or to `/data/pkg/` over
|
||||
1. Copy `build/Control4Free-<version>.pkg` to a USB drive (or to `/data/pkg/` over
|
||||
GoldHEN's FTP server) and install it with GoldHEN's Package Installer.
|
||||
2. Open **Control4Free** from the home screen and press **Cross** once. The app
|
||||
adds Control4Free to GoldHEN's AutoRun (GoldHEN 2.4b18.10 or later), so GoldHEN
|
||||
@@ -63,7 +63,7 @@ When you update Control4Free this way, replace `/data/payloads/control4free.elf`
|
||||
controller page's menu, or restart the PS4. That old version has no launcher API,
|
||||
so the app will not start a second copy over it.
|
||||
|
||||
**Rest mode (since 0.2.1):** the service closes stale connections and rebuilds its
|
||||
**Rest mode:** the service closes stale connections and rebuilds its
|
||||
listener after socket failures or a long pause. Queued input is discarded, and
|
||||
disconnected controllers report neutral input. Reopen the controller page and
|
||||
select your controller after waking; an unused controller is removed after the
|
||||
@@ -79,9 +79,9 @@ client at a time and, after one leaves, can go minutes without serving the next.
|
||||
If a klog viewer is connected to GoldHEN when you add a controller, the page
|
||||
says so; close the viewer and try again.
|
||||
|
||||
Diagnostics are saved at `/data/control4free/spike.log`, with elapsed timestamps
|
||||
Diagnostics are saved at `/data/control4free/control4free.log`, with elapsed timestamps
|
||||
such as `[c4f] [+00:01:23.456]`; the PS4's calendar setting is not used. Starting
|
||||
a new instance preserves the last run in `spike.log.previous`. A heartbeat every
|
||||
a new instance preserves the last run in `control4free.log.previous`. A heartbeat every
|
||||
minute and explicit network recovery messages help locate any remaining hang.
|
||||
|
||||
## Use
|
||||
@@ -105,7 +105,7 @@ controllers one at a time. Touch and keyboard can also operate the selected slot
|
||||
|
||||
The corner menu provides local layout/keyboard preferences, **Disconnect
|
||||
controller**, and **Stop Control4Free**. PS and Share send actual controller
|
||||
buttons. The PS4 assigns users; there are no pad or user settings on the page.
|
||||
buttons. The PS4 assigns users; there are no user settings on the page.
|
||||
|
||||
Some browsers restrict Gamepad API access on an HTTP page. If the page reports
|
||||
that restriction, try opening a saved copy of `client/index.html` locally and
|
||||
@@ -140,9 +140,8 @@ docker build -t control4free-build docker/
|
||||
docker run --rm -v "$PWD:/src" -w /src control4free-build make
|
||||
```
|
||||
|
||||
Output: `build/control4free.elf`, with the compressed page embedded. `C4F_STAGE=0`
|
||||
is the default browser build. Stages 1-8 remain available as diagnostics, with
|
||||
separate `control4free-stageN.elf` outputs.
|
||||
Output: `build/control4free.elf`, with the compressed page embedded. The version
|
||||
comes from the `VERSION` file at the top of the repository.
|
||||
|
||||
To build the installable launcher after the payload image is available:
|
||||
|
||||
@@ -151,17 +150,24 @@ docker build -t control4free-launcher-build -f docker/Dockerfile.launcher docker
|
||||
docker run --rm -v "$PWD:/src" -w /src control4free-launcher-build bash -lc 'make && make -C launcher'
|
||||
```
|
||||
|
||||
Output: `build/Control4Free-0.2.2.pkg` (title ID `CFRE00001`). On Windows,
|
||||
The host test suites build the real sources with the PS4 calls stubbed, so they
|
||||
need no console:
|
||||
|
||||
```sh
|
||||
docker run --rm --network none -v "$PWD:/src" -w /src control4free-launcher-build python3 -B tests/run.py
|
||||
```
|
||||
|
||||
Output: `build/Control4Free-<version>.pkg` (title ID `CFRE00001`). On Windows,
|
||||
`tools/build-launcher.ps1` runs both image builds and the package build. It does
|
||||
not send anything to a console. Packaging uses OpenOrbis and LibOrbisPkg. The
|
||||
launcher draws its screen and its icon in software, in the controller page's style.
|
||||
|
||||
## Current limits
|
||||
|
||||
Rumble, lightbar feedback and motion input are not implemented. Rest-mode
|
||||
recovery and multiple native user sessions are unverified. User-assignment status
|
||||
comes from kernel-log events and may lag or miss an event; check the TV.
|
||||
Logs are written to `/data/control4free/spike.log` on the console.
|
||||
Rumble, lightbar feedback and motion input are not implemented. Multiple native
|
||||
user sessions are unverified. User-assignment status comes from kernel-log events
|
||||
and may lag or miss an event; check the TV.
|
||||
Logs are written to `/data/control4free/control4free.log` on the console.
|
||||
|
||||
## Credits and license
|
||||
|
||||
|
||||
+29
-31
@@ -957,7 +957,7 @@
|
||||
<div class="sparkles"></div>
|
||||
</div>
|
||||
|
||||
<!-- Home: pick a pad -->
|
||||
<!-- Home: pick a controller -->
|
||||
<div class="home" id="home">
|
||||
<header class="topbar">
|
||||
<div class="tb-group">
|
||||
@@ -992,7 +992,7 @@
|
||||
</section>
|
||||
|
||||
<section class="panel" aria-labelledby="gpTitle" id="gamepadSection">
|
||||
<h2 id="gpTitle">Controllers on this device</h2>
|
||||
<h2 id="gpTitle">Gamepads on this device</h2>
|
||||
<p class="hint" id="gamepadHint"></p>
|
||||
<ul class="gp-list" id="gamepadList"></ul>
|
||||
</section>
|
||||
@@ -1006,7 +1006,7 @@
|
||||
|
||||
<!-- Controller: DualShock 4 buttons, each placed from DEFAULT_LAYOUT in the script -->
|
||||
<div class="pad-view" id="padView" data-conn="connecting" hidden>
|
||||
<button class="corner-btn left" id="backBtn" type="button" aria-label="Back to the pads">
|
||||
<button class="corner-btn left" id="backBtn" type="button" aria-label="Back to the controllers">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 18l-6-6 6-6"/></svg>
|
||||
</button>
|
||||
<button class="corner-btn right" id="fsBtn" type="button" aria-label="Full screen">
|
||||
@@ -1107,7 +1107,7 @@
|
||||
|
||||
<section data-page="keyboard" hidden>
|
||||
<h2 class="page-title">Keyboard</h2>
|
||||
<p class="hint">Click a key to change it, then press the new key. Keys drive the pad opened on this page.</p>
|
||||
<p class="hint">Click a key to change it, then press the new key. Keys drive the controller opened on this page.</p>
|
||||
<div class="rows" id="keyRows"></div>
|
||||
<div class="actions" style="margin-top:14px"><button class="btn" type="button" id="resetKeys">Reset keys</button></div>
|
||||
</section>
|
||||
@@ -1174,7 +1174,7 @@
|
||||
|
||||
// Settings of this device (the console's settings live on the console)
|
||||
const DEVICE_SETTINGS = [
|
||||
['keepAwake', 'Keep the screen on', 'While this page controls a pad, the screen doesn\'t turn off.', true],
|
||||
['keepAwake', 'Keep the screen on', 'While this page drives a controller, the screen doesn\'t turn off.', true],
|
||||
['haptics', 'Vibration', 'Buzz this device when you press a button or the game rumbles (Android).', 'vibrate' in navigator],
|
||||
['floating', 'Floating sticks', 'A stick starts wherever your thumb lands inside its area.', false],
|
||||
['tapClick', 'Tap the touchpad to click', 'A quick tap presses the touchpad button. Hold still to keep it pressed.', true],
|
||||
@@ -1213,13 +1213,13 @@
|
||||
// ---------------------------------------------------------------- connection
|
||||
|
||||
const conn = {
|
||||
ws: null, state: 'connecting', address: '', protocol: 1, version: '', nextId: 1,
|
||||
ws: null, state: 'connecting', address: '', version: '', nextId: 1,
|
||||
pending: new Map(), retry: 0, retryTimer: 0, everOpened: false,
|
||||
};
|
||||
|
||||
const status = Array.from({ length: PADS }, (_, i) => ({
|
||||
pad: i, open: false, enabled: true, real: false, logged: false, joined: false, connected: false,
|
||||
full: false, shared: false, player: 0, uid: '', cfg: '', name: 'Pad ' + (i + 1),
|
||||
full: false, shared: false, player: 0, uid: '', cfg: '', name: 'Controller ' + (i + 1),
|
||||
color: FALLBACK_COLORS[i], clients: 0, known: false,
|
||||
}));
|
||||
|
||||
@@ -1270,10 +1270,9 @@
|
||||
$('#addressForm').hidden = servedByConsole;
|
||||
$('#addressHint').classList.remove('error');
|
||||
call('info', []).then(info => {
|
||||
conn.protocol = info.protocol || 1;
|
||||
conn.version = info.version || '';
|
||||
$('#version').textContent = 'Control4Free' + (conn.version ? ' ' + conn.version : '');
|
||||
if (conn.protocol >= 2) sendClaim(true); else renderPads();
|
||||
sendClaim(true);
|
||||
}).catch(() => {});
|
||||
resendAll();
|
||||
};
|
||||
@@ -1433,7 +1432,7 @@
|
||||
let lastClaim = '';
|
||||
function sendClaim(force) {
|
||||
keepAwake.update();
|
||||
if (conn.protocol < 2 || conn.state !== 'open') return;
|
||||
if (conn.state !== 'open') return;
|
||||
const pads = claimedPads();
|
||||
const key = pads.join(',');
|
||||
if (!force && key === lastClaim) return;
|
||||
@@ -1475,11 +1474,10 @@
|
||||
lastSent.fill('');
|
||||
}
|
||||
|
||||
// Leaving a pad: protocol 2 consoles release it when the claim changes (input would claim it again),
|
||||
// protocol 1 consoles need a neutral state
|
||||
// The console releases a controller when the claim changes; sending input
|
||||
// here would only claim it again. Just forget what was last sent.
|
||||
function releasePad(pad) {
|
||||
if (pad < 0) return;
|
||||
if (conn.protocol < 2) send({ method: 'u', params: [pad, 0, 128, 128, 128, 128, 0, 0, 0] });
|
||||
lastSent[pad] = '';
|
||||
}
|
||||
|
||||
@@ -2126,16 +2124,16 @@
|
||||
seen.add(gp.index);
|
||||
let src = gamepadSources.get(gp.index);
|
||||
if (!src) {
|
||||
const saved = undefined;
|
||||
// A new controller drives the pad opened on this page; elsewhere it waits until you pick a pad
|
||||
const pad = saved !== undefined ? saved : (primaryPad >= 0 && ![...gamepadSources.values()].some(s => s.pad === primaryPad) ? primaryPad : -1);
|
||||
// A new gamepad drives the controller opened on this page; elsewhere
|
||||
// it waits until one is picked for it.
|
||||
const pad = primaryPad >= 0 && ![...gamepadSources.values()].some(s => s.pad === primaryPad) ? primaryPad : -1;
|
||||
src = blankSource(pad);
|
||||
src.gp = gp;
|
||||
src.activeAt = 0;
|
||||
gamepadSources.set(gp.index, src);
|
||||
renderGamepads();
|
||||
sendClaim();
|
||||
toast(pad >= 0 ? 'Controller ' + (gp.index + 1) + ' plays as Pad ' + (pad + 1) : 'Controller ' + (gp.index + 1) + ' connected. Pick its pad on the home screen.');
|
||||
toast(pad >= 0 ? 'Gamepad ' + (gp.index + 1) + ' plays as Controller ' + (pad + 1) : 'Gamepad ' + (gp.index + 1) + ' connected. Pick its controller on the home screen.');
|
||||
}
|
||||
src.gp = gp;
|
||||
let buttons = 0;
|
||||
@@ -2272,8 +2270,8 @@
|
||||
head.appendChild(el('span', 'activity'));
|
||||
tile.appendChild(head);
|
||||
const body = el('span');
|
||||
body.appendChild(el('div', 'tile-name', s.known ? s.name : 'Pad ' + (i + 1)));
|
||||
body.appendChild(el('div', 'tile-meta', padMeta(s) || (s.known ? '' : conn.protocol < 2 && conn.version ? 'Waiting for controller status' : '')));
|
||||
body.appendChild(el('div', 'tile-name', s.known ? s.name : 'Controller ' + (i + 1)));
|
||||
body.appendChild(el('div', 'tile-meta', padMeta(s) || ''));
|
||||
tile.appendChild(body);
|
||||
const states = el('span', 'tile-state');
|
||||
if (state === 'ready') states.appendChild(stateItem('live', 'Connected'));
|
||||
@@ -2297,26 +2295,26 @@
|
||||
list.textContent = '';
|
||||
if (!gamepadsAvailable) {
|
||||
$('#gamepadHint').textContent = window.isSecureContext
|
||||
? 'This browser does not support controllers.'
|
||||
: 'This browser only allows controllers on secure pages. Save this page to your device, open the saved file, and enter the console address.';
|
||||
? 'This browser does not support gamepads.'
|
||||
: 'This browser only allows gamepads on secure pages. Save this page to your device, open the saved file, and enter the console address.';
|
||||
return;
|
||||
}
|
||||
$('#gamepadHint').textContent = gamepadSources.size
|
||||
? 'Pick the pad each controller plays as. A row lights up while you use its controller. Keep this window in front: browsers only read controllers on the active window.'
|
||||
: 'Connect controllers to this device and press a button on each one. Each controller can play as a different pad.';
|
||||
? 'Pick the controller each gamepad plays as. A row lights up while you use it. Keep this window in front: browsers only read gamepads on the active window.'
|
||||
: 'Connect gamepads to this device and press a button on each one. Each one can play as a different controller.';
|
||||
for (const [index, src] of gamepadSources) {
|
||||
const row = el('li', 'gp-row');
|
||||
row.dataset.index = index;
|
||||
row.insertAdjacentHTML('afterbegin', '<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M7 7h10a5 5 0 0 1 4.8 6.4l-1 3.4a2.6 2.6 0 0 1-4.4 1.2L14.4 16H9.6l-2 2a2.6 2.6 0 0 1-4.4-1.2l-1-3.4A5 5 0 0 1 7 7z"/></svg>');
|
||||
const text = el('div');
|
||||
const name = (src.gp && src.gp.id || 'Controller').replace(/\s*\(.*?\)\s*/g, ' ').trim();
|
||||
text.appendChild(el('div', 'gp-name', name || 'Controller'));
|
||||
const name = (src.gp && src.gp.id || 'Gamepad').replace(/\s*\(.*?\)\s*/g, ' ').trim();
|
||||
text.appendChild(el('div', 'gp-name', name || 'Gamepad'));
|
||||
const mapped = src.gp && src.gp.mapping === 'standard';
|
||||
text.appendChild(el('div', 'gp-sub', 'Controller ' + (index + 1) + (mapped ? '' : ' · buttons may be mixed up')));
|
||||
text.appendChild(el('div', 'gp-sub', 'Gamepad ' + (index + 1) + (mapped ? '' : ' · buttons may be mixed up')));
|
||||
row.appendChild(text);
|
||||
const seg = el('div', 'seg');
|
||||
seg.setAttribute('role', 'radiogroup');
|
||||
seg.setAttribute('aria-label', 'Pad for controller ' + (index + 1));
|
||||
seg.setAttribute('aria-label', 'Controller for gamepad ' + (index + 1));
|
||||
for (let pad = -1; pad < PADS; pad++) {
|
||||
const b = el('button', '', pad < 0 ? 'Off' : String(pad + 1));
|
||||
b.type = 'button';
|
||||
@@ -2324,7 +2322,7 @@
|
||||
b.setAttribute('aria-checked', String(src.pad === pad));
|
||||
if (pad >= 0) {
|
||||
b.style.setProperty('--pc', css(status[pad].color));
|
||||
b.title = 'Pad ' + (pad + 1) + ' · ' + status[pad].name;
|
||||
b.title = 'Controller ' + (pad + 1) + ' · ' + status[pad].name;
|
||||
}
|
||||
b.addEventListener('click', () => setGamepadPad(index, pad));
|
||||
seg.appendChild(b);
|
||||
@@ -2351,7 +2349,7 @@
|
||||
const s = status[primaryPad];
|
||||
document.documentElement.style.setProperty('--accent', css(s.color));
|
||||
const label = $('#padLabel');
|
||||
label.textContent = 'Pad ' + (primaryPad + 1) + (s.known && s.name ? ' · ' + s.name : '');
|
||||
label.textContent = 'Controller ' + (primaryPad + 1) + (s.known && s.name ? ' · ' + s.name : '');
|
||||
const state = padState(s);
|
||||
const extra = conn.state !== 'open' ? $('#connText').textContent
|
||||
: state === 'ready' ? 'Signed in on the PS4'
|
||||
@@ -2396,7 +2394,7 @@
|
||||
function openPad(pad) {
|
||||
const s = status[pad];
|
||||
if (s.known && !s.enabled) {
|
||||
toast('Pad ' + (pad + 1) + ' is turned off in Settings');
|
||||
toast('Controller ' + (pad + 1) + ' is turned off in Settings');
|
||||
return;
|
||||
}
|
||||
const others = s.clients - (claimedPads().includes(pad) ? 1 : 0);
|
||||
@@ -2419,7 +2417,7 @@
|
||||
$('#settingsView').hidden = true;
|
||||
padView.hidden = false;
|
||||
document.body.classList.add('in-pad');
|
||||
document.title = 'Pad ' + (pad + 1) + ' · Control4Free';
|
||||
document.title = 'Controller ' + (pad + 1) + ' · Control4Free';
|
||||
history.replaceState(null, '', updateQuery('pad', String(pad + 1)));
|
||||
shownButtons = -1;
|
||||
shownStick.L = shownStick.R = '';
|
||||
|
||||
+2
-2
@@ -3,7 +3,7 @@
|
||||
* Everything goes to klog (GoldHEN's klog server, port 3232) and is mirrored to a
|
||||
* file on the console, so a run can be read back over FTP even if the klog
|
||||
* connection dropped. Every line has [c4f] and elapsed HH:MM:SS.mmm, independent
|
||||
* of the console's calendar. The previous run is kept in spike.log.previous.
|
||||
* of the console's calendar. The previous run is kept in control4free.log.previous.
|
||||
*
|
||||
* c4fLogSetKlog(0) turns the klog half off. The /dev/klog scanner needs that:
|
||||
* while it is reading, its own writes would come back round and bury the kernel
|
||||
@@ -14,7 +14,7 @@
|
||||
#define C4F_LOG_H
|
||||
|
||||
#define C4F_LOG_DIR "/data/control4free"
|
||||
#define C4F_LOG_PATH C4F_LOG_DIR "/spike.log"
|
||||
#define C4F_LOG_PATH C4F_LOG_DIR "/control4free.log"
|
||||
|
||||
void c4fLogOpen(void);
|
||||
void c4fLogClose(void);
|
||||
|
||||
+9
-41
@@ -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 */
|
||||
@@ -1,16 +0,0 @@
|
||||
/* Control4Free -- the resident command server (see src/server.c). */
|
||||
|
||||
#ifndef C4F_SERVER_H
|
||||
#define C4F_SERVER_H
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
/* Its own port, so experiments no longer go through GoldHEN's PayLoader. */
|
||||
#define C4F_SERVER_PORT 4264
|
||||
|
||||
/* Listens, runs commands, and returns when told to quit or when idle too long.
|
||||
* `klogFd` is the kernel-log reader new pads are found through. Any pad still
|
||||
* alive is removed before it returns. */
|
||||
int c4fServerRun(int32_t realUserId, int klogFd);
|
||||
|
||||
#endif /* C4F_SERVER_H */
|
||||
+11
-60
@@ -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);
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
#ifndef C4F_VERSION_H
|
||||
#define C4F_VERSION_H
|
||||
/* Set by the Makefile from the VERSION file; "dev" for test builds. */
|
||||
#ifndef C4F_VERSION
|
||||
#define C4F_VERSION "dev"
|
||||
#endif
|
||||
#endif
|
||||
+2
-1
@@ -2,12 +2,13 @@ ifndef OO_PS4_TOOLCHAIN
|
||||
$(error OO_PS4_TOOLCHAIN is undefined; use the launcher Docker image)
|
||||
endif
|
||||
SDK := $(OO_PS4_TOOLCHAIN)
|
||||
VERSION := $(shell cat ../VERSION)
|
||||
OUT := ../build/launcher
|
||||
PKGDIR := $(OUT)/pkg
|
||||
CC := clang-18
|
||||
CXX := clang++-18
|
||||
LD := ld.lld-18
|
||||
CFLAGS := --target=x86_64-pc-freebsd12-elf -fPIC -funwind-tables -O2 -Wall -Wextra -Werror -MMD -MP -D_DEFAULT_SOURCE -isysroot $(SDK) -isystem $(SDK)/include -I../vendor/jsmn -I../vendor/qrcodegen -I../vendor/stb
|
||||
CFLAGS := --target=x86_64-pc-freebsd12-elf -fPIC -funwind-tables -O2 -Wall -Wextra -Werror -MMD -MP -D_DEFAULT_SOURCE -isysroot $(SDK) -isystem $(SDK)/include -I../vendor/jsmn -I../vendor/qrcodegen -I../vendor/stb -DC4F_LAUNCHER_VERSION='"$(VERSION)"'
|
||||
OBJECTS := $(OUT)/main.o $(OUT)/screen.o $(OUT)/draw.o $(OUT)/service.o $(OUT)/autorun.o $(OUT)/sandbox.o $(OUT)/logo.o $(OUT)/qrcodegen.o $(OUT)/stb_truetype.o $(OUT)/fonts.o
|
||||
LIBS := -lc -lkernel -lSceVideoOut -lScePad -lSceUserService -lSceSystemService -lSceNetCtl
|
||||
FONTS := ../vendor/roboto/Roboto-Light.ttf ../vendor/roboto/Roboto-Regular.ttf
|
||||
|
||||
+4
-1
@@ -1,7 +1,10 @@
|
||||
#ifndef C4F_LAUNCHER_SCREEN_H
|
||||
#define C4F_LAUNCHER_SCREEN_H
|
||||
#include <stdint.h>
|
||||
#define C4F_LAUNCHER_VERSION "0.2.2"
|
||||
/* Set by the Makefile from the VERSION file. */
|
||||
#ifndef C4F_LAUNCHER_VERSION
|
||||
#define C4F_LAUNCHER_VERSION "dev"
|
||||
#endif
|
||||
#define C4F_SCREEN_WIDTH 1920
|
||||
#define C4F_SCREEN_HEIGHT 1080
|
||||
typedef struct {
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
|
||||
#include "c4f_log.h"
|
||||
#include "c4f_sce.h"
|
||||
#include "c4f_version.h"
|
||||
|
||||
static int g_logFd = -1;
|
||||
static int g_klogEnabled = 1;
|
||||
@@ -36,7 +37,7 @@ void c4fLogOpen(void)
|
||||
else
|
||||
g_logFd = open(C4F_LOG_PATH, O_WRONLY | O_CREAT | O_TRUNC, 0666);
|
||||
g_startedMs = c4fLogNowMs();
|
||||
c4fLog("---- Control4Free log (stage %d); timestamps are elapsed time ----\n", C4F_STAGE);
|
||||
c4fLog("---- Control4Free %s; timestamps are elapsed time ----\n", C4F_VERSION);
|
||||
}
|
||||
|
||||
void c4fLogClose(void)
|
||||
|
||||
+25
-305
@@ -1,110 +1,33 @@
|
||||
/* Control4Free -- system-wide virtual controllers for the PS4.
|
||||
*
|
||||
* The browser payload builds on a successful hardware experiment: a GoldHEN
|
||||
* payload can create a virtual controller that the PS4 menus accept.
|
||||
* A game plugin cannot (GoldHEN only loads plugins into game processes, and the
|
||||
* menus live in SceShellUI/SceShellCore), so Control4Free is a payload and uses
|
||||
* Sony's own virtual-device API, the one Remote Play goes through.
|
||||
* A GoldHEN payload. A game plugin cannot reach the menus (GoldHEN loads
|
||||
* plugins into game processes only, and the menus live in
|
||||
* SceShellUI/SceShellCore), so Control4Free runs as a payload and uses Sony's
|
||||
* virtual-device API, the one Remote Play goes through. Phones and PCs drive
|
||||
* the controllers from a page the payload serves on port 4264 (web.c).
|
||||
*
|
||||
* The work is split into stages so each call can be checked on klog before the
|
||||
* next one is let near the console. Pick one at build time:
|
||||
*
|
||||
* C4F_STAGE=0 browser controller (default), with explicit creation/sign-in.
|
||||
* C4F_STAGE=1 probe only: firmware, credentials, users. No scePad call.
|
||||
* C4F_STAGE=2 plus libSceMbus, scePadSetProcessPrivilege and scePadInit.
|
||||
* C4F_STAGE=3 plus AddDevice. Holds the device a few seconds so the
|
||||
* "Who's using this controller?" screen can be watched for,
|
||||
* then deletes it. No input is injected.
|
||||
* C4F_STAGE=4 plus one short PS press, then 10 s of neutral input so a user
|
||||
* selection prompt can show. No MBus bind (it crashed; C4F_BIND=1
|
||||
* puts it back).
|
||||
* C4F_STAGE=5 the sign-in attempt: raise the selection screen, press Cross to
|
||||
* confirm the highlighted user, then check whether the pad was
|
||||
* really assigned. Build it with C4F_VDA_USER=1.
|
||||
* C4F_STAGE=6 button mapping: inject one bit at a time and read the pad back,
|
||||
* so the map comes from the system, not from guessing. Build with
|
||||
* the default user so the pad is assigned and readable.
|
||||
* C4F_STAGE=7 visual direction test on the selection screen.
|
||||
* C4F_STAGE=8 command server: stays resident on port 4264 and takes commands
|
||||
* from dev\scripts\console.ps1, so the PayLoader is needed once.
|
||||
*
|
||||
* Diagnostic stages require an explicit build setting.
|
||||
* The staged spike that established the call order is in git history, tag
|
||||
* spike-final.
|
||||
*/
|
||||
|
||||
#include <stdint.h>
|
||||
#include <unistd.h>
|
||||
#include <fcntl.h>
|
||||
#include <stdint.h>
|
||||
#include <sys/file.h>
|
||||
#include <sys/stat.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <ps4/kernel.h>
|
||||
|
||||
#include "c4f_log.h"
|
||||
#include "c4f_sce.h"
|
||||
#include "c4f_vda.h"
|
||||
#include "c4f_server.h"
|
||||
#include "c4f_version.h"
|
||||
#include "c4f_web.h"
|
||||
|
||||
#ifndef C4F_STAGE
|
||||
#define C4F_STAGE 0
|
||||
#endif
|
||||
|
||||
/* The authid SplashDown raises to before the VDA calls. */
|
||||
#define C4F_AUTHID_VDA 0x3800000000010003L
|
||||
|
||||
/* Stage 3: how long the virtual device is left in place to be observed. */
|
||||
#ifndef C4F_STAGE3_DWELL_MS
|
||||
#define C4F_STAGE3_DWELL_MS 8000
|
||||
#endif
|
||||
|
||||
/* Stage 4 press shape. PS is one of only two bits confirmed on this path. A
|
||||
* short press: holding PS for over a second opens the quick menu instead. */
|
||||
#ifndef C4F_PRESS_BUTTONS
|
||||
#define C4F_PRESS_BUTTONS C4F_VDI_BUTTON_PS
|
||||
#endif
|
||||
#ifndef C4F_PRESS_LABEL
|
||||
#define C4F_PRESS_LABEL "PS"
|
||||
#endif
|
||||
#ifndef C4F_PRE_NEUTRAL_MS
|
||||
#define C4F_PRE_NEUTRAL_MS 300
|
||||
#endif
|
||||
#ifndef C4F_PRESS_MS
|
||||
#define C4F_PRESS_MS 200
|
||||
#endif
|
||||
/* Neutral samples keep flowing after the press, like a real pad that is still
|
||||
* switched on, so a "Who's using this controller?" prompt has time to appear
|
||||
* before the device is removed (removing it cancels the prompt). */
|
||||
#ifndef C4F_POST_NEUTRAL_MS
|
||||
#define C4F_POST_NEUTRAL_MS 10000
|
||||
#endif
|
||||
|
||||
/* Stage 5 timings. The screen takes a moment to come up after device creation, and
|
||||
* the pad has to keep reporting the whole time or the system drops it. */
|
||||
#ifndef C4F_S5_SCREEN_WAIT_MS
|
||||
#define C4F_S5_SCREEN_WAIT_MS 4000
|
||||
#endif
|
||||
#ifndef C4F_S5_CROSS_MS
|
||||
#define C4F_S5_CROSS_MS 120
|
||||
#endif
|
||||
#ifndef C4F_S5_SETTLE_MS
|
||||
#define C4F_S5_SETTLE_MS 3000
|
||||
#endif
|
||||
/* How long the pad is kept alive after the attempt, so the result stays on
|
||||
* screen and a second probe can run. */
|
||||
#ifndef C4F_S5_ALIVE_MS
|
||||
#define C4F_S5_ALIVE_MS 15000
|
||||
#endif
|
||||
|
||||
/* sceMbusBindDeviceWithUserId crashed the host process on 2026-10-04 (stage 4,
|
||||
* ScePartyDaemon, reason 0xb). It is also not what we want: the goal is the
|
||||
* system's own user selection. Off unless asked for. */
|
||||
#ifndef C4F_BIND
|
||||
#define C4F_BIND 0
|
||||
#endif
|
||||
|
||||
/* Reports what privileges this process actually has. On a GoldHEN console the
|
||||
* payload may already be jailbroken, so the before/after pair tells us whether
|
||||
* raising them by hand is needed at all. */
|
||||
/* Logs the host process's privileges, before and after they are raised. */
|
||||
static void c4fLogCredentials(const char *when, pid_t pid)
|
||||
{
|
||||
uint8_t caps[16];
|
||||
@@ -177,9 +100,8 @@ static void c4fRestoreCredentials(void)
|
||||
c4fLogCredentials("restored", g_pid);
|
||||
}
|
||||
|
||||
static int c4fFinish(int klogFd, int status)
|
||||
static int c4fFinish(int status)
|
||||
{
|
||||
if (klogFd >= 0) close(klogFd);
|
||||
c4fRestoreCredentials();
|
||||
c4fLog("exiting with status %d\n", status);
|
||||
c4fLogClose();
|
||||
@@ -190,244 +112,42 @@ static int c4fFinish(int klogFd, int status)
|
||||
int main(void)
|
||||
{
|
||||
pid_t pid = getpid();
|
||||
uint32_t fw;
|
||||
int32_t userId;
|
||||
int klogFd = -1;
|
||||
int32_t ret;
|
||||
#if C4F_STAGE >= 3 && C4F_STAGE <= 7
|
||||
C4fVirtualPad pad;
|
||||
#endif
|
||||
|
||||
/* Keep ownership while reopening the listener after wake. A second load
|
||||
* must not change the shared host's credentials or truncate the first log.
|
||||
* flock belongs to this open descriptor, even inside the same process. */
|
||||
/* One copy at a time. A second load (AutoRun plus the launcher, say) must
|
||||
* not change the shared host's credentials or rotate the first one's log.
|
||||
* The lock belongs to this open descriptor, even inside the same process,
|
||||
* and the kernel drops it with the descriptor. */
|
||||
(void)mkdir(C4F_LOG_DIR, 0777);
|
||||
g_instanceFd = open(C4F_LOG_DIR "/instance.lock", O_WRONLY | O_CREAT, 0666);
|
||||
if (g_instanceFd < 0 || flock(g_instanceFd, LOCK_EX | LOCK_NB)) {
|
||||
if (g_instanceFd >= 0) close(g_instanceFd);
|
||||
c4fNotify("Control4Free: another instance owns the service, or its lock is unavailable");
|
||||
c4fNotify("Control4Free is already running");
|
||||
return 1;
|
||||
}
|
||||
c4fLogOpen();
|
||||
#if C4F_STAGE != 0
|
||||
c4fNotify("Control4Free: stage %d", C4F_STAGE);
|
||||
#endif
|
||||
|
||||
fw = kernel_get_fw_version();
|
||||
c4fLog("pid=%d firmware=0x%08x (10.01 reads as 0x1001xxxx)\n", pid, fw);
|
||||
c4fLog("Control4Free %s: pid=%d firmware=0x%08x\n", C4F_VERSION, pid, kernel_get_fw_version());
|
||||
|
||||
c4fLogCredentials("on entry", pid);
|
||||
if (c4fRaiseCredentials(pid) != 0) return c4fFinish(klogFd, 1);
|
||||
if (c4fRaiseCredentials(pid) != 0) return c4fFinish(1);
|
||||
c4fLogCredentials("after raise", pid);
|
||||
|
||||
/* The browser service acquires klog only when a controller needs it. */
|
||||
#if C4F_STAGE != 0
|
||||
klogFd = c4fKlogOpen();
|
||||
c4fKlogDrain(klogFd);
|
||||
/* Stage 3 depends on reading kernel lines back, so prove that works first. */
|
||||
c4fKlogSelfTest(klogFd);
|
||||
#endif
|
||||
|
||||
ret = sceUserServiceInitialize(NULL);
|
||||
c4fLog("sceUserServiceInitialize = 0x%08x\n", (uint32_t)ret);
|
||||
|
||||
userId = c4fPickUserId();
|
||||
c4fLog("chosen userId=0x%08x\n", (uint32_t)userId);
|
||||
|
||||
#if C4F_STAGE == 1
|
||||
c4fLog("stage 1 done: nothing in libScePad was called\n");
|
||||
c4fNotify("Control4Free: probe done");
|
||||
#else
|
||||
/* libScePad has unresolved imports into libSceMbus in a payload, so MBus has
|
||||
* to be loaded first or the first scePad call can kill the process. */
|
||||
if (c4fMbusInit() != 0) {
|
||||
c4fNotify("Control4Free: libSceMbus missing");
|
||||
return c4fFinish(klogFd, 1);
|
||||
return c4fFinish(1);
|
||||
}
|
||||
if (c4fPadInit() != 0) {
|
||||
c4fNotify("Control4Free: scePadInit failed");
|
||||
return c4fFinish(klogFd, 1);
|
||||
}
|
||||
c4fLog("pad service ready\n");
|
||||
#endif
|
||||
|
||||
#if C4F_STAGE == 8
|
||||
/* Resident command server: sent once, then driven over its own port. */
|
||||
if (c4fServerRun(userId, klogFd) != 0)
|
||||
return c4fFinish(klogFd, 1);
|
||||
#endif
|
||||
|
||||
#if C4F_STAGE == 0
|
||||
/* c4fWebRun owns the klog descriptor from here on. */
|
||||
ret = c4fWebRun(klogFd);
|
||||
klogFd = -1;
|
||||
if (ret != 0) {
|
||||
c4fNotify("Control4Free: could not start; check payload log");
|
||||
return c4fFinish(klogFd, 1);
|
||||
}
|
||||
#endif
|
||||
|
||||
#if C4F_STAGE >= 3 && C4F_STAGE <= 7
|
||||
if (c4fVirtualPadAdd(&pad, userId, klogFd) != 0) {
|
||||
c4fNotify("Control4Free: no virtual device");
|
||||
return c4fFinish(klogFd, 1);
|
||||
}
|
||||
/* The toast names the user value, so whoever watches the TV can tell runs apart. */
|
||||
c4fNotify("Control4Free: device 0x%x, user 0x%x",
|
||||
(uint32_t)pad.handle, (uint32_t)pad.vdaUserId);
|
||||
#endif
|
||||
|
||||
#if C4F_STAGE == 3
|
||||
c4fLog("holding the device for %d ms -- watch the screen for a controller "
|
||||
"assignment prompt\n", C4F_STAGE3_DWELL_MS);
|
||||
usleep(C4F_STAGE3_DWELL_MS * 1000);
|
||||
c4fVirtualPadRemove(&pad);
|
||||
c4fNotify("Control4Free: device removed");
|
||||
#endif
|
||||
|
||||
#if C4F_STAGE == 7
|
||||
/* Visual fallback for the button map, for when reading the pad back does not
|
||||
* work. It tests one hypothesis: that the VDA path uses the ordinary
|
||||
* documented layout (PS = 0x10000 fits it, which is why it is likely).
|
||||
* Build with C4F_VDA_USER=1 so the selection screen is what receives the
|
||||
* presses -- a d-pad there only moves a highlight.
|
||||
* Watch the screen and report what moved; nothing is confirmed here. */
|
||||
{
|
||||
ScePadData neutral;
|
||||
int press;
|
||||
|
||||
c4fPadDataNeutral(&neutral);
|
||||
ret = c4fVirtualPadInsert(&pad, &neutral);
|
||||
c4fLog("first neutral InsertData = 0x%08x\n", (uint32_t)ret);
|
||||
|
||||
c4fVirtualPadHold(&pad, 0, 500, "pre-neutral");
|
||||
/* AddDevice(user=1) opens selection itself. PS would cancel it. */
|
||||
c4fNotify("C4F: waiting for user selection");
|
||||
c4fVirtualPadHold(&pad, 0, 4000, "waiting for the screen");
|
||||
|
||||
c4fNotify("C4F: 3x DOWN now");
|
||||
for (press = 0; press < 3; press++) {
|
||||
c4fVirtualPadHold(&pad, C4F_PAD_DOWN, 120, "DOWN 0x40");
|
||||
c4fVirtualPadHold(&pad, 0, 900, "gap");
|
||||
}
|
||||
c4fVirtualPadHold(&pad, 0, 1500, "pause");
|
||||
|
||||
c4fNotify("C4F: 3x UP now");
|
||||
for (press = 0; press < 3; press++) {
|
||||
c4fVirtualPadHold(&pad, C4F_PAD_UP, 120, "UP 0x10");
|
||||
c4fVirtualPadHold(&pad, 0, 900, "gap");
|
||||
}
|
||||
|
||||
c4fVirtualPadHold(&pad, 0, 4000, "staying alive");
|
||||
c4fVirtualPadRemove(&pad);
|
||||
c4fNotify("C4F: direction test finished");
|
||||
}
|
||||
#endif
|
||||
|
||||
#if C4F_STAGE == 6
|
||||
/* Button mapping, read back from the system instead of watched on screen.
|
||||
* Build this one with the default user (0x10000000) so the pad is already
|
||||
* assigned and can be read; an unassigned pad answers nothing. */
|
||||
{
|
||||
int32_t readHandle;
|
||||
ScePadData neutral;
|
||||
|
||||
c4fPadDataNeutral(&neutral);
|
||||
ret = c4fVirtualPadInsert(&pad, &neutral);
|
||||
c4fLog("first neutral InsertData = 0x%08x\n", (uint32_t)ret);
|
||||
|
||||
/* The user the device was created for -- not the signed-in account,
|
||||
* whose handle would be the real DualShock. */
|
||||
readHandle = c4fOpenReadHandle(pad.vdaUserId);
|
||||
if (readHandle < 0) {
|
||||
c4fLog("no read handle: cannot map buttons this way\n");
|
||||
c4fNotify("Control4Free: no read handle");
|
||||
} else {
|
||||
c4fNotify("Control4Free: mapping buttons");
|
||||
c4fProbeButtonMap(&pad, readHandle);
|
||||
(void)scePadClose(readHandle);
|
||||
}
|
||||
c4fVirtualPadRemove(&pad);
|
||||
c4fNotify("Control4Free: probe finished");
|
||||
}
|
||||
#endif
|
||||
|
||||
#if C4F_STAGE == 5
|
||||
c4fLog("no MBus bind: the system's own user selection is the point here\n");
|
||||
{
|
||||
ScePadData neutral;
|
||||
int32_t assigned;
|
||||
|
||||
c4fPadDataNeutral(&neutral);
|
||||
ret = c4fVirtualPadInsert(&pad, &neutral);
|
||||
c4fLog("first neutral InsertData = 0x%08x\n", (uint32_t)ret);
|
||||
if (ret < 0) {
|
||||
c4fNotify("Control4Free: InsertData failed 0x%x", (uint32_t)ret);
|
||||
c4fVirtualPadRemove(&pad);
|
||||
return c4fFinish(klogFd, 1);
|
||||
}
|
||||
|
||||
c4fLog("before anything: ");
|
||||
(void)c4fProbeAssignment(userId);
|
||||
|
||||
/* AddDevice(user=1) opens selection itself. A PS press here cancels
|
||||
* selection and disconnects the pad from the sign-in flow. */
|
||||
c4fVirtualPadHold(&pad, 0, 500, "pre-neutral");
|
||||
c4fNotify("Control4Free: waiting for user selection");
|
||||
c4fVirtualPadHold(&pad, 0, C4F_S5_SCREEN_WAIT_MS, "waiting for the screen");
|
||||
|
||||
/* Cross confirms whoever is highlighted. RIGHT=0x20 is also verified
|
||||
* on this path; stage 8 can use it to choose another user first. */
|
||||
c4fVirtualPadHold(&pad, C4F_VDI_BUTTON_CROSS, C4F_S5_CROSS_MS, "Cross press");
|
||||
c4fVirtualPadHold(&pad, 0, C4F_S5_SETTLE_MS, "settling");
|
||||
|
||||
assigned = c4fProbeAssignment(userId);
|
||||
if (assigned != C4F_USER_ID_INVALID) {
|
||||
/* GetHandle may be the physical controller; it cannot prove that
|
||||
* this virtual DeviceId belongs to the returned user. */
|
||||
c4fLog("read handle found for user 0x%08x; verify this virtual "
|
||||
"DeviceId's OWNER_CHANGED event in klog\n", (uint32_t)assigned);
|
||||
}
|
||||
c4fNotify("Control4Free: confirmation sent; check system log");
|
||||
|
||||
/* Keep reporting so the result stays visible, then look once more. */
|
||||
c4fVirtualPadHold(&pad, 0, C4F_S5_ALIVE_MS, "staying alive");
|
||||
c4fLog("final check: ");
|
||||
(void)c4fProbeAssignment(userId);
|
||||
|
||||
c4fVirtualPadRemove(&pad);
|
||||
c4fNotify("Control4Free: done");
|
||||
}
|
||||
#endif
|
||||
|
||||
#if C4F_STAGE == 4
|
||||
#if C4F_BIND
|
||||
if (c4fVirtualPadBind(&pad, userId) != 0)
|
||||
c4fLog("bind failed; injecting anyway to see what happens\n");
|
||||
#else
|
||||
c4fLog("no MBus bind: leaving user selection to the system\n");
|
||||
#endif
|
||||
|
||||
{
|
||||
ScePadData neutral;
|
||||
c4fPadDataNeutral(&neutral);
|
||||
ret = c4fVirtualPadInsert(&pad, &neutral);
|
||||
c4fLog("first neutral InsertData = 0x%08x\n", (uint32_t)ret);
|
||||
if (ret < 0) {
|
||||
c4fNotify("Control4Free: InsertData failed 0x%x", (uint32_t)ret);
|
||||
c4fVirtualPadRemove(&pad);
|
||||
return c4fFinish(klogFd, 1);
|
||||
}
|
||||
return c4fFinish(1);
|
||||
}
|
||||
|
||||
c4fNotify("Control4Free: pressing %s", C4F_PRESS_LABEL);
|
||||
c4fVirtualPadHold(&pad, 0, C4F_PRE_NEUTRAL_MS, "pre-neutral");
|
||||
c4fVirtualPadHold(&pad, C4F_PRESS_BUTTONS, C4F_PRESS_MS, C4F_PRESS_LABEL " press");
|
||||
c4fVirtualPadHold(&pad, 0, C4F_POST_NEUTRAL_MS, "post-neutral");
|
||||
|
||||
c4fVirtualPadRemove(&pad);
|
||||
c4fNotify("Control4Free: done");
|
||||
#endif
|
||||
|
||||
return c4fFinish(klogFd, 0);
|
||||
/* The service takes the kernel log only while a controller signs in. */
|
||||
ret = c4fWebRun(-1);
|
||||
if (ret != 0) c4fNotify("Control4Free could not start; see /data/control4free/control4free.log");
|
||||
return c4fFinish(ret != 0);
|
||||
}
|
||||
-594
@@ -1,594 +0,0 @@
|
||||
/* Control4Free -- a small command server, so experiments stop depending on
|
||||
* GoldHEN's PayLoader.
|
||||
*
|
||||
* The PayLoader stops listening a few minutes after it is started, which meant
|
||||
* restarting it by hand before every single test. This payload is sent once and
|
||||
* then stays resident, listening on its own port, so the PC can create virtual
|
||||
* pads and inject input as often as it likes over one connection. Replies come
|
||||
* straight back down the socket, so there is no log file to fetch either.
|
||||
*
|
||||
* It is deliberately short-lived: an idle client or a long session makes it shut
|
||||
* down on its own, because it is running inside somebody else's process
|
||||
* (ScePartyDaemon) and must not sit there forever.
|
||||
*
|
||||
* Protocol: one ASCII command per line, one reply line per command.
|
||||
* add [userhex] create a virtual pad (default 1 = selection screen)
|
||||
* padmbus call scePadMbusInit() (start the MBus client)
|
||||
* mbusinit call sceMbusInit()
|
||||
* bind [userhex] [authid] give the pad to a user (default: signed-in, ShellCore authid)
|
||||
* del delete it
|
||||
* press <hexbits> [ms] hold those button bits
|
||||
* stick <lx> <ly> <rx> <ry> [ms] hold stick positions (0-255, 128 centre)
|
||||
* hold <ms> neutral for that long (keeps the pad alive)
|
||||
* sweep <first> <last> [ms] press each single bit from first to last
|
||||
* status what the server thinks is going on
|
||||
* klog [lines] recent system log (LOG lines, then OK klog finished)
|
||||
* quit clean up and exit
|
||||
*/
|
||||
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
#include <stdarg.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <time.h>
|
||||
#include <unistd.h>
|
||||
#include <sys/select.h>
|
||||
#include <sys/socket.h>
|
||||
#include <netinet/in.h>
|
||||
|
||||
#include "c4f_log.h"
|
||||
#include "c4f_sce.h"
|
||||
#include "c4f_server.h"
|
||||
#include "c4f_vda.h"
|
||||
|
||||
/* Shut down rather than linger inside the host process. */
|
||||
#ifndef C4F_IDLE_TIMEOUT_S
|
||||
#define C4F_IDLE_TIMEOUT_S 300
|
||||
#endif
|
||||
#ifndef C4F_SESSION_MAX_S
|
||||
#define C4F_SESSION_MAX_S 1800
|
||||
#endif
|
||||
#define C4F_DEFAULT_HOLD_MS 150
|
||||
#define C4F_MAX_HOLD_MS 30000
|
||||
#define C4F_KLOG_LINES 128
|
||||
#define C4F_KLOG_LINE_SIZE 512
|
||||
|
||||
static C4fVirtualPad g_pad;
|
||||
static int g_havePad;
|
||||
static int g_klogFd = -1; /* how a new pad's handle is found */
|
||||
static uint64_t g_deadlineMs;
|
||||
static uint64_t g_nextFrameMs;
|
||||
static uint64_t g_frames;
|
||||
static uint64_t g_insertErrors;
|
||||
static int32_t g_lastInsert;
|
||||
static int g_replyFailed;
|
||||
|
||||
/* A bounded history: never echo these lines through c4fLog, which would feed
|
||||
* them straight back into the same klog stream. */
|
||||
static char g_klogLines[C4F_KLOG_LINES][C4F_KLOG_LINE_SIZE];
|
||||
static char g_klogPartial[C4F_KLOG_LINE_SIZE];
|
||||
static size_t g_klogUsed;
|
||||
static unsigned g_klogNext;
|
||||
static unsigned g_klogCount;
|
||||
static uint64_t g_klogTotal;
|
||||
|
||||
static uint64_t c4fNowMs(void)
|
||||
{
|
||||
struct timespec now;
|
||||
(void)clock_gettime(CLOCK_MONOTONIC, &now);
|
||||
return (uint64_t)now.tv_sec * 1000u + (uint64_t)now.tv_nsec / 1000000u;
|
||||
}
|
||||
|
||||
static void c4fCaptureKlog(void)
|
||||
{
|
||||
char buf[2048];
|
||||
int batch;
|
||||
|
||||
if (g_klogFd < 0) return;
|
||||
/* Bound the work even when a noisy process keeps writing continuously. */
|
||||
for (batch = 0; batch < 8; batch++) {
|
||||
ssize_t n = read(g_klogFd, buf, sizeof(buf));
|
||||
ssize_t i;
|
||||
if (n < 0 && errno == EINTR) continue;
|
||||
if (n < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) break;
|
||||
if (n <= 0) {
|
||||
g_klogFd = -1; /* main still owns and closes the original fd */
|
||||
break;
|
||||
}
|
||||
for (i = 0; i < n; i++) {
|
||||
unsigned char ch = (unsigned char)buf[i];
|
||||
if (ch == '\n') {
|
||||
g_klogPartial[g_klogUsed] = '\0';
|
||||
if (g_klogUsed && !strstr(g_klogPartial, "[c4f]")) {
|
||||
(void)memcpy(g_klogLines[g_klogNext], g_klogPartial,
|
||||
g_klogUsed + 1);
|
||||
g_klogNext = (g_klogNext + 1) % C4F_KLOG_LINES;
|
||||
if (g_klogCount < C4F_KLOG_LINES) g_klogCount++;
|
||||
g_klogTotal++;
|
||||
}
|
||||
g_klogUsed = 0;
|
||||
} else if ((ch >= 32 || ch == '\t') &&
|
||||
g_klogUsed + 1 < sizeof(g_klogPartial)) {
|
||||
g_klogPartial[g_klogUsed++] = (char)ch;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static int32_t c4fReport(const ScePadData *data)
|
||||
{
|
||||
int32_t ret = c4fVirtualPadInsert(&g_pad, data);
|
||||
g_frames++;
|
||||
if (ret < 0) {
|
||||
g_insertErrors++;
|
||||
if (ret != g_lastInsert)
|
||||
c4fLog("InsertData failed = 0x%08x\n", (uint32_t)ret);
|
||||
}
|
||||
g_lastInsert = ret;
|
||||
g_nextFrameMs = c4fNowMs() + C4F_FRAME_MS;
|
||||
return ret;
|
||||
}
|
||||
|
||||
static void c4fPump(void)
|
||||
{
|
||||
c4fCaptureKlog();
|
||||
if (g_havePad && c4fNowMs() >= g_nextFrameMs) {
|
||||
ScePadData neutral;
|
||||
c4fPadDataNeutral(&neutral);
|
||||
(void)c4fReport(&neutral);
|
||||
}
|
||||
}
|
||||
|
||||
/* Wait in frame-sized slices, so silence or a disconnected client never stops
|
||||
* the pad reporting. All pad calls stay on the same thread. */
|
||||
static int c4fWaitReadable(int fd, uint64_t untilMs)
|
||||
{
|
||||
for (;;) {
|
||||
struct timeval tv;
|
||||
fd_set set;
|
||||
uint64_t now, waitMs;
|
||||
int ready;
|
||||
|
||||
c4fPump();
|
||||
now = c4fNowMs();
|
||||
if (now >= untilMs || now >= g_deadlineMs) return 0;
|
||||
waitMs = untilMs - now;
|
||||
if (waitMs > g_deadlineMs - now) waitMs = g_deadlineMs - now;
|
||||
if (waitMs > C4F_FRAME_MS) waitMs = C4F_FRAME_MS;
|
||||
if (g_havePad && g_nextFrameMs > now && waitMs > g_nextFrameMs - now)
|
||||
waitMs = g_nextFrameMs - now;
|
||||
tv.tv_sec = 0;
|
||||
tv.tv_usec = (int)waitMs * 1000;
|
||||
FD_ZERO(&set);
|
||||
FD_SET(fd, &set);
|
||||
ready = select(fd + 1, &set, NULL, NULL, &tv);
|
||||
if (ready < 0 && errno == EINTR) continue;
|
||||
if (ready != 0) return ready;
|
||||
}
|
||||
}
|
||||
|
||||
static void c4fReply(int fd, const char *text)
|
||||
{
|
||||
size_t left = strlen(text);
|
||||
while (left && !g_replyFailed) {
|
||||
int flags = 0;
|
||||
#ifdef MSG_NOSIGNAL
|
||||
flags = MSG_NOSIGNAL;
|
||||
#endif
|
||||
ssize_t sent = send(fd, text, left, flags);
|
||||
if (sent < 0 && errno == EINTR) continue;
|
||||
if (sent <= 0) { g_replyFailed = 1; break; }
|
||||
text += sent;
|
||||
left -= (size_t)sent;
|
||||
}
|
||||
}
|
||||
|
||||
static void c4fReplyFmt(int fd, const char *fmt, ...)
|
||||
{
|
||||
char line[512];
|
||||
va_list ap;
|
||||
int n;
|
||||
|
||||
va_start(ap, fmt);
|
||||
n = vsnprintf(line, sizeof(line), fmt, ap);
|
||||
va_end(ap);
|
||||
if (n < 0) return;
|
||||
c4fReply(fd, line);
|
||||
c4fLog("> %s", line);
|
||||
}
|
||||
|
||||
/* Holds `buttons` and the given stick positions for durationMs. */
|
||||
static int32_t c4fHold(uint32_t buttons, uint8_t lx, uint8_t ly,
|
||||
uint8_t rx, uint8_t ry, int durationMs)
|
||||
{
|
||||
ScePadData data;
|
||||
uint64_t untilMs;
|
||||
int32_t result = 0;
|
||||
|
||||
if (durationMs < C4F_FRAME_MS) durationMs = C4F_FRAME_MS;
|
||||
if (durationMs > C4F_MAX_HOLD_MS) durationMs = C4F_MAX_HOLD_MS;
|
||||
untilMs = c4fNowMs() + (unsigned)durationMs;
|
||||
c4fPadDataNeutral(&data);
|
||||
data.buttons = buttons;
|
||||
data.lx = lx;
|
||||
data.ly = ly;
|
||||
data.rx = rx;
|
||||
data.ry = ry;
|
||||
|
||||
while (c4fNowMs() < untilMs && c4fNowMs() < g_deadlineMs) {
|
||||
int32_t ret = c4fReport(&data);
|
||||
if (ret < 0 && result == 0) result = ret;
|
||||
c4fCaptureKlog();
|
||||
usleep(C4F_FRAME_MS * 1000);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
static int32_t c4fNeutral(int durationMs)
|
||||
{
|
||||
return c4fHold(0, 128, 128, 128, 128, durationMs);
|
||||
}
|
||||
|
||||
static int c4fDuration(int fd, const char *arg, int fallback, int *out)
|
||||
{
|
||||
char *end;
|
||||
long value;
|
||||
if (!arg) { *out = fallback; return 1; }
|
||||
errno = 0;
|
||||
value = strtol(arg, &end, 10);
|
||||
if (errno || end == arg || *end || value < C4F_FRAME_MS ||
|
||||
value > C4F_MAX_HOLD_MS) {
|
||||
c4fReplyFmt(fd, "ERR duration must be %d..%d ms\n",
|
||||
C4F_FRAME_MS, C4F_MAX_HOLD_MS);
|
||||
return 0;
|
||||
}
|
||||
*out = (int)value;
|
||||
return 1;
|
||||
}
|
||||
|
||||
static int c4fInputResult(int fd, uint64_t errorsBefore)
|
||||
{
|
||||
if (c4fNowMs() >= g_deadlineMs) {
|
||||
c4fReplyFmt(fd, "ERR session deadline reached; input stopped\n");
|
||||
return 0;
|
||||
}
|
||||
if (g_insertErrors == errorsBefore) return 1;
|
||||
c4fReplyFmt(fd, "ERR InsertData failed; errors=%llu last=0x%08x (see log)\n",
|
||||
(unsigned long long)g_insertErrors, (uint32_t)g_lastInsert);
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int c4fNeedPad(int fd)
|
||||
{
|
||||
if (g_havePad) return 1;
|
||||
c4fReplyFmt(fd, "ERR no pad; run add first\n");
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Returns 0 to keep going, 1 to shut down. */
|
||||
static int c4fCommand(int fd, char *line, int32_t realUserId)
|
||||
{
|
||||
char *cmd = strtok(line, " \t");
|
||||
char *a1 = strtok(NULL, " \t");
|
||||
char *a2 = strtok(NULL, " \t");
|
||||
char *a3 = strtok(NULL, " \t");
|
||||
char *a4 = strtok(NULL, " \t");
|
||||
char *a5 = strtok(NULL, " \t");
|
||||
uint64_t errorsBefore = g_insertErrors;
|
||||
|
||||
if (!cmd || !*cmd) return 0;
|
||||
c4fLog("< %s %s %s\n", cmd, a1 ? a1 : "", a2 ? a2 : "");
|
||||
|
||||
if (strcmp(cmd, "quit") == 0) {
|
||||
c4fReplyFmt(fd, "OK bye\n");
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (strcmp(cmd, "status") == 0) {
|
||||
c4fReplyFmt(fd, "OK pad=%d handle=0x%08x deviceId=0x%llx vdaUser=0x%08x realUser=0x%08x frames=%llu errors=%llu last=0x%08x klog=%d\n",
|
||||
g_havePad, (uint32_t)g_pad.handle,
|
||||
(unsigned long long)g_pad.deviceId,
|
||||
(uint32_t)g_pad.vdaUserId, (uint32_t)realUserId,
|
||||
(unsigned long long)g_frames, (unsigned long long)g_insertErrors,
|
||||
(uint32_t)g_lastInsert, g_klogFd >= 0);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (strcmp(cmd, "klog") == 0) {
|
||||
unsigned count = g_klogCount;
|
||||
unsigned i;
|
||||
if (a1) {
|
||||
char *end;
|
||||
long requested;
|
||||
errno = 0;
|
||||
requested = strtol(a1, &end, 10);
|
||||
if (errno || end == a1 || *end || requested < 1 || requested > C4F_KLOG_LINES) {
|
||||
c4fReplyFmt(fd, "ERR klog [1..%d]\n", C4F_KLOG_LINES);
|
||||
return 0;
|
||||
}
|
||||
if ((unsigned)requested < count) count = (unsigned)requested;
|
||||
}
|
||||
for (i = 0; i < count; i++) {
|
||||
unsigned index = (g_klogNext + C4F_KLOG_LINES - count + i) % C4F_KLOG_LINES;
|
||||
c4fReply(fd, "LOG ");
|
||||
c4fReply(fd, g_klogLines[index]);
|
||||
c4fReply(fd, "\n");
|
||||
}
|
||||
c4fReplyFmt(fd, "OK klog finished lines=%u total=%llu available=%d\n",
|
||||
count, (unsigned long long)g_klogTotal, g_klogFd >= 0);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (strcmp(cmd, "add") == 0) {
|
||||
int32_t user = a1 ? (int32_t)strtoul(a1, NULL, 0) : 1;
|
||||
if (g_havePad) {
|
||||
c4fReplyFmt(fd, "ERR pad already exists; del first\n");
|
||||
return 0;
|
||||
}
|
||||
/* Only lines written after this point may name the new device. */
|
||||
c4fKlogDrain(g_klogFd);
|
||||
g_klogUsed = 0; /* drain/scanner consume whole sections of the stream */
|
||||
/* Exactly the value asked for: the whole point of the argument is to try
|
||||
* values the default mapping would rewrite (1 became 0x10000000). */
|
||||
if (c4fVirtualPadAddAs(&g_pad, user, user, g_klogFd) != 0) {
|
||||
c4fReplyFmt(fd, "ERR AddDevice gave no handle\n");
|
||||
return 0;
|
||||
}
|
||||
g_havePad = 1;
|
||||
g_frames = g_insertErrors = 0;
|
||||
g_lastInsert = 0;
|
||||
errorsBefore = 0;
|
||||
/* One neutral sample so the device starts reporting like a real pad. */
|
||||
c4fNeutral(C4F_FRAME_MS * 3);
|
||||
if (!c4fInputResult(fd, errorsBefore)) return 0;
|
||||
c4fReplyFmt(fd, "OK handle=0x%08x deviceId=0x%llx vdaUser=0x%08x\n",
|
||||
(uint32_t)g_pad.handle, (unsigned long long)g_pad.deviceId,
|
||||
(uint32_t)g_pad.vdaUserId);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* padmbus / mbusinit: start the MBus client before a bind. */
|
||||
if (strcmp(cmd, "padmbus") == 0 || strcmp(cmd, "mbusinit") == 0) {
|
||||
int which = strcmp(cmd, "padmbus") == 0 ? 0 : 1;
|
||||
int32_t ret = 0;
|
||||
if (c4fMbusClientInit(which, &ret) != 0) {
|
||||
c4fReplyFmt(fd, "ERR %s not found\n", which == 0 ? "scePadMbusInit" : "sceMbusInit");
|
||||
return 0;
|
||||
}
|
||||
c4fReplyFmt(fd, "OK %s() = 0x%08x\n",
|
||||
which == 0 ? "scePadMbusInit" : "sceMbusInit", (uint32_t)ret);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* bind [userhex] [authidhex]: give the pad to a user. Default user is the
|
||||
* signed-in account; default authid is SceShellCore's, as Ghostcontrol uses
|
||||
* on PS4. "bind <user> 0" makes the call with our own credentials -- that
|
||||
* is the form that crashed the host on 2026-10-04. */
|
||||
if (strcmp(cmd, "bind") == 0) {
|
||||
int32_t user = a1 ? (int32_t)strtoul(a1, NULL, 0) : realUserId;
|
||||
uint64_t authid = a2 ? strtoull(a2, NULL, 0) : C4F_AUTHID_SHELLCORE;
|
||||
if (!c4fNeedPad(fd)) return 0;
|
||||
if (c4fVirtualPadBindAs(&g_pad, user, authid) != 0) {
|
||||
c4fReplyFmt(fd, "ERR bind to 0x%08x failed (see log)\n", (uint32_t)user);
|
||||
return 0;
|
||||
}
|
||||
c4fReplyFmt(fd, "OK bound to 0x%08x (authid 0x%llx)\n",
|
||||
(uint32_t)user, (unsigned long long)authid);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (strcmp(cmd, "del") == 0) {
|
||||
if (!c4fNeedPad(fd)) return 0;
|
||||
c4fVirtualPadRemove(&g_pad);
|
||||
g_havePad = 0;
|
||||
c4fReplyFmt(fd, "OK deleted\n");
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (strcmp(cmd, "press") == 0) {
|
||||
uint32_t bits;
|
||||
int ms;
|
||||
if (!a1) { c4fReplyFmt(fd, "ERR press <hexbits> [ms]\n"); return 0; }
|
||||
if (!c4fNeedPad(fd)) return 0;
|
||||
bits = (uint32_t)strtoul(a1, NULL, 0);
|
||||
if (!c4fDuration(fd, a2, C4F_DEFAULT_HOLD_MS, &ms)) return 0;
|
||||
c4fNeutral(C4F_FRAME_MS * 2);
|
||||
c4fHold(bits, 128, 128, 128, 128, ms);
|
||||
c4fNeutral(C4F_FRAME_MS * 3);
|
||||
if (!c4fInputResult(fd, errorsBefore)) return 0;
|
||||
c4fReplyFmt(fd, "OK pressed 0x%08x for %dms\n", bits, ms);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (strcmp(cmd, "stick") == 0) {
|
||||
int ms;
|
||||
if (!a4) { c4fReplyFmt(fd, "ERR stick <lx> <ly> <rx> <ry> [ms]\n"); return 0; }
|
||||
if (!c4fNeedPad(fd)) return 0;
|
||||
if (!c4fDuration(fd, a5, 1000, &ms)) return 0;
|
||||
c4fHold(0, (uint8_t)atoi(a1), (uint8_t)atoi(a2),
|
||||
(uint8_t)atoi(a3), (uint8_t)atoi(a4), ms);
|
||||
c4fNeutral(C4F_FRAME_MS * 3);
|
||||
if (!c4fInputResult(fd, errorsBefore)) return 0;
|
||||
c4fReplyFmt(fd, "OK sticks %s,%s %s,%s for %dms\n", a1, a2, a3, a4, ms);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (strcmp(cmd, "hold") == 0) {
|
||||
int ms;
|
||||
if (!c4fNeedPad(fd)) return 0;
|
||||
if (!c4fDuration(fd, a1, 1000, &ms)) return 0;
|
||||
c4fNeutral(ms);
|
||||
if (!c4fInputResult(fd, errorsBefore)) return 0;
|
||||
c4fReplyFmt(fd, "OK held neutral %dms\n", ms);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Presses each single bit in a range, so one command can walk a whole byte
|
||||
* while somebody watches the screen. Each one is announced on screen. */
|
||||
if (strcmp(cmd, "sweep") == 0) {
|
||||
int first = a1 ? atoi(a1) : 0;
|
||||
int last = a2 ? atoi(a2) : 15;
|
||||
int ms;
|
||||
int bit;
|
||||
|
||||
if (!c4fNeedPad(fd)) return 0;
|
||||
if (!c4fDuration(fd, a3, 400, &ms)) return 0;
|
||||
if (first < 0) first = 0;
|
||||
if (last > 31) last = 31;
|
||||
|
||||
for (bit = first; bit <= last; bit++) {
|
||||
uint32_t mask = 1u << bit;
|
||||
if (g_replyFailed || c4fNowMs() >= g_deadlineMs) break;
|
||||
c4fNotify("C4F: bit %d (0x%x)", bit, mask);
|
||||
c4fNeutral(400);
|
||||
c4fHold(mask, 128, 128, 128, 128, ms);
|
||||
c4fNeutral(600);
|
||||
if (!c4fInputResult(fd, errorsBefore)) return 0;
|
||||
c4fReplyFmt(fd, "OK bit %d = 0x%08x\n", bit, mask);
|
||||
}
|
||||
c4fReplyFmt(fd, "OK sweep finished %d..%d\n", first, last);
|
||||
return 0;
|
||||
}
|
||||
|
||||
c4fReplyFmt(fd, "ERR unknown command %s\n", cmd);
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int c4fAcceptOne(int listenFd, int timeoutS)
|
||||
{
|
||||
int ready;
|
||||
ready = c4fWaitReadable(listenFd, c4fNowMs() + (unsigned)timeoutS * 1000u);
|
||||
if (ready <= 0) return -1;
|
||||
return accept(listenFd, NULL, NULL);
|
||||
}
|
||||
|
||||
int c4fServerRun(int32_t realUserId, int klogFd)
|
||||
{
|
||||
struct sockaddr_in addr;
|
||||
int listenFd;
|
||||
int one = 1;
|
||||
int sessions = 0;
|
||||
|
||||
g_klogFd = klogFd;
|
||||
g_havePad = 0;
|
||||
g_klogUsed = g_klogNext = g_klogCount = 0;
|
||||
g_klogTotal = 0;
|
||||
g_frames = g_insertErrors = 0;
|
||||
g_deadlineMs = c4fNowMs() + (uint64_t)C4F_SESSION_MAX_S * 1000u;
|
||||
/* Never let an unexpected blocking log source freeze controller reports. */
|
||||
if (g_klogFd >= 0) {
|
||||
int flags = fcntl(g_klogFd, F_GETFL, 0);
|
||||
if (flags < 0 || fcntl(g_klogFd, F_SETFL, flags | O_NONBLOCK) < 0) {
|
||||
c4fLog("klog nonblocking setup failed errno=%d\n", errno);
|
||||
g_klogFd = -1;
|
||||
}
|
||||
}
|
||||
|
||||
listenFd = socket(AF_INET, SOCK_STREAM, 0);
|
||||
if (listenFd < 0) {
|
||||
c4fLog("socket() failed errno=%d\n", errno);
|
||||
return -1;
|
||||
}
|
||||
(void)setsockopt(listenFd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one));
|
||||
|
||||
(void)memset(&addr, 0, sizeof(addr));
|
||||
#ifdef __FreeBSD__
|
||||
addr.sin_len = sizeof(addr);
|
||||
#endif
|
||||
addr.sin_family = AF_INET;
|
||||
addr.sin_port = htons(C4F_SERVER_PORT);
|
||||
addr.sin_addr.s_addr = htonl(INADDR_ANY);
|
||||
|
||||
if (bind(listenFd, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
|
||||
c4fLog("bind(%d) failed errno=%d\n", C4F_SERVER_PORT, errno);
|
||||
(void)close(listenFd);
|
||||
return -1;
|
||||
}
|
||||
if (listen(listenFd, 1) < 0) {
|
||||
c4fLog("listen() failed errno=%d\n", errno);
|
||||
(void)close(listenFd);
|
||||
return -1;
|
||||
}
|
||||
|
||||
c4fLog("command server listening on port %d\n", C4F_SERVER_PORT);
|
||||
c4fNotify("Control4Free: server on port %d", C4F_SERVER_PORT);
|
||||
|
||||
while (c4fNowMs() < g_deadlineMs) {
|
||||
char buf[512];
|
||||
size_t used = 0;
|
||||
int discarding = 0;
|
||||
int clientFd = c4fAcceptOne(listenFd, 30);
|
||||
int done = 0;
|
||||
uint64_t idleDeadline;
|
||||
|
||||
if (clientFd < 0) continue;
|
||||
#ifdef SO_NOSIGPIPE
|
||||
(void)setsockopt(clientFd, SOL_SOCKET, SO_NOSIGPIPE, &one, sizeof(one));
|
||||
#endif
|
||||
{
|
||||
struct timeval sendTimeout = { .tv_sec = 1, .tv_usec = 0 };
|
||||
(void)setsockopt(clientFd, SOL_SOCKET, SO_SNDTIMEO,
|
||||
&sendTimeout, sizeof(sendTimeout));
|
||||
}
|
||||
g_replyFailed = 0;
|
||||
idleDeadline = c4fNowMs() + (uint64_t)C4F_IDLE_TIMEOUT_S * 1000u;
|
||||
sessions++;
|
||||
c4fLog("client %d connected\n", sessions);
|
||||
c4fReplyFmt(clientFd, "OK Control4Free ready (port %d)\n", C4F_SERVER_PORT);
|
||||
|
||||
while (!done && !g_replyFailed) {
|
||||
char incoming[512];
|
||||
ssize_t n;
|
||||
ssize_t i;
|
||||
|
||||
if (c4fWaitReadable(clientFd, idleDeadline) <= 0) {
|
||||
c4fLog("client wait ended (timeout, session limit, or socket error)\n");
|
||||
break;
|
||||
}
|
||||
|
||||
n = recv(clientFd, incoming, sizeof(incoming), 0);
|
||||
if (n < 0 && errno == EINTR) continue;
|
||||
if (n <= 0) break;
|
||||
idleDeadline = c4fNowMs() + (uint64_t)C4F_IDLE_TIMEOUT_S * 1000u;
|
||||
|
||||
for (i = 0; i < n && !done && !g_replyFailed; i++) {
|
||||
char ch = incoming[i];
|
||||
if (c4fNowMs() >= g_deadlineMs) break;
|
||||
if (ch == '\n') {
|
||||
if (!discarding) {
|
||||
if (used && buf[used - 1] == '\r') used--;
|
||||
buf[used] = '\0';
|
||||
done = c4fCommand(clientFd, buf, realUserId);
|
||||
}
|
||||
used = 0;
|
||||
discarding = 0;
|
||||
c4fPump();
|
||||
} else if (!discarding) {
|
||||
if (used == sizeof(buf) - 1 || ch == '\0') {
|
||||
c4fReplyFmt(clientFd, "ERR invalid or too long line\n");
|
||||
discarding = 1; /* discard the entire command, including its tail */
|
||||
used = 0;
|
||||
} else {
|
||||
buf[used++] = ch;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
(void)close(clientFd);
|
||||
c4fLog("client disconnected\n");
|
||||
if (done) break;
|
||||
}
|
||||
|
||||
if (g_havePad) {
|
||||
c4fLog("cleaning up the pad before exit\n");
|
||||
c4fVirtualPadRemove(&g_pad);
|
||||
g_havePad = 0;
|
||||
}
|
||||
(void)close(listenFd);
|
||||
c4fLog("command server stopped\n");
|
||||
c4fNotify("Control4Free: server stopped");
|
||||
return 0;
|
||||
}
|
||||
@@ -2,23 +2,18 @@
|
||||
*
|
||||
* Ported from seregonwar/SplashDown's psbutton.c (GPL-3.0), the only known
|
||||
* working caller of this API on a PS4. The call order and the klog trick for
|
||||
* recovering the device handle both come from there; see dev/notes/vda.md.
|
||||
* recovering the device handle both come from there.
|
||||
*/
|
||||
|
||||
#include <arpa/inet.h>
|
||||
#include <ctype.h>
|
||||
#include <dlfcn.h>
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
#include <netinet/in.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include <time.h>
|
||||
#include <sys/socket.h>
|
||||
#include <sys/select.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <ps4/kernel.h>
|
||||
#include <ps4/klog.h>
|
||||
|
||||
#include "c4f_log.h"
|
||||
@@ -32,9 +27,6 @@
|
||||
#define RTLD_GLOBAL 0x100
|
||||
#endif
|
||||
|
||||
static C4fMbusBindFn g_mbusBind;
|
||||
static C4fMbusDisconnectFn g_mbusDisconnect;
|
||||
static void *g_mbusHandle;
|
||||
static void (*g_waitCallback)(void *);
|
||||
static void *g_waitContext;
|
||||
|
||||
@@ -72,50 +64,6 @@ int c4fMbusInit(void)
|
||||
c4fLog("FATAL: libSceMbus did not load; not touching libScePad\n");
|
||||
return -1;
|
||||
}
|
||||
|
||||
g_mbusBind = (C4fMbusBindFn)dlsym(handle, "sceMbusBindDeviceWithUserId");
|
||||
g_mbusDisconnect = (C4fMbusDisconnectFn)dlsym(handle, "sceMbusDisconnectDevice");
|
||||
c4fLog("sceMbusBindDeviceWithUserId=%p sceMbusDisconnectDevice=%p\n",
|
||||
(void *)g_mbusBind, (void *)g_mbusDisconnect);
|
||||
|
||||
if (!g_mbusBind) {
|
||||
c4fLog("FATAL: sceMbusBindDeviceWithUserId unresolved\n");
|
||||
return -1;
|
||||
}
|
||||
g_mbusHandle = handle;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* libSceMbus is a client of a system service. Our host process (ScePartyDaemon)
|
||||
* never starts that client, and a bind without it faulted twice on 2026-10-04.
|
||||
* libScePad has its own wrapper for starting it, scePadMbusInit, which games
|
||||
* never call; there is also sceMbusInit itself. Neither has a published
|
||||
* signature, so both are called with no arguments and the return is logged.
|
||||
* which: 0 = scePadMbusInit, 1 = sceMbusInit. */
|
||||
typedef int32_t (*C4fNoArgFn)(void);
|
||||
|
||||
int c4fMbusClientInit(int which, int32_t *outRet)
|
||||
{
|
||||
const char *name = which == 0 ? "scePadMbusInit" : "sceMbusInit";
|
||||
void *lib;
|
||||
C4fNoArgFn fn;
|
||||
|
||||
if (which == 0) {
|
||||
lib = dlopen("/system/common/lib/libScePad.sprx", RTLD_NOW | RTLD_GLOBAL);
|
||||
} else {
|
||||
lib = g_mbusHandle;
|
||||
}
|
||||
if (!lib) {
|
||||
c4fLog("%s: library handle missing\n", name);
|
||||
return -1;
|
||||
}
|
||||
|
||||
fn = (C4fNoArgFn)dlsym(lib, name);
|
||||
c4fLog("%s resolved at %p\n", name, (void *)fn);
|
||||
if (!fn) return -1;
|
||||
|
||||
*outRet = fn();
|
||||
c4fLog("%s() = 0x%08x\n", name, (uint32_t)*outRet);
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -141,50 +89,6 @@ int c4fPadInit(void)
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int c4fIsRealUser(int32_t userId)
|
||||
{
|
||||
return userId != 0 && userId != C4F_USER_ID_INVALID;
|
||||
}
|
||||
|
||||
int32_t c4fPickUserId(void)
|
||||
{
|
||||
int32_t userId = C4F_USER_ID_INVALID;
|
||||
int32_t ret;
|
||||
|
||||
if (sceUserServiceGetForegroundUser) {
|
||||
ret = sceUserServiceGetForegroundUser(&userId);
|
||||
c4fLog("GetForegroundUser = 0x%08x id=0x%08x\n", (uint32_t)ret, (uint32_t)userId);
|
||||
if (ret == 0 && c4fIsRealUser(userId)) return userId;
|
||||
} else {
|
||||
c4fLog("GetForegroundUser not present on this firmware\n");
|
||||
}
|
||||
|
||||
userId = C4F_USER_ID_INVALID;
|
||||
ret = sceUserServiceGetInitialUser(&userId);
|
||||
c4fLog("GetInitialUser = 0x%08x id=0x%08x\n", (uint32_t)ret, (uint32_t)userId);
|
||||
if (ret == 0 && c4fIsRealUser(userId)) return userId;
|
||||
|
||||
c4fLog("no real user found; using fallback 0x%08x\n", C4F_VDA_USER_FALLBACK);
|
||||
return C4F_VDA_USER_FALLBACK;
|
||||
}
|
||||
|
||||
/* Which user the device is created for. SplashDown maps any real account id
|
||||
* (like 1A2B3C4D) onto 0x10000000. On our console a device created that way
|
||||
* acts as the signed-in user: PS works, but there is no "Who's using this
|
||||
* controller?" screen (2026-10-04). C4F_VDA_USER overrides it at build time to
|
||||
* look for a value that makes the device arrive unassigned; Ghostcontrol uses
|
||||
* 1 as an "anonymous" slot and sees -1 on devices waiting for assignment. */
|
||||
static int32_t c4fVdaUserId(int32_t userId)
|
||||
{
|
||||
#ifdef C4F_VDA_USER
|
||||
(void)userId;
|
||||
return (int32_t)(C4F_VDA_USER);
|
||||
#else
|
||||
if (userId >= 0x10000000 && userId <= 0x1000000f) return userId;
|
||||
return C4F_VDA_USER_FALLBACK;
|
||||
#endif
|
||||
}
|
||||
|
||||
/* ---- /dev/klog scanning ----
|
||||
*
|
||||
* AddDevice hands back a status, not a handle. The handle we need is the MBus
|
||||
@@ -192,51 +96,6 @@ static int32_t c4fVdaUserId(int32_t userId)
|
||||
* while the device is being added. Ugly, but it is what works.
|
||||
*/
|
||||
|
||||
/* Prefer GoldHEN's stream. Both connection and capture checks are bounded:
|
||||
* a successful TCP connection alone does not mean this client gets any logs. */
|
||||
static int c4fKlogConnectLocal(void)
|
||||
{
|
||||
struct sockaddr_in addr;
|
||||
int s = socket(AF_INET, SOCK_STREAM, 0);
|
||||
|
||||
if (s < 0) {
|
||||
c4fLog("klog socket() failed errno=%d\n", errno);
|
||||
return -1;
|
||||
}
|
||||
|
||||
(void)memset(&addr, 0, sizeof(addr));
|
||||
#ifdef __FreeBSD__
|
||||
addr.sin_len = sizeof(addr);
|
||||
#endif
|
||||
addr.sin_family = AF_INET;
|
||||
addr.sin_port = htons(C4F_KLOG_PORT);
|
||||
addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
|
||||
|
||||
if (s >= (int)FD_SETSIZE || fcntl(s, F_SETFL, O_NONBLOCK) < 0) goto fail;
|
||||
if (connect(s, (struct sockaddr *)&addr, sizeof(addr)) != 0) {
|
||||
if (errno != EINPROGRESS) goto fail;
|
||||
uint64_t deadline = c4fVdaNowMs() + 700;
|
||||
for (;;) {
|
||||
if (g_waitCallback) g_waitCallback(g_waitContext);
|
||||
fd_set wr; FD_ZERO(&wr); FD_SET(s, &wr);
|
||||
struct timeval timeout = {0, 20000};
|
||||
int ready = select(s + 1, NULL, &wr, NULL, &timeout);
|
||||
if (ready > 0) {
|
||||
int error = 0; socklen_t size = sizeof(error);
|
||||
if (getsockopt(s, SOL_SOCKET, SO_ERROR, &error, &size)) goto fail;
|
||||
if (error) { errno = error; goto fail; }
|
||||
break;
|
||||
}
|
||||
if (ready < 0 && errno != EINTR) goto fail;
|
||||
if (c4fVdaNowMs() >= deadline) { errno = ETIMEDOUT; goto fail; }
|
||||
}
|
||||
}
|
||||
return s;
|
||||
fail:
|
||||
c4fLog("klog socket unavailable errno=%d\n", errno);
|
||||
close(s);
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* Drained and proven by a marker that comes back, or closed. */
|
||||
static int c4fKlogVerified(int fd, const char *what)
|
||||
@@ -265,18 +124,6 @@ int c4fKlogOpenDevice(void)
|
||||
return c4fKlogVerified(fd, "/dev/klog");
|
||||
}
|
||||
|
||||
int c4fKlogOpen(void)
|
||||
{
|
||||
int fd = c4fKlogOpenDevice();
|
||||
if (fd >= 0) return fd;
|
||||
/* The diagnostic stages run once, usually with the PC's klog viewer
|
||||
* attached, which holds the device: then the stream is the only way. */
|
||||
fd = c4fKlogConnectLocal();
|
||||
if (fd >= 0 && (fd = c4fKlogVerified(fd, "127.0.0.1:3232")) >= 0) return fd;
|
||||
c4fLog("no verified klog source\n");
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* Throws away whatever is already buffered so only new lines get scanned. A
|
||||
* fresh connection to the klog server may replay a backlog, and an old "device
|
||||
* added" line in it would hand us a stale DeviceId. Keep reading until the
|
||||
@@ -308,7 +155,7 @@ void c4fKlogDrain(int fd)
|
||||
}
|
||||
|
||||
/* Writes a marker into the kernel log and checks we can read it back. If this
|
||||
* fails, stage 3 cannot learn the virtual device's handle. */
|
||||
* fails, a new controller cannot learn its device handle. */
|
||||
int c4fKlogSelfTest(int fd)
|
||||
{
|
||||
static unsigned sequence;
|
||||
@@ -494,11 +341,6 @@ int c4fKlogFindDeviceId(int fd, uint64_t *outDeviceId, int timeoutMs)
|
||||
|
||||
/* ---- device lifecycle ---- */
|
||||
|
||||
int c4fVirtualPadAdd(C4fVirtualPad *out, int32_t userId, int klogFd)
|
||||
{
|
||||
return c4fVirtualPadAddAs(out, userId, c4fVdaUserId(userId), klogFd);
|
||||
}
|
||||
|
||||
int c4fVirtualPadAddAs(C4fVirtualPad *out, int32_t userId, int32_t vdaUser, int klogFd)
|
||||
{
|
||||
C4fVdaParam param;
|
||||
@@ -556,49 +398,6 @@ int c4fVirtualPadAddAs(C4fVirtualPad *out, int32_t userId, int32_t vdaUser, int
|
||||
return 0;
|
||||
}
|
||||
|
||||
int c4fVirtualPadBind(C4fVirtualPad *pad, int32_t userId)
|
||||
{
|
||||
int32_t ret;
|
||||
|
||||
if (!g_mbusBind) {
|
||||
c4fLog("bind skipped: MBus not resolved\n");
|
||||
return -1;
|
||||
}
|
||||
if (!pad->deviceId) {
|
||||
c4fLog("bind skipped: no DeviceId\n");
|
||||
return -1;
|
||||
}
|
||||
|
||||
ret = g_mbusBind(pad->deviceId, userId);
|
||||
c4fLog("sceMbusBindDeviceWithUserId(0x%llx, 0x%08x) = 0x%08x\n",
|
||||
(unsigned long long)pad->deviceId, (uint32_t)userId, (uint32_t)ret);
|
||||
if (ret != 0) return -1;
|
||||
|
||||
pad->bound = 1;
|
||||
pad->userId = userId;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Ghostcontrol's PS4 path makes this call with SceShellCore's authid switched
|
||||
* on for just the call, then puts the old one back. We called it with
|
||||
* SplashDown's 0x3800000000010003 and the host process crashed (stage 4,
|
||||
* 2026-10-04), so the credentials are the first suspect. */
|
||||
int c4fVirtualPadBindAs(C4fVirtualPad *pad, int32_t userId, uint64_t authid)
|
||||
{
|
||||
pid_t pid = getpid();
|
||||
uint64_t saved = kernel_get_ucred_authid(pid);
|
||||
int result;
|
||||
|
||||
if (authid) {
|
||||
c4fLog("bind: authid 0x%016llx -> 0x%016llx for the call\n",
|
||||
(unsigned long long)saved, (unsigned long long)authid);
|
||||
(void)kernel_set_ucred_authid(pid, authid);
|
||||
}
|
||||
result = c4fVirtualPadBind(pad, userId);
|
||||
if (authid) (void)kernel_set_ucred_authid(pid, saved);
|
||||
return result;
|
||||
}
|
||||
|
||||
void c4fPadDataNeutral(ScePadData *data)
|
||||
{
|
||||
(void)memset(data, 0, sizeof(*data));
|
||||
@@ -629,177 +428,19 @@ int32_t c4fVirtualPadInsert(const C4fVirtualPad *pad, const ScePadData *data)
|
||||
return scePadVirtualDeviceInsertData(pad->handle, &stamped);
|
||||
}
|
||||
|
||||
int32_t c4fVirtualPadHold(const C4fVirtualPad *pad, uint32_t buttons,
|
||||
int durationMs, const char *label)
|
||||
{
|
||||
ScePadData data;
|
||||
int32_t ret = 0;
|
||||
int frames = 0;
|
||||
int elapsed;
|
||||
|
||||
if (durationMs < C4F_FRAME_MS) durationMs = C4F_FRAME_MS;
|
||||
c4fPadDataNeutral(&data);
|
||||
data.buttons = buttons;
|
||||
|
||||
/* A real pad reports continuously, so one lone sample is easily missed. */
|
||||
for (elapsed = 0; elapsed < durationMs; elapsed += C4F_FRAME_MS) {
|
||||
ret = c4fVirtualPadInsert(pad, &data);
|
||||
frames++;
|
||||
usleep(C4F_FRAME_MS * 1000);
|
||||
}
|
||||
|
||||
c4fLog("%s: buttons=0x%08x %dms %d frames last=0x%08x\n",
|
||||
label ? label : "hold", buttons, durationMs, frames, (uint32_t)ret);
|
||||
return ret;
|
||||
}
|
||||
|
||||
int32_t c4fProbeAssignment(int32_t realUserId)
|
||||
{
|
||||
/* Device type 3 is what we created; 0 is the ordinary "any pad" type that
|
||||
* the shell uses. Both are worth asking about. */
|
||||
static const int32_t types[] = { 0, 3 };
|
||||
int32_t users[3];
|
||||
size_t u, t;
|
||||
int32_t idx;
|
||||
|
||||
users[0] = realUserId;
|
||||
users[1] = C4F_VDA_USER_FALLBACK; /* 0x10000000 */
|
||||
users[2] = 1;
|
||||
|
||||
for (u = 0; u < sizeof(users) / sizeof(users[0]); u++) {
|
||||
for (t = 0; t < sizeof(types) / sizeof(types[0]); t++) {
|
||||
for (idx = 0; idx < 4; idx++) {
|
||||
int32_t handle = scePadGetHandle(users[u], types[t], idx);
|
||||
if (handle >= 0) {
|
||||
c4fLog("assigned: GetHandle(user=0x%08x type=%d idx=%d) = 0x%08x\n",
|
||||
(uint32_t)users[u], (int)types[t], (int)idx, (uint32_t)handle);
|
||||
return users[u];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
c4fLog("not assigned yet (no GetHandle answered)\n");
|
||||
return C4F_USER_ID_INVALID;
|
||||
}
|
||||
|
||||
/* Only ever called with the user the virtual device was created for. Asking for
|
||||
* the signed-in account instead hands back that person's real DualShock, which
|
||||
* reads as all-zero (nobody is touching it) and is not ours to open and close. */
|
||||
int32_t c4fOpenReadHandle(int32_t userId)
|
||||
{
|
||||
/* Type 3 is what the device was created as, so try it before the generic 0. */
|
||||
static const int32_t types[] = { C4F_VIRTUAL_DEVICE_TYPE, 0 };
|
||||
size_t t;
|
||||
int32_t idx;
|
||||
|
||||
for (t = 0; t < sizeof(types) / sizeof(types[0]); t++) {
|
||||
for (idx = 0; idx < 4; idx++) {
|
||||
int32_t handle = scePadGetHandle(userId, types[t], idx);
|
||||
if (handle >= 0) {
|
||||
c4fLog("read handle from GetHandle(user=0x%08x type=%d idx=%d) = 0x%08x\n",
|
||||
(uint32_t)userId, (int)types[t], (int)idx, (uint32_t)handle);
|
||||
return handle;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (t = 0; t < sizeof(types) / sizeof(types[0]); t++) {
|
||||
int32_t handle = scePadOpen(userId, types[t], 0, NULL);
|
||||
c4fLog("scePadOpen(user=0x%08x type=%d) = 0x%08x\n",
|
||||
(uint32_t)userId, (int)types[t], (uint32_t)handle);
|
||||
if (handle >= 0) return handle;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* Candidate input bits. The documented read-side layout first, then the two
|
||||
* bits SplashDown reported for this path. */
|
||||
typedef struct {
|
||||
uint32_t bit;
|
||||
const char *name;
|
||||
} C4fBitCandidate;
|
||||
|
||||
static const C4fBitCandidate c4fBitCandidates[] = {
|
||||
{ C4F_PAD_L3, "L3?" },
|
||||
{ C4F_PAD_R3, "R3?" },
|
||||
{ C4F_PAD_OPTIONS, "OPTIONS?" },
|
||||
{ C4F_PAD_UP, "UP?" },
|
||||
{ C4F_PAD_RIGHT, "RIGHT?" },
|
||||
{ C4F_PAD_DOWN, "DOWN?" },
|
||||
{ C4F_PAD_LEFT, "LEFT?" },
|
||||
{ C4F_PAD_L2, "L2?" },
|
||||
{ C4F_PAD_R2, "R2?" },
|
||||
{ C4F_PAD_L1, "L1?" },
|
||||
{ C4F_PAD_R1, "R1?" },
|
||||
{ C4F_PAD_TRIANGLE, "TRIANGLE?" },
|
||||
{ C4F_PAD_CIRCLE, "CIRCLE?" },
|
||||
{ C4F_PAD_CROSS, "CROSS?" },
|
||||
{ C4F_PAD_SQUARE, "SQUARE?" },
|
||||
{ C4F_PAD_TOUCHPAD, "TOUCHPAD?" },
|
||||
{ 0x00020000u, "SplashDown's Cross bit" },
|
||||
};
|
||||
|
||||
void c4fProbeButtonMap(const C4fVirtualPad *pad, int32_t readHandle)
|
||||
{
|
||||
ScePadData sample;
|
||||
ScePadData readback;
|
||||
size_t i;
|
||||
int frame;
|
||||
int32_t ret;
|
||||
|
||||
/* What the pad reports with nothing held: anything set here is noise that
|
||||
* has to be subtracted from every result below. */
|
||||
c4fPadDataNeutral(&sample);
|
||||
for (frame = 0; frame < 5; frame++) {
|
||||
(void)c4fVirtualPadInsert(pad, &sample);
|
||||
usleep(C4F_FRAME_MS * 1000);
|
||||
}
|
||||
(void)memset(&readback, 0, sizeof(readback));
|
||||
ret = scePadReadState(readHandle, &readback);
|
||||
c4fLog("probe baseline: ReadState = 0x%08x buttons=0x%08x connected=%u\n",
|
||||
(uint32_t)ret, readback.buttons, (unsigned)readback.connected);
|
||||
if (ret < 0) {
|
||||
c4fLog("probe aborted: cannot read the pad back\n");
|
||||
return;
|
||||
}
|
||||
|
||||
for (i = 0; i < sizeof(c4fBitCandidates) / sizeof(c4fBitCandidates[0]); i++) {
|
||||
uint32_t seen = 0;
|
||||
|
||||
c4fPadDataNeutral(&sample);
|
||||
sample.buttons = c4fBitCandidates[i].bit;
|
||||
for (frame = 0; frame < 5; frame++) {
|
||||
(void)c4fVirtualPadInsert(pad, &sample);
|
||||
usleep(C4F_FRAME_MS * 1000);
|
||||
(void)memset(&readback, 0, sizeof(readback));
|
||||
if (scePadReadState(readHandle, &readback) >= 0) seen |= readback.buttons;
|
||||
}
|
||||
c4fLog("probe bit 0x%08x (%s) -> reported 0x%08x\n",
|
||||
c4fBitCandidates[i].bit, c4fBitCandidates[i].name, seen);
|
||||
|
||||
/* Release, so presses do not run together. */
|
||||
c4fPadDataNeutral(&sample);
|
||||
for (frame = 0; frame < 5; frame++) {
|
||||
(void)c4fVirtualPadInsert(pad, &sample);
|
||||
usleep(C4F_FRAME_MS * 1000);
|
||||
}
|
||||
}
|
||||
c4fLog("probe done\n");
|
||||
}
|
||||
|
||||
void c4fVirtualPadRemove(C4fVirtualPad *pad)
|
||||
{
|
||||
int32_t ret;
|
||||
|
||||
if (!pad->owned || pad->handle < 0) return;
|
||||
|
||||
/* DeleteDevice alone, as SplashDown does. sceMbusDisconnectDevice is resolved
|
||||
* (g_mbusDisconnect) but calling it before the delete has not been tried on
|
||||
* hardware, so it stays out of the cleanup path until it has. */
|
||||
/* DeleteDevice alone, as SplashDown does. sceMbusDisconnectDevice exists, but
|
||||
* calling it before the delete has not been tried on hardware, so it stays
|
||||
* out of the cleanup path until it has. */
|
||||
ret = scePadVirtualDeviceDeleteDevice(pad->handle);
|
||||
c4fLog("DeleteDevice(0x%08x) = 0x%08x\n", (uint32_t)pad->handle, (uint32_t)ret);
|
||||
|
||||
pad->owned = 0;
|
||||
pad->bound = 0;
|
||||
pad->handle = -1;
|
||||
pad->deviceId = 0;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
#include <stdint.h>
|
||||
#include <sys/types.h>
|
||||
uint64_t kernel_get_ucred_authid(pid_t);
|
||||
int kernel_set_ucred_authid(pid_t, uint64_t);
|
||||
@@ -0,0 +1 @@
|
||||
int klog_printf(const char *, ...);
|
||||
@@ -0,0 +1,82 @@
|
||||
/* The real VDA capture functions, with only /dev/klog and the klog writer
|
||||
* stubbed. Modes: direct (the device is free), busy (another reader has it).
|
||||
*
|
||||
* GoldHEN's klog server on port 3232 serves one client at a time and can go
|
||||
* minutes without serving the next one, so the service must never reach for it.
|
||||
* A listener on 3232 stays open through both modes and must never see a
|
||||
* connection. */
|
||||
#include <assert.h>
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
#include <netinet/in.h>
|
||||
#include <pthread.h>
|
||||
#include <stdarg.h>
|
||||
#include <stdatomic.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include <sys/socket.h>
|
||||
#include <unistd.h>
|
||||
#include "c4f_vda.h"
|
||||
|
||||
static atomic_int peer = -1;
|
||||
static int direct[2] = {-1, -1}, enabled = 1, deviceBusy, directOpens, waits;
|
||||
static void waitCallback(void *unused) { (void)unused; waits++; }
|
||||
void c4fLog(const char *fmt, ...) { (void)fmt; }
|
||||
int c4fLogKlogEnabled(void) { return enabled; }
|
||||
void c4fLogSetKlog(int value) { enabled = value; }
|
||||
int klog_printf(const char *fmt, ...)
|
||||
{
|
||||
char line[1024]; va_list ap;
|
||||
va_start(ap, fmt); int n = vsnprintf(line, sizeof(line), fmt, ap); va_end(ap);
|
||||
if (direct[1] >= 0) { assert(write(direct[1], line, n) == n); return n; }
|
||||
return n;
|
||||
}
|
||||
int __real_open(const char *, int, ...);
|
||||
int __wrap_open(const char *path, int flags, ...)
|
||||
{
|
||||
if (strcmp(path, "/dev/klog")) return __real_open(path, flags);
|
||||
directOpens++;
|
||||
if (deviceBusy) { errno = EBUSY; return -1; }
|
||||
assert(pipe(direct) == 0);
|
||||
assert(fcntl(direct[0], F_SETFL, O_NONBLOCK) == 0);
|
||||
return direct[0];
|
||||
}
|
||||
static void *serve(void *arg)
|
||||
{
|
||||
int listener = *(int *)arg;
|
||||
peer = accept(listener, NULL, NULL);
|
||||
return NULL;
|
||||
}
|
||||
int main(int argc, char **argv)
|
||||
{
|
||||
assert(argc == 2);
|
||||
const char *mode = argv[1];
|
||||
int listener, fd; pthread_t thread;
|
||||
deviceBusy = strcmp(mode, "direct") != 0;
|
||||
|
||||
listener = socket(AF_INET, SOCK_STREAM, 0);
|
||||
int one = 1; setsockopt(listener, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one));
|
||||
struct sockaddr_in addr = {.sin_family = AF_INET, .sin_port = htons(3232), .sin_addr.s_addr = htonl(INADDR_LOOPBACK)};
|
||||
assert(bind(listener, (struct sockaddr *)&addr, sizeof(addr)) == 0);
|
||||
assert(listen(listener, 1) == 0);
|
||||
assert(pthread_create(&thread, NULL, serve, &listener) == 0);
|
||||
c4fVdaSetWaitCallback(waitCallback, NULL);
|
||||
|
||||
fd = c4fKlogOpenDevice();
|
||||
if (!deviceBusy) {
|
||||
/* Non-blocking, opened once, and verified by a marker read back, which
|
||||
* the wait callback keeps existing controllers reporting through. */
|
||||
assert(fd >= 0 && (fcntl(fd, F_GETFL) & O_NONBLOCK) && directOpens == 1 && waits > 0);
|
||||
close(fd);
|
||||
} else {
|
||||
assert(fd == -1 && directOpens == 1);
|
||||
}
|
||||
usleep(50000);
|
||||
assert(peer < 0); /* GoldHEN's klog stream was never touched */
|
||||
assert(enabled == 1);
|
||||
|
||||
pthread_cancel(thread);
|
||||
pthread_join(thread, NULL);
|
||||
close(listener);
|
||||
printf("PASS real klog acquisition: %s\n", mode);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
#include "autorun.h"
|
||||
#include "service.h"
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
int main(int argc, char **argv)
|
||||
{
|
||||
char message[160] = {0};
|
||||
if (argc < 2) return 2;
|
||||
if (getenv("C4F_TEST_HOST")) c4fLauncherSetHost(getenv("C4F_TEST_HOST"));
|
||||
if (!strncmp(argv[1], "autorun-", 8)) {
|
||||
/* autorun-check|autorun-on|autorun-off <root> [bundled.elf] */
|
||||
if (argc < 3) return 2;
|
||||
c4fAutorunSetRoot(argv[2]);
|
||||
if (argc > 3 && c4fAutorunLoadBundled(argv[3])) { printf("unreadable\n"); return 0; }
|
||||
if (!strcmp(argv[1], "autorun-check")) {
|
||||
char note[96];
|
||||
int state = c4fAutorunCheck(note, sizeof(note));
|
||||
printf("%d|%s\n", state, note);
|
||||
return 0;
|
||||
}
|
||||
int result = !strcmp(argv[1], "autorun-on") ? c4fAutorunEnable(message, sizeof(message)) :
|
||||
!strcmp(argv[1], "autorun-off") ? c4fAutorunDisable(message, sizeof(message)) : 2;
|
||||
printf("%d %s\n", result, message);
|
||||
return result == 2 ? 2 : 0;
|
||||
}
|
||||
if (!strcmp(argv[1], "probe")) {
|
||||
C4fServiceStatus s;
|
||||
int result = c4fLauncherProbe(&s);
|
||||
printf("%d %d %d %s|%s\n", result, s.controllers, s.stopping, s.version, c4fLauncherProblem());
|
||||
return 0;
|
||||
}
|
||||
if (!strcmp(argv[1], "start") && argc == 3) {
|
||||
size_t size = 0;
|
||||
const unsigned char *payload = c4fAutorunLoadBundled(argv[2]) ? NULL : c4fAutorunBundled(&size);
|
||||
int result = c4fLauncherStart(payload, size, message, sizeof(message));
|
||||
printf("%d %s\n", result, message); return 0;
|
||||
}
|
||||
int result = c4fLauncherStop(message, sizeof(message));
|
||||
printf("%d %s\n", result, message); return 0;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
#include <assert.h>
|
||||
#include <stdio.h>
|
||||
#include <stdarg.h>
|
||||
#include <string.h>
|
||||
#include <time.h>
|
||||
#include <unistd.h>
|
||||
#include "c4f_log.h"
|
||||
|
||||
static unsigned ticks;
|
||||
int __wrap_clock_gettime(clockid_t id, struct timespec *out)
|
||||
{
|
||||
assert(id == CLOCK_MONOTONIC);
|
||||
out->tv_sec = 4000 + ticks; out->tv_nsec = ticks++ ? 234000000 : 0;
|
||||
return 0;
|
||||
}
|
||||
int klog_printf(const char *fmt, ...) { (void)fmt; return 0; }
|
||||
int sceKernelSendNotificationRequest(int a, void *b, size_t c, int d)
|
||||
{ (void)a; (void)b; (void)c; (void)d; return 0; }
|
||||
int main(void)
|
||||
{
|
||||
char data[8192], large[2000];
|
||||
c4fLogOpen(); c4fLog("first\nsecond\n");
|
||||
memset(large, 'x', sizeof(large)-1); large[sizeof(large)-1] = 0;
|
||||
c4fLog("%s", large); c4fLogClose();
|
||||
FILE *file = fopen(C4F_LOG_PATH, "r"); assert(file);
|
||||
size_t n = fread(data, 1, sizeof(data)-1, file); data[n] = 0; fclose(file);
|
||||
assert(strstr(data, "[c4f] [+00:00:02.234] first\n"));
|
||||
assert(strstr(data, "[c4f] [+00:00:02.234] second\n"));
|
||||
char *p = data;
|
||||
while (*p) { assert(strncmp(p, "[c4f] [+", 8) == 0); p = strchr(p, '\n'); assert(p); p++; }
|
||||
c4fLogOpen(); c4fLog("new run\n"); c4fLogClose();
|
||||
file = fopen(C4F_LOG_PATH ".previous", "r"); assert(file);
|
||||
char previous[8192]; size_t m = fread(previous, 1, sizeof(previous), file); fclose(file);
|
||||
assert(m == n && memcmp(data, previous, n) == 0);
|
||||
puts("PASS elapsed timestamps, multiline/truncated messages and previous-log preservation");
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/* Link-time syscall fault injection; never linked into the payload. */
|
||||
#include <errno.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/select.h>
|
||||
#include <sys/socket.h>
|
||||
#include <unistd.h>
|
||||
|
||||
static int fault(const char *wanted)
|
||||
{
|
||||
const char *path = getenv("C4F_TEST_FAULT");
|
||||
char value[32] = {0};
|
||||
FILE *file = path ? fopen(path, "r") : NULL;
|
||||
if (!file) return 0;
|
||||
(void)fgets(value, sizeof(value), file); fclose(file);
|
||||
if (strcmp(value, wanted)) return 0;
|
||||
unlink(path); errno = ENETDOWN; return 1;
|
||||
}
|
||||
int __real_select(int, fd_set *, fd_set *, fd_set *, struct timeval *);
|
||||
int __wrap_select(int n, fd_set *r, fd_set *w, fd_set *e, struct timeval *t)
|
||||
{
|
||||
if (fault("select")) { errno = EBADF; return -1; }
|
||||
return __real_select(n, r, w, e, t);
|
||||
}
|
||||
int __real_accept(int, struct sockaddr *, socklen_t *);
|
||||
int __wrap_accept(int fd, struct sockaddr *a, socklen_t *n)
|
||||
{
|
||||
if (fault("accept")) return -1;
|
||||
return __real_accept(fd, a, n);
|
||||
}
|
||||
int __real_getsockopt(int, int, int, void *, socklen_t *);
|
||||
int __wrap_getsockopt(int fd, int level, int opt, void *out, socklen_t *size)
|
||||
{
|
||||
if (opt == SO_ERROR && fault("socket")) { *(int *)out = ECONNRESET; return 0; }
|
||||
return __real_getsockopt(fd, level, opt, out, size);
|
||||
}
|
||||
@@ -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())
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -0,0 +1,66 @@
|
||||
/* Local HTTP/WebSocket integration harness. No console access. */
|
||||
#include <stdio.h>
|
||||
#include <stdarg.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
#include <fcntl.h>
|
||||
#include "c4f_net.h"
|
||||
#include "c4f_vda.h"
|
||||
#include "c4f_web.h"
|
||||
|
||||
static unsigned nextId = 0x11030d;
|
||||
static void (*waitFn)(void *);
|
||||
static void *waitContext;
|
||||
static int klogSource = -1;
|
||||
void c4fLog(const char *fmt, ...) { va_list ap; va_start(ap, fmt); vprintf(fmt, ap); va_end(ap); }
|
||||
void c4fNotify(const char *fmt, ...) { (void)fmt; }
|
||||
void c4fVdaSetWaitCallback(void (*fn)(void *), void *ctx) { waitFn = fn; waitContext = ctx; }
|
||||
void c4fKlogDrain(int fd) { (void)fd; }
|
||||
int c4fKlogSelfTest(int fd) { (void)fd; return getenv("C4F_TEST_KLOG_BUSY") ? -1 : 0; }
|
||||
/* A klog source that only turns up later (AutoRun before GoldHEN's klog server). */
|
||||
int c4fKlogOpenDevice(void)
|
||||
{
|
||||
int fds[2];
|
||||
if (getenv("C4F_TEST_KLOG_BUSY")) { printf("KLOG none\n"); return -1; }
|
||||
if (klogSource >= 0) { int fd = dup(klogSource); printf("KLOG %d\n", fd); return fd; }
|
||||
if (!getenv("C4F_TEST_KLOG_LATE") || pipe(fds)) { printf("KLOG none\n"); return -1; }
|
||||
fcntl(fds[0], F_SETFL, O_NONBLOCK);
|
||||
printf("KLOG %d\n", fds[0]);
|
||||
return fds[0];
|
||||
}
|
||||
int c4fVirtualPadAddAs(C4fVirtualPad *p, int32_t user, int32_t vdaUser, int fd)
|
||||
{
|
||||
(void)fd;
|
||||
for (int i = 0; i < 3; i++) { if (waitFn) waitFn(waitContext); usleep(20000); }
|
||||
if (getenv("C4F_FAIL_ADD")) return -1;
|
||||
memset(p, 0, sizeof(*p));
|
||||
p->handle = nextId; p->deviceId = nextId; nextId += 0x10000;
|
||||
p->userId = user; p->vdaUserId = vdaUser; p->owned = 1;
|
||||
printf("ADD %x %d\n", p->handle, vdaUser);
|
||||
return 0;
|
||||
}
|
||||
void c4fVirtualPadRemove(C4fVirtualPad *p) { printf("REMOVE %x\n", p->handle); p->owned = 0; }
|
||||
void c4fPadDataNeutral(ScePadData *p)
|
||||
{
|
||||
memset(p, 0, sizeof(*p)); p->lx=p->ly=p->rx=p->ry=128; p->connected=1; p->quat[3]=1;
|
||||
}
|
||||
int32_t c4fVirtualPadInsert(const C4fVirtualPad *p, const ScePadData *d)
|
||||
{
|
||||
printf("FRAME %llu %x %u %u %u %u %u %u %u %u\n", (unsigned long long)c4fTimeMs(),
|
||||
p->handle, d->buttons, d->lx, d->ly, d->rx, d->ry, d->l2, d->r2, d->touchData.fingers);
|
||||
return getenv("C4F_FAIL_INPUT") && d->buttons ? -5 : 0;
|
||||
}
|
||||
int main(void)
|
||||
{
|
||||
const char *fd = getenv("C4F_TEST_KLOG_FD");
|
||||
int logs[2];
|
||||
setvbuf(stdout, NULL, _IONBF, 0);
|
||||
if (fd) { klogSource = atoi(fd); return c4fWebRun(-1) != 0; }
|
||||
if (pipe(logs)) return 1;
|
||||
fcntl(logs[0], F_SETFL, O_NONBLOCK);
|
||||
klogSource = logs[0];
|
||||
int result = c4fWebRun(-1);
|
||||
close(logs[1]);
|
||||
return result != 0;
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in new issue
Block a user