2 Commits
Author SHA1 Message Date
MoHadiShibli 9890560867 Read a linked controller's buttons from its own reports
Over Bluetooth a linked DualShock 4 or DualSense switches to its full
reports, which the browser's gamepad doesn't read, so the PS4 got no
input from it. While linked, its buttons, sticks and triggers now come
from those reports, over USB and Bluetooth alike.

A fault in the experimental code no longer stops the page's input loop,
and "How to turn it on" only shows when the page is insecure.
2026-10-16 23:42:52 +03:00
MoHadiShibli 7e503478c4 Add the PS4 controller test, motion input and experimental extras
PS4 app: Test controllers, opened with Options or from Actions, shows what
each signed-in user's controller sends, read the way a game reads it, on
the controller page's picture. Holding L3 + R3 opens its menu: change user,
rumble each motor, set or reset the light bar, reset orientation, go back.

Service: the control protocol takes motion ('m'), and info says so. Rumble
reached the page with the large and small motors swapped: fixed.

Page: Settings > Experimental, every switch off by default. Lights, rumble
and touchpad fingers for a DualShock 4 or DualSense over WebHID; trigger
rumble on Xbox controllers and a linked DualSense; motion from a phone or a
linked controller.
2026-10-16 16:08:13 +03:00
31 changed files with 3603 additions and 86 deletions

No files matched your search

+5
View File
@@ -44,6 +44,11 @@ jobs:
docker run --rm -v "$PWD:/src" -w /src mcr.microsoft.com/playwright:v1.63.0-noble \
bash -lc 'mkdir -p /tmp/c4f-browser; cd /tmp/c4f-browser; npm init -y >/dev/null; npm install playwright@1.63.0; cd /src; NODE_PATH=/tmp/c4f-browser/node_modules node tests/fake_gamepads_test.cjs'
- name: Experimental feature checks
run: |
docker run --rm -v "$PWD:/src" -w /src mcr.microsoft.com/playwright:v1.63.0-noble \
bash -lc 'mkdir -p /tmp/c4f-browser; cd /tmp/c4f-browser; npm init -y >/dev/null; npm install playwright@1.63.0; cd /src; NODE_PATH=/tmp/c4f-browser/node_modules node tests/experimental_test.cjs'
- name: Collect what was built
run: |
version=$(cat VERSION)
+24
View File
@@ -3,6 +3,30 @@
Versions follow [Semantic Versioning](https://semver.org): PATCH for fixes, MINOR for features that break
nothing, MAJOR for anything people rely on that changes.
## 1.3.0 — Unreleased
A controller test on the PS4, and experimental extras on the page.
- **Test controllers** in the PS4 app shows what each signed-in user's controller sends, read the way a game
reads it: buttons, sticks, triggers, touchpad and motion sensors, on the page's controller picture. Press
**Options** in the app, or choose it under **Actions**. Hold **L3 + R3** for two seconds for its menu:
change user, rumble each motor, set the light bar, reset orientation, or go back. PS and Share go to the
PS4 itself and aren't shown. See [Testing controllers](docs/playing.md#testing-controllers-on-the-ps4).
- **Settings → Experimental** on the page. Every switch stays off until you turn it on:
- **Lights and rumble on Sony controllers**: a DualShock 4 or DualSense connected to a computer lights up
in its PS4 controller's colour, rumbles directly (a DualSense included), and passes on its touchpad's
fingers, which browsers' gamepads leave out. Chrome or Edge on a computer.
- **Trigger rumble**: Xbox controllers rumble in their triggers too, and a linked DualSense vibrates its
triggers, following the game's rumble.
- **Motion controls**: tilting a phone, or a linked DualShock 4 or DualSense, moves the controller's
motion sensors.
Linking and motion need the page treated as secure: see
[Experimental features](docs/troubleshooting.md#experimental-features).
- The control protocol has a motion message, `m`. See [Protocol](docs/protocol.md).
- Fixed: a game's rumble reached gamepads on the controller page with the large and small motors
swapped, so heavy rumble buzzed lightly and light rumble shook hard.
## 1.2.1 — 2026-10-11
HDMI-CEC navigation for the PS4 app.
+13 -2
View File
@@ -46,9 +46,18 @@ sees a virtual DualShock 4, which provides:
- **Rumble and light bar.** When a game rumbles, your phone buzzes (Android), and so does your gamepad,
though not every controller supports vibration in the browser. The page glows in the controller's
light-bar colour, the one the PS4 or the game sets.
- **Experimental extras**, off until you turn them on in **Settings → Experimental**: a DualShock 4 or
DualSense connected to a computer lights up in the game's colour, rumbles directly (DualSense included) and
passes on its touchpad's fingers;
trigger rumble on Xbox controllers and a linked DualSense; and motion controls from a phone or a linked
DualShock 4 or DualSense. They need browser features that secure pages get, and the page is plain `http`:
see [Experimental features](docs/troubleshooting.md#experimental-features).
- **Fast.** Input reaches the console the moment it arrives, at a DualShock 4's own rate while you play.
- **An app on the PS4** that shows the address and a QR code, how many controllers are connected, and starts
or stops Control4Free. Use a controller or your TV remote through [HDMI-CEC](docs/hdmi-cec.md).
- **Test controllers on the PS4.** Press **Options** in the app to see what each signed-in user's controller
sends, read the way a game reads it: buttons, sticks, triggers, touchpad and motion. Its menu rumbles each
motor and sets the light bar. See [Testing controllers](docs/playing.md#testing-controllers-on-the-ps4).
- **Starts by itself.** One button in the app sets up GoldHEN's AutoRun, so Control4Free is ready after
every restart.
- **On your phone's home screen**, like an app.
@@ -107,8 +116,10 @@ New to GoldHEN packages? The **[installation guide](docs/installation.md)** walk
With several Xbox controllers, some Chrome/Edge Windows backends combine their Xbox buttons; see the
[GameInput workaround](docs/troubleshooting.md#gamepads).
- **Six pages at a time.** Up to six phones or computers can have the page open at once.
- **No motion controls.** Browsers only give phone motion sensors to secure (`https`) pages, and the
console's page is plain `http`.
- **Motion and direct controller lights are experimental.** Browsers only give motion sensors and direct
controller access to secure pages, and the console's page is plain `http`. Chrome and Edge can treat it
as secure with one setting; Safari on an iPhone can't. Direct controller access works in Chrome and Edge
on a computer only. See [Experimental features](docs/troubleshooting.md#experimental-features).
- **iPhones don't vibrate.** Safari doesn't let pages use the phone's vibration. A gamepad connected to the
iPhone can still rumble, if the browser supports it.
- **One controller is added at a time.** If two people pick at once, the second is asked to try again a
+5 -1
View File
@@ -36,7 +36,11 @@
by Tokyoship, licensed under CC BY 3.0
(https://creativecommons.org/licenses/by/3.0/). The PS button's mark uses the
diagram's paths, resized and recolored; the surrounding buttons are adapted
to the interactive page.
to the interactive page. The PS4 app's controller test (`launcher/tester_draw.c`)
draws the same picture, PS mark included.
- The DualSense trigger vibration in `client/index.html` follows the effect format
of John "Nielk1" Klein's DualSense trigger effect generator
(https://gist.github.com/Nielk1/6d54cc2c00d2201ccb8c2720ad7538db), MIT.
These license notices are also included inside the package.
+1 -1
View File
@@ -1 +1 @@
1.2.1
1.3.0
+591 -7
View File
@@ -882,6 +882,12 @@
.check:disabled, .field:disabled { opacity: .5; cursor: not-allowed; }
.check:focus-visible { box-shadow: inset 0 0 0 2px #fff, var(--glow); }
/* Settings → Experimental */
.exp-block { margin-top: 26px; }
.exp-title { margin: 0; font-size: 18px; font-weight: 400; }
.row-desc.blocked { color: var(--warn); }
.row-desc a { color: inherit; text-underline-offset: 3px; }
.pad-card { margin-top: 16px; border-radius: 6px; background: var(--panel); box-shadow: inset 0 0 0 1px var(--line-soft); overflow: hidden; }
.pad-card-head { --pc: #3a8cff; display: flex; align-items: center; gap: 14px; padding: 14px 18px; border-bottom: 1px solid var(--line-soft); }
.pad-card-head .ring { width: 40px; height: 40px; font-size: 18px; }
@@ -1384,6 +1390,7 @@
<button class="nav-item" role="tab" data-page="device" aria-selected="false"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><rect x="7" y="2.5" width="10" height="19" rx="2"/><path d="M11 18.5h2"/></svg>This device</button>
<button class="nav-item" role="tab" data-page="keyboard" aria-selected="false"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><rect x="2.5" y="6" width="19" height="12" rx="2"/><path d="M6 10h1M9.5 10h1M13 10h1M16.5 10h1M7 14h10"/></svg>Keyboard</button>
<button class="nav-item" role="tab" data-page="gamepads" aria-selected="false"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"><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"/><path d="M7.5 10v3M6 11.5h3"/></svg>Gamepads</button>
<button class="nav-item" role="tab" data-page="experimental" aria-selected="false"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M9 3h6M10 3v6.5l-5.4 8.7A1.9 1.9 0 0 0 6.2 21h11.6a1.9 1.9 0 0 0 1.6-2.8L14 9.5V3"/><path d="M7.4 15h9.2"/></svg>Experimental</button>
</nav>
<div class="settings-pages">
@@ -1399,6 +1406,23 @@
<div class="rows" id="deviceRows"></div>
</section>
<section data-page="experimental" hidden>
<h2 class="page-title">Experimental</h2>
<p class="hint">Extras that use browser features most pages don't get, or that PS4 games never ask for. Each one stays off until you turn it on here, and it's saved on this phone or computer only.</p>
<div class="rows" id="expRows"></div>
<div class="exp-block" id="expSony" hidden>
<h3 class="exp-title">Sony controllers</h3>
<p class="hint">Link a DualShock 4 or DualSense connected to this computer. The browser asks which controller to allow. A linked controller lights up in its PS4 controller's colour, rumbles directly, and its touchpad works with fingers, not only clicks.</p>
<ul class="gp-list" id="expPads"></ul>
<p class="hint" id="expPadsEmpty">Connect a DualShock 4 or DualSense to this computer and press a button on it.</p>
</div>
<div class="exp-block" id="expMotion" hidden>
<h3 class="exp-title">Motion</h3>
<p class="hint" id="expMotionHint"></p>
<div class="map-actions" style="margin-top:12px"><button class="btn" type="button" id="expRecenter">Recenter</button></div>
</div>
</section>
<section data-page="keyboard" hidden>
<h2 class="page-title">Keyboard</h2>
<p class="hint">Keys drive the controller open on this page. Choose them on a picture of the controller: click a control, then press its key.</p>
@@ -1561,6 +1585,7 @@
const conn = {
ws: null, state: 'connecting', address: '', version: '', nextId: 1,
pending: new Map(), retry: 0, retryTimer: 0, everOpened: false,
motion: false, // the service takes 'm' (its info says so)
};
const status = Array.from({ length: PADS }, (_, i) => ({
@@ -1629,6 +1654,7 @@
$('#addressHint').classList.remove('error');
call('info', []).then(info => {
conn.version = info.version || '';
conn.motion = info.motion === true;
showVersion();
sendClaim(true);
}).catch(() => {});
@@ -1638,6 +1664,8 @@
ws.onclose = () => {
if (conn.ws !== ws) return;
conn.ws = null;
conn.motion = false;
motionLive.fill(false);
// Reconnecting the page must not silently recreate a controller or
// replay held buttons. The user selects a controller again.
resetClaims();
@@ -3900,11 +3928,16 @@
src.snapshot = ControllerMapping.read(gp);
const profile = profileFor(src);
const before = src.buttons + ',' + src.lx + ',' + src.ly + ',' + src.rx + ',' + src.ry + ',' + src.l2 + ',' + src.r2;
// Experimental: a linked Sony controller's own reports stand in for the
// browser's view of it, which goes quiet over Bluetooth once it's linked.
const viaHid = hidLinks.has(gp.index) && src.hidState && t - src.hidAt < 250;
if (viaHid) src.hold = {};
if (!src.hold) src.hold = ControllerMapping.holdBack(profile);
ControllerMapping.letGo(src.snapshot, profile, src.hold);
const waiting = Object.keys(src.hold).length > 0;
if (waiting !== src.waitNeutral) { src.waitNeutral = waiting; renderGamepads(); }
if (mapper.index === gp.index || ControllerMapping.unresolved(profile, signature)) src.mapped = ControllerMapping.empty();
else if (viaHid) src.mapped = ControllerMapping.fromSource(Object.assign(src, src.hidState));
else src.mapped = ControllerMapping.evaluate(src.snapshot, profile, src.transient, src.hold);
Object.assign(src, ControllerMapping.toSource(src.mapped));
const after = src.buttons + ',' + src.lx + ',' + src.ly + ',' + src.rx + ',' + src.ry + ',' + src.l2 + ',' + src.r2;
@@ -3951,7 +3984,8 @@
}
function stopRumble(pad) {
for (const src of gamepadSources.values()) {
for (const [index, src] of gamepadSources) {
if (hidLinks.has(index)) continue; /* a linked controller is told directly */
if (src.pad === pad && src.gp && src.gp.vibrationActuator && src.gp.vibrationActuator.reset) {
try { src.gp.vibrationActuator.reset(); } catch (e) { /* ignore */ }
}
@@ -3972,11 +4006,14 @@
if (!r.large && !r.small) continue;
if (t - r.lastKick < 110) continue;
r.lastKick = t;
for (const src of gamepadSources.values()) {
for (const [index, src] of gamepadSources) {
const act = src.gp && src.gp.vibrationActuator;
if (src.pad === pad && act && act.playEffect) {
act.playEffect('dual-rumble', { startDelay: 0, duration: 150, strongMagnitude: r.large / 255, weakMagnitude: r.small / 255 }).catch(() => {});
}
if (src.pad !== pad || !act || !act.playEffect || hidLinks.has(index)) continue;
// Experimental: Xbox controllers that have them rumble in their triggers too.
const triggers = experiments.triggers && Array.isArray(act.effects) && act.effects.includes('trigger-rumble');
const effect = { startDelay: 0, duration: 150, strongMagnitude: r.large / 255, weakMagnitude: r.small / 255 };
if (triggers) Object.assign(effect, { leftTrigger: r.large / 255 * .6, rightTrigger: r.small / 255 * .6 });
act.playEffect(triggers ? 'trigger-rumble' : 'dual-rumble', effect).catch(() => {});
}
if (pad === primaryPad && settings.haptics) {
const strength = Math.max(r.large, r.small) / 255;
@@ -4080,7 +4117,8 @@
const text = $('.gp-details', device);
const mapped = src.gp && src.gp.mapping === 'standard';
if (!mapped) text.appendChild(el('div', 'gp-note warning', 'Buttons may be mixed up. Check Mapping.'));
if (!canRumble(src.gp)) text.appendChild(el('div', 'gp-note', 'No rumble in this browser'));
if (hidLinks.has(index)) text.appendChild(el('div', 'gp-note', 'Linked: light bar and rumble'));
else if (!canRumble(src.gp)) text.appendChild(el('div', 'gp-note', 'No rumble in this browser'));
if (src.waitNeutral) text.appendChild(el('div', 'gp-note warning', 'Let go of the held buttons'));
row.appendChild(device);
const actions = el('div', 'gp-actions');
@@ -4142,6 +4180,7 @@
row.appendChild(map);
list.appendChild(row);
}
if (experimentOn('hid')) renderSonyPads();
}
const activityTimers = [];
@@ -4428,6 +4467,545 @@
rebuildKeymap();
// ---------------------------------------------------------------- experimental
// Extras that need what browsers give secure pages only (WebHID, motion
// sensors), or that PS4 games never ask for (trigger rumble). Each is off
// until turned on in Settings → Experimental; while one is off, none of its
// code runs: no device prompts, no sensor listeners, no motion messages.
const EXPERIMENTS = [
['hid', 'Lights and rumble on Sony controllers', 'A DualShock 4 or DualSense connected to this computer lights up in its PS4 controller\'s colour, rumbles directly (a DualSense included), and passes on its touchpad\'s fingers. Chrome or Edge on a computer.'],
['triggers', 'Trigger rumble', 'Xbox controllers rumble in their triggers too, and a linked DualSense vibrates its triggers, following the game\'s rumble. PS4 games don\'t ask for this: Control4Free makes it up.'],
['motion', 'Motion controls', 'Tilting this phone, or a linked DualShock 4 or DualSense, moves the PS4 controller\'s motion sensors.'],
];
const EXPERIMENTS_HELP = 'https://github.com/MoHadiShibli/Control4Free/blob/main/docs/troubleshooting.md#experimental-features';
const experiments = {};
for (const [key] of EXPERIMENTS) experiments[key] = store.get('exp.' + key, false) === true;
// Why an experiment can't run in this browser, or '' when it can.
function experimentBlocked(key) {
if (key === 'triggers') return '';
if (!window.isSecureContext) return 'This browser treats the page as insecure, so it holds this back.';
if (key === 'hid' && !('hid' in navigator)) return 'This browser can\'t talk to controllers directly. Use Chrome or Edge on a computer.';
if (key === 'motion' && !('hid' in navigator) && !('DeviceMotionEvent' in window)) return 'This browser has no motion sensors to offer.';
return '';
}
const experimentOn = key => experiments[key] && !experimentBlocked(key);
function setExperiment(key, on) {
experiments[key] = on;
store.set('exp.' + key, on);
if (key === 'hid') { if (on) hidStart(); else hidStop(); }
if (key === 'motion') { if (on) motionStart(); else motionStop(); }
if (key === 'triggers') for (const link of hidLinks.values()) hidUpdate(link);
renderExperiments();
renderGamepads();
}
function renderExperiments() {
const rows = $('#expRows');
rows.textContent = '';
for (const [key, label, desc] of EXPERIMENTS) {
const blocked = experimentBlocked(key);
const row = el('label', 'row');
row.appendChild(el('span', 'row-label', label));
const input = el('input', 'check');
input.type = 'checkbox';
input.checked = experimentOn(key);
input.disabled = !!blocked;
input.dataset.experiment = key;
input.addEventListener('change', () => setExperiment(key, input.checked));
row.appendChild(input);
row.appendChild(el('span', 'row-desc', desc));
if (blocked) {
const why = el('span', 'row-desc blocked', blocked + ' ');
// Only an insecure page has a fix in this browser's settings.
if (!window.isSecureContext) {
const help = el('a', '', 'How to turn it on');
help.href = EXPERIMENTS_HELP;
help.target = '_blank';
help.rel = 'noopener';
why.appendChild(help);
}
row.appendChild(why);
}
rows.appendChild(row);
}
$('#expSony').hidden = !experimentOn('hid');
if (experimentOn('hid')) renderSonyPads();
$('#expMotion').hidden = !experimentOn('motion');
$('#expMotionHint').textContent = ('DeviceMotionEvent' in window ? 'Hold the phone sideways, screen towards you, like a controller. ' : '') +
('hid' in navigator ? 'A linked DualShock 4 or DualSense sends its own motion. ' : '') +
'Recenter makes the way you hold it now the controller\'s resting position.';
}
// ---- Sony controllers over WebHID
const SONY = 0x054c;
const SONY_MODELS = { 0x05c4: 'ds4', 0x09cc: 'ds4', 0x0ba0: 'ds4', 0x0ce6: 'dualsense', 0x0df2: 'dualsense' };
const hidLinks = new Map(); // gamepad index -> its direct connection
// Vendor and product from a gamepad's id: Chrome "… Vendor: 054c Product: 09cc)", Firefox "054c-09cc-…".
function gamepadIds(gp) {
const id = String((gp && gp.id) || '');
const m = /Vendor:\s*([0-9a-f]{4})\b[^]*?Product:\s*([0-9a-f]{4})\b/i.exec(id) || /^([0-9a-f]{1,4})-([0-9a-f]{1,4})-/i.exec(id);
return m ? { vendor: parseInt(m[1], 16), product: parseInt(m[2], 16) } : null;
}
function sonyModel(gp) {
const ids = gamepadIds(gp);
return ids && ids.vendor === SONY ? SONY_MODELS[ids.product] || null : null;
}
function renderSonyPads() {
const list = $('#expPads');
list.textContent = '';
let any = false;
for (const [index, src] of gamepadSources) {
if (!sonyModel(src.gp)) continue;
any = true;
const row = el('li', 'gp-row gp-row--settings');
const device = gamepadDevice(src, index);
const link = hidLinks.get(index);
$('.gp-details', device).appendChild(el('div', 'gp-note', link
? 'Linked' + (link.bt ? ' over Bluetooth' : ' by USB') : 'Not linked'));
row.appendChild(device);
const button = el('button', 'btn gp-mapping', link ? 'Unlink' : 'Link');
button.type = 'button';
button.dataset.link = String(index);
button.addEventListener('click', () => link ? hidDetach(index) : hidLink(index));
row.appendChild(button);
list.appendChild(row);
}
$('#expPadsEmpty').hidden = any;
}
const crcTable = (() => {
const table = new Uint32Array(256);
for (let n = 0; n < 256; n++) {
let c = n;
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
table[n] = c >>> 0;
}
return table;
})();
// Bluetooth output reports end in a CRC-32 of 0xa2, the report ID and the
// rest of the report, stored little-endian in the last four bytes.
function psCrc(reportId, data) {
let c = 0xffffffff;
const add = byte => { c = crcTable[(c ^ byte) & 0xff] ^ (c >>> 8); };
add(0xa2);
add(reportId);
for (let i = 0; i < data.length - 4; i++) add(data[i]);
c = (c ^ 0xffffffff) >>> 0;
for (let i = 0; i < 4; i++) data[data.length - 4 + i] = (c >>> (8 * i)) & 0xff;
}
// DualSense trigger effect, 11 bytes: Vibration (mode 0x26) over the whole
// travel at a strength from 1 to 8, or Off (0x05). After Nielk1's
// DualSense trigger effect generator (MIT).
function triggerEffect(out, at, strength, frequency) {
out.fill(0, at, at + 11);
if (strength <= 0) { out[at] = 0x05; return; }
let zones = 0, amplitude = 0;
for (let i = 0; i < 10; i++) { zones |= 1 << i; amplitude += (strength - 1) * 2 ** (3 * i); }
out[at] = 0x26;
out[at + 1] = zones & 0xff;
out[at + 2] = zones >> 8;
for (let i = 0; i < 4; i++) out[at + 3 + i] = Math.floor(amplitude / 2 ** (8 * i)) & 0xff;
out[at + 9] = frequency;
}
// One output report for a linked controller's current state, as Linux's
// hid-playstation driver lays them out. Returns { id, data } (data without the ID).
function hidReport(link, setupOnly) {
const [large, small] = link.motors, [r, g, b] = link.color;
if (link.model === 'ds4') {
const data = new Uint8Array(link.bt ? 77 : 31), at = link.bt ? 2 : 0;
if (link.bt) data[0] = 0xc4; // HID + CRC, 4 ms input reports
data[at] = 0x03; // motors and light bar
data[at + 3] = small;
data[at + 4] = large;
data[at + 5] = r; data[at + 6] = g; data[at + 7] = b;
if (link.bt) psCrc(0x11, data);
return { id: link.bt ? 0x11 : 0x05, data };
}
const common = new Uint8Array(47);
if (setupOnly) {
common[38] = 0x02; // light bar setup: fade out the start-up light
common[41] = 0x02;
} else {
common[0] = 0x02 | (link.v2 ? 0 : 0x01); // rumble, the DualShock 4 way
if (link.v2) common[38] = 0x04;
common[1] = 0x04; // light bar
common[2] = small;
common[3] = large;
common[44] = r; common[45] = g; common[46] = b;
if (link.triggerSend) {
common[0] |= 0x0c; // both trigger effects
triggerEffect(common, 10, link.trigger[1], 60);
triggerEffect(common, 21, link.trigger[0], 30);
}
}
if (!link.bt) {
const data = new Uint8Array(62);
data.set(common);
return { id: 0x02, data };
}
const data = new Uint8Array(77);
data[0] = (link.seq & 15) << 4;
data[1] = 0x10;
data.set(common, 2);
link.seq = (link.seq + 1) & 15;
psCrc(0x31, data);
return { id: 0x31, data };
}
function hidHasOutput(device, reportId) {
return (device.collections || []).some(c => (c.outputReports || []).some(r => r.reportId === reportId));
}
// What the linked controller should be doing: its PS4 controller's rumble and colour.
function hidUpdate(link) {
const src = gamepadSources.get(link.index);
const pad = src ? src.pad : -1;
const r = pad >= 0 && rumble[pad] ? rumble[pad] : { large: 0, small: 0 };
link.motors = [r.large, r.small];
link.color = pad >= 0 && status[pad] && Array.isArray(status[pad].color) ? status[pad].color.slice(0, 3) : [0, 0, 64];
const level = v => experiments.triggers ? Math.round(v / 255 * 8) : 0;
const trigger = link.model === 'dualsense' ? [level(r.large), level(r.small)] : [0, 0];
// Trigger effects go out only when they change; re-sending them makes the motors whine.
link.triggerSend = link.triggerSend || trigger[0] !== link.trigger[0] || trigger[1] !== link.trigger[1];
link.trigger = trigger;
hidSend(link);
}
async function hidSend(link, force) {
if (link.busy) { link.again = true; return; }
const key = link.motors.join() + '|' + link.color.join() + '|' + link.trigger.join();
const at = now();
// Unchanged: only a slow refresh while it rumbles, in case a report went missing.
if (!force && !link.triggerSend && key === link.sent && !(link.motors.some(v => v) && at - link.sentAt > 500)) return;
link.busy = true;
try {
if (link.setup) { const s = hidReport(link, true); await link.device.sendReport(s.id, s.data); link.setup = false; }
const report = hidReport(link, false);
await link.device.sendReport(report.id, report.data);
link.sent = key;
link.sentAt = at;
link.triggerSend = false;
} catch (e) {
link.sent = '';
}
link.busy = false;
if (link.again) { link.again = false; hidUpdate(link); }
}
async function hidLink(index) {
const src = gamepadSources.get(index), ids = src && gamepadIds(src.gp);
if (!ids || !experimentOn('hid')) return;
let devices = [];
try {
devices = await navigator.hid.requestDevice({ filters: [{ vendorId: SONY, productId: ids.product }] });
} catch (e) {
toast('The browser didn\'t allow the controller');
return;
}
if (devices.length) await hidAttach(index, devices[0]);
}
async function hidAttach(index, device) {
for (const [i, other] of [...hidLinks]) if (other.device === device || i === index) await hidDetach(i, true);
const model = SONY_MODELS[device.productId];
if (!model) return;
try {
if (!device.opened) await device.open();
} catch (e) {
toast('Couldn\'t open the controller. Close other apps that use it, then try again.');
return;
}
const link = {
device, model, index, bt: !hidHasOutput(device, model === 'ds4' ? 0x05 : 0x02) && hidHasOutput(device, model === 'ds4' ? 0x11 : 0x31),
seq: 0, sent: '', sentAt: 0, busy: false, again: false, setup: model === 'dualsense', v2: device.productId === 0x0df2,
motors: [0, 0], color: [0, 0, 0], trigger: [0, 0], triggerSend: false, calib: null, motion: null,
};
link.onInput = ev => hidInput(link, ev);
device.addEventListener('inputreport', link.onInput);
hidLinks.set(index, link);
await hidCalibrate(link);
if (hidLinks.get(index) === link) hidUpdate(link);
renderExperiments();
renderGamepads();
}
async function hidDetach(index, quiet) {
const link = hidLinks.get(index);
if (!link) return;
hidLinks.delete(index);
link.device.removeEventListener('inputreport', link.onInput);
const src = gamepadSources.get(index);
if (src) { src.touches = []; src.hidState = null; }
motionLeave(link.motion);
// Left still, in a dim blue, once any report on its way has gone.
for (let i = 0; link.busy && i < 20; i++) await new Promise(done => setTimeout(done, 10));
link.motors = [0, 0];
link.color = [0, 0, 64];
link.triggerSend = link.trigger.some(v => v);
link.trigger = [0, 0];
link.busy = link.again = false;
await hidSend(link, true);
try { await link.device.close(); } catch (e) { /* already gone */ }
if (!quiet) { renderExperiments(); renderGamepads(); }
}
// A controller the browser was allowed before links again by itself, when
// exactly one gamepad of that kind is waiting for it.
let hidListening = false;
async function hidStart() {
if (!experimentOn('hid')) return;
if (!hidListening) {
hidListening = true;
navigator.hid.addEventListener('disconnect', ev => {
for (const [index, link] of [...hidLinks]) if (link.device === ev.device) hidDetach(index);
});
navigator.hid.addEventListener('connect', () => { if (experimentOn('hid')) hidStart(); });
}
let granted = [];
try { granted = await navigator.hid.getDevices(); } catch (e) { return; }
for (const device of granted) {
if (device.vendorId !== SONY || !SONY_MODELS[device.productId]) continue;
if ([...hidLinks.values()].some(l => l.device === device)) continue;
const waiting = [...gamepadSources].filter(([i, src]) => !hidLinks.has(i) && (gamepadIds(src.gp) || {}).product === device.productId);
if (waiting.length === 1) await hidAttach(waiting[0][0], device);
}
}
async function hidStop() {
for (const index of [...hidLinks.keys()]) await hidDetach(index, true);
}
// Reading the calibration (and, on a DualSense, the firmware version) also
// switches a Bluetooth controller to its full reports, motion included.
async function hidCalibrate(link) {
// Offsets count the report ID as byte 0, as the report's full length does.
const lengths = { 0x02: 37, 0x05: 41, 0x20: 64 };
const feature = async id => {
const view = await link.device.receiveFeatureReport(id);
const shift = view.byteLength >= lengths[id] ? 0 : view.byteLength === lengths[id] - 1 ? -1
: view.getUint8(0) === id ? 0 : -1;
return { u16: at => view.getUint16(at + shift, true), i16: at => view.getInt16(at + shift, true) };
};
try {
const f = await feature(link.model === 'ds4' && !link.bt ? 0x02 : 0x05);
const bias = [f.i16(1), f.i16(3), f.i16(5)];
const plusMinus = link.model === 'ds4' && link.bt
? [[f.i16(7), f.i16(13)], [f.i16(9), f.i16(15)], [f.i16(11), f.i16(17)]]
: [[f.i16(7), f.i16(9)], [f.i16(11), f.i16(13)], [f.i16(15), f.i16(17)]];
const speed = f.i16(19) + f.i16(21);
const gyro = plusMinus.map(([p, m], i) => {
const span = Math.abs(p - bias[i]) + Math.abs(m - bias[i]);
return span ? speed / span : 0;
});
const accel = [[f.i16(23), f.i16(25)], [f.i16(27), f.i16(29)], [f.i16(31), f.i16(33)]].map(([p, m]) => {
const range = p - m;
return range ? { bias: p - range / 2, scale: 2 / range } : null;
});
if (gyro.every(v => v > 0) && accel.every(Boolean)) link.calib = { gyro, accel };
} catch (e) { /* fall back to the sensors' usual ranges */ }
if (link.model === 'dualsense' && !link.v2) {
try { link.v2 = (await feature(0x20)).u16(44) >= 0x0215; } catch (e) { /* older firmware's rumble */ }
}
}
// Up to two fingers from a report: contact (bit 7 set once lifted, the rest
// the finger's id), then 12-bit x and y. A DualSense's touchpad is 1080
// high and the PS4's 942, so its y is scaled.
function hidTouches(data, at, height) {
const touches = [];
for (let i = 0; i < 2 && data.byteLength >= at + 4 * i + 4; i++) {
const p = at + 4 * i, contact = data.getUint8(p);
if (contact & 0x80) continue;
const x = data.getUint8(p + 1) | (data.getUint8(p + 2) & 0x0f) << 8;
const y = data.getUint8(p + 2) >> 4 | data.getUint8(p + 3) << 4;
touches.push({ id: contact & 0x7f, x: clamp(x, 0, TOUCH_W), y: clamp(Math.round(y * (TOUCH_H + 1) / height), 0, TOUCH_H) });
}
return touches;
}
// A report's buttons, sticks and triggers. Over Bluetooth a linked controller
// switches to its full reports, which the browser's own gamepad doesn't read,
// so a linked controller's input comes from here. DS4: sticks, then buttons,
// then triggers; DualSense: sticks, triggers, a counter, buttons. Its short
// Bluetooth report, before the switch, is laid out like a DS4's.
const HAT = [B.UP, B.UP | B.RIGHT, B.RIGHT, B.RIGHT | B.DOWN, B.DOWN, B.DOWN | B.LEFT, B.LEFT, B.LEFT | B.UP];
const HID_BUTTONS = [[0x10, B.SQUARE], [0x20, B.CROSS], [0x40, B.CIRCLE], [0x80, B.TRIANGLE]];
const HID_BUTTONS1 = [[0x01, B.L1], [0x02, B.R1], [0x04, B.L2], [0x08, B.R2], [0x10, B.SHARE], [0x20, B.OPTIONS], [0x40, B.L3], [0x80, B.R3]];
function hidState(link, data, id) {
const n = data.byteLength;
let at, triggersAt, buttonsAt;
if (link.model === 'ds4' || (id === 0x01 && n < 63)) {
if (id === 0x01 && n >= 9) at = 0; else if (id === 0x11 && n >= 11) at = 2; else return null;
buttonsAt = at + 4; triggersAt = at + 7;
} else if (id === 0x01) {
at = 0; triggersAt = 4; buttonsAt = 7;
} else if (id === 0x31 && n >= 11) {
at = 1; triggersAt = 5; buttonsAt = 8;
} else return null;
const b0 = data.getUint8(buttonsAt), b1 = data.getUint8(buttonsAt + 1), b2 = data.getUint8(buttonsAt + 2);
let buttons = (b0 & 0x0f) < 8 ? HAT[b0 & 0x0f] : 0;
for (const [bit, out] of HID_BUTTONS) if (b0 & bit) buttons |= out;
for (const [bit, out] of HID_BUTTONS1) if (b1 & bit) buttons |= out;
if (b2 & 0x01) buttons |= B.PS;
if (b2 & 0x02) buttons |= B.TOUCH;
return { buttons, lx: data.getUint8(at), ly: data.getUint8(at + 1), rx: data.getUint8(at + 2), ry: data.getUint8(at + 3),
l2: data.getUint8(triggersAt), r2: data.getUint8(triggersAt + 1) };
}
// Input from a linked controller: its buttons and sticks (see hidState), the
// touchpad's fingers, which browsers' gamepads leave out, and the motion
// sensors while motion is on. Offsets are hid-playstation's, without the
// report ID: [gyro, touch, touch sets].
function hidInput(link, ev) {
const data = ev.data, id = ev.reportId, ds4 = link.model === 'ds4';
const src = gamepadSources.get(link.index);
const state = hidState(link, data, id);
if (src && state) { src.hidState = state; src.hidAt = ev.timeStamp; }
const layout = ds4 ? (id === 0x01 ? [12, 32, 3] : id === 0x11 ? [14, 34, 4] : null)
: (id === 0x01 ? [15, 32, 0] : id === 0x31 ? [16, 33, 0] : null);
if (!layout || data.byteLength < layout[1] + 8) return;
const [gyroAt, touchAt, sets] = layout, accelAt = gyroAt + 6;
if (src && !ds4) src.touches = hidTouches(data, touchAt, 1080);
if (src && ds4) {
// A DS4 report holds the touch reports since its last one, newest last.
const count = Math.min(data.getUint8(touchAt), sets);
if (count) src.touches = hidTouches(data, touchAt + 1 + 9 * (count - 1) + 1, 942);
}
if (!experimentOn('motion')) return;
const c = link.calib, D = Math.PI / 180;
const gyro = [0, 1, 2].map(i => data.getInt16(gyroAt + 2 * i, true) * (c ? c.gyro[i] : 1 / 16.4) * D);
const accel = [0, 1, 2].map(i => {
const raw = data.getInt16(accelAt + 2 * i, true);
return c ? (raw - c.accel[i].bias) * c.accel[i].scale : raw / 8192;
});
if (!link.motion) link.motion = newMotion();
motionSample(link.motion, gyro, accel, ev.timeStamp);
}
// ---- motion: the phone's sensors, or a linked controller's
// The PS4 reads a DualShock 4 with x to the right, y up out of its face and z
// towards the player, so lying flat it reads +1 G on y. A phone held sideways
// with the screen towards the player lines up with that directly.
function newMotion() { return { q: [1, 0, 0, 0], gyro: [0, 0, 0], accel: [0, 1, 0], at: 0, fresh: false, pad: -1 }; }
const phoneMotion = newMotion();
const motionSentAt = Array(PADS).fill(0);
const motionLive = Array(PADS).fill(false);
// Mahony's filter: the gyro turns the orientation, gravity keeps up up.
function motionSample(m, gyro, accel, t) {
const dt = m.at ? clamp((t - m.at) / 1000, 0, .1) : 0;
m.at = t;
m.gyro = gyro;
m.accel = accel;
m.fresh = true;
if (!dt) return;
let [w, x, y, z] = m.q, [gx, gy, gz] = gyro;
const n = Math.hypot(accel[0], accel[1], accel[2]);
if (n > .5 && n < 1.5) {
const ax = accel[0] / n, ay = accel[1] / n, az = accel[2] / n;
const vx = 2 * (x * y + w * z), vy = 1 - 2 * (x * x + z * z), vz = 2 * (y * z - w * x);
gx += 2 * (ay * vz - az * vy);
gy += 2 * (az * vx - ax * vz);
gz += 2 * (ax * vy - ay * vx);
}
const h = dt / 2;
const q = [w + h * (-x * gx - y * gy - z * gz), x + h * (w * gx + y * gz - z * gy),
y + h * (w * gy - x * gz + z * gx), z + h * (w * gz + x * gy - y * gx)];
const len = Math.hypot(...q) || 1;
m.q = q.map(v => v / len);
}
function onDeviceMotion(ev) {
const a = ev.accelerationIncludingGravity, r = ev.rotationRate;
if (!a || a.x === null || a.x === undefined || !r) return;
// Device axes to the screen's, as the phone is turned.
const angle = ((screen.orientation && screen.orientation.angle) || 0) * Math.PI / 180;
const cos = Math.cos(angle), sin = Math.sin(angle);
const turn = (x, y) => [x * cos - y * sin, x * sin + y * cos];
const [ax, ay] = turn(a.x, a.y), [gx, gy] = turn(r.beta || 0, r.gamma || 0);
const G = 9.80665, D = Math.PI / 180;
motionSample(phoneMotion, [gx * D, gy * D, (r.alpha || 0) * D], [ax / G, ay / G, (a.z || 0) / G], ev.timeStamp);
}
function motionStart() {
if (!experimentOn('motion') || !('DeviceMotionEvent' in window)) return;
window.addEventListener('devicemotion', onDeviceMotion);
// Safari sends nothing until asked from a tap; Chrome sends the events either way.
if (typeof DeviceMotionEvent.requestPermission === 'function') DeviceMotionEvent.requestPermission().catch(() => {});
}
function motionStop() {
window.removeEventListener('devicemotion', onDeviceMotion);
motionLeave(phoneMotion);
for (const link of hidLinks.values()) motionLeave(link.motion);
}
// A source stopped driving its controller: tell the PS4 it is still again.
function motionLeave(m) {
if (!m || m.pad < 0) return;
const pad = m.pad;
m.pad = -1;
m.at = 0;
if (motionLive[pad]) {
motionLive[pad] = false;
if (conn.motion) send({ method: 'm', params: [pad, 0, 0, 0, 10000, 0, 0, 0, 0, 0, 0] });
}
}
function motionSend(pad, m, t) {
if (t - motionSentAt[pad] < 16 || !m.fresh) return;
m.fresh = false;
const q = [m.q[1], m.q[2], m.q[3], m.q[0]];
const params = [pad, ...q.map(v => Math.round(clamp(v, -1, 1) * 10000)),
...m.accel.map(v => Math.round(clamp(v, -16, 16) * 1000)),
...m.gyro.map(v => Math.round(clamp(v, -35, 35) * 1000))];
if (send({ method: 'm', params })) { motionSentAt[pad] = t; motionLive[pad] = true; }
}
// Each frame: linked controllers' rumble and colour, and motion for the controllers that have it.
function experimentsTick(t) {
for (const link of hidLinks.values()) hidUpdate(link);
if (!experimentOn('motion') || !conn.motion) return;
const claimed = claimedPads();
const sources = [];
for (const link of hidLinks.values()) {
const src = gamepadSources.get(link.index);
if (link.motion) sources.push([src ? src.pad : -1, link.motion]);
}
// The phone drives the controller open on it, unless a linked controller does.
const phonePad = !padView.hidden && !document.hidden && primaryPad >= 0 ? primaryPad : -1;
if (!sources.some(([pad]) => pad === phonePad)) sources.push([phonePad, phoneMotion]);
else sources.push([-1, phoneMotion]);
for (const [pad, m] of sources) {
const target = pad >= 0 && claimed.includes(pad) && m.at ? pad : -1;
if (m.pad !== target) motionLeave(m);
if (target < 0) continue;
m.pad = target;
motionSend(target, m, t);
}
}
$('#expRecenter').addEventListener('click', () => {
phoneMotion.q = [1, 0, 0, 0];
for (const link of hidLinks.values()) if (link.motion) link.motion.q = [1, 0, 0, 0];
toast('Motion recentered');
});
renderExperiments();
if (experimentOn('hid')) hidStart();
if (experimentOn('motion')) motionStart();
// ---------------------------------------------------------------- local settings only
function showPage(page) {
for (const tab of $$('.nav-item')) tab.setAttribute('aria-selected', String(tab.dataset.page === page));
@@ -4489,16 +5067,22 @@
flush(t);
measurePing(t);
driveRumble(t);
// Experimental code must never stop the frame loop, and with it the input.
try { experimentsTick(t); } catch (e) { console.error(e); }
renderPressed();
mapper.tick(t);
requestAnimationFrame(frame);
}
window.addEventListener('gamepadconnected', () => { pollGamepads(now()); renderGamepads(); });
window.addEventListener('gamepadconnected', () => {
pollGamepads(now()); renderGamepads();
if (experimentOn('hid')) hidStart();
});
window.addEventListener('gamepaddisconnected', event => {
// An identical replacement may already occupy the reused index when this
// event is delivered. Explicitly discard the old source's transient state.
const index = event.gamepad.index, src = gamepadSources.get(index);
if (hidLinks.has(index)) hidDetach(index);
if (mapper.index === index) mapper.close();
if (src) { clearDevice(src); gamepadSources.delete(index); }
pollGamepads(now()); renderGamepads(); sendClaim();
+36 -2
View File
@@ -78,6 +78,9 @@ appears under **Gamepads on this device**:
Keep the page's window in front: browsers only read gamepads for the active window.
A DualShock 4's or DualSense's touchpad clicks, but browsers don't pass on where the fingers are. Link it
under [Experimental features](#experimental-features) and its touches work too.
With several Xbox controllers on Chrome or Edge for Windows, check that each Xbox button appears on its
own gamepad in **Gamepad Controls**. Some browser backends combine those presses; see the
[GameInput workaround](troubleshooting.md#gamepads). This is not a general Control4Free requirement.
@@ -90,7 +93,8 @@ way.
## Settings
**☰ → All settings** has this device's settings. They stay in this browser.
**Gamepads** in the settings changes each gamepad's mapping, and **Keyboard** the keys.
**Gamepads** in the settings changes each gamepad's mapping, and **Keyboard** the keys. **Experimental** has
extras that stay off until you turn them on: see [Experimental features](#experimental-features).
| Setting | What it does |
|---|---|
@@ -127,12 +131,42 @@ When a game rumbles the controller, the device driving it feels it:
- **a gamepad** playing as that controller rumbles too, though not every controller supports vibration in
the browser. When the browser knows it can't, the gamepad's row says *no rumble in this browser*.
A gamepad's own light bar stays as it is: browsers don't let pages change it.
A gamepad's own light bar stays as it is, unless you link a DualShock 4 or DualSense under
[Experimental features](#experimental-features).
The page also glows in the controller's **light-bar colour**: the player colour the PS4 gives each user, or
whatever colour the game sets. Until one is set, a controller shows its own colour (1 blue, 2 red, 3 green,
4 pink).
## Experimental features
**☰ → All settings → Experimental** has three switches. Each stays off until you turn it on, and is saved in
this browser only. With all three off, the page works exactly as it does without them.
| Switch | What it does | Where it works |
|---|---|---|
| **Lights and rumble on Sony controllers** | A DualShock 4 or DualSense connected to a computer lights up in its PS4 controller's colour and rumbles directly, a DualSense included. Its touchpad passes on fingers, not only clicks: browsers' gamepads leave touches out. While linked, its buttons and sticks come straight from the controller, as on a PS4, so its **Mapping** doesn't apply. Press **Link** next to the controller and choose it in the browser's list. | Chrome or Edge on a computer |
| **Trigger rumble** | Xbox controllers rumble in their triggers as well, and a linked DualSense vibrates its triggers, both following the game's rumble. PS4 games don't ask for this: Control4Free makes it up from the rumble. | Xbox controllers in Chrome or Edge on Windows; a linked DualSense |
| **Motion controls** | Tilting the phone, or a linked DualShock 4 or DualSense, moves the PS4 controller's motion sensors. Hold the phone sideways with the screen towards you, like a controller. **Recenter** makes the way you hold it now the resting position. | Chrome on Android; a linked controller on a computer |
Linking and motion use features browsers only give secure pages, and the console's page is plain `http`.
[Experimental features](troubleshooting.md#experimental-features) in the troubleshooting guide explains how to
let Chrome or Edge treat it as secure.
## Testing controllers on the PS4
The app on the PS4 shows what each signed-in user's controller sends, read the way a game reads it. Press
**Options** in the app, or choose **Actions → Test controllers** with the TV remote.
- The picture lights up each button as it's pressed, moves the sticks, fills L2 and R2 as far as they're
pulled, and shows each finger on the touchpad. The panel beside it has the numbers, motion included.
- **PS** and **Share** go to the PS4 itself, so the test doesn't show them.
- Hold **L3 + R3** for two seconds for its menu: choose another signed-in user, rumble the large or the small
motor, set the light bar red, green or blue, reset it, reset the orientation, or go back. The light bar
goes back to normal when you leave.
- A controller from the page shows up under the user signed in on it, so the test also checks the page, a
gamepad or a keyboard from the PS4's side.
## Leaving
- **☰ → Disconnect controller** removes it from the PS4 straight away.
+21 -1
View File
@@ -42,10 +42,11 @@ The control protocol remains version 2 and the launcher API remains version 1.
| Method | Params | Reply |
|---|---|---|
| `info` | `[]` | `{"version": "<version>", "protocol": 2, "pads": 4}` |
| `info` | `[]` | `{"version": "<version>", "protocol": 2, "pads": 4, "motion": true}` |
| `status` | `[]` | A status object, below. |
| `claim` | the controllers this connection wants, for example `[0]` or `[0, 2]` | A status object, once every controller in the claim exists. |
| `u` | input, below | None. |
| `m` | motion, below | None. |
| `leave` | `[pad]` | A status object. |
| `stop` | `[]` | None; the service stops and the connection closes. |
| `ping` | `[]` | `{}` |
@@ -85,6 +86,25 @@ ignored: input never claims a controller.
| D-pad left | `0x0080` | Square | `0x8000` |
| PS | `0x10000` | Touchpad click | `0x100000` |
**`m`** sends the motion sensors of one controller this connection owns. Like `u`, it is ignored for any
other controller.
```
[pad, qx, qy, qz, qw, ax, ay, az, gx, gy, gz]
```
| Field | Range |
|---|---|
| `qx`, `qy`, `qz`, `qw` | orientation as a quaternion, times 10000: −10000 to 10000 |
| `ax`, `ay`, `az` | acceleration in thousandths of a G: −16000 to 16000 |
| `gx`, `gy`, `gz` | angular velocity in thousandths of a radian per second: −35000 to 35000 |
The axes are the ones the PS4 reads a DualShock 4 in: x to the right, y up out of its face, z towards the
player. A controller lying flat and still reads `+1` G on y. The values go into every sample from then on,
together with the buttons, sticks and touches of the last `u`. Before the first `m`, and whenever the controller goes neutral, it reports no motion: orientation
`0, 0, 0, 1` and everything else 0. `m` counts as input, like `u`. A service that understands `m` says
`"motion": true` in its `info` reply.
Send the state whenever it changes, and at least once a second while the controller is in use. After 3
seconds without input the controller goes neutral and shows as `paused`; after 15 it's removed. When a
connection closes, its controllers' buttons are released at once, and they're removed 15 seconds later unless
+27
View File
@@ -152,6 +152,33 @@ the app and open it again; if that doesn't help, restart the PS4 and run the jai
**Control4Free is listed under Games instead of Applications.** That's a package from before 1.0.0. Delete the
app and install the current package.
## Experimental features
**A switch is greyed out: "This browser treats the page as insecure".** Browsers give motion sensors and
direct controller access only to secure pages, and the PS4 serves the page as plain `http`. Chrome and Edge
can treat your PS4's address as secure:
1. Open `chrome://flags/#unsafely-treat-insecure-origin-as-secure`, or in Edge
`edge://flags/#unsafely-treat-insecure-origin-as-secure`. On Android, open the same address in Chrome.
2. Type the page's address exactly as the app on the PS4 shows it, for example `http://192.168.1.20:4264`,
and set the option to **Enabled**.
3. Press **Relaunch**.
The setting belongs to that browser on that device. If the PS4's address changes, change it here too:
reserving the address for the PS4 in your router avoids that. Chrome warns about the setting on its pages;
it applies only to the address you typed. That address then gets the browser features an `https` site gets,
over a connection that isn't encrypted, so add only your PS4's address. Safari on an iPhone has no such
setting.
**"This browser can't talk to controllers directly."** Linking a DualShock 4 or DualSense needs Chrome or
Edge on a computer. Android, Firefox and Safari don't offer it.
**The controller isn't in the browser's list, or linking fails.** Close other programs that use it, such as
Steam or DS4Windows, reconnect it, press a button on it, then press **Link** again.
**Motion feels off.** Hold the phone sideways with the screen towards you, keep it still, and press
**Recenter** in **Settings → Experimental**.
## Reporting a problem
Every release comes with a diagnostic package, `Control4Free-<version>-diag.pkg`. It's the same Control4Free,
+3 -3
View File
@@ -82,9 +82,9 @@ typedef struct {
uint8_t rx, ry;
uint8_t l2, r2;
uint16_t padding;
float quat[4];
float vel[3];
float accel[3];
float quat[4]; /* orientation, x y z w */
float accel[3]; /* acceleration in G */
float gyro[3]; /* angular velocity in rad/s */
ScePadTouchData touchData;
uint8_t connected;
uint8_t align[3];
+1 -1
View File
@@ -20,7 +20,7 @@ CC := clang-18
CXX := clang++-18
LD := ld.lld-18
CFLAGS := --target=x86_64-pc-freebsd12-elf -fPIC -funwind-tables -O2 -Wall -Wextra -Werror -MMD -MP -D_DEFAULT_SOURCE -isysroot $(SDK) -isystem $(SDK)/include -I../vendor/jsmn -I../vendor/qrcodegen -I../vendor/stb -DC4F_LAUNCHER_VERSION='"$(VERSION)"' $(DIAG_FLAGS)
OBJECTS += $(OUT)/main.o $(OUT)/input.o $(OUT)/navigation.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
OBJECTS += $(OUT)/main.o $(OUT)/input.o $(OUT)/navigation.o $(OUT)/screen.o $(OUT)/tester.o $(OUT)/tester_draw.o $(OUT)/draw.o $(OUT)/service.o $(OUT)/autorun.o $(OUT)/sandbox.o $(OUT)/logo.o $(OUT)/qrcodegen.o $(OUT)/stb_truetype.o $(OUT)/fonts.o
LIBS := -lc -lkernel -lSceVideoOut -lScePad -lSceUserService -lSceSystemService -lSceNetCtl
FONTS := ../vendor/roboto/Roboto-Light.ttf ../vendor/roboto/Roboto-Regular.ttf
.PHONY: all package
+6 -1
View File
@@ -9,18 +9,23 @@ enum { C4F_INPUT_PORT_STANDARD = 0, C4F_INPUT_PORT_REMOTE = 16 };
#define C4F_INPUT_SYSTEM_USER 0xff
#define C4F_INPUT_RETRY_MS 1000
/* Native read-side buttons used by launcher navigation. */
/* Native read-side buttons used by launcher navigation and the controller test. */
#define C4F_INPUT_BUTTON_L3 UINT32_C(0x000002)
#define C4F_INPUT_BUTTON_R3 UINT32_C(0x000004)
#define C4F_INPUT_BUTTON_OPTIONS UINT32_C(0x000008)
#define C4F_INPUT_BUTTON_UP UINT32_C(0x000010)
#define C4F_INPUT_BUTTON_RIGHT UINT32_C(0x000020)
#define C4F_INPUT_BUTTON_DOWN UINT32_C(0x000040)
#define C4F_INPUT_BUTTON_LEFT UINT32_C(0x000080)
#define C4F_INPUT_BUTTON_L2 UINT32_C(0x000100)
#define C4F_INPUT_BUTTON_R2 UINT32_C(0x000200)
#define C4F_INPUT_BUTTON_L1 UINT32_C(0x000400)
#define C4F_INPUT_BUTTON_R1 UINT32_C(0x000800)
#define C4F_INPUT_BUTTON_TRIANGLE UINT32_C(0x001000)
#define C4F_INPUT_BUTTON_CIRCLE UINT32_C(0x002000)
#define C4F_INPUT_BUTTON_CROSS UINT32_C(0x004000)
#define C4F_INPUT_BUTTON_SQUARE UINT32_C(0x008000)
#define C4F_INPUT_BUTTON_TOUCHPAD UINT32_C(0x100000)
/* PS4 reverse definitions identify bit31 as system-intercepted input. This is
* a read-side flag, not an additional virtual-controller button. */
#define C4F_INPUT_BUTTON_INTERCEPTED UINT32_C(0x80000000)
+174 -6
View File
@@ -268,6 +268,119 @@ static int c4fNativeRead(void *context, int handle, uint32_t *buttons, int *conn
static void c4fNativeClose(void *, int handle) { scePadClose(handle); }
/* ---- the controller test's native side ---- */
/* The pinned header names the two motion vectors vel and acell. Sony's order
* is orientation, acceleration (G), angular velocity (rad/s). */
static_assert(offsetof(OrbisPadData, quat) == 12, "orientation");
static_assert(offsetof(OrbisPadData, vel) == 28, "acceleration");
static_assert(offsetof(OrbisPadData, acell) == 40, "angular velocity");
static_assert(offsetof(OrbisPadData, touch) == 52, "touch data");
static_assert(C4F_INPUT_BUTTON_L3 == ORBIS_PAD_BUTTON_L3, "L3 button");
static_assert(C4F_INPUT_BUTTON_R3 == ORBIS_PAD_BUTTON_R3, "R3 button");
static_assert(C4F_INPUT_BUTTON_L2 == ORBIS_PAD_BUTTON_L2, "L2 button");
static_assert(C4F_INPUT_BUTTON_R2 == ORBIS_PAD_BUTTON_R2, "R2 button");
static_assert(C4F_INPUT_BUTTON_L1 == ORBIS_PAD_BUTTON_L1, "L1 button");
static_assert(C4F_INPUT_BUTTON_R1 == ORBIS_PAD_BUTTON_R1, "R1 button");
static_assert(C4F_INPUT_BUTTON_TRIANGLE == ORBIS_PAD_BUTTON_TRIANGLE, "Triangle button");
static_assert(C4F_INPUT_BUTTON_SQUARE == ORBIS_PAD_BUTTON_SQUARE, "Square button");
static_assert(C4F_INPUT_BUTTON_TOUCHPAD == ORBIS_PAD_BUTTON_TOUCH_PAD, "touchpad button");
static int c4fTesterLoginUsers(void *, int32_t *users)
{
OrbisUserServiceLoginUserIdList list;
memset(&list, 0, sizeof(list));
int result = sceUserServiceGetLoginUserIdList(&list);
if (result) return result < 0 ? result : -1;
int count = 0;
for (int i = 0; i < ORBIS_USER_SERVICE_MAX_LOGIN_USERS && count < C4F_TESTER_USERS; i++)
if (list.userId[i] != ORBIS_USER_SERVICE_USER_ID_INVALID) users[count++] = list.userId[i];
return count;
}
static void c4fTesterUserName(void *, int32_t user, char *name, int size)
{
if (sceUserServiceGetUserName(user, name, (size_t)size)) name[0] = 0;
}
static int c4fTesterOpenPad(void *context, int32_t user, int *owned)
{
C4fNativeInput *input = (C4fNativeInput *)context;
*owned = 0;
if (!input->initialized) {
int result = scePadInit();
if (result) return result < 0 ? result : -1;
input->initialized = 1;
}
/* The launcher's own reader may hold this user's handle: borrow it. */
int handle = scePadOpen(user, ORBIS_PAD_PORT_TYPE_STANDARD, 0, NULL);
if ((uint32_t)handle == C4F_PAD_ERROR_ALREADY_OPENED)
handle = scePadGetHandle(user, ORBIS_PAD_PORT_TYPE_STANDARD, 0);
else if (handle >= 0)
*owned = 1;
return handle;
}
static int c4fTesterRead(void *, int handle, C4fTesterSample *out)
{
OrbisPadData data;
memset(&data, 0, sizeof(data));
int result = scePadReadState(handle, &data);
if (result) return result;
out->buttons = data.buttons;
out->lx = data.leftStick.x; out->ly = data.leftStick.y;
out->rx = data.rightStick.x; out->ry = data.rightStick.y;
out->l2 = data.analogButtons.l2; out->r2 = data.analogButtons.r2;
out->touches = data.touch.fingers > 2 ? 2 : data.touch.fingers;
for (int i = 0; i < out->touches; i++) {
out->touch[i].x = data.touch.touch[i].x;
out->touch[i].y = data.touch.touch[i].y;
out->touch[i].id = data.touch.touch[i].finger;
}
out->quat[0] = data.quat.x; out->quat[1] = data.quat.y; out->quat[2] = data.quat.z; out->quat[3] = data.quat.w;
out->accel[0] = data.vel.x; out->accel[1] = data.vel.y; out->accel[2] = data.vel.z;
out->gyro[0] = data.acell.x; out->gyro[1] = data.acell.y; out->gyro[2] = data.acell.z;
out->connected = data.connected;
return 0;
}
static void c4fTesterClosePad(void *, int handle) { scePadClose(handle); }
static int c4fTesterVibrate(void *, int handle, uint8_t large, uint8_t small)
{
OrbisPadVibeParam param = { large, small };
int result = scePadSetVibration(handle, &param);
if (result) printf("[c4f-launcher] controller test: scePadSetVibration = 0x%08x\n", (unsigned)result);
return result;
}
static int c4fTesterLight(void *, int handle, uint8_t r, uint8_t g, uint8_t b)
{
OrbisPadColor color = { r, g, b, 255 };
int result = scePadSetLightBar(handle, &color);
if (result) printf("[c4f-launcher] controller test: scePadSetLightBar = 0x%08x\n", (unsigned)result);
return result;
}
static int c4fTesterResetLight(void *, int handle) { return scePadResetLightBar(handle); }
static int c4fTesterResetOrientation(void *, int handle) { return scePadResetOrientation(handle); }
static int c4fTesterMotion(void *, int handle, int on)
{
/* OpenOrbis declares this without parameters; Sony's call (as shadPS4
* documents it) takes the handle and an on/off flag. */
int result = reinterpret_cast<int32_t (*)(int32_t, bool)>(&scePadSetMotionSensorState)(handle, on != 0);
printf("[c4f-launcher] controller test: scePadSetMotionSensorState = 0x%08x\n", (unsigned)result);
return result;
}
static void c4fTesterLogState(void *, int slot, int state, uint32_t error)
{
static const char *const names[] = { "opening", "ready", "no controller", "failed", "signed out" };
printf("[c4f-launcher] controller test reader %d: %s (0x%08x)\n", slot + 1,
state >= 0 && state <= C4F_TESTER_SIGNED_OUT ? names[state] : "?", (unsigned)error);
}
static void c4fNativeInputNote(const C4fNativeInput *input, char *note, size_t size)
{
snprintf(note, size, "%s: %s (0x%08x); %s: %s (0x%08x), last key 0x%02x / raw 0x%08x",
@@ -301,6 +414,13 @@ int main(void)
C4fInputOps inputOps = { &nativeInput, c4fNativeInitialUser, c4fNativeOpen, c4fNativeRead, c4fNativeClose };
C4fInput inputs;
c4fInputInit(&inputs, &inputOps);
C4fTesterOps testerOps = { &nativeInput, c4fTesterLoginUsers, c4fTesterUserName, c4fTesterOpenPad, c4fTesterRead,
c4fTesterClosePad, c4fTesterVibrate, c4fTesterLight, c4fTesterResetLight,
c4fTesterResetOrientation, c4fTesterMotion, c4fTesterLogState };
static C4fTester tester;
c4fTesterInit(&tester, &testerOps);
C4fTesterHold launcherHold = {};
int waitRelease = 0;
c4fScreen.running = -1; c4fScreen.busy = 1; c4fScreen.autorun = C4F_AUTORUN_UNKNOWN;
snprintf(c4fScreen.message, sizeof(c4fScreen.message), "Checking Control4Free...");
/* Read before leaving the sandbox: /app0 is only visible from inside it. */
@@ -327,6 +447,9 @@ int main(void)
int64_t frame = 1;
C4fLauncherScreen drawnState;
memset(&drawnState, 0, sizeof(drawnState));
#ifdef C4F_DIAG
uint64_t testDraws = 0, testDrawMax = 0, testDrawReportAt = 0;
#endif
while (!done) {
uint64_t now = c4fLauncherTimeMs();
/* A suspended app must not resume a pending confirmation or reuse a
@@ -336,6 +459,11 @@ int main(void)
lastInputAt = now;
C4fInputFrame input;
c4fInputPoll(&inputs, now, &input);
if (resumed) c4fTesterSuspend(&tester, now);
/* L3+R3 on the launcher's controller opens the controller test. It is
* tracked while testing too, so a hold still down after leaving the
* test does not open it again. */
int held = c4fTesterHoldUpdate(&launcherHold, input.standard.buttons, now);
pthread_mutex_lock(&c4fMutex);
c4fNativeInputNote(&nativeInput, c4fScreen.inputNote, sizeof(c4fScreen.inputNote));
if (resumed) {
@@ -348,8 +476,26 @@ int main(void)
#else
const int diagnostic = 0;
#endif
C4fLauncherAction action = c4fNavigate(&c4fScreen, input.standard.pressed, input.remote.pressed,
diagnostic, &standardUnhandled, &remoteUnhandled);
C4fLauncherAction action = C4F_ACTION_NONE;
if (tester.active) {
/* Every button is under test, so the launcher's shortcuts are off. */
if (!c4fTesterFrame(&tester, now, input.remote.pressed)) waitRelease = 1;
} else {
uint32_t standardPressed = input.standard.pressed, remotePressed = input.remote.pressed;
if (waitRelease) {
/* The press that left the test must not act here as well. */
if (!input.standard.buttons && !input.remote.buttons) waitRelease = 0;
standardPressed = remotePressed = 0;
}
action = c4fNavigate(&c4fScreen, standardPressed, remotePressed,
diagnostic, &standardUnhandled, &remoteUnhandled);
if (action == C4F_ACTION_TESTER || (action == C4F_ACTION_NONE && held && !c4fScreen.actionMenu &&
!c4fScreen.confirmStop && !c4fScreen.locked)) {
c4fTesterOpen(&tester, now);
action = C4F_ACTION_NONE;
}
}
c4fTesterView(&tester, &c4fScreen.tester);
if (action == C4F_ACTION_CLOSE) done = 1;
else if (action == C4F_ACTION_START) {
/* Until auto-start is on, Start sets it up and starts it. */
@@ -367,8 +513,8 @@ int main(void)
off ? "Turning auto-start off..." : "Turning auto-start on...");
}
#ifdef C4F_DIAG
/* Pages and scrolling work while busy too. */
uint32_t pressed = standardUnhandled | remoteUnhandled;
/* Pages and scrolling work while busy too; not during the controller test. */
uint32_t pressed = tester.active ? 0 : standardUnhandled | remoteUnhandled;
int page = c4fScreen.diagPage;
if (pressed & (ORBIS_PAD_BUTTON_R1 | ORBIS_PAD_BUTTON_RIGHT)) c4fScreen.diagPage = (page + 1) % C4F_DIAG_PAGES;
if (pressed & (ORBIS_PAD_BUTTON_L1 | ORBIS_PAD_BUTTON_LEFT)) c4fScreen.diagPage = (page + C4F_DIAG_PAGES - 1) % C4F_DIAG_PAGES;
@@ -384,13 +530,34 @@ int main(void)
pthread_mutex_unlock(&c4fMutex);
/* Redraw only when something changed, then copy it to each buffer once. */
if (!drawn || memcmp(&snapshot, &drawnState, sizeof(snapshot))) {
if (snapshot.tester.active) {
#ifdef C4F_DIAG
c4fDrawDiag(canvas, &snapshot);
uint64_t drawStart = c4fLauncherTimeMs();
c4fDrawTester(canvas, &snapshot);
uint64_t took = c4fLauncherTimeMs() - drawStart;
if (took > testDrawMax) testDrawMax = took;
testDraws++;
#else
c4fDrawLauncher(canvas, &snapshot);
c4fDrawTester(canvas, &snapshot);
#endif
} else {
#ifdef C4F_DIAG
c4fDrawDiag(canvas, &snapshot);
#else
c4fDrawLauncher(canvas, &snapshot);
#endif
}
drawnState = snapshot; drawn = 1; shown[0] = shown[1] = 0;
}
#ifdef C4F_DIAG
/* How long the controller test takes to draw, for the frame budget. */
if (testDraws && now >= testDrawReportAt) {
printf("[c4f-launcher] controller test: %llu redraws, slowest %llu ms\n",
(unsigned long long)testDraws, (unsigned long long)testDrawMax);
testDraws = testDrawMax = 0;
testDrawReportAt = now + 5000;
}
#endif
if (!shown[buffer]) { memcpy(c4fBuffers[buffer], canvas, frameBytes); shown[buffer] = 1; }
if (sceVideoOutSubmitFlip(c4fVideo, buffer, ORBIS_VIDEO_OUT_FLIP_VSYNC, frame) < 0) break;
for (;;) {
@@ -410,6 +577,7 @@ int main(void)
#ifdef C4F_DIAG
c4fDiagStop();
#endif
c4fTesterClose(&tester);
c4fInputClose(&inputs);
sceNetCtlTerm();
c4fCloseVideo();
+12 -3
View File
@@ -17,6 +17,7 @@ int c4fActionAvailable(const C4fLauncherScreen *screen, C4fLauncherAction action
return !screen->busy && !screen->locked && screen->autorun != C4F_AUTORUN_UNKNOWN;
case C4F_ACTION_STOP: return !screen->busy && !screen->locked && screen->running == 1;
case C4F_ACTION_CAPTURE: return !!diagnostic;
case C4F_ACTION_TESTER: return !screen->locked;
case C4F_ACTION_CLOSE: return !screen->busy;
default: return 0;
}
@@ -26,7 +27,7 @@ int c4fActionList(const C4fLauncherScreen *screen, int diagnostic,
C4fLauncherAction *actions, int capacity)
{
const C4fLauncherAction order[] = { C4F_ACTION_RESUME, C4F_ACTION_START,
C4F_ACTION_AUTORUN, C4F_ACTION_STOP, C4F_ACTION_CAPTURE, C4F_ACTION_CLOSE };
C4F_ACTION_AUTORUN, C4F_ACTION_STOP, C4F_ACTION_TESTER, C4F_ACTION_CAPTURE, C4F_ACTION_CLOSE };
int count = 0;
for (unsigned i = 0; i < sizeof(order) / sizeof(order[0]); i++) {
if (!c4fActionAvailable(screen, order[i], diagnostic)) continue;
@@ -48,6 +49,7 @@ const char *c4fActionLabel(const C4fLauncherScreen *screen, C4fLauncherAction ac
case C4F_ACTION_STOP: return "Stop Control4Free";
case C4F_ACTION_CLOSE: return "Close app";
case C4F_ACTION_CAPTURE: return "Capture kernel log";
case C4F_ACTION_TESTER: return "Test controllers";
default: return "";
}
}
@@ -130,8 +132,8 @@ C4fLauncherAction c4fNavigate(C4fLauncherScreen *screen, uint32_t standardPresse
if (pressed & C4F_NAV_DIRECTIONS) {
int direction = c4fDirection(pressed);
if (direction) {
C4fLauncherAction actions[6];
int count = c4fActionList(screen, diagnostic, actions, 6), at = 0;
C4fLauncherAction actions[8];
int count = c4fActionList(screen, diagnostic, actions, 8), at = 0;
while (at < count && (int)actions[at] != screen->actionFocus) at++;
screen->actionFocus = actions[(at + count + direction) % count];
}
@@ -159,6 +161,13 @@ C4fLauncherAction c4fNavigate(C4fLauncherScreen *screen, uint32_t standardPresse
screen->actionFocus = c4fDefaultAction(screen, diagnostic);
return C4F_ACTION_NONE;
}
/* A DS4's Options opens the controller test. The diagnostic build keeps
* Options for capturing the kernel log. */
if (!diagnostic && (standardPressed & C4F_INPUT_BUTTON_OPTIONS) &&
c4fActionAvailable(screen, C4F_ACTION_TESTER, diagnostic)) {
c4fConsume(standardUnhandled, remoteUnhandled);
return C4F_ACTION_TESTER;
}
if (screen->busy) return C4F_ACTION_NONE;
if (pressed & C4F_NAV_BACK) {
c4fConsume(standardUnhandled, remoteUnhandled);
+37 -39
View File
@@ -11,9 +11,7 @@
#include "logo.h"
#include "navigation.h"
#include "qrcodegen.h"
/* Page CSS pixels to TV pixels. */
#define C4F_S 1.5f
#include "style.h"
/* Layout, in TV pixels. */
#define C4F_LEFT 150.0f
@@ -27,25 +25,6 @@
#define C4F_PANEL_TOP (C4F_ROW_TOP + C4F_ROW_HEIGHT + 28)
#define C4F_PANEL_HEIGHT 128.0f
/* The page's palette (:root). */
#define C4F_TEXT C4F_RGBA(244, 247, 255, 1)
#define C4F_MUTED C4F_RGBA(226, 236, 255, .72f)
#define C4F_FAINT C4F_RGBA(226, 236, 255, .46f)
#define C4F_LINE C4F_RGBA(255, 255, 255, .16f)
#define C4F_LINE_SOFT C4F_RGBA(255, 255, 255, .09f)
#define C4F_PANEL C4F_RGBA(3, 16, 44, .55f)
#define C4F_PANEL_STRONG C4F_RGBA(2, 11, 31, .86f)
#define C4F_TILE C4F_RGBA(12, 40, 98, .5f)
#define C4F_ACCENT C4F_RGBA(58, 140, 255, 1)
#define C4F_OK C4F_RGBA(69, 224, 138, 1)
#define C4F_WARN C4F_RGBA(255, 208, 77, 1)
#define C4F_BAD C4F_RGBA(255, 107, 125, 1)
#define C4F_CIRCLE_KEY C4F_RGBA(255, 102, 128, 1)
#define C4F_CROSS_KEY C4F_RGBA(134, 182, 255, 1)
#define C4F_SQUARE_KEY C4F_RGBA(255, 146, 220, 1)
#define C4F_TRIANGLE_KEY C4F_RGBA(63, 224, 184, 1)
#define C4F_WHITE C4F_RGBA(255, 255, 255, 1)
static uint32_t *c4fBackdrop;
static float c4fClamp(float v) { return v < 0 ? 0 : v > 1 ? 1 : v; }
@@ -172,7 +151,7 @@ static void c4fPaintWave(C4fCanvas *c, const float path[25], float offset, float
}
}
static void c4fPaintBackdrop(C4fCanvas *c, float scale)
void c4fPaintBackdrop(C4fCanvas *c, float scale)
{
const float w = (float)c->w, h = (float)c->h, sx = 2 * w / 3200;
c4fPaintGradient(c);
@@ -226,7 +205,7 @@ static void c4fBoxPass(const uint8_t *src, uint8_t *dst, int w, int h, int radiu
/* backdrop-filter: blur(sigma) under a tile. The backdrop never changes, so
* this is done once, clipped to the tile's rounded shape. */
static void c4fBlurUnder(C4fCanvas *c, float x, float y, float w, float h, float r, float sigma)
void c4fBlurUnder(C4fCanvas *c, float x, float y, float w, float h, float r, float sigma)
{
/* Three box passes of this radius approximate a Gaussian of that sigma. */
const int radius = (int)((sqrtf(4 * sigma * sigma + 1) - 1) / 2 + 0.5f), margin = 3 * radius;
@@ -270,7 +249,7 @@ static void c4fBlurUnder(C4fCanvas *c, float x, float y, float w, float h, float
/* ---- pieces of the page ---- */
/* The DualShock 4 mark, its cut-outs showing what is behind. */
static void c4fMark(C4fCanvas *c, float x, float y, float width, C4fColor color)
void c4fMark(C4fCanvas *c, float x, float y, float width, C4fColor color)
{
C4fPath path = { 0 };
c4fLogoPath(&path, 1);
@@ -280,8 +259,8 @@ static void c4fMark(C4fCanvas *c, float x, float y, float width, C4fColor color)
/* A DualShock 4 with its light bar lit: the bar glows over the top edge, and
* the cut-outs read as dark recesses. */
static void c4fLitController(C4fCanvas *c, float x, float y, float width, float angle,
C4fColor body, C4fColor light, int shadow)
void c4fLitController(C4fCanvas *c, float x, float y, float width, float angle,
C4fColor body, C4fColor light, int shadow)
{
C4fPath outline = { 0 }, detail = { 0 }, bar = { 0 };
C4fPlacement at = c4fLogoAt(x, y, width, angle);
@@ -310,13 +289,13 @@ static void c4fLitController(C4fCanvas *c, float x, float y, float width, float
c4fPathFree(&bar);
}
static void c4fTextCentered(C4fCanvas *c, int font, float size, float cx, float y, C4fColor color, const char *text)
void c4fTextCentered(C4fCanvas *c, int font, float size, float cx, float y, C4fColor color, const char *text)
{
c4fText(c, font, size, cx - c4fTextWidth(font, size, text) / 2, y, color, text);
}
/* .tile: --tile with a light sheen, a 1px line, a drop shadow and the colour bar. */
static void c4fTile(C4fCanvas *c, float x, float y, float w, float h, C4fColor bar)
void c4fTile(C4fCanvas *c, float x, float y, float w, float h, C4fColor bar)
{
const float r = 6 * C4F_S;
c4fShadowRoundRect(c, x, y, w, h, r, 0, 16 * C4F_S, 34 * C4F_S, -18 * C4F_S, C4F_RGBA(0, 0, 0, .9f));
@@ -328,7 +307,7 @@ static void c4fTile(C4fCanvas *c, float x, float y, float w, float h, C4fColor b
}
/* .panel */
static void c4fPanel(C4fCanvas *c, float x, float y, float w, float h, C4fColor fill, C4fColor line)
void c4fPanel(C4fCanvas *c, float x, float y, float w, float h, C4fColor fill, C4fColor line)
{
c4fFillRoundRect(c, x, y, w, h, 6 * C4F_S, fill);
c4fInsetRoundRect(c, x, y, w, h, 6 * C4F_S, 1 * C4F_S, line);
@@ -352,21 +331,18 @@ static void c4fRingBadge(C4fCanvas *c, float cx, float cy, C4fColor color, const
}
/* .dot, with the glow the page gives a live connection. */
static void c4fDot(C4fCanvas *c, float cx, float cy, C4fColor color, int glow)
void c4fDot(C4fCanvas *c, float cx, float cy, C4fColor color, int glow)
{
if (glow) c4fGlowCircle(c, cx, cy, 4 * C4F_S, 10 * C4F_S, 0, color);
c4fFillCircle(c, cx, cy, 4 * C4F_S, color);
}
/* Button guide in the PS4's manner, with the page's face-button colours. */
enum { C4F_KEY_CROSS, C4F_KEY_CIRCLE, C4F_KEY_SQUARE, C4F_KEY_TRIANGLE };
typedef struct { int key; const char *label; } C4fHint;
#define C4F_HINT_RADIUS 22.0f
#define C4F_HINT_GAP 14.0f
#define C4F_HINT_SPACING 40.0f
#define C4F_HINT_SIZE 26.0f
static void c4fHints(C4fCanvas *c, float right, float cy, const C4fHint *hints, int count)
void c4fHints(C4fCanvas *c, float right, float cy, const C4fHint *hints, int count)
{
float x = right;
for (int i = 0; i < count; i++)
@@ -383,6 +359,10 @@ static void c4fHints(C4fCanvas *c, float right, float cy, const C4fHint *hints,
c4fRing(c, cx, cy, 10.5f, 3.6f, C4F_CIRCLE_KEY);
} else if (hints[i].key == C4F_KEY_TRIANGLE) {
c4fTriangleOutline(c, cx, cy + 1.5f, 11.5f, 3.4f, C4F_TRIANGLE_KEY);
} else if (hints[i].key == C4F_KEY_OPTIONS) {
/* The page's menu mark, three lines, for the Options button. */
for (int line = -1; line <= 1; line++)
c4fSegment(c, cx - 9, cy + line * 6.5f, cx + 9, cy + line * 6.5f, 3, C4F_TEXT);
} else {
c4fSquareOutline(c, cx, cy, 8.5f, 3.4f, C4F_SQUARE_KEY);
}
@@ -468,8 +448,8 @@ void c4fDrawActionOverlay(uint32_t *pixels, const C4fLauncherScreen *s)
#ifdef C4F_DIAG
diagnostic = 1;
#endif
C4fLauncherAction actions[7];
int count = s->confirmStop ? 0 : c4fActionList(s, diagnostic, actions, 7);
C4fLauncherAction actions[8];
int count = s->confirmStop ? 0 : c4fActionList(s, diagnostic, actions, 8);
const float dh = s->confirmStop ? (s->confirmMenu ? 410 : 330) : 220 + count * 72;
const float dy = (c.h - dh) / 2;
c4fFillRect(&c, 0, 0, c.w, c.h, C4F_RGBA(2, 11, 31, .65f));
@@ -633,7 +613,7 @@ void c4fDrawLauncher(uint32_t *pixels, const C4fLauncherScreen *s)
c4fText(&c, C4F_FONT_LIGHT, 23, left, 973, C4F_MUTED, "TV remote: OK opens actions / arrows select / Back returns");
c4fText(&c, C4F_FONT_LIGHT, 20, left, 1030, C4F_FAINT, "Control4Free " C4F_LAUNCHER_VERSION);
if (!s->confirmStop && !s->busy) {
C4fHint hints[4];
C4fHint hints[5];
int n = 0;
if (!s->locked && !live) {
hints[n].key = C4F_KEY_CROSS;
@@ -648,6 +628,10 @@ void c4fDrawLauncher(uint32_t *pixels, const C4fLauncherScreen *s)
hints[n].key = C4F_KEY_SQUARE;
hints[n++].label = "Stop";
}
if (!s->locked) {
hints[n].key = C4F_KEY_OPTIONS;
hints[n++].label = "Test controllers";
}
hints[n].key = C4F_KEY_CIRCLE;
hints[n++].label = "Close";
c4fHints(&c, right, 1022, hints, n);
@@ -668,14 +652,28 @@ void c4fDrawIcon(uint32_t *pixels, int size)
c4fTextCentered(&c, C4F_FONT_LIGHT, s * 0.1f, s / 2.0f, s * 0.89f, C4F_TEXT, "Control4Free");
}
/* The backdrop before this screen's blurs, for the controller test to build its
* own layer on. Taken once; NULL if memory was short. */
static uint32_t *c4fCleanBackdrop;
uint32_t *c4fTakeCleanBackdrop(void)
{
uint32_t *taken = c4fCleanBackdrop;
c4fCleanBackdrop = NULL;
return taken;
}
int c4fScreenInit(void)
{
if (c4fDrawInit()) return -1;
if (!c4fBackdrop) {
c4fBackdrop = malloc((size_t)C4F_SCREEN_WIDTH * C4F_SCREEN_HEIGHT * sizeof(uint32_t));
const size_t bytes = (size_t)C4F_SCREEN_WIDTH * C4F_SCREEN_HEIGHT * sizeof(uint32_t);
c4fBackdrop = malloc(bytes);
if (!c4fBackdrop) return -1;
C4fCanvas c = { c4fBackdrop, C4F_SCREEN_WIDTH, C4F_SCREEN_HEIGHT };
c4fPaintBackdrop(&c, C4F_S);
c4fCleanBackdrop = malloc(bytes);
if (c4fCleanBackdrop) memcpy(c4fCleanBackdrop, c4fBackdrop, bytes);
/* .tile has backdrop-filter: blur(6px), .panel blur(10px). */
c4fBlurUnder(&c, C4F_LEFT, C4F_ROW_TOP, C4F_MAIN_WIDTH, C4F_ROW_HEIGHT, 6 * C4F_S, 6 * C4F_S);
c4fBlurUnder(&c, C4F_QR_LEFT, C4F_ROW_TOP, C4F_QR_WIDTH, C4F_ROW_HEIGHT, 6 * C4F_S, 6 * C4F_S);
+6 -1
View File
@@ -1,6 +1,7 @@
#ifndef C4F_LAUNCHER_SCREEN_H
#define C4F_LAUNCHER_SCREEN_H
#include <stdint.h>
#include "tester.h"
/* Set by the Makefile from the VERSION file. */
#ifndef C4F_LAUNCHER_VERSION
#define C4F_LAUNCHER_VERSION "dev"
@@ -14,7 +15,8 @@ typedef enum {
C4F_ACTION_AUTORUN,
C4F_ACTION_STOP,
C4F_ACTION_CLOSE,
C4F_ACTION_CAPTURE
C4F_ACTION_CAPTURE,
C4F_ACTION_TESTER
} C4fLauncherAction;
typedef struct {
int running, busy, controllers, confirmStop, locked;
@@ -22,6 +24,7 @@ typedef struct {
int confirmChoice, confirmMenu; /* 0 Cancel / 1 Stop; menu-origin confirmation. */
int autorun; /* C4F_AUTORUN_* from autorun.h */
char address[64], message[160], autorunNote[96], runningVersion[32], inputNote[160];
C4fTesterView tester; /* the controller test, when active */
#ifdef C4F_DIAG
unsigned diagGeneration; /* bumped when a diagnostic page changes */
int diagPage, diagScroll[8];
@@ -37,6 +40,8 @@ int c4fScreenInit(void);
void c4fDrawLauncher(uint32_t *pixels, const C4fLauncherScreen *state);
/* Shared actions and stop-confirmation overlay for both package variants. */
void c4fDrawActionOverlay(uint32_t *pixels, const C4fLauncherScreen *state);
/* The controller test, in both package variants (tester_draw.c). */
void c4fDrawTester(uint32_t *pixels, const C4fLauncherScreen *state);
/* The package icon (size x size), in the same style. */
void c4fDrawIcon(uint32_t *pixels, int size);
#ifdef __cplusplus
+52
View File
@@ -0,0 +1,52 @@
#ifndef C4F_LAUNCHER_STYLE_H
#define C4F_LAUNCHER_STYLE_H
/* The controller page's look, shared by the launcher's screens (screen.c,
* tester_draw.c): palette, sizes and the drawn pieces. */
#include "draw.h"
/* Page CSS pixels to TV pixels. */
#define C4F_S 1.5f
/* The page's palette (:root). */
#define C4F_TEXT C4F_RGBA(244, 247, 255, 1)
#define C4F_MUTED C4F_RGBA(226, 236, 255, .72f)
#define C4F_FAINT C4F_RGBA(226, 236, 255, .46f)
#define C4F_LINE C4F_RGBA(255, 255, 255, .16f)
#define C4F_LINE_SOFT C4F_RGBA(255, 255, 255, .09f)
#define C4F_PANEL C4F_RGBA(3, 16, 44, .55f)
#define C4F_PANEL_STRONG C4F_RGBA(2, 11, 31, .86f)
#define C4F_TILE C4F_RGBA(12, 40, 98, .5f)
#define C4F_ACCENT C4F_RGBA(58, 140, 255, 1)
#define C4F_OK C4F_RGBA(69, 224, 138, 1)
#define C4F_WARN C4F_RGBA(255, 208, 77, 1)
#define C4F_BAD C4F_RGBA(255, 107, 125, 1)
#define C4F_CIRCLE_KEY C4F_RGBA(255, 102, 128, 1)
#define C4F_CROSS_KEY C4F_RGBA(134, 182, 255, 1)
#define C4F_SQUARE_KEY C4F_RGBA(255, 146, 220, 1)
#define C4F_TRIANGLE_KEY C4F_RGBA(63, 224, 184, 1)
#define C4F_WHITE C4F_RGBA(255, 255, 255, 1)
/* Button guide in the PS4's manner, with the page's face-button colours. */
enum { C4F_KEY_CROSS, C4F_KEY_CIRCLE, C4F_KEY_SQUARE, C4F_KEY_TRIANGLE, C4F_KEY_OPTIONS };
typedef struct { int key; const char *label; } C4fHint;
void c4fPaintBackdrop(C4fCanvas *c, float scale);
/* The full-screen backdrop without the launcher's blurs, handed over once
* (the caller frees it); NULL when it is gone or memory was short. */
uint32_t *c4fTakeCleanBackdrop(void);
/* backdrop-filter: blur(sigma) under a rounded box, done once on a still backdrop. */
void c4fBlurUnder(C4fCanvas *c, float x, float y, float w, float h, float r, float sigma);
/* The DualShock 4 mark, its cut-outs showing what is behind. */
void c4fMark(C4fCanvas *c, float x, float y, float width, C4fColor color);
/* A DualShock 4 with its light bar lit. */
void c4fLitController(C4fCanvas *c, float x, float y, float width, float angle,
C4fColor body, C4fColor light, int shadow);
void c4fTextCentered(C4fCanvas *c, int font, float size, float cx, float y, C4fColor color, const char *text);
/* .tile, .panel, .dot */
void c4fTile(C4fCanvas *c, float x, float y, float w, float h, C4fColor bar);
void c4fPanel(C4fCanvas *c, float x, float y, float w, float h, C4fColor fill, C4fColor line);
void c4fDot(C4fCanvas *c, float cx, float cy, C4fColor color, int glow);
/* Right-aligned at `right`, centred on cy. */
void c4fHints(C4fCanvas *c, float right, float cy, const C4fHint *hints, int count);
#endif
+438
View File
@@ -0,0 +1,438 @@
#include "tester.h"
#include <math.h>
#include <stdio.h>
#include <string.h>
#include "input.h"
#define C4F_TESTER_STICKS (C4F_INPUT_BUTTON_L3 | C4F_INPUT_BUTTON_R3)
/* Held keys that keep a new menu from accepting anything yet. */
#define C4F_TESTER_MENU_KEYS (C4F_TESTER_STICKS | C4F_INPUT_BUTTON_UP | C4F_INPUT_BUTTON_DOWN | \
C4F_INPUT_BUTTON_LEFT | C4F_INPUT_BUTTON_RIGHT | C4F_INPUT_BUTTON_CROSS | \
C4F_INPUT_BUTTON_CIRCLE)
int c4fTesterHoldUpdate(C4fTesterHold *hold, uint32_t buttons, uint64_t now)
{
if ((buttons & C4F_TESTER_STICKS) != C4F_TESTER_STICKS) {
hold->holding = hold->fired = 0;
return 0;
}
if (!hold->holding) {
hold->holding = 1;
hold->since = now;
}
if (hold->fired || now - hold->since < C4F_TESTER_HOLD_MS) return 0;
hold->fired = 1;
return 1;
}
static void c4fReaderState(C4fTester *t, int slot, int state, uint32_t error)
{
C4fTesterReader *r = &t->readers[slot];
if ((r->state != state || r->error != error) && t->ops.state) t->ops.state(t->ops.context, slot, state, error);
r->state = state;
r->error = error;
}
/* Undo this test's output on a reader whose handle still works. */
static void c4fReaderQuiet(C4fTester *t, int slot)
{
C4fTesterReader *r = &t->readers[slot];
if (r->handle >= 0 && t->rumble && t->rumbleReader == slot && t->ops.vibrate)
t->ops.vibrate(t->ops.context, r->handle, 0, 0);
if (r->handle >= 0 && r->lightSet && t->ops.resetLightBar)
t->ops.resetLightBar(t->ops.context, r->handle);
if (t->rumble && t->rumbleReader == slot) t->rumble = 0;
r->lightSet = 0;
}
static void c4fReaderRelease(C4fTester *t, int slot)
{
C4fTesterReader *r = &t->readers[slot];
if (r->handle >= 0 && r->owned && t->ops.close) t->ops.close(t->ops.context, r->handle);
if (t->rumble && t->rumbleReader == slot) t->rumble = 0;
r->handle = -1;
r->owned = r->lightSet = r->seen = 0;
r->previous = r->pressed = 0;
memset(&r->sample, 0, sizeof(r->sample));
}
static void c4fReaderEmpty(C4fTester *t, int slot)
{
c4fReaderRelease(t, slot);
C4fTesterReader *r = &t->readers[slot];
r->user = -1;
r->state = C4F_TESTER_OPENING;
r->error = 0;
r->name[0] = 0;
}
/* Signed-out readers stay only while they are the one on screen. */
static void c4fTesterForgetSignedOut(C4fTester *t)
{
for (int s = 0; s < C4F_TESTER_USERS; s++)
if (s != t->selected && t->readers[s].user >= 0 && t->readers[s].state == C4F_TESTER_SIGNED_OUT)
c4fReaderEmpty(t, s);
}
static void c4fTesterSelectFirst(C4fTester *t)
{
if (t->selected >= 0 && t->readers[t->selected].user >= 0) return;
t->selected = -1;
for (int s = 0; s < C4F_TESTER_USERS && t->selected < 0; s++)
if (t->readers[s].user >= 0) t->selected = s;
}
static void c4fTesterUsers(C4fTester *t, uint64_t now)
{
int32_t users[C4F_TESTER_USERS];
t->usersAt = now + C4F_TESTER_RETRY_MS;
if (!t->ops.loginUsers) return;
for (int i = 0; i < C4F_TESTER_USERS; i++) users[i] = -1;
int count = t->ops.loginUsers(t->ops.context, users);
if (count < 0) return; /* keep what is known until the next try */
if (count > C4F_TESTER_USERS) count = C4F_TESTER_USERS;
for (int s = 0; s < C4F_TESTER_USERS; s++) {
C4fTesterReader *r = &t->readers[s];
if (r->user < 0) continue;
int present = 0;
for (int i = 0; i < count; i++) present |= users[i] == r->user;
if (present) {
if (r->state == C4F_TESTER_SIGNED_OUT) {
c4fReaderState(t, s, C4F_TESTER_OPENING, 0);
r->retryAt = now;
}
} else if (r->state != C4F_TESTER_SIGNED_OUT) {
c4fReaderQuiet(t, s);
if (s == t->selected) {
c4fReaderRelease(t, s);
c4fReaderState(t, s, C4F_TESTER_SIGNED_OUT, 0);
} else {
c4fReaderEmpty(t, s);
}
}
}
for (int i = 0; i < count; i++) {
int known = users[i] < 0;
for (int s = 0; s < C4F_TESTER_USERS && !known; s++) known = t->readers[s].user == users[i];
for (int s = 0; s < C4F_TESTER_USERS && !known; s++) {
C4fTesterReader *r = &t->readers[s];
if (r->user >= 0) continue;
r->user = users[i];
r->name[0] = 0;
if (t->ops.userName) t->ops.userName(t->ops.context, r->user, r->name, (int)sizeof(r->name));
r->name[sizeof(r->name) - 1] = 0;
/* Names are drawn in plain ASCII. */
for (char *p = r->name; *p; p++) if ((unsigned char)*p < 32 || (unsigned char)*p > 126) *p = '?';
if (!r->name[0]) snprintf(r->name, sizeof(r->name), "User %d", s + 1);
r->retryAt = now;
c4fReaderState(t, s, C4F_TESTER_OPENING, 0);
known = 1;
}
}
c4fTesterSelectFirst(t);
}
static void c4fReaderPoll(C4fTester *t, int slot, uint64_t now)
{
C4fTesterReader *r = &t->readers[slot];
r->pressed = 0;
if (r->user < 0 || r->state == C4F_TESTER_SIGNED_OUT) return;
if (r->handle < 0) {
if (now < r->retryAt || !t->ops.open) return;
int owned = 0, handle = t->ops.open(t->ops.context, r->user, &owned);
if (handle < 0) {
c4fReaderState(t, slot, C4F_TESTER_FAILED, (uint32_t)handle);
r->retryAt = now + C4F_TESTER_RETRY_MS;
return;
}
r->handle = handle;
r->owned = !!owned;
r->seen = 0;
/* Games turn the motion sensor on for their own handles; so does this. */
if (t->ops.motionSensor) t->ops.motionSensor(t->ops.context, handle, 1);
}
C4fTesterSample sample;
memset(&sample, 0, sizeof(sample));
int result = t->ops.read ? t->ops.read(t->ops.context, r->handle, &sample) : -1;
if (result != 0) {
c4fReaderRelease(t, slot);
c4fReaderState(t, slot, C4F_TESTER_FAILED, (uint32_t)result);
r->retryAt = now + C4F_TESTER_RETRY_MS;
return;
}
if (!sample.connected) {
memset(&r->sample, 0, sizeof(r->sample));
r->previous = 0;
r->seen = 0;
c4fReaderState(t, slot, C4F_TESTER_NO_CONTROLLER, 0);
return;
}
if (sample.touches > 2) sample.touches = 2;
/* Buttons already held when reading starts are shown, but are not presses. */
r->pressed = r->seen ? sample.buttons & ~r->previous : 0;
r->previous = sample.buttons;
r->seen = 1;
r->sample = sample;
c4fReaderState(t, slot, C4F_TESTER_READY, 0);
}
static void c4fTesterStopRumble(C4fTester *t)
{
if (!t->rumble) return;
C4fTesterReader *r = &t->readers[t->rumbleReader];
if (r->handle >= 0 && t->ops.vibrate) t->ops.vibrate(t->ops.context, r->handle, 0, 0);
t->rumble = 0;
}
static void c4fTesterNextUser(C4fTester *t, int direction)
{
int start = t->selected >= 0 ? t->selected : 0;
for (int step = 1; step <= C4F_TESTER_USERS; step++) {
int s = (start + direction * step + 2 * C4F_TESTER_USERS) % C4F_TESTER_USERS;
if (t->readers[s].user >= 0 && t->readers[s].state != C4F_TESTER_SIGNED_OUT) {
t->selected = s;
break;
}
}
c4fTesterForgetSignedOut(t);
t->note[0] = 0;
}
static void c4fTesterResult(C4fTester *t, int result, const char *done)
{
if (result) snprintf(t->note, sizeof(t->note), "The PS4 refused that (0x%08x).", (unsigned)result);
else snprintf(t->note, sizeof(t->note), "%s", done);
}
static void c4fTesterAct(C4fTester *t, int item, uint64_t now)
{
static const uint8_t colors[3][3] = { { 255, 0, 0 }, { 0, 255, 0 }, { 0, 0, 255 } };
C4fTesterReader *r = t->selected >= 0 ? &t->readers[t->selected] : NULL;
if (item == C4F_TESTER_RESUME) {
t->menu = 0;
return;
}
if (item == C4F_TESTER_USER) {
c4fTesterNextUser(t, 1);
return;
}
if (!r || r->user < 0 || r->handle < 0 || r->state != C4F_TESTER_READY) {
snprintf(t->note, sizeof(t->note), "This user has no controller to test.");
return;
}
switch (item) {
case C4F_TESTER_RUMBLE_LARGE:
case C4F_TESTER_RUMBLE_SMALL: {
int large = item == C4F_TESTER_RUMBLE_LARGE;
c4fTesterStopRumble(t);
int result = t->ops.vibrate ? t->ops.vibrate(t->ops.context, r->handle, large ? 255 : 0, large ? 0 : 255) : -1;
if (!result) {
t->rumble = large ? 1 : 2;
t->rumbleReader = t->selected;
t->rumbleUntil = now + C4F_TESTER_RUMBLE_MS;
}
c4fTesterResult(t, result, large ? "Large motor: rumbling for a second." : "Small motor: rumbling for a second.");
break;
}
case C4F_TESTER_LIGHT_RED:
case C4F_TESTER_LIGHT_GREEN:
case C4F_TESTER_LIGHT_BLUE: {
const uint8_t *rgb = colors[item - C4F_TESTER_LIGHT_RED];
int result = t->ops.lightBar ? t->ops.lightBar(t->ops.context, r->handle, rgb[0], rgb[1], rgb[2]) : -1;
if (!result) {
r->lightSet = 1;
memcpy(r->light, rgb, 3);
}
c4fTesterResult(t, result, "Light bar set. It goes back to normal when you leave.");
break;
}
case C4F_TESTER_LIGHT_RESET: {
int result = t->ops.resetLightBar ? t->ops.resetLightBar(t->ops.context, r->handle) : -1;
if (!result) r->lightSet = 0;
c4fTesterResult(t, result, "Light bar reset.");
break;
}
case C4F_TESTER_ORIENTATION: {
int result = t->ops.resetOrientation ? t->ops.resetOrientation(t->ops.context, r->handle) : -1;
c4fTesterResult(t, result, "Orientation reset.");
break;
}
default:
break;
}
}
void c4fTesterInit(C4fTester *t, const C4fTesterOps *ops)
{
memset(t, 0, sizeof(*t));
if (ops) t->ops = *ops;
t->selected = -1;
for (int s = 0; s < C4F_TESTER_USERS; s++) {
t->readers[s].user = -1;
t->readers[s].handle = -1;
}
}
void c4fTesterOpen(C4fTester *t, uint64_t now)
{
if (t->active) return;
C4fTesterOps ops = t->ops;
c4fTesterInit(t, &ops);
t->active = 1;
/* The L3+R3 hold that opened the test must end before another counts. */
t->hold.holding = t->hold.fired = 1;
t->hold.since = now;
c4fTesterUsers(t, now);
}
static void c4fTesterQuietAll(C4fTester *t)
{
c4fTesterStopRumble(t);
for (int s = 0; s < C4F_TESTER_USERS; s++) c4fReaderQuiet(t, s);
}
void c4fTesterSuspend(C4fTester *t, uint64_t now)
{
if (!t->active) return;
c4fTesterQuietAll(t);
for (int s = 0; s < C4F_TESTER_USERS; s++) {
C4fTesterReader *r = &t->readers[s];
c4fReaderRelease(t, s);
if (r->user >= 0 && r->state != C4F_TESTER_SIGNED_OUT) {
c4fReaderState(t, s, C4F_TESTER_OPENING, 0);
r->retryAt = now;
}
}
t->menu = 0;
t->hold.holding = t->hold.fired = 1;
t->hold.since = now;
}
void c4fTesterClose(C4fTester *t)
{
if (!t->active) return;
c4fTesterQuietAll(t);
for (int s = 0; s < C4F_TESTER_USERS; s++) c4fReaderRelease(t, s);
C4fTesterOps ops = t->ops;
c4fTesterInit(t, &ops);
}
int c4fTesterFrame(C4fTester *t, uint64_t now, uint32_t remotePressed)
{
if (!t->active) return 0;
if (now >= t->usersAt) c4fTesterUsers(t, now);
uint32_t held = 0, pressed = 0;
int both = 0;
for (int s = 0; s < C4F_TESTER_USERS; s++) {
c4fReaderPoll(t, s, now);
const C4fTesterReader *r = &t->readers[s];
if (r->state != C4F_TESTER_READY) continue;
held |= r->sample.buttons;
pressed |= r->pressed;
/* One controller holding both, not two holding one each. */
both |= (r->sample.buttons & C4F_TESTER_STICKS) == C4F_TESTER_STICKS;
}
if (t->rumble && now >= t->rumbleUntil) c4fTesterStopRumble(t);
if (c4fTesterHoldUpdate(&t->hold, both ? C4F_TESTER_STICKS : 0, now) && !t->menu) {
t->menu = 1;
t->focus = C4F_TESTER_RESUME;
t->armed = 0;
}
if (!t->menu) {
/* The remote is not under test, so it may leave and open the menu. */
if (remotePressed & C4F_INPUT_BUTTON_CIRCLE) {
c4fTesterClose(t);
return 0;
}
if (remotePressed & (C4F_INPUT_BUTTON_CROSS | C4F_INPUT_BUTTON_OPTIONS)) {
t->menu = 1;
t->focus = C4F_TESTER_RESUME;
t->armed = 0;
}
return 1;
}
if (!t->armed) {
/* Nothing counts until the keys that opened the menu are let go. */
if (!(held & C4F_TESTER_MENU_KEYS)) t->armed = 1;
return 1;
}
pressed |= remotePressed;
if (pressed & C4F_INPUT_BUTTON_CIRCLE) {
t->menu = 0;
} else if (pressed & C4F_INPUT_BUTTON_UP) {
t->focus = (t->focus + C4F_TESTER_ITEMS - 1) % C4F_TESTER_ITEMS;
} else if (pressed & C4F_INPUT_BUTTON_DOWN) {
t->focus = (t->focus + 1) % C4F_TESTER_ITEMS;
} else if (t->focus == C4F_TESTER_USER && (pressed & (C4F_INPUT_BUTTON_LEFT | C4F_INPUT_BUTTON_RIGHT))) {
c4fTesterNextUser(t, (pressed & C4F_INPUT_BUTTON_RIGHT) ? 1 : -1);
} else if (pressed & C4F_INPUT_BUTTON_CROSS) {
if (t->focus == C4F_TESTER_EXIT) {
c4fTesterClose(t);
return 0;
}
c4fTesterAct(t, t->focus, now);
}
return 1;
}
static int16_t c4fScaled(float value, float scale)
{
if (isnan(value)) return 0;
float v = roundf(value * scale);
return (int16_t)(v > 32767 ? 32767 : v < -32767 ? -32767 : v);
}
void c4fTesterView(const C4fTester *t, C4fTesterView *v)
{
memset(v, 0, sizeof(*v));
if (!t->active) return;
v->active = 1;
v->menu = t->menu;
v->focus = t->focus;
v->selected = t->selected;
for (int s = 0; s < C4F_TESTER_USERS; s++) {
const C4fTesterReader *r = &t->readers[s];
v->user[s].used = r->user >= 0;
if (!v->user[s].used) continue;
snprintf(v->user[s].name, sizeof(v->user[s].name), "%s", r->name);
v->user[s].state = r->state;
v->user[s].error = r->error;
}
snprintf(v->note, sizeof(v->note), "%s", t->note);
if (t->selected < 0) return;
const C4fTesterReader *r = &t->readers[t->selected];
v->rumble = t->rumble && t->rumbleReader == t->selected ? t->rumble : 0;
v->lightSet = r->lightSet;
memcpy(v->light, r->light, 3);
if (r->state != C4F_TESTER_READY) return;
const C4fTesterSample *s = &r->sample;
v->buttons = s->buttons;
v->lx = s->lx; v->ly = s->ly; v->rx = s->rx; v->ry = s->ry; v->l2 = s->l2; v->r2 = s->r2;
v->touches = s->touches;
for (int i = 0; i < v->touches; i++) {
v->touchX[i] = s->touch[i].x;
v->touchY[i] = s->touch[i].y;
}
for (int i = 0; i < 4; i++) v->quat[i] = c4fScaled(s->quat[i], 1000);
for (int i = 0; i < 3; i++) {
v->accel[i] = c4fScaled(s->accel[i], 100);
v->gyro[i] = c4fScaled(s->gyro[i], 100);
}
}
const char *c4fTesterItemLabel(int item)
{
switch (item) {
case C4F_TESTER_RESUME: return "Resume";
case C4F_TESTER_USER: return "User";
case C4F_TESTER_RUMBLE_LARGE: return "Rumble the large motor";
case C4F_TESTER_RUMBLE_SMALL: return "Rumble the small motor";
case C4F_TESTER_LIGHT_RED: return "Light bar: red";
case C4F_TESTER_LIGHT_GREEN: return "Light bar: green";
case C4F_TESTER_LIGHT_BLUE: return "Light bar: blue";
case C4F_TESTER_LIGHT_RESET: return "Reset the light bar";
case C4F_TESTER_ORIENTATION: return "Reset orientation";
case C4F_TESTER_EXIT: return "Back to the launcher";
default: return "";
}
}
+115
View File
@@ -0,0 +1,115 @@
#ifndef C4F_LAUNCHER_TESTER_H
#define C4F_LAUNCHER_TESTER_H
/* Controller test: what each signed-in user's controller sends, read the way a
* game reads it, with rumble and light-bar test actions. The native pad and
* user calls are injected, so this logic also runs in host tests. */
#include <stdint.h>
#define C4F_TESTER_USERS 4
#define C4F_TESTER_HOLD_MS 2000
#define C4F_TESTER_RUMBLE_MS 1000
#define C4F_TESTER_RETRY_MS 1000
#define C4F_TESTER_NAME 24
/* One sample as the read side reports it. */
typedef struct {
uint32_t buttons;
uint8_t lx, ly, rx, ry, l2, r2, touches;
struct { uint16_t x, y; uint8_t id; } touch[2];
float quat[4]; /* orientation x y z w */
float accel[3]; /* G */
float gyro[3]; /* rad/s */
int connected;
} C4fTesterSample;
typedef struct {
void *context;
/* Fills up to C4F_TESTER_USERS signed-in user IDs; returns how many, or a
* negative error. */
int (*loginUsers)(void *context, int32_t *users);
/* The name the PS4 shows. Only ever drawn, never logged. */
void (*userName)(void *context, int32_t user, char *name, int size);
/* A handle, or a negative error. owned as in C4fInputOps: a handle borrowed
* from the launcher's own reader must not be closed here. */
int (*open)(void *context, int32_t user, int *owned);
int (*read)(void *context, int handle, C4fTesterSample *sample);
void (*close)(void *context, int handle);
int (*vibrate)(void *context, int handle, uint8_t large, uint8_t small);
int (*lightBar)(void *context, int handle, uint8_t r, uint8_t g, uint8_t b);
int (*resetLightBar)(void *context, int handle);
int (*resetOrientation)(void *context, int handle);
int (*motionSensor)(void *context, int handle, int on);
/* Optional: a reader changed state (for the log; slot, never the user). */
void (*state)(void *context, int slot, int state, uint32_t error);
} C4fTesterOps;
/* Per user. */
enum { C4F_TESTER_OPENING, C4F_TESTER_READY, C4F_TESTER_NO_CONTROLLER, C4F_TESTER_FAILED, C4F_TESTER_SIGNED_OUT };
/* The menu, in order. */
typedef enum {
C4F_TESTER_RESUME, C4F_TESTER_USER, C4F_TESTER_RUMBLE_LARGE, C4F_TESTER_RUMBLE_SMALL,
C4F_TESTER_LIGHT_RED, C4F_TESTER_LIGHT_GREEN, C4F_TESTER_LIGHT_BLUE, C4F_TESTER_LIGHT_RESET,
C4F_TESTER_ORIENTATION, C4F_TESTER_EXIT, C4F_TESTER_ITEMS
} C4fTesterItem;
/* L3 and R3 held together for C4F_TESTER_HOLD_MS. */
typedef struct {
int holding, fired;
uint64_t since;
} C4fTesterHold;
typedef struct {
int32_t user; /* -1: empty slot */
int handle, owned, state, lightSet, seen;
uint32_t error, previous, pressed;
uint64_t retryAt;
uint8_t light[3];
char name[C4F_TESTER_NAME];
C4fTesterSample sample;
} C4fTesterReader;
typedef struct {
C4fTesterOps ops;
int active, menu, focus, armed, selected, rumble, rumbleReader;
uint64_t usersAt, rumbleUntil;
C4fTesterHold hold;
C4fTesterReader readers[C4F_TESTER_USERS];
char note[96];
} C4fTester;
/* What the screen shows, in whole display units so that an unchanged reading
* compares equal and causes no redraw. */
typedef struct {
int active, menu, focus, selected, rumble, lightSet;
uint8_t light[3];
struct { char name[C4F_TESTER_NAME]; int state, used; uint32_t error; } user[C4F_TESTER_USERS];
uint32_t buttons;
uint8_t lx, ly, rx, ry, l2, r2, touches;
uint16_t touchX[2], touchY[2];
int16_t quat[4]; /* x 1000 */
int16_t accel[3]; /* x 100, G */
int16_t gyro[3]; /* x 100, rad/s */
char note[96];
} C4fTesterView;
#ifdef __cplusplus
extern "C" {
#endif
/* True once per hold; the buttons must be released before it fires again. */
int c4fTesterHoldUpdate(C4fTesterHold *hold, uint32_t buttons, uint64_t now);
void c4fTesterInit(C4fTester *tester, const C4fTesterOps *ops);
void c4fTesterOpen(C4fTester *tester, uint64_t now);
/* One frame. remotePressed is the TV remote's new presses: OK and Options open
* the menu, Back closes it or leaves. Returns 0 once the tester has closed. */
int c4fTesterFrame(C4fTester *tester, uint64_t now, uint32_t remotePressed);
/* The app lost focus: stop test output and reopen every reader afterwards. */
void c4fTesterSuspend(C4fTester *tester, uint64_t now);
/* Stops rumble, resets light bars this test set, closes owned handles. */
void c4fTesterClose(C4fTester *tester);
void c4fTesterView(const C4fTester *tester, C4fTesterView *view);
const char *c4fTesterItemLabel(int item);
#ifdef __cplusplus
}
#endif
#endif
+956
View File
@@ -0,0 +1,956 @@
/* The controller test screen, in the launcher's look, with the controller drawn
* the way the controller page draws it (client/index.html: DEFAULT_LAYOUT and the
* .pad-area styles). A still layer is built once; each frame copies it and draws
* only what the controller is doing, so a moving stick redraws quickly. */
#include "screen.h"
#include <math.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "input.h"
#include "style.h"
/* Layout, in TV pixels. */
#define C4F_T_LEFT 150.0f
#define C4F_T_RIGHT 1770.0f
#define C4F_T_TABS_Y 246.0f
#define C4F_T_TOP 320.0f
#define C4F_T_HEIGHT 600.0f
#define C4F_T_PAD_W 1000.0f
#define C4F_T_GAP 30.0f
#define C4F_T_INFO_X (C4F_T_LEFT + C4F_T_PAD_W + C4F_T_GAP)
#define C4F_T_INFO_W (C4F_T_RIGHT - C4F_T_INFO_X)
#define C4F_T_TEXT_X (C4F_T_INFO_X + 36)
/* The controller picture: 2:1, like the page's mapping picture. */
#define C4F_P_X 166.0f
#define C4F_P_Y 336.0f
#define C4F_P_W 968.0f
#define C4F_P_H 484.0f
/* Right panel rows: baselines. */
#define C4F_T_ROW_STICKS 372.0f
#define C4F_T_ROW_TRIGGERS 496.0f
#define C4F_T_ROW_TOUCH 620.0f
#define C4F_T_ROW_MOTION 690.0f
#define C4F_T_ROW_LIGHT 862.0f
#define C4F_T_BAR_X (C4F_T_TEXT_X + 70)
#define C4F_T_BAR_W 300.0f
#define C4F_T_COLUMN_X (C4F_T_TEXT_X + 236)
#define C4F_T_COLUMN_W 70.0f
/* The page's controller colours. */
#define C4F_P_ENGRAVED C4F_RGBA(236, 240, 248, .55f)
#define C4F_P_PRINT C4F_RGBA(236, 240, 248, .4f)
#define C4F_P_HIGHLIGHT C4F_RGBA(255, 255, 255, .16f)
#define C4F_P_EDGE C4F_RGBA(255, 255, 255, .06f)
/* The page's DEFAULT_LAYOUT: centre in % of the picture, size in units of u. */
enum { C4F_C_L2, C4F_C_L1, C4F_C_R2, C4F_C_R1, C4F_C_SHARE, C4F_C_OPTIONS, C4F_C_TOUCHPAD, C4F_C_DPAD,
C4F_C_FACE, C4F_C_PS, C4F_C_LSTICK, C4F_C_RSTICK, C4F_C_L3, C4F_C_R3, C4F_C_COUNT };
static const float c4fLayout[C4F_C_COUNT][4] = {
{ 14, 10, 22, 11 }, { 14, 23, 22, 8 }, { 86, 10, 22, 11 }, { 86, 23, 22, 8 },
{ 28.5f, 13, 10, 14 }, { 71.5f, 13, 10, 14 }, { 50, 35, 76, 48 },
{ 15, 55, 40, 40 }, { 85, 55, 40, 40 }, { 50, 75, 10, 10 },
{ 33, 79, 34, 34 }, { 67, 79, 34, 34 }, { 17, 88, 11, 11 }, { 83, 88, 11, 11 },
};
/* The page's stick travel: the knob moves this share of the stick's width. */
#define C4F_KNOB_TRAVEL 0.27f
typedef struct { float x, y, w, h; } C4fBox;
typedef struct { float at; C4fColor color; } C4fStop;
static const C4fPlacement c4fIdentity = { 0, 0, 1, 0, 0, 0 };
static uint32_t *c4fLayer;
/* The page's size unit, --u: min(1cqh, .46cqw) of the picture. */
static float c4fUnit(void) { return fminf(C4F_P_H / 100, .46f * C4F_P_W / 100); }
static C4fBox c4fControl(int id)
{
const float u = c4fUnit(), *l = c4fLayout[id];
C4fBox b = { C4F_P_X + l[0] / 100 * C4F_P_W, C4F_P_Y + l[1] / 100 * C4F_P_H, l[2] * u, l[3] * u };
b.x -= b.w / 2;
b.y -= b.h / 2;
return b;
}
static C4fColor c4fMix(C4fColor a, C4fColor b, float share)
{
return (C4fColor){ (uint8_t)(a.r * share + b.r * (1 - share) + .5f), (uint8_t)(a.g * share + b.g * (1 - share) + .5f),
(uint8_t)(a.b * share + b.b * (1 - share) + .5f), 255 };
}
static C4fColor c4fAlpha(C4fColor c, float alpha)
{
c.a = (uint8_t)(alpha * 255 + .5f);
return c;
}
static C4fColor c4fHex(uint32_t rgb) { return (C4fColor){ (uint8_t)(rgb >> 16), (uint8_t)(rgb >> 8), (uint8_t)rgb, 255 }; }
static C4fColor c4fStopsAt(const C4fStop *s, int n, float t)
{
if (t <= s[0].at) return s[0].color;
for (int i = 1; i < n; i++) {
if (t > s[i].at) continue;
float f = (t - s[i - 1].at) / (s[i].at - s[i - 1].at);
const C4fColor a = s[i - 1].color, b = s[i].color;
return (C4fColor){ (uint8_t)(a.r + (b.r - a.r) * f + .5f), (uint8_t)(a.g + (b.g - a.g) * f + .5f),
(uint8_t)(a.b + (b.b - a.b) * f + .5f), (uint8_t)(a.a + (b.a - a.a) * f + .5f) };
}
return s[n - 1].color;
}
/* ---- shapes ---- */
/* A box with CSS border-radius corners, as horizontal/vertical radius pairs for
* top-left, top-right, bottom-right and bottom-left, scaled down together the
* way CSS does when they do not fit; inset shrinks it for an inner edge. */
static void c4fRoundedPath(C4fPath *p, C4fBox b, const float radii[8], float inset)
{
const float k = .5523f;
float r[8], fit = 1;
const float sums[4][2] = { { b.w, radii[0] + radii[2] }, { b.w, radii[6] + radii[4] },
{ b.h, radii[1] + radii[7] }, { b.h, radii[3] + radii[5] } };
for (int i = 0; i < 4; i++) if (sums[i][1] > 0) fit = fminf(fit, sums[i][0] / sums[i][1]);
for (int i = 0; i < 8; i++) r[i] = fmaxf(0, radii[i] * fit - inset);
const float x = b.x + inset, y = b.y + inset, w = b.w - 2 * inset, h = b.h - 2 * inset;
c4fPathMove(p, x + r[0], y);
c4fPathLine(p, x + w - r[2], y);
c4fPathCubic(p, x + w - r[2] + r[2] * k, y, x + w, y + r[3] - r[3] * k, x + w, y + r[3]);
c4fPathLine(p, x + w, y + h - r[5]);
c4fPathCubic(p, x + w, y + h - r[5] + r[5] * k, x + w - r[4] + r[4] * k, y + h, x + w - r[4], y + h);
c4fPathLine(p, x + r[6], y + h);
c4fPathCubic(p, x + r[6] - r[6] * k, y + h, x, y + h - r[7] + r[7] * k, x, y + h - r[7]);
c4fPathLine(p, x, y + r[1]);
c4fPathCubic(p, x, y + r[1] - r[1] * k, x + r[0] - r[0] * k, y, x + r[0], y);
}
/* A shape's radii, from CSS percentages of its box. */
static void c4fPercentRadii(float out[8], C4fBox b, float tl, float tr, float br, float bl,
float tlv, float trv, float brv, float blv)
{
const float r[8] = { tl * b.w, tlv * b.h, tr * b.w, trv * b.h, br * b.w, brv * b.h, bl * b.w, blv * b.h };
memcpy(out, r, sizeof(r));
}
static void c4fShapePath(C4fPath *p, int id, float inset)
{
const C4fBox b = c4fControl(id);
const float u = c4fUnit();
float r[8];
if (id == C4F_C_L2 || id == C4F_C_R2)
c4fPercentRadii(r, b, .44f, .44f, .18f, .18f, .64f, .64f, .24f, .24f);
else if (id == C4F_C_L1 || id == C4F_C_R1)
c4fPercentRadii(r, b, .34f, .34f, .26f, .26f, .70f, .70f, .50f, .50f);
else if (id == C4F_C_TOUCHPAD) {
const float t = 3 * u, m = 7 * u, radii[8] = { t, t, t, t, m, m, m, m };
memcpy(r, radii, sizeof(r));
} else {
for (int i = 0; i < 8; i++) r[i] = b.w;
}
c4fRoundedPath(p, b, r, inset);
}
/* Share and Options: a capsule under the printed name. */
static C4fBox c4fCapsule(int id)
{
const float u = c4fUnit(), label = fmaxf(8, 1.9f * u), gap = 1.1f * u, w = 4.2f * u, h = 8 * u;
const C4fBox b = c4fControl(id);
const float top = b.y + (b.h - (label + gap + h)) / 2 + label + gap;
return (C4fBox){ b.x + (b.w - w) / 2, top, w, h };
}
static void c4fCapsulePath(C4fPath *p, int id, float inset)
{
const C4fBox b = c4fCapsule(id);
float r[8];
for (int i = 0; i < 8; i++) r[i] = b.w / 2;
c4fRoundedPath(p, b, r, inset);
}
/* ---- painting through a shape ---- */
/* Fills a path with a gradient: vertical over [top, top + height], or radial
* from (cx, cy) out to `radius` when it is above 0. */
static void c4fGradientPath(C4fCanvas *c, const C4fPath *p, const C4fStop *s, int n,
float top, float height, float cx, float cy, float radius)
{
unsigned char *mask;
int x0, y0, w, h;
if (!c4fPathMask(c, p, c4fIdentity, &mask, &x0, &y0, &w, &h)) return;
for (int y = 0; y < h; y++)
for (int x = 0; x < w; x++) {
unsigned m = mask[y * w + x];
if (!m) continue;
float px = x0 + x + .5f, py = y0 + y + .5f;
float t = radius > 0 ? hypotf(px - cx, py - cy) / radius : (py - top) / height;
c4fBlend(c, x0 + x, y0 + y, c4fStopsAt(s, n, t), m / 255.0f);
}
free(mask);
}
/* CSS inset shadows along a shape's own edge: a ring `width` pixels wide, or
* only the top edge (inset 0 1px 0). `below` keeps the part under that line. */
static void c4fInsetPath(C4fCanvas *c, const C4fPath *p, int width, int topOnly, float below, C4fColor color)
{
unsigned char *mask;
int x0, y0, w, h;
if (!c4fPathMask(c, p, c4fIdentity, &mask, &x0, &y0, &w, &h)) return;
#define C4F_AT(sx, sy) ((sx) >= 0 && (sy) >= 0 && (sx) < w && (sy) < h ? mask[(sy) * w + (sx)] : 0)
for (int y = 0; y < h; y++)
for (int x = 0; x < w; x++) {
int m = mask[y * w + x], inner = m;
if (!m || y0 + y < below) continue;
/* Well inside the shape: nothing to draw, and no need to look around. */
if (m == 255 && C4F_AT(x, y - width) == 255 && (topOnly || (C4F_AT(x, y + width) == 255 &&
C4F_AT(x - width, y) == 255 && C4F_AT(x + width, y) == 255 && C4F_AT(x - width, y - width) == 255 &&
C4F_AT(x + width, y - width) == 255 && C4F_AT(x - width, y + width) == 255 &&
C4F_AT(x + width, y + width) == 255))) continue;
for (int dy = -width; dy <= width && inner; dy++)
for (int dx = topOnly ? 0 : -width; dx <= (topOnly ? 0 : width) && inner; dx++) {
if (topOnly && dy >= 0) continue;
int sx = x + dx, sy = y + dy;
int s = sx >= 0 && sy >= 0 && sx < w && sy < h ? mask[sy * w + sx] : 0;
if (s < inner) inner = s;
}
if (m > inner) c4fBlend(c, x0 + x, y0 + y, color, (m - inner) / 255.0f);
}
#undef C4F_AT
free(mask);
}
/* A path's outline as a stroke of even width, joints without double darkening. */
static void c4fStrokePath(C4fCanvas *c, const C4fPath *p, float width, C4fColor color)
{
float minX = 1e9f, minY = 1e9f, maxX = -1e9f, maxY = -1e9f;
for (int i = 0; i < p->count; i++)
for (int k = 0; k < (p->ops[i].op == 'C' ? 3 : 1); k++) {
minX = fminf(minX, p->ops[i].v[k * 2]); maxX = fmaxf(maxX, p->ops[i].v[k * 2]);
minY = fminf(minY, p->ops[i].v[k * 2 + 1]); maxY = fmaxf(maxY, p->ops[i].v[k * 2 + 1]);
}
const int x0 = (int)floorf(minX - width) - 1, y0 = (int)floorf(minY - width) - 1;
const int w = (int)ceilf(maxX + width) + 2 - x0, h = (int)ceilf(maxY + width) + 2 - y0;
if (w <= 0 || h <= 0 || w > c->w * 2 || h > c->h * 2) return;
float *cover = calloc((size_t)w * (size_t)h, sizeof(float));
if (!cover) return;
float x = 0, y = 0;
for (int i = 0; i < p->count; i++) {
const C4fPathOp *o = &p->ops[i];
if (o->op == 'M') { x = o->v[0]; y = o->v[1]; continue; }
const int steps = o->op == 'C' ? 10 : 1;
for (int s = 1; s <= steps; s++) {
float t = (float)s / steps, mt = 1 - t, nx, ny;
if (o->op == 'C') {
nx = mt * mt * mt * x + 3 * mt * mt * t * o->v[0] + 3 * mt * t * t * o->v[2] + t * t * t * o->v[4];
ny = mt * mt * mt * y + 3 * mt * mt * t * o->v[1] + 3 * mt * t * t * o->v[3] + t * t * t * o->v[5];
} else {
nx = o->v[0]; ny = o->v[1];
}
/* Distance from each pixel to the segment, into the coverage map. */
const float ax = x, ay = y, dx = nx - ax, dy = ny - ay, len2 = dx * dx + dy * dy;
const int sx0 = (int)floorf(fminf(ax, nx) - width) - x0, sx1 = (int)ceilf(fmaxf(ax, nx) + width) - x0;
const int sy0 = (int)floorf(fminf(ay, ny) - width) - y0, sy1 = (int)ceilf(fmaxf(ay, ny) + width) - y0;
for (int py = sy0 < 0 ? 0 : sy0; py <= sy1 && py < h; py++)
for (int px = sx0 < 0 ? 0 : sx0; px <= sx1 && px < w; px++) {
float qx = x0 + px + .5f - ax, qy = y0 + py + .5f - ay;
float f = len2 > 0 ? fmaxf(0, fminf(1, (qx * dx + qy * dy) / len2)) : 0;
float d = hypotf(qx - f * dx, qy - f * dy);
float cov = fmaxf(0, fminf(1, width / 2 + .5f - d));
if (cov > cover[py * w + px]) cover[py * w + px] = cov;
}
x = nx; y = ny;
}
}
for (int py = 0; py < h; py++)
for (int px = 0; px < w; px++)
if (cover[py * w + px] > 0) c4fBlend(c, x0 + px, y0 + py, color, cover[py * w + px]);
free(cover);
}
/* Appends an SVG path's d (M L H V C S Q Z, either case) to p, a point (x, y)
* landing at (ox + x * scale, oy + y * scale). Closing adds the line back to the
* start, so the edges can be stroked as well as filled. */
static void c4fSvgPath(C4fPath *p, const char *d, float ox, float oy, float scale)
{
float x = 0, y = 0, sx = 0, sy = 0, cx = 0, cy = 0, v[6];
char command = 0, last = 0;
const char *s = d;
for (;;) {
while (*s == ' ' || *s == ',') s++;
if (!*s) break;
if ((*s >= 'A' && *s <= 'Z') || (*s >= 'a' && *s <= 'z')) {
command = *s++;
if (command == 'z' || command == 'Z') {
c4fPathLine(p, ox + sx * scale, oy + sy * scale);
x = sx; y = sy; last = command;
continue;
}
} else if (!command) {
break;
}
const char lower = command | 0x20;
const int relative = command == lower, count = lower == 'h' || lower == 'v' ? 1 : lower == 'c' ? 6 :
lower == 's' || lower == 'q' ? 4 : 2;
int read = 0;
for (; read < count; read++) {
while (*s == ' ' || *s == ',') s++;
char *end;
v[read] = strtof(s, &end);
if (end == s) break;
s = end;
}
if (read < count) break;
float bx = relative ? x : 0, by = relative ? y : 0;
if (lower == 'm') {
x = bx + v[0]; y = by + v[1]; sx = x; sy = y;
c4fPathMove(p, ox + x * scale, oy + y * scale);
command = relative ? 'l' : 'L'; /* later pairs are lines */
} else if (lower == 'l' || lower == 'h' || lower == 'v') {
if (lower == 'l') { x = bx + v[0]; y = by + v[1]; }
else if (lower == 'h') x = bx + v[0];
else y = by + v[0];
c4fPathLine(p, ox + x * scale, oy + y * scale);
} else {
float c1x, c1y, c2x, c2y, ex, ey;
if (lower == 'c') {
c1x = bx + v[0]; c1y = by + v[1]; c2x = bx + v[2]; c2y = by + v[3]; ex = bx + v[4]; ey = by + v[5];
} else if (lower == 's') {
int smooth = last == 'c' || last == 'C' || last == 's' || last == 'S';
c1x = smooth ? 2 * x - cx : x; c1y = smooth ? 2 * y - cy : y;
c2x = bx + v[0]; c2y = by + v[1]; ex = bx + v[2]; ey = by + v[3];
} else { /* q: the quadratic as a cubic */
float qx = bx + v[0], qy = by + v[1];
ex = bx + v[2]; ey = by + v[3];
c1x = x + 2.0f / 3 * (qx - x); c1y = y + 2.0f / 3 * (qy - y);
c2x = ex + 2.0f / 3 * (qx - ex); c2y = ey + 2.0f / 3 * (qy - ey);
}
c4fPathCubic(p, ox + c1x * scale, oy + c1y * scale, ox + c2x * scale, oy + c2y * scale,
ox + ex * scale, oy + ey * scale);
cx = c2x; cy = c2y; x = ex; y = ey;
}
last = command;
}
}
static void c4fTextRight(C4fCanvas *c, int font, float size, float right, float y, C4fColor color, const char *text)
{
c4fText(c, font, size, right - c4fTextWidth(font, size, text), y, color, text);
}
/* Letter-spaced capitals, centred, like the page's SHARE and OPTIONS. */
static void c4fSpacedCentered(C4fCanvas *c, float size, float cx, float y, float spacing, C4fColor color, const char *text)
{
float width = 0;
for (const char *t = text; *t; t++) {
const char one[2] = { *t, 0 };
width += c4fTextWidth(C4F_FONT_REGULAR, size, one) + (t[1] ? spacing : 0);
}
float x = cx - width / 2;
for (const char *t = text; *t; t++) x += c4fTextN(c, C4F_FONT_REGULAR, size, x, y, color, t, 1) + spacing;
}
/* ---- the controller, at rest (the still layer) ---- */
static const C4fStop c4fPlastic[3] = { { 0, { 0x34, 0x3c, 0x4f, 255 } }, { .46f, { 0x1f, 0x25, 0x32, 255 } },
{ 1, { 0x15, 0x19, 0x23, 255 } } };
/* Glossy dark plastic lit from above: L2, L1, R2, R1, L3, R3 and the capsules. */
static void c4fPlasticShape(C4fCanvas *c, const C4fPath *outer, C4fBox b)
{
c4fShadowPath(c, outer, c4fIdentity, 0, 6, 9, C4F_RGBA(0, 0, 0, .6f));
c4fGradientPath(c, outer, c4fPlastic, 3, b.y, b.h, 0, 0, 0);
c4fInsetPath(c, outer, 1, 0, 0, C4F_P_EDGE);
c4fInsetPath(c, outer, 1, 1, 0, C4F_P_HIGHLIGHT);
}
static void c4fEngraved(C4fCanvas *c, C4fBox b, const char *label, C4fColor color)
{
c4fTextCentered(c, C4F_FONT_REGULAR, 18, b.x + b.w / 2, b.y + b.h / 2 + 6.5f, color, label);
}
static const char *const c4fShoulderNames[4] = { "L2", "L1", "R2", "R1" };
static void c4fFaceCentre(int i, float *cx, float *cy, float *r)
{
/* Triangle, Circle, Cross, Square: 34% wide, at the page's corners of the box. */
static const float at[4][2] = { { .50f, .17f }, { .83f, .50f }, { .50f, .83f }, { .17f, .50f } };
const C4fBox b = c4fControl(C4F_C_FACE);
*cx = b.x + at[i][0] * b.w;
*cy = b.y + at[i][1] * b.h;
*r = .17f * b.w;
}
static C4fColor c4fFaceColor(int i)
{
return i == 0 ? C4F_TRIANGLE_KEY : i == 1 ? C4F_CIRCLE_KEY : i == 2 ? C4F_CROSS_KEY : C4F_SQUARE_KEY;
}
static void c4fFaceSymbol(C4fCanvas *c, int i, float cx, float cy, float r, C4fColor color)
{
/* The page's 24-unit symbols at 56% of the button. */
const float s = 2 * r * .56f / 24, stroke = 1.7f * s;
if (i == 0) {
C4fPath tri = { 0 };
c4fSvgPath(&tri, "M12 4.6l7.2 12.4H4.8z", cx - 12 * s, cy - 12 * s, s);
c4fStrokePath(c, &tri, stroke, color);
c4fPathFree(&tri);
} else if (i == 1) {
c4fRing(c, cx, cy, 6.6f * s, stroke, color);
} else if (i == 2) {
c4fSegment(c, cx - 5.4f * s, cy - 5.4f * s, cx + 5.4f * s, cy + 5.4f * s, stroke, color);
c4fSegment(c, cx + 5.4f * s, cy - 5.4f * s, cx - 5.4f * s, cy + 5.4f * s, stroke, color);
} else {
c4fSquareOutline(c, cx, cy, 5.6f * s, stroke, color);
}
}
static void c4fFaceButton(C4fCanvas *c, int i, float scale, int lit)
{
float cx, cy, r;
c4fFaceCentre(i, &cx, &cy, &r);
r *= scale;
C4fPath circle = { 0 };
c4fPathCircle(&circle, cx, cy, r, 1);
if (lit) c4fShadowPath(c, &circle, c4fIdentity, 0, 0, 12, c4fAlpha(c4fFaceColor(i), .7f));
else c4fShadowPath(c, &circle, c4fIdentity, 0, 6, 9, C4F_RGBA(0, 0, 0, .6f));
/* radial-gradient(circle at 50% 28%, #3c4458, #222837 48%, #141822): brighter when pressed. */
const C4fStop rest[3] = { { 0, c4fHex(0x3c4458) }, { .48f, c4fHex(0x222837) }, { 1, c4fHex(0x141822) } };
const C4fStop bright[3] = { { 0, c4fHex(0x4e5873) }, { .48f, c4fHex(0x2c3448) }, { 1, c4fHex(0x1a1f2c) } };
c4fGradientPath(c, &circle, lit ? bright : rest, 3, 0, 0, cx, cy - .44f * r, 1.75f * r);
c4fInsetPath(c, &circle, 1, 1, 0, C4F_P_HIGHLIGHT);
if (lit) c4fInsetPath(c, &circle, 2, 0, 0, c4fFaceColor(i));
else c4fRing(c, cx, cy, r + .5f, 1, C4F_RGBA(255, 255, 255, .05f));
c4fFaceSymbol(c, i, cx, cy, r, c4fFaceColor(i));
c4fPathFree(&circle);
}
/* The D-pad's four arms, from the page's SVG (viewBox -128 -128 256 256). */
static const char *const c4fArms[4] = {
"M0-9L30-39V-88Q30-100 18-100H-18Q-30-100-30-88V-39Z",
"M9 0L39 30H88Q100 30 100 18V-18Q100-30 88-30H39Z",
"M0 9L-30 39V88Q-30 100-18 100H18Q30 100 30 88V39Z",
"M-9 0L-39-30H-88Q-100-30-100-18V18Q-100 30-88 30H-39Z",
};
static void c4fArmPath(C4fPath *p, int arm)
{
const C4fBox b = c4fControl(C4F_C_DPAD);
c4fSvgPath(p, c4fArms[arm], b.x + b.w / 2, b.y + b.h / 2, b.w / 256);
}
static void c4fArm(C4fCanvas *c, int arm, int lit, C4fColor accent)
{
const C4fBox b = c4fControl(C4F_C_DPAD);
const float s = b.w / 256;
C4fPath p = { 0 };
c4fArmPath(&p, arm);
if (lit) {
const C4fStop fill[1] = { { 0, c4fMix(accent, c4fHex(0x232a3a), .42f) } };
c4fShadowPath(c, &p, c4fIdentity, 0, 0, 10, c4fAlpha(accent, .55f));
c4fGradientPath(c, &p, fill, 1, b.y, b.h, 0, 0, 0);
c4fStrokePath(c, &p, 2 * s, accent);
} else {
const C4fStop fill[3] = { { 0, c4fHex(0x3d4558) }, { .5f, c4fHex(0x232a38) }, { 1, c4fHex(0x161a24) } };
float top = b.y + b.h / 2 + (arm == 0 ? -100 : arm == 2 ? 9 : -30) * s;
float height = (arm == 0 || arm == 2 ? 91 : 60) * s;
c4fShadowPath(c, &p, c4fIdentity, 0, 5 * s, 5, C4F_RGBA(0, 0, 0, .55f));
c4fGradientPath(c, &p, fill, 3, top, height, 0, 0, 0);
c4fStrokePath(c, &p, 1.2f * s, C4F_RGBA(255, 255, 255, .13f));
}
c4fPathFree(&p);
}
static void c4fTouchpadRest(C4fCanvas *c)
{
const C4fBox b = c4fControl(C4F_C_TOUCHPAD);
const float u = c4fUnit();
C4fPath p = { 0 };
c4fShapePath(&p, C4F_C_TOUCHPAD, 0);
c4fShadowPath(c, &p, c4fIdentity, 0, 10, 12, C4F_RGBA(0, 0, 0, .6f));
const C4fStop plate[3] = { { 0, c4fHex(0x1d2331) }, { .7f, c4fHex(0x151a25) }, { 1, c4fHex(0x121620) } };
c4fGradientPath(c, &p, plate, 3, b.y, b.h, 0, 0, 0);
/* The fine dot grain, one dot every 1.5u. */
unsigned char *mask;
int x0, y0, w, h;
if (c4fPathMask(c, &p, c4fIdentity, &mask, &x0, &y0, &w, &h)) {
const float step = 1.5f * u;
for (int y = 0; y < h; y++)
for (int x = 0; x < w; x++) {
if (mask[y * w + x] < 255) continue;
float fx = fmodf(x0 + x + .5f - b.x, step) - step / 2, fy = fmodf(y0 + y + .5f - b.y, step) - step / 2;
float d = hypotf(fx, fy), cov = fmaxf(0, fminf(1, 1.2f - d));
if (cov > 0) c4fBlend(c, x0 + x, y0 + y, C4F_RGBA(255, 255, 255, .06f), cov);
}
free(mask);
}
/* inset 0 -10px 24px rgba(0, 0, 0, .35): darker towards the bottom edge. */
const C4fStop shade[2] = { { .72f, C4F_RGBA(0, 0, 0, 0) }, { 1, C4F_RGBA(0, 0, 0, .35f) } };
c4fGradientPath(c, &p, shade, 2, b.y, b.h, 0, 0, 0);
c4fInsetPath(c, &p, 1, 0, 0, C4F_RGBA(255, 255, 255, .07f));
c4fPathFree(&p);
}
static void c4fPsButton(C4fCanvas *c)
{
const C4fBox b = c4fControl(C4F_C_PS);
const float u = c4fUnit(), cx = b.x + b.w / 2, cy = b.y + b.h / 2, r = b.w / 2;
C4fPath p = { 0 };
c4fPathCircle(&p, cx, cy, r + .6f * u + 1, 1);
c4fShadowPath(c, &p, c4fIdentity, 0, 6, 9, C4F_RGBA(0, 0, 0, .6f));
c4fFillCircle(c, cx, cy, r + .6f * u + 1, C4F_RGBA(255, 255, 255, .08f));
c4fFillCircle(c, cx, cy, r + .6f * u, c4fHex(0x121620));
c4fPathFree(&p);
c4fPathCircle(&p, cx, cy, r, 1);
const C4fStop cap[2] = { { 0, c4fHex(0x414a5f) }, { .72f, c4fHex(0x1a1f2b) } };
c4fGradientPath(c, &p, cap, 2, 0, 0, cx, cy - .4f * r, 1.4f * r);
c4fInsetPath(c, &p, 1, 1, 0, C4F_RGBA(255, 255, 255, .2f));
c4fPathFree(&p);
/* The PlayStation mark (Tokyoship, CC BY 3.0), viewBox 1240 1136 378 296, at 58%. */
static const char *const mark[3] = {
"m1442.9581 1196.8425-.242 234.3159-59.4601-20.4178-.4185-274.2048 75.8973 21.1864c48.5826 13.7861 78.3137 "
"40.5845 77.8302 87.5507-.4829 54.6248-25.8649 75.8164-68.8533 55.8404v-101.2762c0-14.941-24.7536-25.8506-24.7536-2.9946z",
"m1370.7136 1303.1522-8.9415 2.8809-86.5464 31.0437c-18.2193 8.3547-33.4371 13.7582-33.5335 27.3708.4569 17.2306 "
"27.4593 25.6141 66.9974 32.2183 21.0698 4.2232 42.5958 1.3924 61.026-2.5123l.015-32.4651s-34.1651 13.278-42.2947 "
"14.3829l-16.4389-.09c-12.2037-.067-17.8867-7.8843-5.7302-12.0759l65.4355-24.7626.011-35.9902z",
"m1524.5024 1317.7768c-22.722-.075-54.1883 6.1167-69.7875 13.3735v37.0026l41.1316-15.041 32.581-10.3583c33.9325-5.5872 "
"47.4232 6.2615 14.3731 16.1649l-88.0833 32.2321-.1072 35.8824 128.695-46.2998c14.2026-4.9527 34.177-14.9519 "
"34.225-27.2419-3.4472-19.028-32.0697-30.1323-76.5199-34.8392-3.9869-.4111-7.9933-.6688-11.9728-.7879-1.5161-.044-3.0225-.083-4.5372-.088z",
};
const float size = .58f * b.w, scale = size / 378, top = cy - 296 * scale / 2;
for (int i = 0; i < 3; i++) {
C4fPath m = { 0 };
c4fSvgPath(&m, mark[i], cx - size / 2 - 1240 * scale, top - 1136 * scale, scale);
c4fFillPath(c, &m, c4fIdentity, C4F_RGBA(236, 240, 248, .75f));
c4fPathFree(&m);
}
}
static void c4fStickWell(C4fCanvas *c, int id)
{
const C4fBox b = c4fControl(id);
const float cx = b.x + b.w / 2, cy = b.y + b.h / 2, r = b.w / 2;
C4fPath p = { 0 };
c4fPathCircle(&p, cx, cy, r, 1);
const C4fStop well[2] = { { 0, c4fHex(0x1d2333) }, { .7f, c4fHex(0x121621) } };
c4fGradientPath(c, &p, well, 2, 0, 0, cx, cy - .1f * b.h, 1.08f * r);
/* inset 0 8px 20px -10px rgba(0, 0, 0, .9): sunk at the top. */
const C4fStop shade[2] = { { 0, C4F_RGBA(0, 0, 0, .45f) }, { .3f, C4F_RGBA(0, 0, 0, 0) } };
c4fGradientPath(c, &p, shade, 2, b.y, b.h, 0, 0, 0);
c4fInsetPath(c, &p, 1, 0, 0, C4F_RGBA(255, 255, 255, .07f));
c4fPathFree(&p);
}
static void c4fBuildPicture(C4fCanvas *c)
{
/* .pad-view's background: near black lit faintly from the top left. */
C4fPath panel = { 0 };
const C4fBox area = { C4F_P_X, C4F_P_Y, C4F_P_W, C4F_P_H };
const float corners[8] = { 12, 12, 12, 12, 12, 12, 12, 12 };
c4fRoundedPath(&panel, area, corners, 0);
unsigned char *mask;
int x0, y0, w, h;
if (c4fPathMask(c, &panel, c4fIdentity, &mask, &x0, &y0, &w, &h)) {
for (int y = 0; y < h; y++)
for (int x = 0; x < w; x++) {
unsigned m = mask[y * w + x];
if (!m) continue;
float px = x0 + x + .5f, py = y0 + y + .5f, rgb[3] = { 11, 14, 20 };
/* radial-gradient(75% 95% at 10% -15%, accent 14%, transparent 62%) */
float ex = (px - (area.x + .10f * area.w)) / (.75f * area.w), ey = (py - (area.y - .15f * area.h)) / (.95f * area.h);
float a = .14f * fmaxf(0, 1 - sqrtf(ex * ex + ey * ey) / .62f);
rgb[0] += (58 - rgb[0]) * a; rgb[1] += (140 - rgb[1]) * a; rgb[2] += (255 - rgb[2]) * a;
/* radial-gradient(65% 80% at 105% 112%, rgba(120, 140, 200, .08), transparent 62%) */
ex = (px - (area.x + 1.05f * area.w)) / (.65f * area.w); ey = (py - (area.y + 1.12f * area.h)) / (.8f * area.h);
a = .08f * fmaxf(0, 1 - sqrtf(ex * ex + ey * ey) / .62f);
rgb[0] += (120 - rgb[0]) * a; rgb[1] += (140 - rgb[1]) * a; rgb[2] += (200 - rgb[2]) * a;
c4fBlend(c, x0 + x, y0 + y, (C4fColor){ (uint8_t)rgb[0], (uint8_t)rgb[1], (uint8_t)rgb[2], 255 }, m / 255.0f);
}
free(mask);
}
c4fInsetPath(c, &panel, 1, 0, 0, C4F_LINE_SOFT);
c4fPathFree(&panel);
const float u = c4fUnit();
for (int i = 0; i < 4; i++) {
C4fPath p = { 0 };
c4fShapePath(&p, i, 0);
c4fPlasticShape(c, &p, c4fControl(i));
c4fEngraved(c, c4fControl(i), c4fShoulderNames[i], C4F_P_ENGRAVED);
c4fPathFree(&p);
}
for (int id = C4F_C_SHARE; id <= C4F_C_OPTIONS; id++) {
const C4fBox b = c4fControl(id), cap = c4fCapsule(id);
c4fSpacedCentered(c, 13, b.x + b.w / 2, cap.y - 1.1f * u - 2, 1.6f, C4F_P_ENGRAVED,
id == C4F_C_SHARE ? "SHARE" : "OPTIONS");
C4fPath p = { 0 };
c4fCapsulePath(&p, id, 0);
c4fPlasticShape(c, &p, cap);
c4fPathFree(&p);
}
c4fTouchpadRest(c);
{
/* The pivot ring and the printed direction marks around the arms. */
const C4fBox b = c4fControl(C4F_C_DPAD);
const float s = b.w / 256, cx = b.x + b.w / 2, cy = b.y + b.h / 2;
c4fRing(c, cx, cy, 34 * s, 1.5f * s, C4F_RGBA(255, 255, 255, .07f));
C4fPath marks = { 0 };
c4fSvgPath(&marks, "M0-122l12 14h-24zM0 122l12-14h-24zM-122 0l14 12v-24zM122 0l-14 12v-24z", cx, cy, s);
c4fStrokePath(c, &marks, 2.2f * s, C4F_P_PRINT);
c4fPathFree(&marks);
for (int arm = 0; arm < 4; arm++) c4fArm(c, arm, 0, C4F_ACCENT);
}
for (int i = 0; i < 4; i++) c4fFaceButton(c, i, 1, 0);
c4fPsButton(c);
c4fStickWell(c, C4F_C_LSTICK);
c4fStickWell(c, C4F_C_RSTICK);
for (int id = C4F_C_L3; id <= C4F_C_R3; id++) {
C4fPath p = { 0 };
c4fShapePath(&p, id, 0);
c4fPlasticShape(c, &p, c4fControl(id));
c4fEngraved(c, c4fControl(id), id == C4F_C_L3 ? "L3" : "R3", C4F_P_ENGRAVED);
c4fPathFree(&p);
}
}
static void c4fBuildLayer(void)
{
const size_t bytes = (size_t)C4F_SCREEN_WIDTH * C4F_SCREEN_HEIGHT * sizeof(uint32_t);
uint32_t *px = c4fTakeCleanBackdrop();
C4fCanvas c = { px, C4F_SCREEN_WIDTH, C4F_SCREEN_HEIGHT };
if (!px) {
if (!(px = malloc(bytes))) return;
c.px = px;
c4fPaintBackdrop(&c, C4F_S);
}
/* .tile has backdrop-filter: blur(6px). */
c4fBlurUnder(&c, C4F_T_LEFT, C4F_T_TOP, C4F_T_PAD_W, C4F_T_HEIGHT, 6 * C4F_S, 6 * C4F_S);
c4fBlurUnder(&c, C4F_T_INFO_X, C4F_T_TOP, C4F_T_INFO_W, C4F_T_HEIGHT, 6 * C4F_S, 6 * C4F_S);
/* .topbar */
c4fMark(&c, 64 + 24, 54, 48, C4F_WHITE);
float x = 64 + 48 + 16;
x += c4fText(&c, C4F_FONT_LIGHT, 17 * C4F_S, x, 63, C4F_TEXT, "Control4Free") + 14 * C4F_S;
c4fDot(&c, x + 4 * C4F_S, 55, C4F_ACCENT, 0);
c4fText(&c, C4F_FONT_LIGHT, 13 * C4F_S, x + 16 * C4F_S, 62, C4F_MUTED, "Controller test");
c4fText(&c, C4F_FONT_LIGHT, 48, C4F_T_LEFT, 140, C4F_TEXT, "Controller test");
c4fTextWrapped(&c, C4F_FONT_LIGHT, 24, C4F_T_LEFT, 184, C4F_T_RIGHT - C4F_T_LEFT, 34, 2, C4F_MUTED,
"See the controller input received by this PS4. Select a user, then press buttons or move "
"the sticks. PS and Share go to the PS4 itself and aren't shown here.");
c4fTile(&c, C4F_T_LEFT, C4F_T_TOP, C4F_T_PAD_W, C4F_T_HEIGHT, C4F_ACCENT);
c4fTile(&c, C4F_T_INFO_X, C4F_T_TOP, C4F_T_INFO_W, C4F_T_HEIGHT, C4F_ACCENT);
c4fBuildPicture(&c);
/* The panel's headings and labels. */
const float tx = C4F_T_TEXT_X;
c4fText(&c, C4F_FONT_REGULAR, 24, tx, C4F_T_ROW_STICKS, C4F_MUTED, "Sticks");
c4fText(&c, C4F_FONT_LIGHT, 26, tx, C4F_T_ROW_STICKS + 40, C4F_TEXT, "Left");
c4fText(&c, C4F_FONT_LIGHT, 26, tx, C4F_T_ROW_STICKS + 76, C4F_TEXT, "Right");
c4fText(&c, C4F_FONT_REGULAR, 24, tx, C4F_T_ROW_TRIGGERS, C4F_MUTED, "Triggers");
for (int i = 0; i < 2; i++) {
float y = C4F_T_ROW_TRIGGERS + 40 + i * 36;
c4fText(&c, C4F_FONT_LIGHT, 26, tx, y, C4F_TEXT, i ? "R2" : "L2");
c4fFillRoundRect(&c, C4F_T_BAR_X, y - 14, C4F_T_BAR_W, 14, 7, C4F_RGBA(0, 0, 0, .35f));
}
c4fText(&c, C4F_FONT_REGULAR, 24, tx, C4F_T_ROW_TOUCH, C4F_MUTED, "Touchpad");
c4fText(&c, C4F_FONT_REGULAR, 24, tx, C4F_T_ROW_MOTION, C4F_MUTED, "Motion");
static const char *columns[4] = { "x", "y", "z", "w" };
for (int i = 0; i < 4; i++)
c4fTextRight(&c, C4F_FONT_REGULAR, 22, C4F_T_COLUMN_X + (i + 1) * C4F_T_COLUMN_W, C4F_T_ROW_MOTION,
C4F_FAINT, columns[i]);
c4fText(&c, C4F_FONT_LIGHT, 24, tx, C4F_T_ROW_MOTION + 40, C4F_TEXT, "Acceleration (G)");
c4fText(&c, C4F_FONT_LIGHT, 24, tx, C4F_T_ROW_MOTION + 76, C4F_TEXT, "Gyro (rad/s)");
c4fText(&c, C4F_FONT_LIGHT, 24, tx, C4F_T_ROW_MOTION + 112, C4F_TEXT, "Orientation");
c4fText(&c, C4F_FONT_REGULAR, 24, tx, C4F_T_ROW_LIGHT, C4F_MUTED, "Light bar");
c4fText(&c, C4F_FONT_LIGHT, 20, C4F_T_LEFT, 1030, C4F_FAINT, "Control4Free " C4F_LAUNCHER_VERSION);
c4fTextRight(&c, C4F_FONT_LIGHT, 23, C4F_T_RIGHT, 1030, C4F_MUTED,
"Hold L3 + R3: menu TV remote: OK menu / Back leave");
c4fLayer = px;
}
/* ---- what the controller is doing ---- */
static C4fColor c4fStateColor(int state)
{
switch (state) {
case C4F_TESTER_READY: return C4F_OK;
case C4F_TESTER_NO_CONTROLLER: return C4F_FAINT;
case C4F_TESTER_FAILED: return C4F_BAD;
default: return C4F_WARN;
}
}
static void c4fDrawTabs(C4fCanvas *c, const C4fTesterView *v)
{
const float y = C4F_T_TABS_Y, h = 50;
float x = C4F_T_LEFT;
int any = 0;
for (int i = 0; i < C4F_TESTER_USERS; i++) {
if (!v->user[i].used) continue;
int on = i == v->selected;
float w = c4fTextWidth(C4F_FONT_REGULAR, 26, v->user[i].name) + 84;
any = 1;
c4fFillRoundRect(c, x, y, w, h, 6 * C4F_S, on ? C4F_RGBA(40, 103, 205, .85f) : C4F_TILE);
c4fInsetRoundRect(c, x, y, w, h, 6 * C4F_S, on ? 3 : 1 * C4F_S, on ? C4F_ACCENT : C4F_LINE);
c4fDot(c, x + 28, y + h / 2, c4fStateColor(v->user[i].state), v->user[i].state == C4F_TESTER_READY);
c4fText(c, C4F_FONT_REGULAR, 26, x + 52, y + h / 2 + 9, on ? C4F_TEXT : C4F_MUTED, v->user[i].name);
x += w + 16;
}
if (!any) c4fText(c, C4F_FONT_LIGHT, 26, C4F_T_LEFT, y + 34, C4F_MUTED, "Waiting for the PS4's signed-in users...");
}
/* A pressed plastic control: lit in the accent colour, like the page's .on. */
static void c4fPlasticLit(C4fCanvas *c, const C4fPath *p, C4fBox b, C4fColor accent)
{
const C4fStop lit[2] = { { 0, c4fMix(accent, c4fHex(0x2a3142), .40f) }, { 1, c4fMix(accent, c4fHex(0x151923), .22f) } };
c4fShadowPath(c, p, c4fIdentity, 0, 0, 10, c4fAlpha(accent, .6f));
c4fGradientPath(c, p, lit, 2, b.y, b.h, 0, 0, 0);
c4fInsetPath(c, p, 2, 0, 0, c4fAlpha(accent, .75f));
}
/* L2 / R2: the fill rises with the pressure, inside the trigger's shape. */
static void c4fTriggerFill(C4fCanvas *c, int id, int pressure, C4fColor accent)
{
if (pressure <= 0) return;
const C4fBox b = c4fControl(id);
const float top = b.y + b.h * (1 - pressure / 255.0f);
C4fPath p = { 0 };
c4fShapePath(&p, id, 0);
unsigned char *mask;
int x0, y0, w, h;
if (c4fPathMask(c, &p, c4fIdentity, &mask, &x0, &y0, &w, &h)) {
for (int y = 0; y < h; y++) {
float rowTop = y0 + y, cover = fmaxf(0, fminf(1, rowTop + 1 - top));
if (cover <= 0) continue;
for (int x = 0; x < w; x++)
if (mask[y * w + x]) c4fBlend(c, x0 + x, y0 + y, c4fAlpha(accent, .42f), cover * mask[y * w + x] / 255.0f);
}
free(mask);
}
c4fPathFree(&p);
}
static void c4fKnob(C4fCanvas *c, int id, int sx, int sy, int clicked, C4fColor accent)
{
const C4fBox b = c4fControl(id);
const int moved = sx != 128 || sy != 128;
const float travel = b.w * C4F_KNOB_TRAVEL;
const float cx = b.x + b.w / 2 + (sx - 128) / 127.5f * travel, cy = b.y + b.h / 2 + (sy - 128) / 127.5f * travel;
const float r = b.w / 4 * (clicked ? .9f : 1);
C4fPath p = { 0 };
c4fPathCircle(&p, cx, cy, r, 1);
c4fShadowPath(c, &p, c4fIdentity, 0, 8, 9, C4F_RGBA(0, 0, 0, .7f));
if (moved || clicked) c4fShadowPath(c, &p, c4fIdentity, 0, 0, 9, c4fAlpha(accent, .65f));
const C4fStop body[3] = { { 0, c4fHex(clicked ? 0x56607b : 0x3f475b) }, { .55f, c4fHex(clicked ? 0x323a4d : 0x252b39) },
{ 1, c4fHex(clicked ? 0x1e2331 : 0x161a24) } };
c4fGradientPath(c, &p, body, 3, cy - r, 2 * r, 0, 0, 0);
/* The ridged rim: repeating-conic-gradient, 4 degree stripes. */
for (int y = (int)floorf(cy - r); y <= (int)ceilf(cy + r); y++)
for (int x = (int)floorf(cx - r); x <= (int)ceilf(cx + r); x++) {
float dx = x + .5f - cx, dy = y + .5f - cy, d = hypotf(dx, dy);
if (d > r) continue;
float degrees = atan2f(dx, -dy) * 57.29578f + 360;
if (fmodf(degrees, 8) < 4) c4fBlend(c, x, y, C4F_RGBA(255, 255, 255, .045f), fminf(1, r - d));
}
c4fInsetPath(c, &p, 1, 1, 0, C4F_RGBA(255, 255, 255, .2f));
c4fRing(c, cx, cy, r + .5f, 1, (moved || clicked) ? c4fAlpha(accent, .8f) : C4F_RGBA(0, 0, 0, .55f));
c4fPathFree(&p);
/* The thumb dish: dark at the top and lit at the bottom, so it reads as hollow. */
const float dish = r * .66f;
c4fPathCircle(&p, cx, cy, dish, 1);
const C4fStop hollow[3] = { { 0, c4fHex(0x0e1118) }, { .55f, c4fHex(0x1a1f2b) }, { 1, c4fHex(0x2b3242) } };
c4fGradientPath(c, &p, hollow, 3, cy - dish, 2 * dish, 0, 0, 0);
c4fRing(c, cx, cy, dish + .5f, 1, C4F_RGBA(0, 0, 0, .45f));
c4fPathFree(&p);
}
static void c4fDrawInput(C4fCanvas *c, const C4fTesterView *v, C4fColor accent)
{
const uint32_t b = v->buttons;
static const uint32_t shoulders[4] = { C4F_INPUT_BUTTON_L2, C4F_INPUT_BUTTON_L1, C4F_INPUT_BUTTON_R2, C4F_INPUT_BUTTON_R1 };
for (int i = 0; i < 4; i++) {
if (b & shoulders[i]) {
C4fPath p = { 0 };
c4fShapePath(&p, i, 0);
c4fPlasticLit(c, &p, c4fControl(i), accent);
c4fPathFree(&p);
}
if (i == C4F_C_L2 || i == C4F_C_R2) c4fTriggerFill(c, i, i == C4F_C_L2 ? v->l2 : v->r2, accent);
if ((b & shoulders[i]) || (i == C4F_C_L2 && v->l2) || (i == C4F_C_R2 && v->r2))
c4fEngraved(c, c4fControl(i), c4fShoulderNames[i], C4F_P_ENGRAVED);
}
if (b & C4F_INPUT_BUTTON_OPTIONS) {
C4fPath p = { 0 };
c4fCapsulePath(&p, C4F_C_OPTIONS, 0);
c4fPlasticLit(c, &p, c4fCapsule(C4F_C_OPTIONS), accent);
c4fPathFree(&p);
}
static const uint32_t arms[4] = { C4F_INPUT_BUTTON_UP, C4F_INPUT_BUTTON_RIGHT, C4F_INPUT_BUTTON_DOWN, C4F_INPUT_BUTTON_LEFT };
for (int arm = 0; arm < 4; arm++) if (b & arms[arm]) c4fArm(c, arm, 1, accent);
static const uint32_t faces[4] = { C4F_INPUT_BUTTON_TRIANGLE, C4F_INPUT_BUTTON_CIRCLE, C4F_INPUT_BUTTON_CROSS, C4F_INPUT_BUTTON_SQUARE };
for (int i = 0; i < 4; i++) if (b & faces[i]) c4fFaceButton(c, i, 1, 1);
const C4fBox pad = c4fControl(C4F_C_TOUCHPAD);
if (b & C4F_INPUT_BUTTON_TOUCHPAD) {
/* .touchpad.clicked: a lit edge (its outer glow stays under the plate). */
C4fPath p = { 0 };
c4fShapePath(&p, C4F_C_TOUCHPAD, 0);
c4fInsetPath(c, &p, 2, 0, 0, c4fAlpha(accent, .7f));
c4fPathFree(&p);
}
const float u = c4fUnit();
for (int i = 0; i < v->touches && i < 2; i++) {
float tx = pad.x + pad.w * (v->touchX[i] / 1919.0f), ty = pad.y + pad.h * (v->touchY[i] / 941.0f);
c4fGlowCircle(c, tx, ty, 3.5f * u, 20, 0, c4fAlpha(accent, .8f));
c4fFillCircle(c, tx, ty, 3.5f * u, c4fAlpha(accent, .45f));
c4fRing(c, tx, ty, 3.5f * u, 1, c4fAlpha(accent, .85f));
}
c4fKnob(c, C4F_C_LSTICK, v->lx, v->ly, !!(b & C4F_INPUT_BUTTON_L3), accent);
c4fKnob(c, C4F_C_RSTICK, v->rx, v->ry, !!(b & C4F_INPUT_BUTTON_R3), accent);
for (int id = C4F_C_L3; id <= C4F_C_R3; id++) {
if (!(b & (id == C4F_C_L3 ? C4F_INPUT_BUTTON_L3 : C4F_INPUT_BUTTON_R3))) continue;
C4fPath p = { 0 };
c4fShapePath(&p, id, 0);
c4fPlasticLit(c, &p, c4fControl(id), accent);
c4fEngraved(c, c4fControl(id), id == C4F_C_L3 ? "L3" : "R3", C4F_P_ENGRAVED);
c4fPathFree(&p);
}
}
/* The touchpad's light strip along its top edge, and its name label, as the page shows them. */
static void c4fDrawTouchpad(C4fCanvas *c, C4fColor accent, const char *line1, const char *line2)
{
const C4fBox pad = c4fControl(C4F_C_TOUCHPAD);
const float u = c4fUnit(), sx = pad.x + .07f * pad.w, sw = .86f * pad.w, sh = fmaxf(3, .8f * u);
/* The light spills up over the plate's top edge. */
c4fShadowRoundRect(c, sx, pad.y - 2, sw, sh + 2, 2, 0, -2, 3.5f * u, 0, c4fAlpha(accent, .55f));
c4fShadowRoundRect(c, sx, pad.y, sw, sh, 2, 0, 0, 2.5f * u, 0, c4fAlpha(accent, .9f));
c4fFillRoundRect(c, sx, pad.y, sw, sh, sh / 2, accent);
if (line1) c4fTextCentered(c, C4F_FONT_LIGHT, 22, pad.x + pad.w / 2, pad.y + pad.h * .9f - 30, C4F_P_ENGRAVED, line1);
if (line2) c4fTextCentered(c, C4F_FONT_LIGHT, 20, pad.x + pad.w / 2, pad.y + pad.h * .9f - 4,
C4F_RGBA(236, 240, 248, .36f), line2);
}
static void c4fDrawValues(C4fCanvas *c, const C4fTesterView *v, int ready)
{
const float tx = C4F_T_TEXT_X;
char text[96];
for (int i = 0; i < 2; i++) {
if (ready) snprintf(text, sizeof(text), "x %3u y %3u", (unsigned)(i ? v->rx : v->lx), (unsigned)(i ? v->ry : v->ly));
else snprintf(text, sizeof(text), "-");
c4fText(c, C4F_FONT_REGULAR, 26, tx + 120, C4F_T_ROW_STICKS + 40 + i * 36, C4F_TEXT, text);
}
for (int i = 0; i < 2; i++) {
unsigned value = i ? v->r2 : v->l2;
float y = C4F_T_ROW_TRIGGERS + 40 + i * 36;
if (ready && value) c4fFillRoundRect(c, C4F_T_BAR_X, y - 14, C4F_T_BAR_W * value / 255.0f, 14, 7, C4F_ACCENT);
if (ready) snprintf(text, sizeof(text), "%u", value);
else snprintf(text, sizeof(text), "-");
c4fText(c, C4F_FONT_REGULAR, 26, C4F_T_BAR_X + C4F_T_BAR_W + 20, y, C4F_TEXT, text);
}
if (!ready) snprintf(text, sizeof(text), "-");
else if (!v->touches) snprintf(text, sizeof(text), "No touch");
else if (v->touches == 1) snprintf(text, sizeof(text), "%u, %u", (unsigned)v->touchX[0], (unsigned)v->touchY[0]);
else snprintf(text, sizeof(text), "%u, %u %u, %u", (unsigned)v->touchX[0], (unsigned)v->touchY[0],
(unsigned)v->touchX[1], (unsigned)v->touchY[1]);
c4fText(c, C4F_FONT_REGULAR, 26, tx + 140, C4F_T_ROW_TOUCH, C4F_TEXT, text);
for (int row = 0; row < 3; row++) {
int count = row == 2 ? 4 : 3;
for (int i = 0; i < count; i++) {
if (!ready) snprintf(text, sizeof(text), "-");
else if (row == 0) snprintf(text, sizeof(text), "%.2f", v->accel[i] / 100.0);
else if (row == 1) snprintf(text, sizeof(text), "%.2f", v->gyro[i] / 100.0);
else snprintf(text, sizeof(text), "%.2f", v->quat[i] / 1000.0);
c4fTextRight(c, C4F_FONT_REGULAR, 24, C4F_T_COLUMN_X + (i + 1) * C4F_T_COLUMN_W,
C4F_T_ROW_MOTION + 40 + row * 36, C4F_TEXT, text);
}
}
if (v->lightSet) {
C4fColor color = { v->light[0], v->light[1], v->light[2], 255 };
c4fFillRoundRect(c, tx + 140, C4F_T_ROW_LIGHT - 22, 44, 26, 6, color);
c4fText(c, C4F_FONT_LIGHT, 24, tx + 198, C4F_T_ROW_LIGHT, C4F_TEXT, "Set by this test");
} else {
c4fText(c, C4F_FONT_LIGHT, 24, tx + 140, C4F_T_ROW_LIGHT, C4F_MUTED, "As the PS4 sets it");
}
}
static void c4fDrawMenu(C4fCanvas *c, const C4fTesterView *v)
{
const float dw = 900, row = 60, dh = 168 + C4F_TESTER_ITEMS * row + 70;
const float dx = (c->w - dw) / 2, dy = (c->h - dh) / 2;
c4fFillRect(c, 0, 0, c->w, c->h, C4F_RGBA(2, 11, 31, .65f));
c4fShadowRoundRect(c, dx, dy, dw, dh, 6 * C4F_S, 0, 16 * C4F_S, 34 * C4F_S, -18 * C4F_S, C4F_RGBA(0, 0, 0, .9f));
c4fPanel(c, dx, dy, dw, dh, C4F_RGBA(2, 11, 31, .97f), C4F_LINE);
c4fBandRoundRect(c, dx, dy, dw, dh, 6 * C4F_S, 3 * C4F_S, C4F_ACCENT);
c4fText(c, C4F_FONT_LIGHT, 44, dx + 48, dy + 80, C4F_TEXT, "Controller test");
c4fText(c, C4F_FONT_LIGHT, 24, dx + 48, dy + 122, C4F_MUTED, "Choose with the D-pad or arrows, then press Cross or OK.");
for (int i = 0; i < C4F_TESTER_ITEMS; i++) {
const float rx = dx + 32, ry = dy + 150 + i * row, rw = dw - 64, rh = row - 8;
int focused = i == v->focus;
c4fFillRoundRect(c, rx, ry, rw, rh, 6 * C4F_S, focused ? C4F_RGBA(40, 103, 205, .85f) : C4F_RGBA(255, 255, 255, .04f));
c4fInsetRoundRect(c, rx, ry, rw, rh, 6 * C4F_S, focused ? 3 : 1 * C4F_S, focused ? C4F_ACCENT : C4F_LINE_SOFT);
c4fText(c, C4F_FONT_REGULAR, 28, rx + 24, ry + 36, focused ? C4F_TEXT : C4F_MUTED, c4fTesterItemLabel(i));
if (i == C4F_TESTER_USER) {
const char *name = v->selected >= 0 ? v->user[v->selected].name : "None";
float right = rx + rw - (focused ? 48 : 24);
c4fTextRight(c, C4F_FONT_REGULAR, 28, right, ry + 36, C4F_TEXT, name);
if (focused) {
float lx = right - c4fTextWidth(C4F_FONT_REGULAR, 28, name) - 22, cy = ry + rh / 2;
c4fSegment(c, lx + 4, cy - 9, lx - 5, cy, 3, C4F_TEXT);
c4fSegment(c, lx - 5, cy, lx + 4, cy + 9, 3, C4F_TEXT);
}
}
if (focused) {
float cx = rx + rw - 26, cy = ry + rh / 2;
c4fSegment(c, cx - 5, cy - 9, cx + 4, cy, 3, C4F_TEXT);
c4fSegment(c, cx + 4, cy, cx - 5, cy + 9, 3, C4F_TEXT);
}
}
static const C4fHint hints[2] = { { C4F_KEY_CROSS, "Select" }, { C4F_KEY_CIRCLE, "Back" } };
c4fText(c, C4F_FONT_LIGHT, 23, dx + 48, dy + dh - 30, C4F_MUTED, "TV remote: OK select / Back return");
c4fHints(c, dx + dw - 48, dy + dh - 38, hints, 2);
}
void c4fDrawTester(uint32_t *pixels, const C4fLauncherScreen *s)
{
const C4fTesterView *v = &s->tester;
C4fCanvas c = { pixels, C4F_SCREEN_WIDTH, C4F_SCREEN_HEIGHT };
const size_t bytes = (size_t)c.w * (size_t)c.h * sizeof(uint32_t);
if (!c4fLayer) c4fBuildLayer();
if (c4fLayer) memcpy(pixels, c4fLayer, bytes);
else memset(pixels, 0, bytes);
c4fDrawTabs(&c, v);
const int state = v->selected >= 0 ? v->user[v->selected].state : -1, ready = state == C4F_TESTER_READY;
/* The page lights the controller in its colour; here, the colour this test set. */
const C4fColor accent = v->lightSet ? (C4fColor){ v->light[0], v->light[1], v->light[2], 255 } : C4F_ACCENT;
char line[96];
const char *name = v->selected >= 0 ? v->user[v->selected].name : "", *detail;
if (ready) {
c4fDrawInput(&c, v, accent);
detail = "Reading this user's controller";
} else {
/* Dim the picture so it does not read as a controller at rest. */
c4fFillRoundRect(&c, C4F_P_X, C4F_P_Y, C4F_P_W, C4F_P_H, 12, C4F_RGBA(2, 6, 14, .55f));
if (state == C4F_TESTER_NO_CONTROLLER) detail = "No controller connected";
else if (state == C4F_TESTER_FAILED) {
snprintf(line, sizeof(line), "The PS4 would not read it (0x%08x)", (unsigned)v->user[v->selected].error);
detail = line;
} else if (state == C4F_TESTER_SIGNED_OUT) detail = "Signed out";
else if (state < 0) { name = "No one is signed in"; detail = NULL; }
else detail = "Opening this user's controller...";
}
c4fDrawTouchpad(&c, accent, name, detail);
c4fDrawValues(&c, v, ready);
if (v->rumble)
c4fText(&c, C4F_FONT_REGULAR, 26, C4F_P_X + 4, C4F_T_TOP + C4F_T_HEIGHT - 30, C4F_WARN,
v->rumble == 1 ? "Rumbling: large motor" : "Rumbling: small motor");
if (v->note[0]) c4fText(&c, C4F_FONT_LIGHT, 24, C4F_T_LEFT, 972, C4F_TEXT, v->note);
else c4fText(&c, C4F_FONT_LIGHT, 24, C4F_T_LEFT, 972, C4F_MUTED,
"Hold L3 + R3 for two seconds for the menu: change user, rumble, light bar, leave.");
if (v->menu) c4fDrawMenu(&c, v);
}
+9 -7
View File
@@ -10,14 +10,16 @@
#include "c4f_vda.h"
/* scePadVirtualDeviceGetRemoteSetting fills a buffer laid out like the DualShock
* 4's HID output report without its report ID. Read on firmware 10.01
* (2026-10-05) while games rumbled and users signed in:
/* scePadVirtualDeviceGetRemoteSetting fills a buffer much like the DualShock 4's
* HID output report without its report ID. Read on firmware 10.01 while games
* rumbled and users signed in (2026-10-05), and with the PS4 app's controller
* test driving each motor on its own (2026-10-16):
*
* [0] update flags: bit 0 rumble, bit 1 light bar, as in the DS4 report.
* Pulses for a few milliseconds, so it is not relied on here.
* [3] right motor, the small fast one: 0xff for ~300 ms on a game's hit.
* [4] left motor, the large slow one.
* [3] the large, slow motor: 0xff for ~300 ms on a game's hit. Not the
* DS4 report's order: scePadSetVibration's large motor lands here.
* [4] the small, fast motor.
* [5..7] light bar red, green, blue: 0x40 at full, as the PS4 sets them
* (40 00 00 for player 2, 00 40 00 for player 3).
* [16] 1 once a user is assigned.
@@ -26,8 +28,8 @@ void c4fPadFeedbackParse(const uint8_t *buf, size_t size, C4fPadFeedback *out)
{
(void)memset(out, 0, sizeof(*out));
if (size < 8) return;
out->small = buf[3];
out->large = buf[4];
out->large = buf[3];
out->small = buf[4];
out->r = buf[5];
out->g = buf[6];
out->b = buf[7];
+48 -2
View File
@@ -1,5 +1,6 @@
/* Browser-controlled VDA lifecycle. No automatic button presses or user binds.
* The page talks JSON over a WebSocket: info, status, claim, u (input), leave, stop. */
* The page talks JSON over a WebSocket: info, status, claim, u (input), m (motion),
* leave, stop. */
#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
@@ -55,6 +56,10 @@ typedef struct {
char userName[72];
int nameTries;
uint64_t nameAt;
/* Motion its owner sends ('m'), put into every sample. Until then, and after
* any neutral reset, samples carry the still values c4fPadDataNeutral sets. */
int motion;
float quat[4], accel[3], gyro[3];
} C4fWebPad;
/* Creating a controller means issuing AddDevice and then finding its DeviceId in
@@ -236,9 +241,18 @@ static uint64_t c4fSince(uint64_t now, uint64_t then)
static void c4fNeutralize(C4fWebPad *p)
{
p->head = p->count = 0;
p->motion = 0;
c4fPadDataNeutral(&p->current);
}
static void c4fApplyMotion(const C4fWebPad *p, ScePadData *data)
{
if (!p->motion) return;
memcpy(data->quat, p->quat, sizeof(data->quat));
memcpy(data->accel, p->accel, sizeof(data->accel));
memcpy(data->gyro, p->gyro, sizeof(data->gyro));
}
static void c4fRemove(C4fWeb *app, C4fWebPad *p)
{
if (p->active) {
@@ -811,6 +825,7 @@ static void c4fUpdate(C4fWeb *app, C4fNetClient *c, C4fRequest *r)
if (t[0] < 0 || t[0] > 127 || t[1] < 0 || t[1] > 1919 || t[2] < 0 || t[2] > 941) { c4fError(c, r, 400, "A touch landed outside the touchpad"); return; }
data.touchData.touch[i].finger = t[0]; data.touchData.touch[i].x = t[1]; data.touchData.touch[i].y = t[2];
}
c4fApplyMotion(p, &data);
if (p->stale) { p->stale = 0; app->changed = 1; }
uint64_t now = c4fTimeMs();
p->lastInput = now; c4fEnqueue(p, &data);
@@ -819,6 +834,36 @@ static void c4fUpdate(C4fWeb *app, C4fNetClient *c, C4fRequest *r)
if (c4fSince(now, p->lastReport) >= C4F_REPORT_FAST_MS) c4fReportPad(app, p, (int)r->args[0], now);
}
/* m: [pad, qx, qy, qz, qw, ax, ay, az, gx, gy, gz] in whole numbers: the
* orientation x 10000, acceleration in thousandths of a G, angular velocity in
* thousandths of a radian per second. The buttons, sticks and touches stay as
* the last u left them. */
static void c4fMotion(C4fWeb *app, C4fNetClient *c, C4fRequest *r)
{
static const int64_t limits[3] = { 10000, 16000, 35000 };
if (r->argc != 11 || r->args[0] < 0 || r->args[0] >= C4F_MAX_PADS) { c4fError(c, r, 400, "That motion was not in a form Control4Free understands"); return; }
C4fWebPad *p = &app->pads[r->args[0]];
if (p->owner != c || !p->active) return;
for (unsigned i = 1; i < 11; i++) {
int64_t limit = limits[i < 5 ? 0 : i < 8 ? 1 : 2];
if (r->args[i] < -limit || r->args[i] > limit) { c4fError(c, r, 400, "Motion was outside its range"); return; }
}
for (int i = 0; i < 4; i++) p->quat[i] = (float)r->args[1+i] / 10000.0f;
for (int i = 0; i < 3; i++) {
p->accel[i] = (float)r->args[5+i] / 1000.0f;
p->gyro[i] = (float)r->args[8+i] / 1000.0f;
}
p->motion = 1;
if (p->stale) { p->stale = 0; app->changed = 1; }
uint64_t now = c4fTimeMs();
p->lastInput = now;
/* The newest state, moved: coalesces with a queued sample of the same buttons. */
ScePadData data = p->count ? p->queue[(p->head+p->count-1)%C4F_QUEUE_SIZE] : p->current;
c4fApplyMotion(p, &data);
c4fEnqueue(p, &data);
if (c4fSince(now, p->lastReport) >= C4F_REPORT_FAST_MS) c4fReportPad(app, p, (int)r->args[0], now);
}
static void c4fWebEvent(C4fNetClient *c, int event, const char *text, size_t len, void *context)
{
C4fWeb *app = context;
@@ -851,7 +896,7 @@ static void c4fWebEvent(C4fNetClient *c, int event, const char *text, size_t len
if (app->stopAt) { c4fError(c, &r, 503, "Control4Free is shutting down"); return; }
if (!strcmp(r.method, "info")) {
char reply[160];
if (r.hasId) { snprintf(reply, sizeof(reply), "{\"id\":%lld,\"result\":{\"version\":\"%s\",\"protocol\":2,\"pads\":%d}}", (long long)r.id, C4F_VERSION, C4F_MAX_PADS); c4fNetText(c, reply); }
if (r.hasId) { snprintf(reply, sizeof(reply), "{\"id\":%lld,\"result\":{\"version\":\"%s\",\"protocol\":2,\"pads\":%d,\"motion\":true}}", (long long)r.id, C4F_VERSION, C4F_MAX_PADS); c4fNetText(c, reply); }
} else if (!strcmp(r.method, "ping")) {
char reply[64];
if (r.hasId) { snprintf(reply, sizeof(reply), "{\"id\":%lld,\"result\":{}}", (long long)r.id); c4fNetText(c, reply); }
@@ -860,6 +905,7 @@ static void c4fWebEvent(C4fNetClient *c, int event, const char *text, size_t len
} else if (!strcmp(r.method, "status")) c4fStatus(app, c, &r);
else if (!strcmp(r.method, "claim")) c4fClaim(app, c, &r);
else if (!strcmp(r.method, "u")) c4fUpdate(app, c, &r);
else if (!strcmp(r.method, "m")) c4fMotion(app, c, &r);
else if (!strcmp(r.method, "leave")) {
if (app->add.client == c) { c4fError(c, &r, 409, "Wait for the pending controller claim"); return; }
if (r.argc != 1 || r.args[0] < 0 || r.args[0] >= C4F_MAX_PADS || app->pads[r.args[0]].owner != c) { c4fError(c, &r, 409, "That controller is not yours to disconnect"); return; }
+389
View File
@@ -0,0 +1,389 @@
// Settings → Experimental in a real browser, against the real service with the PS4
// stubbed (build/web-host-test). WebHID devices, gamepads and motion sensors are
// fakes that record what the page does. Test instrumentation is inserted into
// the page in memory, never shipped. Run in the Playwright Docker image:
// NODE_PATH=… node tests/experimental_test.cjs
const { chromium } = require('playwright');
const fs = require('node:fs'), assert = require('node:assert/strict'), { spawn } = require('node:child_process');
const hook = '\nwindow.c4fTest = { experiments, hidLinks, setExperiment, hidReport, triggerEffect, phoneMotion, status, ' +
'gamepadSources, setGamepadPad, openPad, closePad, openSettings, showPage, conn, rumble };\n';
const html = fs.readFileSync('/src/client/index.html', 'utf8').replace('\n })();', hook + ' })();');
fs.copyFileSync('/src/build/web-host-test', '/tmp/c4f-experimental-service');
// An independent CRC-32 (zlib's), to check the Bluetooth reports' last four bytes.
function crc32(bytes) {
let c = ~0;
for (const b of bytes) {
c ^= b;
for (let k = 0; k < 8; k++) c = c & 1 ? (c >>> 1) ^ 0xedb88320 : c >>> 1;
}
return ~c >>> 0;
}
const le32 = (bytes, at) => (bytes[at] | bytes[at + 1] << 8 | bytes[at + 2] << 16 | bytes[at + 3] << 24) >>> 0;
// In the page before its own script: fake gamepads, WebHID and motion, and a
// record of the socket's messages and the page's sensor listeners.
function fakes({ insecure = false } = {}) {
window.sentMessages = [];
const socketSend = WebSocket.prototype.send;
WebSocket.prototype.send = function(data) {
try { sentMessages.push(JSON.parse(data)); } catch {}
return socketSend.call(this, data);
};
window.motionListeners = 0;
const addListener = window.addEventListener;
window.addEventListener = function(type, ...rest) {
if (type === 'devicemotion') motionListeners++;
return addListener.call(this, type, ...rest);
};
window.effects = [];
const pad = (index, id, actuator) => ({ index, id, mapping: 'standard', connected: true, timestamp: 0,
buttons: Array.from({ length: 17 }, () => ({ value: 0, pressed: false })), axes: [0, 0, 0, 0], vibrationActuator: actuator });
const xbox = { type: 'dual-rumble', effects: ['dual-rumble', 'trigger-rumble'],
playEffect: async (type, params) => { effects.push([type, params]); return 'complete'; }, reset: async () => 'complete' };
window.testPads = [
pad(0, 'Wireless Controller (STANDARD GAMEPAD Vendor: 054c Product: 09cc)'),
pad(1, 'Xbox 360 Controller (XInput STANDARD GAMEPAD)', xbox),
pad(2, 'DualSense Wireless Controller (STANDARD GAMEPAD Vendor: 054c Product: 0ce6)'),
];
Object.defineProperty(navigator, 'getGamepads', { value: () => testPads });
if (insecure) {
Object.defineProperty(window, 'isSecureContext', { value: false });
delete Navigator.prototype.hid;
delete window.DeviceMotionEvent;
return;
}
class FakeDevice extends EventTarget {
constructor(productId, outputs, features) {
super();
Object.assign(this, { vendorId: 0x054c, productId, productName: 'Fake', opened: false, closes: 0, sent: [], features,
collections: [{ outputReports: outputs.map(reportId => ({ reportId })) }] });
}
async open() { this.opened = true; }
async close() { this.opened = false; this.closes++; }
async sendReport(id, data) { this.sent.push({ id, data: Array.from(data) }); }
async receiveFeatureReport(id) {
if (!this.features[id]) throw new DOMException('no such report', 'NotAllowedError');
return new DataView(Uint8Array.from(this.features[id]).buffer);
}
input(reportId, bytes) {
const ev = new Event('inputreport');
Object.assign(ev, { reportId, data: new DataView(Uint8Array.from(bytes).buffer) });
this.dispatchEvent(ev);
}
}
// DS4 calibration (report 0x02 over USB, ID included): biases 0, every gyro axis
// ±8192 for a speed of 1024 + 1024 (so 1/8 degree per second per step), and
// the accelerometer ±8192 for ±1 G.
const calibration = new Uint8Array(37);
const put = (at, v) => { calibration[at] = v & 0xff; calibration[at + 1] = (v >> 8) & 0xff; };
calibration[0] = 0x02;
for (const at of [7, 11, 15]) put(at, 8192);
for (const at of [9, 13, 17]) put(at, -8192);
put(19, 1024); put(21, 1024);
for (const at of [23, 27, 31]) put(at, 8192);
for (const at of [25, 29, 33]) put(at, -8192);
// DualSense firmware 0x20, update version 2.32 at byte 44.
const firmware = new Uint8Array(64);
firmware[0] = 0x20; firmware[44] = 0x32; firmware[45] = 0x02;
window.fakeHid = {
ds4: new FakeDevice(0x09cc, [0x05], { 0x02: Array.from(calibration) }),
ds4bt: new FakeDevice(0x09cc, [0x11], {}),
dualsense: new FakeDevice(0x0ce6, [0x02], { 0x20: Array.from(firmware) }),
dualsensebt: new FakeDevice(0x0ce6, [0x31], { 0x20: Array.from(firmware) }),
granted: [], next: null, calls: [],
};
Object.defineProperty(navigator, 'hid', { configurable: true, value: {
requestDevice: async options => { fakeHid.calls.push(['requestDevice', options]); return fakeHid.next ? [fakeHid.next] : []; },
getDevices: async () => { fakeHid.calls.push(['getDevices']); return fakeHid.granted; },
addEventListener: type => { fakeHid.calls.push(['addEventListener', type]); },
} });
}
// A fresh service for each scenario, so its controllers' handles start over:
// the first controller created is 11030d, the second 12030d.
let server = null;
async function startService() {
if (server) {
const old = server;
old.kill();
await new Promise(done => old.exitCode !== null || old.signalCode !== null ? done() : old.once('exit', done));
}
server = spawn('/tmp/c4f-experimental-service', [], { env: { ...process.env, C4F_TEST_KLOG_FD: '0' }, stdio: ['pipe', 'pipe', 'pipe'] });
server.on('error', error => console.error('Service harness:', error));
server.stderr.on('data', data => console.error(data.toString()));
server.stdout.on('data', () => {});
}
const feedback = (handle, large, small, r = 0, g = 0, b = 0) => server.stdin.write(`C4F-TEST-FEEDBACK ${handle} ${large} ${small} ${r} ${g} ${b}\n`);
async function open(browser, options) {
await startService();
const context = await browser.newContext({ viewport: { width: 1280, height: 900 } });
await context.grantPermissions(['local-network-access']);
await context.addInitScript(fakes, options);
const page = await context.newPage();
page.on('pageerror', e => { console.error('Page error:', e.stack); process.exitCode = 1; });
await page.route(/^http:\/\/127\.0\.0\.1:4264\/(?:\?.*)?$/, async r => r.fulfill({ response: await r.fetch(), body: html }));
for (let tries = 0; ; tries++) {
try { await page.goto('http://127.0.0.1:4264/'); break; } catch (e) {
if (tries > 50) throw e;
await new Promise(done => setTimeout(done, 100)); // the service is still starting
}
}
await page.waitForFunction(() => window.c4fTest?.gamepadSources.size === 3);
await page.waitForFunction(() => document.querySelector('#conn').dataset.conn === 'open' && c4fTest.conn.motion);
return { context, page };
}
const lastReport = (page, device) => page.evaluate(d => fakeHid[d].sent.at(-1), device);
(async () => {
let browser;
try {
browser = await chromium.launch({ headless: true });
// Off by default: the tab is there, nothing new runs.
let { context, page } = await open(browser);
await page.evaluate(() => { c4fTest.openSettings('experimental'); });
assert.deepEqual(await page.$$eval('#expRows .check', inputs => inputs.map(i => [i.dataset.experiment, i.checked, i.disabled])),
[['hid', false, false], ['triggers', false, false], ['motion', false, false]]);
assert.equal(await page.locator('#expSony').isHidden(), true);
assert.equal(await page.locator('#expMotion').isHidden(), true);
await page.evaluate(() => { c4fTest.setGamepadPad(0, 0); c4fTest.setGamepadPad(1, 0); c4fTest.openPad(0); });
await page.waitForFunction(() => c4fTest.status[0].mine);
feedback('11030d', 200, 40);
await page.waitForFunction(() => effects.length > 0);
await page.waitForTimeout(300);
assert.equal(await page.evaluate(() => fakeHid.calls.length), 0, 'WebHID was used while switched off');
assert.equal(await page.evaluate(() => motionListeners), 0, 'motion sensors were listened to while switched off');
assert.equal(await page.evaluate(() => sentMessages.some(m => m.method === 'm')), false, 'motion was sent while switched off');
assert.equal(await page.evaluate(() => effects.every(([type]) => type === 'dual-rumble')), true);
feedback('11030d', 0, 0);
console.log('PASS experimental: three switches, all off; nothing of them runs while off');
await context.close();
// A page the browser treats as insecure: the WebHID and motion switches explain why they can't be used.
({ context, page } = await open(browser, { insecure: true }));
await page.evaluate(() => c4fTest.openSettings('experimental'));
assert.deepEqual(await page.$$eval('#expRows .check', inputs => inputs.map(i => i.disabled)), [true, false, true]);
assert.equal(await page.locator('#expRows .blocked').count(), 2);
assert.match(await page.locator('#expRows .blocked').first().textContent(), /insecure.*How to turn it on/);
assert.match(await page.locator('#expRows .blocked a').first().getAttribute('href'), /troubleshooting\.md#experimental-features$/);
console.log('PASS experimental: insecure page: WebHID and motion off with the reason and a link; trigger rumble available');
await context.close();
// Lights and rumble on a DualShock 4 by USB, then by Bluetooth.
({ context, page } = await open(browser));
await page.evaluate(() => { c4fTest.setGamepadPad(0, 0); c4fTest.setGamepadPad(1, 0); c4fTest.setGamepadPad(2, 1); c4fTest.openSettings('experimental'); });
await page.waitForFunction(() => c4fTest.status[0].mine && c4fTest.status[1].mine);
await page.check('#expRows [data-experiment="hid"]');
await page.waitForFunction(() => fakeHid.calls.some(([call]) => call === 'getDevices'));
assert.equal(await page.locator('#expSony').isVisible(), true);
assert.equal(await page.locator('#expPads [data-link]').count(), 2, 'both Sony gamepads listed, the Xbox one not');
await page.evaluate(() => { fakeHid.next = fakeHid.ds4; });
await page.click('#expPads [data-link="0"]');
await page.waitForFunction(() => fakeHid.ds4.sent.length > 0);
assert.deepEqual(await page.evaluate(() => fakeHid.calls.find(([c]) => c === 'requestDevice')[1]),
{ filters: [{ vendorId: 0x054c, productId: 0x09cc }] });
let report = await lastReport(page, 'ds4');
assert.equal(report.id, 0x05);
assert.equal(report.data.length, 31);
assert.deepEqual(report.data.slice(0, 8), [0x03, 0, 0, 0, 0, 32, 96, 255], 'still, in controller 1\'s own colour');
const effectsBefore = await page.evaluate(() => effects.length);
feedback('11030d', 200, 40, 64, 0, 0);
await page.waitForFunction(() => { const r = fakeHid.ds4.sent.at(-1).data; return r[3] === 40 && r[4] === 200 && r[5] === 255; });
report = await lastReport(page, 'ds4');
assert.deepEqual(report.data.slice(0, 8), [0x03, 0, 0, 40, 200, 255, 0, 0], 'small motor, large motor, the game\'s red');
await page.waitForFunction(n => effects.length > n, effectsBefore);
assert.equal(await page.evaluate(() => effects.every(([type]) => type === 'dual-rumble')), true, 'the Xbox pad keeps its own rumble');
// Unchanged state is not sent again every frame.
const count = await page.evaluate(() => fakeHid.ds4.sent.length);
await page.waitForTimeout(200);
assert.ok(await page.evaluate(() => fakeHid.ds4.sent.length) - count <= 1, 'unchanged reports were repeated');
feedback('11030d', 0, 0, 64, 0, 0);
await page.waitForFunction(() => fakeHid.ds4.sent.at(-1).data[4] === 0);
// Unlinking leaves it still and dim, and closes it.
await page.click('#expPads [data-link="0"]');
await page.waitForFunction(() => fakeHid.ds4.closes === 1);
assert.deepEqual((await lastReport(page, 'ds4')).data.slice(0, 8), [0x03, 0, 0, 0, 0, 0, 0, 64]);
assert.equal(await page.evaluate(() => c4fTest.hidLinks.size), 0);
// Bluetooth: report 0x11, the CRC over 0xa2, the ID and the rest.
await page.evaluate(() => { fakeHid.next = fakeHid.ds4bt; });
await page.click('#expPads [data-link="0"]');
await page.waitForFunction(() => fakeHid.ds4bt.sent.length > 0);
report = await lastReport(page, 'ds4bt');
assert.equal(report.id, 0x11);
assert.equal(report.data.length, 77);
assert.deepEqual(report.data.slice(0, 10), [0xc4, 0, 0x03, 0, 0, 0, 0, 255, 0, 0]);
assert.equal(le32(report.data, 73), crc32([0xa2, 0x11, ...report.data.slice(0, 73)]), 'Bluetooth CRC');
console.log('PASS experimental: DualShock 4 by USB and Bluetooth: colour, both motors, unchanged state not resent, CRC, unlink');
// DualSense by USB with trigger rumble: start-up light faded once, then the
// newer rumble (firmware 2.32), the colour, and trigger effects only on change.
await page.check('#expRows [data-experiment="triggers"]');
await page.evaluate(() => { fakeHid.next = fakeHid.dualsense; });
await page.click('#expPads [data-link="2"]');
await page.waitForFunction(() => fakeHid.dualsense.sent.length >= 2);
let sent = await page.evaluate(() => fakeHid.dualsense.sent);
assert.equal(sent[0].id, 0x02);
assert.equal(sent[0].data.length, 62);
assert.deepEqual([sent[0].data[0], sent[0].data[1], sent[0].data[38], sent[0].data[41]], [0, 0, 0x02, 0x02], 'light bar setup alone first');
report = sent[1];
assert.equal(report.data[0] & 0x03, 0x02, 'firmware 2.32: no old-style rumble flag');
assert.equal(report.data[38] & 0x04, 0x04, 'newer rumble flag');
assert.equal(report.data[1], 0x04);
assert.deepEqual(report.data.slice(44, 47), [255, 48, 64], 'controller 2\'s own colour');
// Its touchpad's fingers reach the PS4, its 1080-high pad scaled to the PS4's 942.
await page.evaluate(() => {
const bytes = new Array(63).fill(0);
bytes.fill(128, 0, 4); bytes[7] = 0x08; // sticks centred, D-pad released
bytes[32] = 7; bytes[33] = 960 & 0xff; bytes[34] = (960 >> 8) | (1079 & 0x0f) << 4; bytes[35] = 1079 >> 4;
bytes[36] = 0x80;
fakeHid.dualsense.input(0x01, bytes);
});
await page.waitForFunction(() => sentMessages.findLast(m => m.method === 'u' && m.params[0] === 1)?.params[8] === 1);
assert.deepEqual((await page.evaluate(() => sentMessages.findLast(m => m.method === 'u' && m.params[0] === 1).params)).slice(8),
[1, 7, 960, 941]);
await page.evaluate(() => {
const bytes = new Array(63).fill(0);
bytes.fill(128, 0, 4); bytes[7] = 0x08; bytes[32] = 0x87; bytes[36] = 0x80;
fakeHid.dualsense.input(0x01, bytes);
});
await page.waitForFunction(() => sentMessages.findLast(m => m.method === 'u' && m.params[0] === 1)?.params[8] === 0);
feedback('12030d', 255, 128);
await page.waitForFunction(() => fakeHid.dualsense.sent.at(-1).data[3] === 255);
report = await lastReport(page, 'dualsense');
assert.deepEqual([report.data[2], report.data[3]], [128, 255], 'small motor, large motor');
assert.equal(report.data[0] & 0x0c, 0x0c, 'both trigger effects');
// Right trigger from the small motor (strength 4), left from the large one (strength 8).
assert.deepEqual(report.data.slice(10, 21), [0x26, 0xff, 0x03, 0xdb, 0xb6, 0x6d, 0x1b, 0, 0, 60, 0]);
assert.deepEqual(report.data.slice(21, 32), [0x26, 0xff, 0x03, 0xff, 0xff, 0xff, 0x3f, 0, 0, 30, 0]);
// The light bar alone changing does not repeat the trigger effects.
feedback('12030d', 255, 128, 0, 64, 0);
await page.waitForFunction(() => fakeHid.dualsense.sent.at(-1).data[45] === 255);
assert.equal((await lastReport(page, 'dualsense')).data[0] & 0x0c, 0, 'trigger effects re-sent without a change');
feedback('12030d', 0, 0, 0, 64, 0);
await page.waitForFunction(() => fakeHid.dualsense.sent.at(-1).data[3] === 0);
report = await lastReport(page, 'dualsense');
assert.deepEqual([report.data[0] & 0x0c, report.data[10], report.data[21]], [0x0c, 0x05, 0x05], 'trigger effects off when the rumble stops');
// The Xbox pad rumbles in its triggers too, while the switch is on.
feedback('11030d', 200, 100);
await page.waitForFunction(() => effects.some(([type]) => type === 'trigger-rumble'));
const [, params] = await page.evaluate(() => effects.findLast(([type]) => type === 'trigger-rumble'));
assert.ok(Math.abs(params.leftTrigger - 200 / 255 * .6) < 1e-6 && Math.abs(params.rightTrigger - 100 / 255 * .6) < 1e-6, JSON.stringify(params));
feedback('11030d', 0, 0);
console.log('PASS experimental: DualSense setup, newer rumble, colour, trigger effects on change; Xbox trigger rumble');
// DualSense over Bluetooth: report 0x31 with its sequence, tag and CRC. Once
// linked the browser's gamepad goes quiet (here: frozen at rest), so the
// controller's own reports carry its buttons, sticks and triggers.
await page.click('#expPads [data-link="2"]');
await page.waitForFunction(() => !c4fTest.hidLinks.has(2) && fakeHid.dualsense.closes === 1);
await page.evaluate(() => { fakeHid.next = fakeHid.dualsensebt; });
await page.click('#expPads [data-link="2"]');
await page.waitForFunction(() => fakeHid.dualsensebt.sent.length >= 2);
sent = await page.evaluate(() => fakeHid.dualsensebt.sent);
for (const [i, r] of sent.slice(0, 2).entries()) {
assert.equal(r.id, 0x31);
assert.equal(r.data.length, 77);
assert.deepEqual([r.data[0], r.data[1]], [i << 4, 0x10], 'sequence in the high nibble, then the tag');
assert.equal(le32(r.data, 73), crc32([0xa2, 0x31, ...r.data.slice(0, 73)]), 'Bluetooth CRC');
}
assert.deepEqual([sent[0].data[2 + 38], sent[0].data[2 + 41]], [0x02, 0x02], 'light bar setup first');
assert.deepEqual(sent[1].data.slice(2 + 44, 2 + 47), [0, 255, 0], 'the green the game set earlier');
const btInput = (b0, b1, b2, x, r2) => {
const bytes = new Array(77).fill(0);
bytes.fill(128, 1, 5);
bytes[1] = x; bytes[6] = r2; bytes[8] = b0; bytes[9] = b1; bytes[10] = b2;
return bytes;
};
const pad1 = () => page.evaluate(() => sentMessages.findLast(m => m.method === 'u' && m.params[0] === 1)?.params);
// Cross, D-pad up (hat 0), L1, PS; left stick fully left; R2 at 200.
await page.evaluate(bytes => fakeHid.dualsensebt.input(0x31, bytes), btInput(0x20, 0x01, 0x01, 0, 200));
await page.waitForFunction(() => (sentMessages.findLast(m => m.method === 'u' && m.params[0] === 1)?.params[1] & 0x4000) !== 0);
let u = await pad1();
assert.equal(u[1], 0x4000 | 0x0010 | 0x0400 | 0x10000 | 0x0200, 'Cross, up, L1, PS and R2 pulled: ' + u[1].toString(16));
assert.deepEqual([u[2], u[3], u[4], u[5], u[6], u[7]], [0, 128, 128, 128, 0, 200]);
await page.evaluate(bytes => fakeHid.dualsensebt.input(0x31, bytes), btInput(0x08, 0, 0, 128, 0));
await page.waitForFunction(() => sentMessages.findLast(m => m.method === 'u' && m.params[0] === 1)?.params[1] === 0);
// Unlinked, the browser's gamepad drives it again.
await page.click('#expPads [data-link="2"]');
await page.waitForFunction(() => !c4fTest.hidLinks.has(2));
await page.evaluate(() => { testPads[2].buttons[0].value = 1; testPads[2].buttons[0].pressed = true; });
await page.waitForFunction(() => (sentMessages.findLast(m => m.method === 'u' && m.params[0] === 1)?.params[1] & 0x4000) !== 0);
await page.evaluate(() => { testPads[2].buttons[0].value = 0; testPads[2].buttons[0].pressed = false; });
console.log('PASS experimental: DualSense by Bluetooth: reports and CRC; buttons, sticks and triggers from its own reports');
// Motion: a phone held sideways, then a linked DualShock 4's own sensors.
await page.check('#expRows [data-experiment="motion"]');
await page.waitForFunction(() => motionListeners === 1, null, { timeout: 3000 }).catch(async () => {
throw new Error('no devicemotion listener: ' + await page.evaluate(() => JSON.stringify({
secure: isSecureContext, motion: 'DeviceMotionEvent' in window, on: c4fTest.experiments.motion,
permission: typeof (window.DeviceMotionEvent && DeviceMotionEvent.requestPermission) })));
});
await page.evaluate(() => { c4fTest.openPad(0); });
const motions = () => page.evaluate(() => sentMessages.filter(m => m.method === 'm').map(m => m.params));
const shake = (beta, at) => page.evaluate(([beta, at]) => {
const ev = new DeviceMotionEvent('devicemotion', { accelerationIncludingGravity: { x: 0, y: 9.80665, z: 0 },
rotationRate: { alpha: 0, beta, gamma: 0 }, interval: 16 });
Object.defineProperty(ev, 'timeStamp', { value: at });
window.dispatchEvent(ev);
}, [beta, at]);
for (let i = 0; i < 6; i++) { await shake(0, 1000 + i * 20); await page.waitForTimeout(25); }
await page.waitForFunction(() => sentMessages.some(m => m.method === 'm' && m.params[0] === 0));
let m = (await motions()).filter(p => p[0] === 0).at(-1);
assert.deepEqual(m.slice(1, 5), [0, 0, 0, 10000], 'held still and sideways: level');
assert.deepEqual(m.slice(5, 8), [0, 1000, 0], 'gravity along y, like a DualShock 4 lying flat');
await shake(90, 1200);
await page.waitForFunction(() => sentMessages.findLast(m => m.method === 'm')?.params[8] === 1571);
m = (await motions()).at(-1);
assert.ok(m[1] > 0 && m[4] < 10000, 'turning about x tilts the orientation: ' + m);
// Leaving the controller view tells the PS4 the controller is still again.
await page.evaluate(() => c4fTest.closePad());
await page.waitForFunction(() => JSON.stringify(sentMessages.findLast(m => m.method === 'm')?.params) === '[0,0,0,0,10000,0,0,0,0,0,0]');
// A linked DualShock 4's own gyro and accelerometer, calibrated. The
// Bluetooth one is unlinked first; the USB one has calibration to read.
await page.evaluate(() => c4fTest.openSettings('experimental'));
assert.equal(await page.textContent('#expPads [data-link="0"]'), 'Unlink');
await page.click('#expPads [data-link="0"]');
await page.waitForFunction(() => !c4fTest.hidLinks.has(0) && fakeHid.ds4bt.closes === 1);
await page.evaluate(() => { fakeHid.next = fakeHid.ds4; });
await page.click('#expPads [data-link="0"]');
await page.waitForFunction(() => c4fTest.hidLinks.get(0)?.calib);
const report1 = (gyro, accel) => {
const bytes = new Array(63).fill(0);
bytes.fill(128, 0, 4); bytes[4] = 0x08; // sticks centred, D-pad released
[...gyro, ...accel].forEach((v, i) => { bytes[12 + 2 * i] = v & 0xff; bytes[13 + 2 * i] = (v >> 8) & 0xff; });
return bytes;
};
await page.evaluate(bytes => { for (let i = 0; i < 3; i++) fakeHid.ds4.input(0x01, bytes); }, report1([8 * 90, 0, 0], [0, 8192, 0]));
await page.waitForFunction(() => sentMessages.findLast(m => m.method === 'm' && m.params[0] === 0)?.params[8] === 1571);
m = (await motions()).filter(p => p[0] === 0).at(-1);
assert.deepEqual(m.slice(5, 11), [0, 1000, 0, 1571, 0, 0], '90 degrees per second about x, 1 G on y');
// Its touchpad's fingers, which browsers' gamepads leave out: the newest of a
// report's touch reports, after its timestamp byte.
const touchReport = (contact, x, y) => {
const bytes = new Array(63).fill(0);
bytes.fill(128, 0, 4); bytes[4] = 0x08;
bytes[32] = 1;
bytes[34] = contact; bytes[35] = x & 0xff; bytes[36] = (x >> 8) | (y & 0x0f) << 4; bytes[37] = y >> 4;
bytes[38] = 0x80;
return bytes;
};
const lastInput = () => page.evaluate(() => sentMessages.findLast(m => m.method === 'u' && m.params[0] === 0)?.params);
await page.evaluate(bytes => fakeHid.ds4.input(0x01, bytes), touchReport(5, 1000, 500));
await page.waitForFunction(() => sentMessages.findLast(m => m.method === 'u' && m.params[0] === 0)?.params[8] === 1);
assert.deepEqual((await lastInput()).slice(8), [1, 5, 1000, 500]);
await page.evaluate(bytes => fakeHid.ds4.input(0x01, bytes), touchReport(0x85, 1000, 500));
await page.waitForFunction(() => sentMessages.findLast(m => m.method === 'u' && m.params[0] === 0)?.params[8] === 0);
// Switching motion off: still again, and no more listening.
await page.uncheck('#expRows [data-experiment="motion"]');
await page.waitForFunction(() => JSON.stringify(sentMessages.findLast(m => m.method === 'm')?.params) === '[0,0,0,0,10000,0,0,0,0,0,0]');
console.log('PASS experimental: phone motion (level, gravity on y, gyro), still on leaving; DualShock 4 sensors calibrated and touches');
await context.close();
} finally {
if (browser) await browser.close();
if (server) server.kill();
}
})().catch(error => { console.error(error); process.exitCode = 1; });
+6 -5
View File
@@ -25,15 +25,16 @@ int main(void)
f = parse(green, sizeof(green));
assert(f.r == 0 && f.g == 0x40 && f.b == 0);
/* A game's hit: the small motor at full for ~300 ms. */
/* A game's hit: the large motor at full for ~300 ms. */
static const uint8_t hit[] = { 0, 0, 0, 0xff, 0, 0, 0x40, 0 };
f = parse(hit, sizeof(hit));
assert(f.small == 0xff && f.large == 0 && f.g == 0x40);
assert(f.large == 0xff && f.small == 0 && f.g == 0x40);
/* Both motors, by the DualShock 4 layout: [3] small, [4] large. */
static const uint8_t both[] = { 0x01, 0, 0, 0x30, 0xc0, 0x20, 0, 0x20 };
/* Each motor on its own from the PS4 app's controller test: the large motor
* in [3], the small one in [4], the reverse of the DS4 report's order. */
static const uint8_t both[] = { 0x01, 0, 0, 0xc0, 0x30, 0x20, 0, 0x20 };
f = parse(both, sizeof(both));
assert(f.small == 0x30 && f.large == 0xc0 && f.r == 0x20 && f.b == 0x20);
assert(f.large == 0xc0 && f.small == 0x30 && f.r == 0x20 && f.b == 0x20);
/* Too short to hold the fields: nothing, rather than a misread. */
uint8_t shortBuf[4] = { 1, 2, 3, 4 };
+6
View File
@@ -374,6 +374,9 @@ static void heldRemoteConfirmation(void)
remoteFrame(&fake, &input, &screen, 40, C4F_INPUT_BUTTON_UP);
remoteFrame(&fake, &input, &screen, 50, 0);
remoteFrame(&fake, &input, &screen, 60, C4F_INPUT_BUTTON_UP);
assert(screen.actionFocus == C4F_ACTION_TESTER);
remoteFrame(&fake, &input, &screen, 62, 0);
remoteFrame(&fake, &input, &screen, 64, C4F_INPUT_BUTTON_UP);
assert(screen.actionFocus == C4F_ACTION_STOP);
remoteFrame(&fake, &input, &screen, 70, 0);
assert(remoteFrame(&fake, &input, &screen, 80, C4F_INPUT_BUTTON_CROSS) == C4F_ACTION_NONE);
@@ -432,6 +435,8 @@ static void stopConfirmation(void)
navigate(&screen, 0, C4F_INPUT_BUTTON_CROSS, 0);
navigate(&screen, 0, C4F_INPUT_BUTTON_UP, 0);
navigate(&screen, 0, C4F_INPUT_BUTTON_UP, 0);
assert(screen.actionFocus == C4F_ACTION_TESTER);
navigate(&screen, 0, C4F_INPUT_BUTTON_UP, 0);
assert(screen.actionFocus == C4F_ACTION_STOP);
assert(navigate(&screen, 0, C4F_INPUT_BUTTON_CROSS, 0) == C4F_ACTION_NONE);
assert(screen.confirmStop && screen.confirmMenu && !screen.confirmChoice && !screen.actionMenu);
@@ -441,6 +446,7 @@ static void stopConfirmation(void)
navigate(&screen, 0, C4F_INPUT_BUTTON_CROSS, 0);
navigate(&screen, 0, C4F_INPUT_BUTTON_UP, 0);
navigate(&screen, 0, C4F_INPUT_BUTTON_UP, 0);
navigate(&screen, 0, C4F_INPUT_BUTTON_UP, 0);
navigate(&screen, 0, C4F_INPUT_BUTTON_CROSS, 0);
assert(navigate(&screen, 0, C4F_INPUT_BUTTON_RIGHT, 0) == C4F_ACTION_NONE);
assert(screen.confirmChoice);
+484
View File
@@ -0,0 +1,484 @@
/* The controller test's logic with simulated users and pads. No console, no
* screen: the real tester.c and navigation.c against fake native calls. */
#include <assert.h>
#include <math.h>
#include <stdio.h>
#include <string.h>
#include "autorun.h"
#include "input.h"
#include "navigation.h"
#include "screen.h"
#include "tester.h"
#define STICKS (C4F_INPUT_BUTTON_L3 | C4F_INPUT_BUTTON_R3)
typedef struct {
int32_t user;
int handle, owned, openError, readError, connected;
int opens, closes, reads, motionOn, lightResets, orientationResets, vibrations;
int vibrating, large, small, lit;
uint8_t rgb[3];
C4fTesterSample sample;
} FakePad;
typedef struct {
int32_t users[4];
int count, listError;
FakePad pads[4];
int stateCalls;
} Fake;
static FakePad *byUser(Fake *f, int32_t user)
{
for (int i = 0; i < 4; i++) if (f->pads[i].user == user) return &f->pads[i];
assert(!"unknown user");
return NULL;
}
static FakePad *byHandle(Fake *f, int handle)
{
for (int i = 0; i < 4; i++) if (f->pads[i].handle == handle) return &f->pads[i];
assert(!"unknown handle");
return NULL;
}
static int fakeUsers(void *context, int32_t *users)
{
Fake *f = context;
if (f->listError) return f->listError;
for (int i = 0; i < f->count; i++) users[i] = f->users[i];
return f->count;
}
static void fakeName(void *context, int32_t user, char *name, int size)
{
(void)context;
/* User 3 has no name the app can draw: the tester falls back. */
if (user == 3) { name[0] = 0; return; }
snprintf(name, (size_t)size, user == 1 ? "Alex" : user == 2 ? "Sam\x01" : "Kim");
}
static int fakeOpen(void *context, int32_t user, int *owned)
{
FakePad *p = byUser(context, user);
p->opens++;
*owned = p->owned;
return p->openError ? p->openError : p->handle;
}
static int fakeRead(void *context, int handle, C4fTesterSample *sample)
{
FakePad *p = byHandle(context, handle);
p->reads++;
*sample = p->sample;
sample->connected = p->connected;
return p->readError;
}
static void fakeClose(void *context, int handle)
{
FakePad *p = byHandle(context, handle);
assert(p->owned);
p->closes++;
assert(p->closes <= p->opens);
}
static int fakeVibrate(void *context, int handle, uint8_t large, uint8_t small)
{
FakePad *p = byHandle(context, handle);
p->vibrations++;
p->large = large;
p->small = small;
p->vibrating = large || small;
return 0;
}
static int fakeLight(void *context, int handle, uint8_t r, uint8_t g, uint8_t b)
{
FakePad *p = byHandle(context, handle);
p->lit = 1;
p->rgb[0] = r; p->rgb[1] = g; p->rgb[2] = b;
return 0;
}
static int fakeResetLight(void *context, int handle)
{
FakePad *p = byHandle(context, handle);
p->lightResets++;
p->lit = 0;
return 0;
}
static int fakeResetOrientation(void *context, int handle)
{
byHandle(context, handle)->orientationResets++;
return 0;
}
static int fakeMotion(void *context, int handle, int on)
{
byHandle(context, handle)->motionOn = on;
return 0;
}
static void fakeState(void *context, int slot, int state, uint32_t error)
{
(void)slot; (void)state; (void)error;
((Fake *)context)->stateCalls++;
}
static void setup(Fake *f, C4fTester *t)
{
memset(f, 0, sizeof(*f));
f->count = 2;
f->users[0] = 1;
f->users[1] = 2;
for (int i = 0; i < 4; i++) {
f->pads[i] = (FakePad){ .user = i + 1, .handle = 100 + i, .owned = 1, .connected = 1 };
f->pads[i].sample.lx = f->pads[i].sample.ly = f->pads[i].sample.rx = f->pads[i].sample.ry = 128;
}
/* The launcher's own reader already holds the first user's handle. */
f->pads[0].owned = 0;
C4fTesterOps ops = { f, fakeUsers, fakeName, fakeOpen, fakeRead, fakeClose, fakeVibrate, fakeLight,
fakeResetLight, fakeResetOrientation, fakeMotion, fakeState };
c4fTesterInit(t, &ops);
}
/* A frame with the given buttons held on the first user's controller. */
static int frame(Fake *f, C4fTester *t, uint64_t now, uint32_t held, uint32_t remote)
{
f->pads[0].sample.buttons = held;
return c4fTesterFrame(t, now, remote);
}
static void holdTiming(void)
{
C4fTesterHold hold = { 0 };
assert(!c4fTesterHoldUpdate(&hold, C4F_INPUT_BUTTON_L3, 0));
assert(!c4fTesterHoldUpdate(&hold, STICKS, 100));
assert(!c4fTesterHoldUpdate(&hold, STICKS | C4F_INPUT_BUTTON_CROSS, 2099));
assert(c4fTesterHoldUpdate(&hold, STICKS, 2100));
assert(!c4fTesterHoldUpdate(&hold, STICKS, 5000));
assert(!c4fTesterHoldUpdate(&hold, 0, 5001));
assert(!c4fTesterHoldUpdate(&hold, STICKS, 6000));
assert(c4fTesterHoldUpdate(&hold, STICKS, 8000));
puts("PASS L3+R3 must be held together for two seconds, once per hold");
}
static void readsUsers(void)
{
Fake f;
static C4fTester t;
setup(&f, &t);
c4fTesterOpen(&t, 1000);
assert(t.active && t.selected == 0);
f.pads[0].sample.lx = 12; f.pads[0].sample.l2 = 200; f.pads[0].sample.touches = 3;
f.pads[0].sample.touch[1].x = 1919; f.pads[0].sample.touch[1].y = 941;
f.pads[0].sample.accel[1] = 0.987f; f.pads[0].sample.gyro[0] = -1.234f; f.pads[0].sample.quat[3] = 0.5f;
f.pads[0].sample.quat[0] = NAN;
assert(frame(&f, &t, 1000, C4F_INPUT_BUTTON_CROSS, 0));
assert(f.pads[0].opens == 1 && f.pads[1].opens == 1 && f.pads[0].motionOn && f.pads[1].motionOn);
C4fTesterView v;
c4fTesterView(&t, &v);
assert(v.active && !v.menu && v.selected == 0);
assert(v.user[0].used && v.user[1].used && !v.user[2].used);
assert(!strcmp(v.user[0].name, "Alex") && !strcmp(v.user[1].name, "Sam?"));
assert(v.user[0].state == C4F_TESTER_READY && v.user[1].state == C4F_TESTER_READY);
assert(v.buttons == C4F_INPUT_BUTTON_CROSS && v.lx == 12 && v.l2 == 200);
assert(v.touches == 2 && v.touchX[1] == 1919 && v.touchY[1] == 941);
assert(v.accel[1] == 99 && v.gyro[0] == -123 && v.quat[3] == 500 && v.quat[0] == 0);
/* The Cross held when reading started is shown, but is not a press. */
assert(t.readers[0].pressed == 0);
frame(&f, &t, 1010, C4F_INPUT_BUTTON_CROSS | C4F_INPUT_BUTTON_CIRCLE, 0);
assert(t.readers[0].pressed == C4F_INPUT_BUTTON_CIRCLE && t.active && !t.menu);
/* A third user with no drawable name gets a stand-in. */
f.count = 3; f.users[2] = 3;
frame(&f, &t, 2010, 0, 0);
c4fTesterView(&t, &v);
assert(!strcmp(v.user[2].name, "User 3"));
c4fTesterClose(&t);
assert(!t.active && f.pads[0].closes == 0 && f.pads[1].closes == 1 && f.pads[2].closes == 1);
c4fTesterView(&t, &v);
assert(!v.active);
puts("PASS readers for every signed-in user, borrowed handle kept, values in display units");
}
/* Opens the test, lets the opening hold go, then holds again until the menu opens. */
static uint64_t openMenu(Fake *f, C4fTester *t)
{
c4fTesterOpen(t, 0);
/* Still holding the L3+R3 that opened it: no menu, however long. */
for (uint64_t now = 0; now <= 4000; now += 500) assert(frame(f, t, now, STICKS, 0) && !t->menu);
frame(f, t, 4100, 0, 0);
frame(f, t, 4200, STICKS, 0);
frame(f, t, 6199, STICKS, 0);
assert(!t->menu);
frame(f, t, 6200, STICKS, 0);
assert(t->menu && !t->armed && t->focus == C4F_TESTER_RESUME);
return 6200;
}
static void menuActions(void)
{
Fake f;
static C4fTester t;
setup(&f, &t);
uint64_t now = openMenu(&f, &t);
/* Presses while the opening keys are still down count for nothing. */
frame(&f, &t, now + 10, STICKS | C4F_INPUT_BUTTON_DOWN, 0);
frame(&f, &t, now + 20, STICKS | C4F_INPUT_BUTTON_DOWN | C4F_INPUT_BUTTON_CROSS, 0);
assert(t.menu && !t.armed && t.focus == C4F_TESTER_RESUME);
frame(&f, &t, now + 30, 0, 0);
assert(t.armed);
frame(&f, &t, now + 40, C4F_INPUT_BUTTON_DOWN, 0);
frame(&f, &t, now + 50, 0, 0);
frame(&f, &t, now + 60, C4F_INPUT_BUTTON_DOWN, 0);
frame(&f, &t, now + 70, 0, 0);
assert(t.focus == C4F_TESTER_RUMBLE_LARGE);
frame(&f, &t, now + 80, C4F_INPUT_BUTTON_CROSS, 0);
assert(f.pads[0].vibrating && f.pads[0].large == 255 && f.pads[0].small == 0);
C4fTesterView v;
c4fTesterView(&t, &v);
assert(v.rumble == 1 && strstr(v.note, "Large motor"));
frame(&f, &t, now + 1079, 0, 0);
assert(f.pads[0].vibrating);
frame(&f, &t, now + 1080, 0, 0);
assert(!f.pads[0].vibrating && !t.rumble);
/* Small motor, then the light bar and orientation. */
frame(&f, &t, now + 1100, C4F_INPUT_BUTTON_DOWN, 0);
frame(&f, &t, now + 1105, 0, 0);
/* A move and a select arriving together only move. */
frame(&f, &t, now + 1110, C4F_INPUT_BUTTON_DOWN | C4F_INPUT_BUTTON_CROSS, 0);
assert(t.focus == C4F_TESTER_LIGHT_RED && !f.pads[0].vibrating && !f.pads[0].lit);
frame(&f, &t, now + 1115, 0, 0);
frame(&f, &t, now + 1120, C4F_INPUT_BUTTON_UP, 0);
frame(&f, &t, now + 1125, 0, 0);
assert(t.focus == C4F_TESTER_RUMBLE_SMALL);
frame(&f, &t, now + 1130, C4F_INPUT_BUTTON_CROSS, 0);
assert(f.pads[0].vibrating && f.pads[0].small == 255 && f.pads[0].large == 0);
frame(&f, &t, now + 1140, C4F_INPUT_BUTTON_DOWN, 0);
frame(&f, &t, now + 1150, 0, 0);
frame(&f, &t, now + 1160, C4F_INPUT_BUTTON_CROSS, 0);
assert(f.pads[0].lit && f.pads[0].rgb[0] == 255 && f.pads[0].rgb[1] == 0);
c4fTesterView(&t, &v);
assert(v.lightSet && v.light[0] == 255);
for (int i = 0; i < 4; i++) {
frame(&f, &t, now + 1170 + i * 20, C4F_INPUT_BUTTON_DOWN, 0);
frame(&f, &t, now + 1180 + i * 20, 0, 0);
}
assert(t.focus == C4F_TESTER_ORIENTATION);
frame(&f, &t, now + 1300, C4F_INPUT_BUTTON_CROSS, 0);
assert(f.pads[0].orientationResets == 1);
/* Back to the launcher leaves and cleans up. */
frame(&f, &t, now + 1310, 0, 0);
frame(&f, &t, now + 1320, C4F_INPUT_BUTTON_DOWN, 0);
frame(&f, &t, now + 1330, 0, 0);
assert(t.focus == C4F_TESTER_EXIT);
assert(!frame(&f, &t, now + 1340, C4F_INPUT_BUTTON_CROSS, 0));
assert(!t.active);
assert(f.pads[0].lightResets == 1 && !f.pads[0].lit && !f.pads[0].vibrating);
assert(f.pads[0].closes == 0 && f.pads[1].closes == 1);
puts("PASS menu after a fresh hold and release; rumble stops after a second; light bar reset on leaving");
}
static void switchUsers(void)
{
Fake f;
static C4fTester t;
setup(&f, &t);
uint64_t now = openMenu(&f, &t);
frame(&f, &t, now + 10, 0, 0);
frame(&f, &t, now + 20, C4F_INPUT_BUTTON_DOWN, 0);
frame(&f, &t, now + 30, 0, 0);
assert(t.focus == C4F_TESTER_USER);
frame(&f, &t, now + 40, C4F_INPUT_BUTTON_RIGHT, 0);
assert(t.selected == 1);
frame(&f, &t, now + 50, 0, 0);
frame(&f, &t, now + 60, C4F_INPUT_BUTTON_RIGHT, 0);
assert(t.selected == 0);
frame(&f, &t, now + 70, 0, 0);
/* The second user's own controller drives the menu as well. */
f.pads[1].sample.buttons = C4F_INPUT_BUTTON_LEFT;
frame(&f, &t, now + 80, 0, 0);
assert(t.selected == 1);
f.pads[1].sample.buttons = 0;
frame(&f, &t, now + 90, 0, 0);
/* Rumble goes to the selected user's controller. */
frame(&f, &t, now + 100, C4F_INPUT_BUTTON_DOWN, 0);
frame(&f, &t, now + 110, 0, 0);
frame(&f, &t, now + 120, C4F_INPUT_BUTTON_CROSS, 0);
assert(f.pads[1].vibrating && !f.pads[0].vibrating);
/* Back closes the menu; the test stays open. */
frame(&f, &t, now + 130, 0, 0);
frame(&f, &t, now + 140, C4F_INPUT_BUTTON_CIRCLE, 0);
assert(t.active && !t.menu);
c4fTesterClose(&t);
assert(!f.pads[1].vibrating && f.pads[1].closes == 1);
puts("PASS user choice from either controller, output to the chosen user, Back resumes testing");
}
static void failures(void)
{
Fake f;
static C4fTester t;
setup(&f, &t);
c4fTesterOpen(&t, 0);
frame(&f, &t, 0, 0, 0);
/* A read failure releases the handle and retries a second later. */
f.pads[1].readError = (int)0x80920003u;
frame(&f, &t, 10, 0, 0);
assert(t.readers[1].state == C4F_TESTER_FAILED && t.readers[1].handle < 0 && f.pads[1].closes == 1);
f.pads[1].readError = 0;
frame(&f, &t, 1009, 0, 0);
assert(f.pads[1].opens == 1);
frame(&f, &t, 1010, 0, 0);
assert(f.pads[1].opens == 2 && t.readers[1].state == C4F_TESTER_READY);
/* An open failure is shown with its code and retried. */
c4fTesterClose(&t);
setup(&f, &t);
f.pads[1].openError = (int)0x80920001u;
c4fTesterOpen(&t, 0);
frame(&f, &t, 0, 0, 0);
C4fTesterView v;
c4fTesterView(&t, &v);
assert(v.user[1].state == C4F_TESTER_FAILED && v.user[1].error == 0x80920001u);
f.pads[1].openError = 0;
frame(&f, &t, 1000, 0, 0);
assert(t.readers[1].state == C4F_TESTER_READY);
/* No controller: shown as such, the handle kept, nothing pressed. */
f.pads[0].connected = 0;
frame(&f, &t, 1010, C4F_INPUT_BUTTON_CROSS, 0);
c4fTesterView(&t, &v);
assert(v.user[0].state == C4F_TESTER_NO_CONTROLLER && !v.buttons && t.readers[0].handle >= 0);
/* An action needs a controller to act on. */
f.pads[0].connected = 1;
frame(&f, &t, 1020, 0, 0);
f.pads[0].connected = 0;
t.menu = t.armed = 1;
t.focus = C4F_TESTER_RUMBLE_LARGE;
frame(&f, &t, 1030, 0, 0);
f.pads[1].sample.buttons = C4F_INPUT_BUTTON_CROSS;
frame(&f, &t, 1040, 0, 0);
assert(!f.pads[0].vibrating && strstr(t.note, "no controller"));
f.pads[1].sample.buttons = 0;
c4fTesterClose(&t);
puts("PASS read/open failures retried after a second, no-controller state, actions need a controller");
}
static void logout(void)
{
Fake f;
static C4fTester t;
setup(&f, &t);
f.count = 3; f.users[2] = 3;
c4fTesterOpen(&t, 0);
frame(&f, &t, 0, 0, 0);
t.selected = 1;
/* The user on screen signs out: kept, marked, its handle closed. */
f.count = 2; f.users[1] = 3;
frame(&f, &t, 1000, 0, 0);
assert(t.selected == 1 && t.readers[1].state == C4F_TESTER_SIGNED_OUT && f.pads[1].closes == 1);
C4fTesterView v;
c4fTesterView(&t, &v);
assert(v.user[1].used && v.user[1].state == C4F_TESTER_SIGNED_OUT);
/* Signs in again: read again. */
f.count = 3; f.users[1] = 2; f.users[2] = 3;
frame(&f, &t, 2000, 0, 0);
frame(&f, &t, 2001, 0, 0);
assert(t.readers[1].state == C4F_TESTER_READY && f.pads[1].opens == 2);
/* A user not on screen signs out: dropped at once. */
f.count = 2;
frame(&f, &t, 3000, 0, 0);
assert(t.readers[2].user < 0 && f.pads[2].closes == 1);
/* A failed user list keeps what is known. */
f.listError = -1;
frame(&f, &t, 4000, 0, 0);
assert(t.readers[0].user == 1 && t.readers[1].user == 2);
c4fTesterClose(&t);
puts("PASS sign-out keeps the user on screen as signed out; others dropped; sign-in reads again");
}
static void remoteAndSuspend(void)
{
Fake f;
static C4fTester t;
setup(&f, &t);
c4fTesterOpen(&t, 0);
frame(&f, &t, 0, 0, 0);
/* The remote's OK opens the menu, its Back closes it, and Back again leaves. */
frame(&f, &t, 10, 0, C4F_INPUT_BUTTON_CROSS);
assert(t.menu);
frame(&f, &t, 20, 0, 0);
assert(t.armed);
frame(&f, &t, 30, 0, C4F_INPUT_BUTTON_DOWN);
assert(t.focus == C4F_TESTER_USER);
frame(&f, &t, 40, 0, C4F_INPUT_BUTTON_CIRCLE);
assert(t.active && !t.menu);
/* A DS4's Circle is under test and does not leave. */
frame(&f, &t, 50, C4F_INPUT_BUTTON_CIRCLE, 0);
assert(t.active && !t.menu);
/* Losing focus stops output and reopens every reader. */
t.menu = t.armed = 1;
t.focus = C4F_TESTER_RUMBLE_LARGE;
frame(&f, &t, 60, 0, 0);
frame(&f, &t, 70, C4F_INPUT_BUTTON_CROSS, 0);
t.focus = C4F_TESTER_LIGHT_BLUE;
frame(&f, &t, 80, 0, 0);
frame(&f, &t, 90, C4F_INPUT_BUTTON_CROSS, 0);
assert(f.pads[0].vibrating && f.pads[0].lit && f.pads[0].rgb[2] == 255);
c4fTesterSuspend(&t, 5000);
assert(!f.pads[0].vibrating && !f.pads[0].lit && !t.menu && t.active);
assert(t.readers[0].handle < 0 && t.readers[1].handle < 0 && f.pads[1].closes == 1);
frame(&f, &t, 5000, 0, 0);
assert(t.readers[0].state == C4F_TESTER_READY && t.readers[1].state == C4F_TESTER_READY);
assert(!frame(&f, &t, 5010, 0, C4F_INPUT_BUTTON_CIRCLE));
assert(!t.active && f.pads[1].closes == 2);
puts("PASS TV remote opens/closes the menu and leaves; focus loss stops output and reopens readers");
}
static void launcherEntry(void)
{
C4fLauncherScreen screen;
memset(&screen, 0, sizeof(screen));
screen.autorun = C4F_AUTORUN_ON;
assert(c4fActionAvailable(&screen, C4F_ACTION_TESTER, 0));
assert(!strcmp(c4fActionLabel(&screen, C4F_ACTION_TESTER), "Test controllers"));
C4fLauncherAction actions[8];
int count = c4fActionList(&screen, 0, actions, 8), found = 0;
for (int i = 0; i < count; i++) found |= actions[i] == C4F_ACTION_TESTER;
assert(found && actions[count - 1] == C4F_ACTION_CLOSE);
screen.busy = 1;
assert(c4fActionAvailable(&screen, C4F_ACTION_TESTER, 0));
screen.locked = 1;
assert(!c4fActionAvailable(&screen, C4F_ACTION_TESTER, 0));
for (int i = 0; i < C4F_TESTER_ITEMS; i++) assert(c4fTesterItemLabel(i)[0]);
/* A DS4's Options opens it straight from the launcher, even while busy; the
* diagnostic build keeps Options for its kernel-log capture. */
memset(&screen, 0, sizeof(screen));
screen.autorun = C4F_AUTORUN_ON;
uint32_t standard, remote;
assert(c4fNavigate(&screen, C4F_INPUT_BUTTON_OPTIONS, 0, 0, &standard, &remote) == C4F_ACTION_TESTER && !standard);
assert(c4fNavigate(&screen, C4F_INPUT_BUTTON_OPTIONS, 0, 1, &standard, &remote) == C4F_ACTION_NONE &&
standard == C4F_INPUT_BUTTON_OPTIONS);
screen.busy = 1;
assert(c4fNavigate(&screen, C4F_INPUT_BUTTON_OPTIONS, 0, 0, &standard, &remote) == C4F_ACTION_TESTER);
screen.locked = 1;
assert(c4fNavigate(&screen, C4F_INPUT_BUTTON_OPTIONS, 0, 0, &standard, &remote) == C4F_ACTION_NONE);
puts("PASS Test controllers: in Actions unless locked, Options opens it, every menu item has a label");
}
int main(void)
{
holdTiming();
readsUsers();
menuActions();
switchUsers();
failures();
logout();
remoteAndSuspend();
launcherEntry();
return 0;
}
+9 -1
View File
@@ -55,6 +55,12 @@ def main():
*(['-DC4F_DIAG'] if diagnostic else []), '-Ilauncher', 'launcher/input.c',
'launcher/navigation.c', 'tests/launcher_input_test.c', '-o', binary], check=True)
subprocess.run([binary], check=True)
# The controller test's logic, against simulated users and pads.
binary = 'build/launcher-tester-diag-host-test' if diagnostic else 'build/launcher-tester-host-test'
subprocess.run(['clang-18', '-std=gnu11', '-D_DEFAULT_SOURCE', '-Wall', '-Wextra', '-Werror',
*(['-DC4F_DIAG'] if diagnostic else []), '-Ilauncher', 'launcher/tester.c',
'launcher/navigation.c', 'tests/launcher_tester_test.c', '-lm', '-o', binary], check=True)
subprocess.run([binary], check=True)
subprocess.run(['clang-18', '-std=gnu11', '-D_DEFAULT_SOURCE', '-Wall', '-Wextra', '-Werror',
'-ffunction-sections', '-fdata-sections', '-Ilauncher', '-Itests/include',
'tests/diag_test.c', '-Wl,--gc-sections,--wrap=write,--wrap=fsync', '-pthread',
@@ -187,7 +193,8 @@ def main():
package.build_art_tool()
for state in ('setup', 'running', 'stopped', 'payloader-unavailable', 'outdated', 'mismatch', 'confirm', 'busy', 'locked', 'unknown',
'remote-actions-stopped', 'remote-actions-running', 'remote-confirm-cancel',
'remote-confirm-stop', 'remote-actions-busy', 'remote-actions-unavailable'):
'remote-confirm-stop', 'remote-actions-busy', 'remote-actions-unavailable',
'tester', 'tester-motion', 'tester-menu', 'tester-remote', 'tester-disconnected', 'tester-error'):
png = ROOT / f'build/launcher-preview-{state}.png'
package.render(['screen', state], png, 1920, 1080)
assert png.stat().st_size > 50000, png
@@ -198,6 +205,7 @@ def main():
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
assert subprocess.run([str(package.ART), 'screen', 'tester-nonsense', '/dev/null']).returncode != 0
# The icon the payload serves is committed, so the payload build needs no
# renderer. Catch it drifting away from the app's own icon.
fresh = ROOT / 'build/icon-192.png'
+70
View File
@@ -419,6 +419,76 @@ def main():
finally:
server.close()
# Motion: the owner's only, ranges checked, put into every later sample, and
# back to still whenever the controller is reset.
def motion_rows():
return [r.split() for r in server.rows() if r.startswith('FRAME') and r.split()[2] == '11030d']
def motion(c, params):
c.frame(json.dumps(dict(method='m', params=params)))
# A small message right after another can wait for the first one's ACK
# (Nagle's algorithm on this test client), so wait for the frame itself.
def motion_row(match, timeout=.3):
end = time.monotonic() + timeout
while True:
rows = motion_rows()
if rows and match(rows[-1]):
return rows[-1]
assert time.monotonic() < end, rows[-1] if rows else 'no frames'
time.sleep(.01)
server = Server()
try:
c = server.c
info = c.request('info')['result']
assert info['motion'] is True and info['protocol'] == 2, info
assert c.request('claim', [0])['result']['pads'][0]['mine']
c.input(0)
time.sleep(.05)
still = motion_rows()[-1][11:21]
assert still == ['0.0000', '0.0000', '0.0000', '1.0000'] + ['0.000'] * 6, still
for params in ([0, 0, 0, 0, 10000, 0, 1000, 0, 0, 0], [0, 0, 0, 0, 10001, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 10000, 16001, 0, 0, 0, 0, 0], [0, 0, 0, 0, 10000, 0, 0, 0, 0, 0, -35001],
[4, 0, 0, 0, 10000, 0, 0, 0, 0, 0, 0]):
assert c.request('m', params)['error']['code'] == 400, params
other = Client()
motion(other, [0, 0, 0, 0, 10000, 500, 0, 0, 0, 0, 0])
other.request('ping') # answered only after the motion before it was handled
time.sleep(.05)
assert all(r[15] == '0.000' for r in motion_rows()), 'a non-owner moved the controller'
c.input(0, 0x4000)
motion(c, [0, 1000, -2000, 3000, 9000, -500, 1000, 250, 1500, -35000, 12])
motion_row(lambda r: r[3] == '16384' and r[11:21] == ['0.1000', '-0.2000', '0.3000', '0.9000', '-0.500',
'1.000', '0.250', '1.500', '-35.000', '0.012'])
c.input(0, 0x2000, lx=10)
row = motion_row(lambda r: r[3] == '8192')
assert row[4] == '10' and row[16] == '1.000', 'motion dropped by new input'
# A burst of motion alone coalesces instead of queueing up behind itself.
start = len(motion_rows())
for i in range(200):
motion(c, [0, 0, 0, 0, 10000, i, 1000, 0, 0, 0, 0])
row = motion_row(lambda r: r[15] == '0.199')
assert row[3] == '8192', row
assert len(motion_rows()) - start < 100, f'{len(motion_rows()) - start} reports for 200 motion messages'
# Idle: neutral and still. Motion counts as input and wakes it.
time.sleep(.6)
assert c.request('status')['result']['pads'][0]['state'] == 'paused'
assert motion_rows()[-1][11:21] == still, motion_rows()[-1]
motion(c, [0, 0, 0, 0, 10000, 0, 1000, 0, 0, 0, 0])
time.sleep(.05)
assert c.request('status')['result']['pads'][0]['state'] != 'paused'
# A new owner starts still.
c.close()
server.c = c = Client()
assert c.request('claim', [0])['result']['pads'][0]['mine']
c.input(0)
time.sleep(.05)
assert motion_rows()[-1][11:21] == still, motion_rows()[-1]
other.close()
print('PASS motion: owner only, ranges checked, merged into samples, coalesced, reset to still', flush=True)
finally:
server.close()
# Without the call, controllers work as before.
server = Server(C4F_TEST_NO_FEEDBACK='1')
try:
+5 -2
View File
@@ -264,8 +264,11 @@ void c4fPadDataNeutral(ScePadData *p)
}
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);
/* Motion last, so the earlier columns keep their places. */
printf("FRAME %llu %x %u %u %u %u %u %u %u %u %.4f %.4f %.4f %.4f %.3f %.3f %.3f %.3f %.3f %.3f\n",
(unsigned long long)c4fTimeMs(), p->handle, d->buttons, d->lx, d->ly, d->rx, d->ry, d->l2, d->r2,
d->touchData.fingers, d->quat[0], d->quat[1], d->quat[2], d->quat[3],
d->accel[0], d->accel[1], d->accel[2], d->gyro[0], d->gyro[1], d->gyro[2]);
return getenv("C4F_FAIL_INPUT") && d->buttons ? -5 : 0;
}
+53 -1
View File
@@ -43,6 +43,50 @@ static int c4fPrintSvg(void)
return 0;
}
/* Controller test states, with example readings: 1 when state is one, 0 when it
* is not a test state, -1 for an unknown one. */
static int c4fTesterPreview(const char *state, C4fLauncherScreen *s)
{
C4fTesterView *v = &s->tester;
if (strncmp(state, "tester", 6)) return 0;
v->active = 1;
v->selected = 0;
snprintf(v->user[0].name, sizeof(v->user[0].name), "Alex");
snprintf(v->user[1].name, sizeof(v->user[1].name), "Sam");
v->user[0].used = v->user[1].used = 1;
v->user[0].state = v->user[1].state = C4F_TESTER_READY;
v->lx = v->ly = v->rx = v->ry = 128;
v->quat[3] = 1000;
if (!strcmp(state, "tester")) {
v->buttons = 0x4000 | 0x0400 | 0x0010 | 0x0200; /* Cross, L1, Up, R2 */
v->lx = 40; v->ly = 90; v->rx = 210; v->r2 = 180;
v->touches = 1; v->touchX[0] = 1300; v->touchY[0] = 420;
v->accel[1] = 100;
} else if (!strcmp(state, "tester-motion")) {
v->buttons = 0x0002 | 0x100000; /* L3, touchpad click */
v->l2 = 60;
v->touches = 2; v->touchX[0] = 420; v->touchY[0] = 300; v->touchX[1] = 1500; v->touchY[1] = 700;
v->accel[0] = -12; v->accel[1] = 98; v->accel[2] = 21;
v->gyro[0] = 150; v->gyro[1] = -32; v->gyro[2] = 4;
v->quat[0] = 120; v->quat[1] = -40; v->quat[2] = 10; v->quat[3] = 991;
v->lightSet = 1; v->light[0] = 255;
v->rumble = 1;
snprintf(v->note, sizeof(v->note), "Large motor: rumbling for a second.");
} else if (!strcmp(state, "tester-menu") || !strcmp(state, "tester-remote")) {
v->menu = 1;
v->focus = !strcmp(state, "tester-menu") ? C4F_TESTER_RUMBLE_LARGE : C4F_TESTER_USER;
} else if (!strcmp(state, "tester-disconnected")) {
v->user[0].state = C4F_TESTER_NO_CONTROLLER;
} else if (!strcmp(state, "tester-error")) {
v->selected = 1;
v->user[1].state = C4F_TESTER_FAILED;
v->user[1].error = 0x80920001u;
} else {
return -1;
}
return 1;
}
int main(int argc, char **argv)
{
if (argc == 2 && !strcmp(argv[1], "svg")) return c4fPrintSvg();
@@ -60,7 +104,11 @@ int main(int argc, char **argv)
snprintf(s.address, sizeof(s.address), "http://192.168.1.20:4264");
const char *state = argv[2], *message = "";
s.autorun = C4F_AUTORUN_ON;
if (!strcmp(state, "running") || !strcmp(state, "confirm") || !strcmp(state, "outdated") || !strcmp(state, "mismatch") ||
int tester = c4fTesterPreview(state, &s);
if (tester < 0) return 2;
if (tester) {
s.running = 1; s.controllers = 2;
} else if (!strcmp(state, "running") || !strcmp(state, "confirm") || !strcmp(state, "outdated") || !strcmp(state, "mismatch") ||
!strcmp(state, "remote-actions-running") || !strcmp(state, "remote-confirm-cancel") ||
!strcmp(state, "remote-confirm-stop") || !strcmp(state, "remote-confirm-diag")) {
s.running = 1; s.controllers = 2; s.confirmStop = !strcmp(state, "confirm");
@@ -98,6 +146,10 @@ int main(int argc, char **argv)
snprintf(s.message, sizeof(s.message), "%s", message);
uint32_t *pixels = calloc((size_t)C4F_SCREEN_WIDTH * C4F_SCREEN_HEIGHT, sizeof(uint32_t));
if (!pixels) return 2;
if (s.tester.active) {
c4fDrawTester(pixels, &s);
return c4fWrite(argv[3], pixels, (size_t)C4F_SCREEN_WIDTH * C4F_SCREEN_HEIGHT);
}
#ifdef C4F_DIAG
if (!strcmp(state, "remote-input-diag") || !strcmp(state, "payloader-unavailable-diag")) {
snprintf(s.inputNote, sizeof(s.inputNote),
+1
View File
@@ -53,6 +53,7 @@ def build_art_tool(diagnostic=False):
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'),
str(ROOT / 'launcher/screen.c'), str(ROOT / 'launcher/navigation.c'),
str(ROOT / 'launcher/tester.c'), str(ROOT / 'launcher/tester_draw.c'),
str(ROOT / 'launcher/draw.c'), str(ROOT / 'launcher/logo.c'), *extra_sources,
str(ROOT / 'vendor/qrcodegen/qrcodegen.c'), str(FONTS), str(stb), '-lm', *extra_flags, '-o', str(art)],
check=True)