mirror of
https://github.com/MoHadiShibli/Control4Free.git
synced 2026-10-17 21:01:07 +02:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9890560867 | ||
|
|
7e503478c4 | ||
|
|
5a8f9e3d0e | ||
|
|
75743ef24b | ||
|
|
0b3b448ca5 | ||
|
|
ed5e828701 | ||
|
|
1242d7346e |
No files matched your search
+1
-1
@@ -1 +1 @@
|
||||
ko_fi: mohadishibli
|
||||
liberapay: MoHadiShibli
|
||||
@@ -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)
|
||||
|
||||
@@ -3,10 +3,51 @@
|
||||
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.
|
||||
|
||||
- Use your TV remote to control the PS4 app through HDMI-CEC. Press **OK** to open **Actions**,
|
||||
use the arrows to choose, and press **OK** again. **Back** returns; Stop has a separate confirmation.
|
||||
The diagnostic app also supports remote navigation and retains the last received remote key.
|
||||
See [HDMI-CEC navigation](docs/hdmi-cec.md).
|
||||
- If the app cannot reach PayLoader, it tells you to enable **GoldHEN → Server Settings → Enable
|
||||
PayLoader Server** and retry Start. The setup message now fits on screen, and the installation
|
||||
guide explains when the server is needed.
|
||||
|
||||
Thanks to [NotDev-Kike](https://github.com/MoHadiShibli/Control4Free/issues/3#issuecomment-6103983733)
|
||||
for testing and confirming OK, arrows, Back and service startup with a Samsung TV remote on firmware 11.02.
|
||||
|
||||
## 1.2.0 — 2026-10-09
|
||||
|
||||
Gamepad mapping.
|
||||
|
||||

|
||||
|
||||
- Map a gamepad on a DualShock 4: each control shows what drives it and lights up when pressed.
|
||||
Click a control, then press the gamepad button for it. Map browser-exposed inputs from gamepads, wheels,
|
||||
guitars, pedals and arcade sticks to DualShock 4 buttons, sticks and triggers. Wheel and guitar model
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<a href="https://github.com/MoHadiShibli/Control4Free/releases/latest"><img src="https://img.shields.io/github/v/release/MoHadiShibli/Control4Free?label=release" alt="Latest release"></a>
|
||||
<a href="https://github.com/MoHadiShibli/Control4Free/actions/workflows/build.yml"><img src="https://github.com/MoHadiShibli/Control4Free/actions/workflows/build.yml/badge.svg" alt="Build"></a>
|
||||
<a href="LICENSE"><img src="https://img.shields.io/badge/license-GPL--3.0-blue" alt="License: GPL v3"></a>
|
||||
<a href="https://ko-fi.com/mohadishibli"><img src="https://img.shields.io/badge/Ko--fi-support%20this%20project-FF5E5B?logo=ko-fi&logoColor=white" alt="Support on Ko-fi"></a>
|
||||
<a href="https://liberapay.com/MoHadiShibli/"><img src="https://img.shields.io/badge/Liberapay-support%20this%20project-F6C915?logo=liberapay&logoColor=black" alt="Support on Liberapay"></a>
|
||||
</p>
|
||||
|
||||
**Use your phone or PC as a PS4 controller: on the home screen, at sign-in, and in games.**
|
||||
@@ -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.
|
||||
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.
|
||||
@@ -60,8 +69,8 @@ sees a virtual DualShock 4, which provides:
|
||||
1. Download `Control4Free-<version>.pkg` from the [latest release](https://github.com/MoHadiShibli/Control4Free/releases/latest).
|
||||
2. Install it with GoldHEN's Package Installer: **Settings → Debug Settings → Game → Package Installer**, from a
|
||||
USB drive or `/data/pkg/`.
|
||||
3. Open **Control4Free** from **Library → Applications** and press **Cross**. It sets up auto-start and
|
||||
starts Control4Free.
|
||||
3. Enable **GoldHEN → Server Settings → Enable PayLoader Server**, then open **Control4Free** from
|
||||
**Library → Applications** and press **Cross**. It sets up auto-start and starts Control4Free.
|
||||
4. On your phone or PC, scan the QR code or open the address shown, for example `http://192.168.1.20:4264`.
|
||||
Pick a controller, then choose your user on the PS4.
|
||||
|
||||
@@ -78,6 +87,7 @@ New to GoldHEN packages? The **[installation guide](docs/installation.md)** walk
|
||||
- **[Installation](docs/installation.md)**: requirements, installing, auto-start, updating, uninstalling.
|
||||
- **[Playing](docs/playing.md)**: signing in, touch, keyboard and gamepads, the layout editor, settings.
|
||||
- **[Gamepad mapping](docs/gamepad-mapping.md)**: profiles, calibration and using several input devices together.
|
||||
- **[HDMI-CEC navigation](docs/hdmi-cec.md)**: using your TV remote in the PS4 app.
|
||||
- **[Troubleshooting](docs/troubleshooting.md)**: the messages you might see and what to do about each.
|
||||
- **[How it works](docs/how-it-works.md)**: the virtual-device API, sign-in, and what runs where.
|
||||
- **[Protocol](docs/protocol.md)**: the WebSocket protocol between the page and the console.
|
||||
@@ -87,10 +97,12 @@ New to GoldHEN packages? The **[installation guide](docs/installation.md)** walk
|
||||
|
||||
## Requirements and compatibility
|
||||
|
||||
- A PS4 with GoldHEN v2.4b18.10 or later. Tested on firmware 10.01, 13.02, 13.04 and 13.52; the
|
||||
- A PS4 with GoldHEN v2.4b18.10 or later. Tested on firmware 10.01, 11.02, 13.02, 13.04 and 13.52; the
|
||||
[compatibility table](docs/installation.md#compatibility) lists every firmware and the GoldHEN version it
|
||||
needs.
|
||||
- A phone, tablet or computer with a browser, on the same network as the PS4.
|
||||
- To start the service directly from the app, enable **GoldHEN → Server Settings → Enable PayLoader
|
||||
Server**. AutoRun can start it without this server.
|
||||
- Turn off other GoldHEN controller plugins in games you play with Control4Free. A plugin that takes over a
|
||||
signed-in user's controller can stop games from reading the virtual ones.
|
||||
- A Remote Play session (chiaki-ng, for example) works alongside Control4Free.
|
||||
@@ -104,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
|
||||
@@ -121,7 +135,7 @@ Found a bug? Install the diagnostic package from the latest release first, then
|
||||
[open an issue](https://github.com/MoHadiShibli/Control4Free/issues/new/choose) with its screenshots (see
|
||||
[Reporting a problem](docs/troubleshooting.md#reporting-a-problem)). I maintain this project and read every
|
||||
report. If Control4Free is useful to you, you can help keep it going on
|
||||
[Ko-fi](https://ko-fi.com/mohadishibli).
|
||||
[Liberapay](https://liberapay.com/MoHadiShibli/).
|
||||
|
||||
## Credits
|
||||
|
||||
|
||||
+5
-1
@@ -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.
|
||||
|
||||
|
||||
+591
-7
@@ -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();
|
||||
|
||||
+2
-1
@@ -2,7 +2,8 @@
|
||||
#
|
||||
# Holds clang/lld plus a pinned ps4-payload-sdk release, so a build needs nothing
|
||||
# installed on the host but Docker. Matches the SDK's own CI (clang-18 / lld-18).
|
||||
FROM ubuntu:24.04
|
||||
# Docker's official Ubuntu mirror avoids Docker Hub's shared pull quota.
|
||||
FROM public.ecr.aws/docker/library/ubuntu:24.04@sha256:534baea6a22c03a63003dbc8dbe78fe34bc0d7e595d9a9dc9834884ff530eb55
|
||||
|
||||
ARG SDK_VERSION=v0.9
|
||||
|
||||
|
||||
@@ -1,7 +1,19 @@
|
||||
# Reuse the pinned OpenOrbis release from the existing plugin toolchain image.
|
||||
ARG OPENORBIS_IMAGE=xfangfang/goldhen_plugin@sha256:e3d2acad69a1905472bd9d73e373d1d0fb6d3ca293d9f9a6ba957b9b2f49fffc
|
||||
FROM ${OPENORBIS_IMAGE} AS openorbis
|
||||
FROM ubuntu:20.04 AS pkgcompat
|
||||
# The official release contains the same OpenOrbis files as the former pinned
|
||||
# toolchain image. Fetch it directly so this build needs no Docker Hub pulls.
|
||||
FROM control4free-build:latest AS openorbis
|
||||
ARG OPENORBIS_VERSION=v0.5.3
|
||||
ARG OPENORBIS_ARCHIVE=toolchain-llvm-18.2.zip
|
||||
ARG OPENORBIS_SHA256=5006bb8629a40093b712fac971456fa4d5ad3fafaadb16d11c10b8a93b8b496f
|
||||
RUN curl -fsSL --retry 3 -o /tmp/openorbis.zip \
|
||||
"https://github.com/OpenOrbis/OpenOrbis-PS4-Toolchain/releases/download/${OPENORBIS_VERSION}/${OPENORBIS_ARCHIVE}" \
|
||||
&& echo "${OPENORBIS_SHA256} /tmp/openorbis.zip" | sha256sum -c - \
|
||||
&& mkdir /tmp/openorbis \
|
||||
&& unzip -q /tmp/openorbis.zip -d /tmp/openorbis \
|
||||
&& tar -xzf /tmp/openorbis/toolchain-llvm-18.tar.gz -C /opt \
|
||||
&& test -f /opt/OpenOrbis/PS4Toolchain/lib/crt1.o \
|
||||
&& rm -rf /tmp/openorbis /tmp/openorbis.zip
|
||||
# Docker's official Ubuntu mirror, pinned to the same image as Docker Hub.
|
||||
FROM public.ecr.aws/docker/library/ubuntu:20.04@sha256:8feb4d8ca5354def3d8fce243717141ce31e2c428701f6682bd2fafe15388214 AS pkgcompat
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends libssl1.1 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
FROM control4free-build:latest
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
# HDMI-CEC navigation
|
||||
|
||||
Use your TV remote to start and stop Control4Free, manage auto-start, and navigate the PS4 app.
|
||||
The normal and diagnostic packages include an **Actions** menu for remotes.
|
||||
|
||||
## Set up
|
||||
|
||||
Enable **Settings → System → Enable HDMI Device Link** on the PS4 and HDMI-CEC on the TV.
|
||||
The setting's name depends on the TV model. First check that the
|
||||
remote's arrows and OK can navigate the PS4 home screen. TV and receiver/switch compatibility
|
||||
varies; see [Sony's HDMI Device Link guide](https://manuals.playstation.net/document/en/ps4/settings/devicelink.html).
|
||||
|
||||
To start the service directly from the app, enable **GoldHEN → Server Settings → Enable
|
||||
PayLoader Server**. If the app says PayLoader did not answer, enable it and retry Start.
|
||||
The app cannot enable this GoldHEN setting automatically. AutoRun can start the service
|
||||
after a restart without the server; see [Installation](installation.md).
|
||||
|
||||
## Using the remote
|
||||
|
||||
1. Press **OK** to open **Actions**. A Menu key can also open it if exposed by the PS4.
|
||||
2. Use the arrows to choose an action, then press **OK** again. The menu includes setup/start,
|
||||
auto-start, Stop and Close when those actions are available.
|
||||
3. **Stop** opens a separate confirmation with **Cancel** selected. Choose **Stop**, release
|
||||
OK, then press it again to confirm. Stopping disconnects every virtual controller.
|
||||
4. **Back**, when the PS4 exposes it, returns from the menu or cancels confirmation. **Resume**
|
||||
also returns without needing a Back button. **Close app** leaves the service running.
|
||||
|
||||
Existing DualShock 4 shortcuts work outside the menu. Its D-pad can also open and navigate
|
||||
Actions in the normal app. Service operations finish before another operation or Close is
|
||||
available. Release held buttons after opening the app, reconnecting or returning to it.
|
||||
|
||||
## Diagnostic app
|
||||
|
||||
**Left/Right** change pages and **Up/Down** scroll while the menu is
|
||||
closed. **OK** opens Actions, which also includes **Capture kernel log**. Menu arrows move
|
||||
the selection without changing the page underneath it. The Summary page reports DS4 and
|
||||
TV-remote input status; a ready source shows its navigation button mask, and a failed source
|
||||
shows the native error code. It also retains the last active remote sample's key code and raw
|
||||
button mask, so a short press remains visible after you release it. These values describe
|
||||
the PS4's input data; they are not HDMI-CEC wire command numbers.
|
||||
|
||||
If a remote fails inside the app, press and release the affected button, then photograph
|
||||
Summary or save its report. See [Reporting a problem](troubleshooting.md#reporting-a-problem).
|
||||
|
||||
## Confirmed setup and limits
|
||||
|
||||
| TV | Remote | PS4 firmware | GoldHEN | Confirmed behavior |
|
||||
|---|---|---|---|---|
|
||||
| Samsung UN55NU7090P | AA59-00818A (TM1240) | 11.02 | v2.4b18.12 | OK, arrows, RETURN/Back and service startup with PayLoader Server enabled |
|
||||
|
||||
This setup was [confirmed by NotDev-Kike](https://github.com/MoHadiShibli/Control4Free/issues/3#issuecomment-6103983733)
|
||||
with no physical DS4 connected. Other TV/remote models, simultaneous DS4 use, HDMI input
|
||||
switching and rest/wake behavior still need hardware reports. Held-button handling, recovery
|
||||
and Stop confirmation have automated tests; their hardware behavior was not separately reported.
|
||||
|
||||
The app reads the PS4's remote-control stream independently of DS4 input. TV keys control
|
||||
the app only. Media/color keys and TV power/volume control are outside this implementation.
|
||||
|
||||
For another setup, report the TV and remote models, PS4 firmware, GoldHEN version, any
|
||||
receiver/switch between TV and console, and which buttons work. Diagnostic screenshots help
|
||||
identify inputs the PS4 exposes.
|
||||
+14
-4
@@ -14,6 +14,9 @@ Control4Free comes in two pieces:
|
||||
run ELF payloads like Control4Free reliably, and have no auto-start. See [Compatibility](#compatibility) for
|
||||
each firmware.
|
||||
- **Debug Settings** turned on in GoldHEN, for the Package Installer.
|
||||
- **PayLoader Server** enabled for starting the service directly from the app:
|
||||
**GoldHEN → Server Settings → Enable PayLoader Server**. AutoRun and Payloader LaunchPad can start the
|
||||
service without this server.
|
||||
- A phone, tablet or computer with a modern browser, on the same network as the PS4.
|
||||
|
||||
## Compatibility
|
||||
@@ -25,12 +28,13 @@ Control4Free's start-up code, but nobody has tried them yet.
|
||||
| Firmware | GoldHEN | Status |
|
||||
|---|---|---|
|
||||
| 10.01 | v2.4b18.10 or later | **Tested** (v2.4b18.10) |
|
||||
| 11.02 | v2.4b18.10 or later | **Tested** (v2.4b18.12) |
|
||||
| 13.02, 13.04 | v2.4b18.12 or later | **Tested** (v2.4b18.12) |
|
||||
| 13.52 | v2.4b18.11 or later | **Tested** (v2.4b18.12), with Control4Free 1.1.1 or later |
|
||||
| 13.50 | v2.4b18.12 or later | Not tested |
|
||||
| 13.00 | v2.4b18.10 or later | Not tested |
|
||||
| 12.00, 12.02, 12.50, 12.52 | v2.4b18.10 or later | Not tested |
|
||||
| 5.05, 6.71, 6.72, 7.0x, 7.5x, 8.0x, 8.5x, 9.0x, 9.5x, 9.60, 10.00, 10.50, 10.70, 10.71, 11.0x, 11.5x | v2.4b18.10 or later | Not tested |
|
||||
| 5.05, 6.71, 6.72, 7.0x, 7.5x, 8.0x, 8.5x, 9.0x, 9.5x, 9.60, 10.00, 10.50, 10.70, 10.71, other 11.0x, 11.5x | v2.4b18.10 or later | Not tested |
|
||||
|
||||
The older a firmware, the less certain it is that the PS4's virtual-controller API behaves as on the tested
|
||||
ones. If you try an untested firmware, an issue saying whether it worked helps others, with a
|
||||
@@ -50,13 +54,15 @@ The app appears in **Library → Applications**.
|
||||
|
||||
## First start
|
||||
|
||||
Open **Control4Free** and press **Cross**. The app:
|
||||
In GoldHEN, open **Server Settings** and check **Enable PayLoader Server**. Then open **Control4Free** and
|
||||
press **Cross**. The app:
|
||||
|
||||
1. copies the service to `/data/payloads/control4free.elf`;
|
||||
2. adds it to GoldHEN's AutoRun list, `/data/GoldHEN/payloads.ini`, so GoldHEN starts it every time it
|
||||
loads;
|
||||
3. starts it straight away if GoldHEN's **PayLoader** is on. If it isn't, turn PayLoader on in GoldHEN's
|
||||
settings and press **Cross** again. AutoRun also starts Control4Free each time GoldHEN loads.
|
||||
3. starts it straight away through GoldHEN's PayLoader Server. If the server is unavailable, auto-start
|
||||
stays enabled; enable PayLoader Server and retry, or let AutoRun start Control4Free the next time
|
||||
GoldHEN loads.
|
||||
|
||||
Once it's running, the app shows the address to open, for example `http://192.168.1.20:4264`, and a QR code
|
||||
for your phone's camera. The PS4 also shows the address in a notification when the service starts.
|
||||
@@ -78,6 +84,10 @@ screen, that's fine too: the app doesn't get in the way of Control4Free.
|
||||
| **Square**, then **Cross** | Stops Control4Free and disconnects every controller. |
|
||||
| **Circle** | Closes the app. Control4Free keeps running without it. |
|
||||
|
||||
With a TV remote over HDMI-CEC, press **OK** to open **Actions**, use the arrows to choose,
|
||||
and press **OK** again. **Back** returns. See [HDMI-CEC navigation](hdmi-cec.md) for setup
|
||||
and compatibility.
|
||||
|
||||
## Updating
|
||||
|
||||
1. Install the new package over the old one. Delete the old app first if the installer refuses.
|
||||
|
||||
+36
-2
@@ -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
@@ -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
|
||||
|
||||
+30
-2
@@ -129,8 +129,9 @@ has loaded, and check it has a controller under **Gamepads on this device**.
|
||||
|
||||
## In the app on the PS4
|
||||
|
||||
**"PayLoader did not answer."** Turn on GoldHEN's PayLoader and press **Cross** again, or restart the PS4 and
|
||||
run the jailbreak: with auto-start on, Control4Free starts by itself.
|
||||
**"PayLoader did not answer."** Open **GoldHEN → Server Settings → Enable PayLoader Server**, check the
|
||||
option and retry **Start** in the app, or restart the PS4 and run the jailbreak: with auto-start on,
|
||||
Control4Free starts by itself.
|
||||
|
||||
**"Control4Free is not responding."** Wait a few seconds after the PS4 wakes from rest mode, then press
|
||||
**Cross** again. If it stays stuck, restart the PS4 and run the jailbreak.
|
||||
@@ -151,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
@@ -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
@@ -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)/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
|
||||
|
||||
+1
-1
@@ -184,7 +184,7 @@ int c4fAutorunCheck(char *problem, size_t size)
|
||||
state = installedSize == c4fBundledSize && !memcmp(installed, c4fBundled, installedSize) ? C4F_AUTORUN_ON : C4F_AUTORUN_OUTDATED;
|
||||
else if (errno == ENOENT) {
|
||||
state = C4F_AUTORUN_OUTDATED;
|
||||
snprintf(problem, size, "Enabled, but the payload is missing; Triangle repairs it");
|
||||
snprintf(problem, size, "Enabled, but the payload is missing; choose Update auto-start in Actions or press Triangle");
|
||||
} else {
|
||||
state = C4F_AUTORUN_UNKNOWN;
|
||||
snprintf(problem, size, "Enabled, but the payload cannot be inspected (errno %d)", errno);
|
||||
|
||||
+12
-6
@@ -371,8 +371,8 @@ static char *c4fSummary(const C4fDiagState *s)
|
||||
">> here and take screenshots of pages 1 and 2: page 2 lists every connection it saw.\n",
|
||||
s->version, s->address[0] ? s->address : "the PS4's address with :4264");
|
||||
else if (c4fStarted && c4fContains(c4fStartMessage, "PayLoader did not answer"))
|
||||
c4fAdd(&t, ">> GoldHEN's PayLoader (port 9090) did not take the payload. Turn PayLoader on in GoldHEN's\n"
|
||||
">> settings, then press Cross again.\n");
|
||||
c4fAdd(&t, ">> GoldHEN's PayLoader Server (port 9090) did not take the payload. Open GoldHEN > Server Settings,\n"
|
||||
">> check Enable PayLoader Server, then retry Start.\n");
|
||||
else if (c4fStarted && c4fStartResult == -2 && !c4fLogChanged)
|
||||
c4fAdd(&t, ">> The payload was handed to PayLoader, but Control4Free's own code never ran (it wrote no log).\n"
|
||||
">> PayLoader did not run it, or it stopped while starting up on this firmware. The kernel log\n"
|
||||
@@ -396,6 +396,7 @@ static char *c4fSummary(const C4fDiagState *s)
|
||||
c4fAdd(&t, "\nThis app\n");
|
||||
c4fAdd(&t, " version %s, bundled payload %s, open for %llu s\n", C4F_LAUNCHER_VERSION, c4fBundled,
|
||||
(unsigned long long)((c4fLauncherTimeMs() - c4fBootMs) / 1000));
|
||||
c4fAdd(&t, " input: %s\n", s->inputNote && s->inputNote[0] ? s->inputNote : "waiting");
|
||||
|
||||
c4fAdd(&t, "\nService (port 4264)\n");
|
||||
if (s->running == 1) c4fAdd(&t, " running, version %s, %d controller(s)\n", s->version, s->controllers);
|
||||
@@ -579,11 +580,12 @@ void c4fDrawDiag(uint32_t *pixels, const C4fLauncherScreen *s)
|
||||
__DATE__, __TIME__, page + 1, C4F_DIAG_PAGES, c4fPageNames[page]);
|
||||
c4fText(&c, C4F_FONT_REGULAR, C4F_D_TITLE, C4F_D_LEFT, 48, C4F_D_ACCENT, line);
|
||||
c4fText(&c, C4F_FONT_REGULAR, C4F_D_SIZE, C4F_D_LEFT, 84, C4F_D_FAINT,
|
||||
"L1 / R1: change page Up / Down: scroll Options: read the kernel log again "
|
||||
"Cross: start Triangle: auto-start on/off Square: stop Circle: close");
|
||||
"Left / Right: page Up / Down: scroll TV remote OK: actions "
|
||||
"Cross: start Triangle: auto-start Square: stop Circle: close");
|
||||
c4fText(&c, C4F_FONT_REGULAR, C4F_D_SIZE, C4F_D_LEFT, 114, C4F_D_WARN,
|
||||
"Please take a screenshot of EVERY page (hold SHARE, or press SHARE then Triangle) and send them all.");
|
||||
if (s->confirmStop) snprintf(line, sizeof(line), "Press Cross to stop Control4Free, or Circle to cancel.");
|
||||
if (s->confirmStop) snprintf(line, sizeof(line), "%s", s->confirmMenu ?
|
||||
"Select Cancel or Stop with the arrows, then press OK." : "Press Cross to stop Control4Free, or Circle to cancel.");
|
||||
else snprintf(line, sizeof(line), "%s%s", s->busy ? "WORKING: " : "Now: ", s->message);
|
||||
c4fText(&c, C4F_FONT_REGULAR, C4F_D_SIZE, C4F_D_LEFT, 146, C4F_D_TEXT, line);
|
||||
c4fFillRect(&c, C4F_D_LEFT, 160, (int)width, 1, C4F_D_LINE);
|
||||
@@ -591,7 +593,7 @@ void c4fDrawDiag(uint32_t *pixels, const C4fLauncherScreen *s)
|
||||
pthread_mutex_lock(&c4fLock);
|
||||
char *text = strdup(c4fPages[page] ? c4fPages[page] : "(not read yet)");
|
||||
pthread_mutex_unlock(&c4fLock);
|
||||
if (!text) return;
|
||||
if (!text) { c4fDrawActionOverlay(pixels, s); return; }
|
||||
|
||||
/* Wrap every line, then show the window the scroll position picks. */
|
||||
int count = 0, capacity = 256;
|
||||
@@ -626,8 +628,12 @@ void c4fDrawDiag(uint32_t *pixels, const C4fLauncherScreen *s)
|
||||
}
|
||||
snprintf(line, sizeof(line), "lines %d-%d of %d", count ? first + 1 : 0, first + visible < count ? first + visible : count, count);
|
||||
c4fText(&c, C4F_FONT_REGULAR, C4F_D_SIZE, C4F_D_LEFT, 1072, C4F_D_FAINT, line);
|
||||
const char *remoteHint = "TV remote: OK actions / Back returns L1 / R1: page Options: kernel log";
|
||||
c4fText(&c, C4F_FONT_REGULAR, C4F_D_SIZE, C4F_SCREEN_WIDTH - C4F_D_LEFT -
|
||||
c4fTextWidth(C4F_FONT_REGULAR, C4F_D_SIZE, remoteHint), 1072, C4F_D_FAINT, remoteHint);
|
||||
free(rows);
|
||||
free(text);
|
||||
c4fDrawActionOverlay(pixels, s);
|
||||
}
|
||||
|
||||
int c4fDiagMaxScroll(int page)
|
||||
|
||||
+1
-1
@@ -15,7 +15,7 @@ extern "C" {
|
||||
|
||||
typedef struct {
|
||||
int running, controllers, autorun, locked, busy, confirmStop;
|
||||
const char *version, *problem, *message, *address, *autorunNote;
|
||||
const char *version, *problem, *message, *address, *autorunNote, *inputNote;
|
||||
} C4fDiagState;
|
||||
|
||||
/* Records what is known at start-up and reads the kernel log's backlog. */
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
#include "input.h"
|
||||
#include <string.h>
|
||||
|
||||
uint32_t c4fInputRemoteButtons(uint32_t rawButtons, uint8_t keyCode)
|
||||
{
|
||||
/* PS5 SDL reads this Sony key table alongside ordinary pad buttons:
|
||||
* https://github.com/ps5-payload-dev/SDL/blob/ee4c47dc0d617b3bc8f35108f9956baf228a1322/src/video/ps5/SDL_ps5remote.c
|
||||
* Applying these navigation codes to PS4 is a hardware-test candidate;
|
||||
* they are not the HDMI-CEC wire command numbers. */
|
||||
if (rawButtons & C4F_INPUT_BUTTON_INTERCEPTED) return rawButtons;
|
||||
switch (keyCode) {
|
||||
case 13: return rawButtons | C4F_INPUT_BUTTON_CROSS;
|
||||
case 15: return rawButtons | C4F_INPUT_BUTTON_CIRCLE;
|
||||
case 18: return rawButtons | C4F_INPUT_BUTTON_OPTIONS;
|
||||
default: return rawButtons;
|
||||
}
|
||||
}
|
||||
|
||||
static void c4fInputRelease(C4fInput *input, C4fInputSource *source, uint64_t retryAt)
|
||||
{
|
||||
if (source->handle >= 0 && source->owned && input->ops.close)
|
||||
input->ops.close(input->ops.context, source->handle);
|
||||
source->handle = -1;
|
||||
source->owned = 0;
|
||||
source->ready = 0;
|
||||
source->user = -1;
|
||||
source->previous = 0;
|
||||
source->retryAt = retryAt;
|
||||
}
|
||||
|
||||
void c4fInputInit(C4fInput *input, const C4fInputOps *ops)
|
||||
{
|
||||
memset(input, 0, sizeof(*input));
|
||||
if (ops) input->ops = *ops;
|
||||
for (int i = 0; i < C4F_INPUT_SOURCE_COUNT; i++) {
|
||||
input->sources[i].handle = -1;
|
||||
input->sources[i].user = -1;
|
||||
}
|
||||
}
|
||||
|
||||
static void c4fInputRead(C4fInput *input, int index, uint64_t now, C4fInputSample *sample)
|
||||
{
|
||||
C4fInputSource *source = &input->sources[index];
|
||||
if (!input->ops.open || !input->ops.read) return;
|
||||
if (source->handle < 0) {
|
||||
if (now < source->retryAt) return;
|
||||
int32_t user = C4F_INPUT_SYSTEM_USER;
|
||||
if (index == C4F_INPUT_STANDARD &&
|
||||
(!input->ops.initialUser || input->ops.initialUser(input->ops.context, &user) != 0 || user < 0)) {
|
||||
source->retryAt = now + C4F_INPUT_RETRY_MS;
|
||||
return;
|
||||
}
|
||||
int owned = 0;
|
||||
int port = index == C4F_INPUT_STANDARD ? C4F_INPUT_PORT_STANDARD : C4F_INPUT_PORT_REMOTE;
|
||||
int handle = input->ops.open(input->ops.context, user, port, &owned);
|
||||
if (handle < 0) {
|
||||
source->retryAt = now + C4F_INPUT_RETRY_MS;
|
||||
return;
|
||||
}
|
||||
source->handle = handle;
|
||||
source->owned = !!owned;
|
||||
source->user = user;
|
||||
source->ready = 0;
|
||||
source->previous = 0;
|
||||
}
|
||||
uint32_t buttons = 0;
|
||||
int connected = 0;
|
||||
if (input->ops.read(input->ops.context, source->handle, &buttons, &connected) != 0 || !connected) {
|
||||
c4fInputRelease(input, source, now + C4F_INPUT_RETRY_MS);
|
||||
return;
|
||||
}
|
||||
sample->available = 1;
|
||||
if (!source->ready) {
|
||||
if (!buttons) source->ready = 1;
|
||||
return;
|
||||
}
|
||||
sample->buttons = buttons;
|
||||
sample->pressed = buttons & ~source->previous;
|
||||
source->previous = buttons;
|
||||
}
|
||||
|
||||
void c4fInputPoll(C4fInput *input, uint64_t now, C4fInputFrame *frame)
|
||||
{
|
||||
memset(frame, 0, sizeof(*frame));
|
||||
c4fInputRead(input, C4F_INPUT_STANDARD, now, &frame->standard);
|
||||
c4fInputRead(input, C4F_INPUT_REMOTE, now, &frame->remote);
|
||||
}
|
||||
|
||||
void c4fInputReset(C4fInput *input, uint64_t now)
|
||||
{
|
||||
for (int i = 0; i < C4F_INPUT_SOURCE_COUNT; i++)
|
||||
c4fInputRelease(input, &input->sources[i], now);
|
||||
}
|
||||
|
||||
void c4fInputClose(C4fInput *input)
|
||||
{
|
||||
c4fInputReset(input, 0);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
#ifndef C4F_LAUNCHER_INPUT_H
|
||||
#define C4F_LAUNCHER_INPUT_H
|
||||
#include <stdint.h>
|
||||
|
||||
/* These sources use the native pad API independently. Remote-control input
|
||||
* belongs to the system user, rather than the launcher's signed-in user. */
|
||||
enum { C4F_INPUT_STANDARD, C4F_INPUT_REMOTE, C4F_INPUT_SOURCE_COUNT };
|
||||
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 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)
|
||||
|
||||
typedef struct {
|
||||
void *context;
|
||||
int (*initialUser)(void *context, int32_t *user);
|
||||
/* Returns a handle, or a negative error. owned is true only for handles
|
||||
* opened here; an already-open handle may be borrowed and must not close. */
|
||||
int (*open)(void *context, int32_t user, int port, int *owned);
|
||||
int (*read)(void *context, int handle, uint32_t *buttons, int *connected);
|
||||
void (*close)(void *context, int handle);
|
||||
} C4fInputOps;
|
||||
|
||||
typedef struct {
|
||||
int handle, owned, ready;
|
||||
int32_t user;
|
||||
uint32_t previous;
|
||||
uint64_t retryAt;
|
||||
} C4fInputSource;
|
||||
|
||||
typedef struct {
|
||||
C4fInputOps ops;
|
||||
C4fInputSource sources[C4F_INPUT_SOURCE_COUNT];
|
||||
} C4fInput;
|
||||
|
||||
typedef struct {
|
||||
uint32_t buttons, pressed;
|
||||
int available;
|
||||
} C4fInputSample;
|
||||
|
||||
typedef struct {
|
||||
C4fInputSample standard, remote;
|
||||
} C4fInputFrame;
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
void c4fInputInit(C4fInput *input, const C4fInputOps *ops);
|
||||
/* Adds navigation keys reported separately by the system remote-control
|
||||
* stream, preserving the ordinary button mask and unknown key codes. */
|
||||
uint32_t c4fInputRemoteButtons(uint32_t rawButtons, uint8_t keyCode);
|
||||
/* Each recovered source requires a neutral sample before generating edges.
|
||||
* Failure or disconnect produces neutral input and a bounded retry. */
|
||||
void c4fInputPoll(C4fInput *input, uint64_t now, C4fInputFrame *frame);
|
||||
/* Focus changes may reset sources; durable launcher state is unaffected. */
|
||||
void c4fInputReset(C4fInput *input, uint64_t now);
|
||||
void c4fInputClose(C4fInput *input);
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
+331
-39
@@ -1,6 +1,7 @@
|
||||
/* Native PS4 launcher. The controller service runs outside this application. */
|
||||
#include <errno.h>
|
||||
#include <signal.h>
|
||||
#include <stddef.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
@@ -13,6 +14,8 @@
|
||||
#include <orbis/UserService.h>
|
||||
#include <orbis/VideoOut.h>
|
||||
#include "autorun.h"
|
||||
#include "input.h"
|
||||
#include "navigation.h"
|
||||
#include "sandbox.h"
|
||||
#include "screen.h"
|
||||
#include "service.h"
|
||||
@@ -40,7 +43,7 @@ static int c4fSetUp(char *message, size_t size)
|
||||
result = c4fLauncherStart(payload, payloadSize, started, sizeof(started));
|
||||
if (result == 0) snprintf(message, size, "Set up and running. GoldHEN will also start it after each restart.");
|
||||
else if (result == -2) snprintf(message, size, "%s", started);
|
||||
else snprintf(message, size, "Auto-start is on: Control4Free starts the next time GoldHEN loads. %s", started);
|
||||
else snprintf(message, size, "Auto-start is on. %s", started);
|
||||
return result == -2 ? -2 : 0;
|
||||
}
|
||||
|
||||
@@ -111,7 +114,7 @@ static void *c4fWorker(void *)
|
||||
C4fLauncherScreen copy = c4fScreen;
|
||||
pthread_mutex_unlock(&c4fMutex);
|
||||
C4fDiagState diag = { running, status.controllers, autorun, copy.locked, copy.busy, copy.confirmStop,
|
||||
status.version, problem, copy.message, copy.address, copy.autorunNote };
|
||||
status.version, problem, copy.message, copy.address, copy.autorunNote, copy.inputNote };
|
||||
unsigned generation = c4fDiagRefresh(&diag);
|
||||
pthread_mutex_lock(&c4fMutex);
|
||||
c4fScreen.diagGeneration = generation;
|
||||
@@ -166,6 +169,228 @@ static void c4fCloseVideo(void)
|
||||
if (c4fVideoSize) sceKernelReleaseDirectMemory(c4fVideoOffset, c4fVideoSize);
|
||||
}
|
||||
|
||||
/* The pinned SDK omits this error from Pad.h. A handle already opened by the
|
||||
* application can be borrowed, but must not be closed by our reader. */
|
||||
#define C4F_PAD_ERROR_ALREADY_OPENED 0x80920004u
|
||||
static_assert(C4F_INPUT_SYSTEM_USER == ORBIS_USER_SERVICE_USER_ID_SYSTEM, "system input user");
|
||||
static_assert(C4F_INPUT_PORT_STANDARD == ORBIS_PAD_PORT_TYPE_STANDARD, "standard input port");
|
||||
static_assert(C4F_INPUT_BUTTON_UP == ORBIS_PAD_BUTTON_UP, "Up button");
|
||||
static_assert(C4F_INPUT_BUTTON_RIGHT == ORBIS_PAD_BUTTON_RIGHT, "Right button");
|
||||
static_assert(C4F_INPUT_BUTTON_DOWN == ORBIS_PAD_BUTTON_DOWN, "Down button");
|
||||
static_assert(C4F_INPUT_BUTTON_LEFT == ORBIS_PAD_BUTTON_LEFT, "Left button");
|
||||
static_assert(C4F_INPUT_BUTTON_CROSS == ORBIS_PAD_BUTTON_CROSS, "OK button");
|
||||
static_assert(C4F_INPUT_BUTTON_CIRCLE == ORBIS_PAD_BUTTON_CIRCLE, "Back button");
|
||||
static_assert(C4F_INPUT_BUTTON_OPTIONS == ORBIS_PAD_BUTTON_OPTIONS, "Menu button");
|
||||
static_assert(offsetof(OrbisPadData, unknown) + 3 == 108, "remote-control key byte");
|
||||
|
||||
enum { C4F_INPUT_WAITING, C4F_INPUT_READY, C4F_INPUT_DISCONNECTED,
|
||||
C4F_INPUT_OPEN_FAILED, C4F_INPUT_READ_FAILED, C4F_INPUT_USER_FAILED };
|
||||
|
||||
typedef struct {
|
||||
int initialized, handles[2], state[2];
|
||||
uint32_t error[2], buttons[2];
|
||||
uint32_t remoteLastRawButtons;
|
||||
uint8_t remoteLastKey;
|
||||
} C4fNativeInput;
|
||||
|
||||
static const char *const c4fInputNames[] = { "DS4", "TV remote" };
|
||||
static const char *const c4fInputStates[] = {
|
||||
"waiting", "ready", "disconnected", "open failed", "read failed", "user unavailable"
|
||||
};
|
||||
|
||||
static void c4fNativeInputState(C4fNativeInput *input, int source, int state, int result)
|
||||
{
|
||||
if (input->state[source] != state || input->error[source] != (uint32_t)result)
|
||||
printf("[c4f-launcher] %s input: %s (0x%08x)\n",
|
||||
c4fInputNames[source], c4fInputStates[state], (unsigned)result);
|
||||
input->state[source] = state;
|
||||
input->error[source] = (uint32_t)result;
|
||||
if (state != C4F_INPUT_READY) input->buttons[source] = 0;
|
||||
}
|
||||
|
||||
static int c4fNativeInitialUser(void *context, int32_t *user)
|
||||
{
|
||||
int result = sceUserServiceGetInitialUser(user);
|
||||
if (result) c4fNativeInputState((C4fNativeInput *)context, 0, C4F_INPUT_USER_FAILED, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
static int c4fNativeOpen(void *context, int32_t user, int port, int *owned)
|
||||
{
|
||||
C4fNativeInput *input = (C4fNativeInput *)context;
|
||||
int source = port == ORBIS_PAD_PORT_TYPE_STANDARD ? 0 : 1;
|
||||
*owned = 0;
|
||||
if (!input->initialized) {
|
||||
int result = scePadInit();
|
||||
if (result) {
|
||||
c4fNativeInputState(input, source, C4F_INPUT_OPEN_FAILED, result);
|
||||
return -1;
|
||||
}
|
||||
input->initialized = 1;
|
||||
}
|
||||
int handle = scePadOpen(user, port, 0, NULL);
|
||||
if ((uint32_t)handle == C4F_PAD_ERROR_ALREADY_OPENED)
|
||||
handle = scePadGetHandle(user, port, 0);
|
||||
else if (handle >= 0)
|
||||
*owned = 1;
|
||||
input->handles[source] = handle;
|
||||
if (handle < 0) c4fNativeInputState(input, source, C4F_INPUT_OPEN_FAILED, handle);
|
||||
return handle;
|
||||
}
|
||||
|
||||
static int c4fNativeRead(void *context, int handle, uint32_t *buttons, int *connected)
|
||||
{
|
||||
C4fNativeInput *input = (C4fNativeInput *)context;
|
||||
int source = handle == input->handles[0] ? 0 : 1;
|
||||
OrbisPadData sample;
|
||||
memset(&sample, 0, sizeof(sample));
|
||||
int result = scePadReadState(handle, &sample);
|
||||
uint32_t mappedButtons = sample.buttons;
|
||||
if (source == C4F_INPUT_REMOTE && result == 0) {
|
||||
/* The SDK's byte108 is deviceUniqueData[0] in the reverse PS4 layout.
|
||||
* PS5 implementations use it for remote Enter/Back/Menu even when the
|
||||
* device-data length is zero. PS4 key decoding still needs hardware
|
||||
* confirmation. */
|
||||
uint8_t keyCode = sample.unknown[3];
|
||||
if (keyCode || sample.buttons) {
|
||||
input->remoteLastKey = keyCode;
|
||||
input->remoteLastRawButtons = sample.buttons;
|
||||
}
|
||||
mappedButtons = c4fInputRemoteButtons(sample.buttons, keyCode);
|
||||
}
|
||||
*buttons = result == 0 && sample.connected ? mappedButtons : 0;
|
||||
*connected = result == 0 && sample.connected;
|
||||
c4fNativeInputState(input, source, result ? C4F_INPUT_READ_FAILED :
|
||||
sample.connected ? C4F_INPUT_READY : C4F_INPUT_DISCONNECTED, result);
|
||||
input->buttons[source] = *buttons;
|
||||
return result;
|
||||
}
|
||||
|
||||
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, ¶m);
|
||||
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",
|
||||
c4fInputNames[0], c4fInputStates[input->state[0]],
|
||||
input->error[0] ? input->error[0] : input->buttons[0],
|
||||
c4fInputNames[1], c4fInputStates[input->state[1]],
|
||||
input->error[1] ? input->error[1] : input->buttons[1],
|
||||
(unsigned)input->remoteLastKey, input->remoteLastRawButtons);
|
||||
}
|
||||
|
||||
int main(void)
|
||||
{
|
||||
const size_t frameBytes = (size_t)C4F_SCREEN_WIDTH * C4F_SCREEN_HEIGHT * sizeof(uint32_t);
|
||||
@@ -184,9 +409,18 @@ int main(void)
|
||||
OrbisUserServiceInitializeParams params = {};
|
||||
params.priority = ORBIS_KERNEL_PRIO_FIFO_LOWEST;
|
||||
sceUserServiceInitialize(¶ms);
|
||||
int32_t user = -1, pad = -1;
|
||||
if (scePadInit() == 0 && sceUserServiceGetInitialUser(&user) == 0)
|
||||
pad = scePadOpen(user, ORBIS_PAD_PORT_TYPE_STANDARD, 0, NULL);
|
||||
C4fNativeInput nativeInput = {};
|
||||
nativeInput.handles[0] = nativeInput.handles[1] = -1;
|
||||
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. */
|
||||
@@ -201,50 +435,86 @@ int main(void)
|
||||
#endif
|
||||
pthread_t worker;
|
||||
int workerStarted = pthread_create(&worker, NULL, c4fWorker, NULL) == 0;
|
||||
if (!workerStarted || pad < 0) {
|
||||
if (!workerStarted) {
|
||||
pthread_mutex_lock(&c4fMutex);
|
||||
c4fScreen.locked = 1;
|
||||
snprintf(c4fScreen.message, sizeof(c4fScreen.message), "Could not initialize launcher. Close with PS and retry.");
|
||||
pthread_mutex_unlock(&c4fMutex);
|
||||
}
|
||||
sceSystemServiceHideSplashScreen();
|
||||
uint32_t previous = 0;
|
||||
uint64_t lastInputAt = c4fLauncherTimeMs();
|
||||
int buffer = 0, done = 0, drawn = 0, shown[2] = { 0, 0 };
|
||||
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) {
|
||||
OrbisPadData input;
|
||||
memset(&input, 0, sizeof(input));
|
||||
uint32_t buttons = pad >= 0 && scePadReadState(pad, &input) == 0 && input.connected ? input.buttons : 0;
|
||||
uint32_t pressed = buttons & ~previous;
|
||||
previous = buttons;
|
||||
uint64_t now = c4fLauncherTimeMs();
|
||||
/* A suspended app must not resume a pending confirmation or reuse a
|
||||
* held navigation button. Do not guess unreversed focus API signatures. */
|
||||
int resumed = now > lastInputAt && now - lastInputAt > 1000;
|
||||
if (resumed) c4fInputReset(&inputs, now);
|
||||
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);
|
||||
if (!c4fScreen.busy) {
|
||||
if (pressed & ORBIS_PAD_BUTTON_CIRCLE) {
|
||||
if (c4fScreen.confirmStop) c4fScreen.confirmStop = 0;
|
||||
else done = 1;
|
||||
} else if (pressed & ORBIS_PAD_BUTTON_CROSS) {
|
||||
if (c4fScreen.confirmStop) {
|
||||
c4fCommand = C4F_DO_STOP; c4fScreen.confirmStop = 0; c4fScreen.busy = 1;
|
||||
snprintf(c4fScreen.message, sizeof(c4fScreen.message), "Stopping and releasing controllers...");
|
||||
} else if (!c4fScreen.locked && c4fScreen.running != 1) {
|
||||
/* Until auto-start is on, Cross sets it up and starts it. */
|
||||
int setUp = c4fScreen.autorun != C4F_AUTORUN_ON;
|
||||
c4fCommand = setUp ? C4F_DO_SET_UP : C4F_DO_START; c4fScreen.busy = 1;
|
||||
snprintf(c4fScreen.message, sizeof(c4fScreen.message), "%s",
|
||||
setUp ? "Setting up Control4Free. Please wait..." : "Starting Control4Free. Please wait...");
|
||||
}
|
||||
} else if ((pressed & ORBIS_PAD_BUTTON_TRIANGLE) && !c4fScreen.confirmStop &&
|
||||
c4fScreen.autorun != C4F_AUTORUN_UNKNOWN) {
|
||||
int off = c4fScreen.autorun == C4F_AUTORUN_ON;
|
||||
c4fCommand = off ? C4F_DO_AUTORUN_OFF : C4F_DO_AUTORUN_ON; c4fScreen.busy = 1;
|
||||
snprintf(c4fScreen.message, sizeof(c4fScreen.message), "%s",
|
||||
off ? "Turning auto-start off..." : "Turning auto-start on...");
|
||||
} else if ((pressed & ORBIS_PAD_BUTTON_SQUARE) && c4fScreen.running == 1) c4fScreen.confirmStop = 1;
|
||||
c4fNativeInputNote(&nativeInput, c4fScreen.inputNote, sizeof(c4fScreen.inputNote));
|
||||
if (resumed) {
|
||||
c4fScreen.actionMenu = c4fScreen.confirmStop = c4fScreen.confirmMenu = 0;
|
||||
c4fScreen.actionFocus = C4F_ACTION_NONE;
|
||||
}
|
||||
uint32_t standardUnhandled = 0, remoteUnhandled = 0;
|
||||
#ifdef C4F_DIAG
|
||||
const int diagnostic = 1;
|
||||
#else
|
||||
const int diagnostic = 0;
|
||||
#endif
|
||||
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. */
|
||||
int setUp = c4fScreen.autorun != C4F_AUTORUN_ON;
|
||||
c4fCommand = setUp ? C4F_DO_SET_UP : C4F_DO_START; c4fScreen.busy = 1;
|
||||
snprintf(c4fScreen.message, sizeof(c4fScreen.message), "%s",
|
||||
setUp ? "Setting up Control4Free. Please wait..." : "Starting Control4Free. Please wait...");
|
||||
} else if (action == C4F_ACTION_STOP) {
|
||||
c4fCommand = C4F_DO_STOP; c4fScreen.busy = 1;
|
||||
snprintf(c4fScreen.message, sizeof(c4fScreen.message), "Stopping and releasing controllers...");
|
||||
} else if (action == C4F_ACTION_AUTORUN) {
|
||||
int off = c4fScreen.autorun == C4F_AUTORUN_ON;
|
||||
c4fCommand = off ? C4F_DO_AUTORUN_OFF : C4F_DO_AUTORUN_ON; c4fScreen.busy = 1;
|
||||
snprintf(c4fScreen.message, sizeof(c4fScreen.message), "%s",
|
||||
off ? "Turning auto-start off..." : "Turning auto-start on...");
|
||||
}
|
||||
#ifdef C4F_DIAG
|
||||
/* Pages and scrolling work while busy too. */
|
||||
/* 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;
|
||||
@@ -254,19 +524,40 @@ int main(void)
|
||||
at += pressed & ORBIS_PAD_BUTTON_UP ? -10 : 10;
|
||||
c4fScreen.diagScroll[page] = at >= max ? C4F_DIAG_END : at < 0 ? 0 : at;
|
||||
}
|
||||
if (pressed & ORBIS_PAD_BUTTON_OPTIONS) c4fDiagCapture(4000);
|
||||
if (action == C4F_ACTION_CAPTURE || (pressed & ORBIS_PAD_BUTTON_OPTIONS)) c4fDiagCapture(4000);
|
||||
#endif
|
||||
C4fLauncherScreen snapshot = c4fScreen;
|
||||
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 (;;) {
|
||||
@@ -286,7 +577,8 @@ int main(void)
|
||||
#ifdef C4F_DIAG
|
||||
c4fDiagStop();
|
||||
#endif
|
||||
if (pad >= 0) scePadClose(pad);
|
||||
c4fTesterClose(&tester);
|
||||
c4fInputClose(&inputs);
|
||||
sceNetCtlTerm();
|
||||
c4fCloseVideo();
|
||||
free(canvas);
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
#include "navigation.h"
|
||||
#include "autorun.h"
|
||||
#include "input.h"
|
||||
|
||||
#define C4F_NAV_BACK C4F_INPUT_BUTTON_CIRCLE
|
||||
#define C4F_NAV_OK C4F_INPUT_BUTTON_CROSS
|
||||
#define C4F_NAV_PREVIOUS (C4F_INPUT_BUTTON_UP | C4F_INPUT_BUTTON_LEFT)
|
||||
#define C4F_NAV_NEXT (C4F_INPUT_BUTTON_DOWN | C4F_INPUT_BUTTON_RIGHT)
|
||||
#define C4F_NAV_DIRECTIONS (C4F_NAV_PREVIOUS | C4F_NAV_NEXT)
|
||||
|
||||
int c4fActionAvailable(const C4fLauncherScreen *screen, C4fLauncherAction action, int diagnostic)
|
||||
{
|
||||
switch (action) {
|
||||
case C4F_ACTION_RESUME: return 1;
|
||||
case C4F_ACTION_START: return !screen->busy && !screen->locked && screen->running != 1;
|
||||
case C4F_ACTION_AUTORUN:
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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_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;
|
||||
if (actions && count < capacity) actions[count] = order[i];
|
||||
count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
const char *c4fActionLabel(const C4fLauncherScreen *screen, C4fLauncherAction action)
|
||||
{
|
||||
switch (action) {
|
||||
case C4F_ACTION_RESUME: return "Resume";
|
||||
case C4F_ACTION_START: return screen->autorun == C4F_AUTORUN_ON ? "Start Control4Free" : "Set up and start";
|
||||
case C4F_ACTION_AUTORUN:
|
||||
if (screen->autorun == C4F_AUTORUN_ON) return "Turn auto-start off";
|
||||
if (screen->autorun == C4F_AUTORUN_OUTDATED) return "Update auto-start";
|
||||
return "Turn auto-start on";
|
||||
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 "";
|
||||
}
|
||||
}
|
||||
|
||||
static C4fLauncherAction c4fDefaultAction(const C4fLauncherScreen *screen, int diagnostic)
|
||||
{
|
||||
return c4fActionAvailable(screen, C4F_ACTION_START, diagnostic) ? C4F_ACTION_START : C4F_ACTION_RESUME;
|
||||
}
|
||||
|
||||
static int c4fDirection(uint32_t buttons)
|
||||
{
|
||||
int previous = !!(buttons & C4F_NAV_PREVIOUS), next = !!(buttons & C4F_NAV_NEXT);
|
||||
return previous == next ? 0 : next ? 1 : -1;
|
||||
}
|
||||
|
||||
static void c4fConsume(uint32_t *standardUnhandled, uint32_t *remoteUnhandled)
|
||||
{
|
||||
if (standardUnhandled) *standardUnhandled = 0;
|
||||
if (remoteUnhandled) *remoteUnhandled = 0;
|
||||
}
|
||||
|
||||
static void c4fCancelConfirmation(C4fLauncherScreen *screen)
|
||||
{
|
||||
screen->confirmStop = 0;
|
||||
screen->confirmChoice = 0;
|
||||
screen->confirmMenu = 0;
|
||||
}
|
||||
|
||||
C4fLauncherAction c4fNavigate(C4fLauncherScreen *screen, uint32_t standardPressed,
|
||||
uint32_t remotePressed, int diagnostic,
|
||||
uint32_t *standardUnhandled, uint32_t *remoteUnhandled)
|
||||
{
|
||||
if (standardUnhandled) *standardUnhandled = standardPressed;
|
||||
if (remoteUnhandled) *remoteUnhandled = remotePressed;
|
||||
uint32_t pressed = standardPressed | remotePressed;
|
||||
if (screen->confirmStop) {
|
||||
c4fConsume(standardUnhandled, remoteUnhandled);
|
||||
if (!c4fActionAvailable(screen, C4F_ACTION_STOP, diagnostic)) {
|
||||
c4fCancelConfirmation(screen);
|
||||
return C4F_ACTION_NONE;
|
||||
}
|
||||
if (pressed & C4F_NAV_BACK) {
|
||||
c4fCancelConfirmation(screen);
|
||||
return C4F_ACTION_RESUME;
|
||||
}
|
||||
/* The DS4 shortcut displays Cross-to-stop. A remote's first event
|
||||
* instead reveals an explicit, Cancel-focused selection and cannot
|
||||
* accept the confirmation that another source opened. */
|
||||
if (!screen->confirmMenu && remotePressed) {
|
||||
screen->confirmMenu = 1;
|
||||
screen->confirmChoice = 0;
|
||||
return C4F_ACTION_NONE;
|
||||
}
|
||||
if (pressed & C4F_NAV_DIRECTIONS) {
|
||||
if (!screen->confirmMenu) {
|
||||
screen->confirmMenu = 1;
|
||||
screen->confirmChoice = 0;
|
||||
}
|
||||
int direction = c4fDirection(pressed);
|
||||
if (direction) screen->confirmChoice = direction > 0;
|
||||
return C4F_ACTION_NONE;
|
||||
}
|
||||
if (pressed & C4F_NAV_OK) {
|
||||
int stop = screen->confirmChoice;
|
||||
c4fCancelConfirmation(screen);
|
||||
return stop ? C4F_ACTION_STOP : C4F_ACTION_RESUME;
|
||||
}
|
||||
return C4F_ACTION_NONE;
|
||||
}
|
||||
if (screen->actionMenu) {
|
||||
c4fConsume(standardUnhandled, remoteUnhandled);
|
||||
if (!c4fActionAvailable(screen, (C4fLauncherAction)screen->actionFocus, diagnostic)) {
|
||||
screen->actionFocus = c4fDefaultAction(screen, diagnostic);
|
||||
return C4F_ACTION_NONE;
|
||||
}
|
||||
if (pressed & C4F_NAV_BACK) {
|
||||
screen->actionMenu = 0;
|
||||
return C4F_ACTION_RESUME;
|
||||
}
|
||||
if (pressed & C4F_NAV_DIRECTIONS) {
|
||||
int direction = c4fDirection(pressed);
|
||||
if (direction) {
|
||||
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];
|
||||
}
|
||||
return C4F_ACTION_NONE;
|
||||
}
|
||||
if (pressed & C4F_NAV_OK) {
|
||||
C4fLauncherAction action = (C4fLauncherAction)screen->actionFocus;
|
||||
screen->actionMenu = 0;
|
||||
if (action == C4F_ACTION_STOP) {
|
||||
screen->confirmStop = 1;
|
||||
screen->confirmChoice = 0;
|
||||
screen->confirmMenu = 1;
|
||||
return C4F_ACTION_NONE;
|
||||
}
|
||||
return action;
|
||||
}
|
||||
return C4F_ACTION_NONE;
|
||||
}
|
||||
/* A TV remote needs an action menu because it has no Triangle or Square.
|
||||
* In diagnostic mode its arrows keep the existing paging and scrolling. */
|
||||
if ((remotePressed & (C4F_NAV_OK | C4F_INPUT_BUTTON_OPTIONS)) ||
|
||||
(!diagnostic && (pressed & C4F_NAV_DIRECTIONS))) {
|
||||
c4fConsume(standardUnhandled, remoteUnhandled);
|
||||
screen->actionMenu = 1;
|
||||
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);
|
||||
return C4F_ACTION_CLOSE;
|
||||
}
|
||||
if (standardPressed & C4F_NAV_OK)
|
||||
return c4fActionAvailable(screen, C4F_ACTION_START, diagnostic) ? C4F_ACTION_START : C4F_ACTION_NONE;
|
||||
if ((standardPressed & C4F_INPUT_BUTTON_TRIANGLE) &&
|
||||
c4fActionAvailable(screen, C4F_ACTION_AUTORUN, diagnostic)) return C4F_ACTION_AUTORUN;
|
||||
if ((standardPressed & C4F_INPUT_BUTTON_SQUARE) && c4fActionAvailable(screen, C4F_ACTION_STOP, diagnostic)) {
|
||||
screen->confirmStop = 1;
|
||||
screen->confirmChoice = 1;
|
||||
screen->confirmMenu = 0;
|
||||
c4fConsume(standardUnhandled, remoteUnhandled);
|
||||
}
|
||||
return C4F_ACTION_NONE;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
#ifndef C4F_LAUNCHER_NAVIGATION_H
|
||||
#define C4F_LAUNCHER_NAVIGATION_H
|
||||
#include "screen.h"
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
/* Returns the number of available actions, writing up to capacity entries. */
|
||||
int c4fActionList(const C4fLauncherScreen *screen, int diagnostic,
|
||||
C4fLauncherAction *actions, int capacity);
|
||||
int c4fActionAvailable(const C4fLauncherScreen *screen, C4fLauncherAction action, int diagnostic);
|
||||
const char *c4fActionLabel(const C4fLauncherScreen *screen, C4fLauncherAction action);
|
||||
/* Only rising edges are accepted. The remaining bits preserve diagnostic
|
||||
* paging/scrolling outside the menu, without moving both a page and focus. */
|
||||
C4fLauncherAction c4fNavigate(C4fLauncherScreen *screen, uint32_t standardPressed,
|
||||
uint32_t remotePressed, int diagnostic,
|
||||
uint32_t *standardUnhandled, uint32_t *remoteUnhandled);
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
+118
-57
@@ -9,10 +9,9 @@
|
||||
#include "autorun.h"
|
||||
#include "draw.h"
|
||||
#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
|
||||
@@ -26,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; }
|
||||
@@ -171,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);
|
||||
@@ -225,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;
|
||||
@@ -269,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);
|
||||
@@ -279,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);
|
||||
@@ -309,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));
|
||||
@@ -327,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);
|
||||
@@ -351,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++)
|
||||
@@ -382,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);
|
||||
}
|
||||
@@ -443,18 +424,90 @@ static C4fLook c4fLookFor(const C4fLauncherScreen *s, char *meta, size_t metaSiz
|
||||
meta, C4F_OK, C4F_ACCENT, 1 };
|
||||
} else if (s->running == 0 && s->autorun == C4F_AUTORUN_ON) {
|
||||
look = (C4fLook){ "Stopped", "Start Control4Free",
|
||||
"It starts with GoldHEN after each restart. To start it now, turn on GoldHEN's PayLoader "
|
||||
"and press Cross.",
|
||||
"It starts with GoldHEN after each restart. To start now, enable GoldHEN's PayLoader "
|
||||
"Server and press Cross.",
|
||||
"Not running", C4F_FAINT, C4F_FAINT, 0 };
|
||||
} else if (s->running == 0) {
|
||||
look = (C4fLook){ "Stopped", "Set up Control4Free",
|
||||
"Press Cross once. GoldHEN will start Control4Free each time it loads, and it starts "
|
||||
"right away if GoldHEN's PayLoader is on.",
|
||||
"Press Cross to set up auto-start. To start Control4Free now, enable GoldHEN's "
|
||||
"PayLoader Server.",
|
||||
"Not running", C4F_FAINT, C4F_FAINT, 0 };
|
||||
}
|
||||
return look;
|
||||
}
|
||||
|
||||
/* The TV remote and controller share a visible action menu. Keep its rendering
|
||||
* here so the normal and diagnostic screens use the same focus and confirmation. */
|
||||
void c4fDrawActionOverlay(uint32_t *pixels, const C4fLauncherScreen *s)
|
||||
{
|
||||
if (!s->confirmStop && !s->actionMenu) return;
|
||||
C4fCanvas c = { pixels, C4F_SCREEN_WIDTH, C4F_SCREEN_HEIGHT };
|
||||
const float dw = 980;
|
||||
const float dx = (c.w - dw) / 2;
|
||||
int diagnostic = 0;
|
||||
#ifdef C4F_DIAG
|
||||
diagnostic = 1;
|
||||
#endif
|
||||
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));
|
||||
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);
|
||||
|
||||
if (s->confirmStop) {
|
||||
c4fText(&c, C4F_FONT_LIGHT, 48, dx + 48, dy + 92, C4F_TEXT, "Stop Control4Free?");
|
||||
c4fTextWrapped(&c, C4F_FONT_LIGHT, 26, dx + 48, dy + 148, dw - 96, 39, 2, C4F_MUTED,
|
||||
"Every controller disconnects. You can start Control4Free again from this screen.");
|
||||
if (!s->confirmMenu) {
|
||||
static const C4fHint hints[2] = { { C4F_KEY_CROSS, "Stop" }, { C4F_KEY_CIRCLE, "Cancel" } };
|
||||
c4fHints(&c, dx + dw - 48, dy + dh - 58, hints, 2);
|
||||
return;
|
||||
}
|
||||
const float bw = 240, gap = 24, by = dy + 235;
|
||||
for (int i = 0; i < 2; i++) {
|
||||
float bx = dx + dw - 48 - 2 * bw - gap + i * (bw + gap);
|
||||
int focused = s->confirmChoice == i;
|
||||
c4fFillRoundRect(&c, bx, by, bw, 64, 6 * C4F_S,
|
||||
focused ? C4F_RGBA(40, 103, 205, .85f) : C4F_TILE);
|
||||
c4fInsetRoundRect(&c, bx, by, bw, 64, 6 * C4F_S,
|
||||
focused ? 3 : 1 * C4F_S, focused ? C4F_ACCENT : C4F_LINE);
|
||||
c4fTextCentered(&c, C4F_FONT_REGULAR, 30, bx + bw / 2, by + 42,
|
||||
focused ? C4F_TEXT : C4F_MUTED, i ? "Stop" : "Cancel");
|
||||
}
|
||||
c4fText(&c, C4F_FONT_LIGHT, 23, dx + 48, dy + dh - 48, C4F_MUTED,
|
||||
"Arrows / D-pad: select OK / Cross: confirm Back / Circle: cancel");
|
||||
return;
|
||||
}
|
||||
|
||||
c4fText(&c, C4F_FONT_LIGHT, 48, dx + 48, dy + 84, C4F_TEXT, "Actions");
|
||||
c4fText(&c, C4F_FONT_LIGHT, 24, dx + 48, dy + 125, C4F_MUTED,
|
||||
s->busy ? "Working... You can return to the screen." : "Use arrows or the D-pad to choose an action.");
|
||||
int focus = 0;
|
||||
for (int i = 0; i < count; i++) if ((int)actions[i] == s->actionFocus) focus = i;
|
||||
for (int i = 0; i < count; i++) {
|
||||
const float rx = dx + 32, ry = dy + 153 + i * 72, rw = dw - 64;
|
||||
int focused = i == focus;
|
||||
c4fFillRoundRect(&c, rx, ry, rw, 64, 6 * C4F_S,
|
||||
focused ? C4F_RGBA(40, 103, 205, .85f) : C4F_RGBA(255, 255, 255, .04f));
|
||||
c4fInsetRoundRect(&c, rx, ry, rw, 64, 6 * C4F_S,
|
||||
focused ? 3 : 1 * C4F_S, focused ? C4F_ACCENT : C4F_LINE_SOFT);
|
||||
c4fText(&c, C4F_FONT_REGULAR, 30, rx + 24, ry + 42,
|
||||
focused ? C4F_TEXT : C4F_MUTED, c4fActionLabel(s, actions[i]));
|
||||
if (focused) {
|
||||
float cx = rx + rw - 30, cy = ry + 32;
|
||||
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 - 32, C4F_MUTED, "TV remote: OK select / Back return");
|
||||
c4fHints(&c, dx + dw - 48, dy + dh - 40, hints, 2);
|
||||
}
|
||||
|
||||
void c4fDrawLauncher(uint32_t *pixels, const C4fLauncherScreen *s)
|
||||
{
|
||||
C4fCanvas c = { pixels, C4F_SCREEN_WIDTH, C4F_SCREEN_HEIGHT };
|
||||
@@ -529,7 +582,7 @@ void c4fDrawLauncher(uint32_t *pixels, const C4fLauncherScreen *s)
|
||||
if (mismatch) {
|
||||
snprintf(versions, sizeof(versions), "Installed %s; running %s", C4F_LAUNCHER_VERSION, s->runningVersion);
|
||||
panelTitle = versions;
|
||||
panelText = "To apply this app's version now, press Square, confirm the stop with Cross, then press Cross to start. This disconnects all players.";
|
||||
panelText = "Press Square, then Cross to stop, and Cross again to start this app's version. All players disconnect.";
|
||||
panelDot = C4F_WARN;
|
||||
} else if (s->autorun == C4F_AUTORUN_ON) {
|
||||
panelTitle = "Starts by itself with GoldHEN";
|
||||
@@ -538,12 +591,11 @@ void c4fDrawLauncher(uint32_t *pixels, const C4fLauncherScreen *s)
|
||||
panelDot = C4F_OK;
|
||||
} else if (s->autorun == C4F_AUTORUN_OUTDATED) {
|
||||
panelTitle = "Auto-start uses an older Control4Free";
|
||||
panelText = "Press Triangle to switch GoldHEN's auto-start to this version. It takes effect after the "
|
||||
"next restart.";
|
||||
panelText = "Press Triangle to update auto-start. GoldHEN uses this version after the next restart.";
|
||||
panelDot = C4F_WARN;
|
||||
} else if (s->autorun == C4F_AUTORUN_OFF) {
|
||||
panelTitle = "Auto-start is off";
|
||||
panelText = "Press Triangle and GoldHEN starts Control4Free each time it loads, with no PC needed.";
|
||||
panelText = "Press Triangle to turn auto-start on. GoldHEN then starts Control4Free each time it loads.";
|
||||
} else {
|
||||
panelTitle = "Auto-start status unknown";
|
||||
panelText = s->autorunNote[0] ? s->autorunNote : "Checking with GoldHEN...";
|
||||
@@ -557,9 +609,11 @@ void c4fDrawLauncher(uint32_t *pixels, const C4fLauncherScreen *s)
|
||||
panelText);
|
||||
|
||||
/* .home-foot and the button guide. */
|
||||
if (!s->confirmStop && !s->actionMenu)
|
||||
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;
|
||||
@@ -574,23 +628,16 @@ 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);
|
||||
}
|
||||
|
||||
if (s->confirmStop) {
|
||||
const float dw = 980, dh = 330, dx = (c.w - dw) / 2, dy = (c.h - dh) / 2;
|
||||
static const C4fHint hints[2] = { { C4F_KEY_CROSS, "Stop" }, { C4F_KEY_CIRCLE, "Cancel" } };
|
||||
c4fFillRect(&c, 0, 0, c.w, c.h, C4F_RGBA(2, 11, 31, .6f));
|
||||
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));
|
||||
/* A little more opaque than --panel-strong, so the tiles do not ghost through. */
|
||||
c4fPanel(&c, dx, dy, dw, dh, C4F_RGBA(2, 11, 31, .94f), C4F_LINE);
|
||||
c4fText(&c, C4F_FONT_LIGHT, 48, dx + 48, dy + 96, C4F_TEXT, "Stop Control4Free?");
|
||||
c4fTextWrapped(&c, C4F_FONT_LIGHT, 26, dx + 48, dy + 152, dw - 96, 39, 2, C4F_MUTED,
|
||||
"Every controller disconnects. You can start Control4Free again from this screen.");
|
||||
c4fHints(&c, dx + dw - 48, dy + dh - 58, hints, 2);
|
||||
}
|
||||
c4fDrawActionOverlay(pixels, s);
|
||||
}
|
||||
|
||||
void c4fDrawIcon(uint32_t *pixels, int size)
|
||||
@@ -605,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);
|
||||
|
||||
+19
-1
@@ -1,16 +1,30 @@
|
||||
#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"
|
||||
#endif
|
||||
#define C4F_SCREEN_WIDTH 1920
|
||||
#define C4F_SCREEN_HEIGHT 1080
|
||||
typedef enum {
|
||||
C4F_ACTION_NONE,
|
||||
C4F_ACTION_RESUME,
|
||||
C4F_ACTION_START,
|
||||
C4F_ACTION_AUTORUN,
|
||||
C4F_ACTION_STOP,
|
||||
C4F_ACTION_CLOSE,
|
||||
C4F_ACTION_CAPTURE,
|
||||
C4F_ACTION_TESTER
|
||||
} C4fLauncherAction;
|
||||
typedef struct {
|
||||
int running, busy, controllers, confirmStop, locked;
|
||||
int actionMenu, actionFocus;
|
||||
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];
|
||||
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];
|
||||
@@ -24,6 +38,10 @@ int c4fScreenInit(void);
|
||||
/* Draws a whole frame. Blending reads pixels back, so pass cached memory,
|
||||
* never the write-combined framebuffer. */
|
||||
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
|
||||
|
||||
+5
-5
@@ -239,10 +239,10 @@ int c4fLauncherStart(const unsigned char *payload, size_t payloadSize, char *mes
|
||||
const char *targets[2];
|
||||
int count = c4fTargets(targets), fd = -1;
|
||||
for (int i = 0; i < count && fd < 0; i++) fd = c4fConnect(targets[i], C4F_PAYLOADER_PORT);
|
||||
if (fd < 0) { snprintf(message, size, "PayLoader did not answer (errno %d). Turn it on in GoldHEN, then press Cross again.", errno); return -1; }
|
||||
if (fd < 0) { snprintf(message, size, "PayLoader did not answer (errno %d). Enable PayLoader Server in GoldHEN > Server Settings, then retry Start.", errno); return -1; }
|
||||
int failed = c4fSendAll(fd, payload, payloadSize, c4fLauncherTimeMs() + 10000);
|
||||
shutdown(fd, SHUT_WR); close(fd);
|
||||
if (failed) { snprintf(message, size, "The transfer to PayLoader broke off. Press Cross to try again."); return -2; }
|
||||
if (failed) { snprintf(message, size, "The transfer to PayLoader broke off. Retry Start."); return -2; }
|
||||
uint64_t deadline;
|
||||
deadline = c4fLauncherTimeMs() + 20000;
|
||||
while (c4fLauncherTimeMs() < deadline) {
|
||||
@@ -251,7 +251,7 @@ int c4fLauncherStart(const unsigned char *payload, size_t payloadSize, char *mes
|
||||
}
|
||||
usleep(250000);
|
||||
}
|
||||
snprintf(message, size, "Sent, but Control4Free did not start (%s). Press Cross to try again.", c4fProblem);
|
||||
snprintf(message, size, "Sent, but Control4Free did not start (%s). Retry Start.", c4fProblem);
|
||||
/* Sending again is safe: a copy that did start holds the payload's
|
||||
* instance lock, so a second one quits at once. */
|
||||
return -2;
|
||||
@@ -261,7 +261,7 @@ int c4fLauncherStop(char *message, size_t size)
|
||||
{
|
||||
C4fServiceStatus status;
|
||||
int probe = c4fLauncherProbe(&status);
|
||||
if (probe == 0) { snprintf(message, size, "Stopped. Press Cross to start it again."); return 0; }
|
||||
if (probe == 0) { snprintf(message, size, "Stopped. Ready to start again."); return 0; }
|
||||
if (probe != 1 && !c4fReached) { snprintf(message, size, "The app cannot reach Control4Free (%s). Stop it on its controller page.", c4fProblem); return -1; }
|
||||
if (probe != 1) { snprintf(message, size, "This copy does not answer the app (%s). Stop it on its controller page.", c4fProblem); return -1; }
|
||||
char reply[256];
|
||||
@@ -269,7 +269,7 @@ int c4fLauncherStop(char *message, size_t size)
|
||||
if (ret != 1) { snprintf(message, size, "Stop was not confirmed (%s). Check the controller page.", c4fProblem); return -1; }
|
||||
uint64_t deadline = c4fLauncherTimeMs() + 5000;
|
||||
while (c4fLauncherTimeMs() < deadline) {
|
||||
if (c4fLauncherProbe(&status) == 0) { snprintf(message, size, "Stopped. Press Cross to start it again."); return 0; }
|
||||
if (c4fLauncherProbe(&status) == 0) { snprintf(message, size, "Stopped. Ready to start again."); return 0; }
|
||||
usleep(100000);
|
||||
}
|
||||
snprintf(message, size, "Still stopping. Wait a moment, then try again."); return -1;
|
||||
|
||||
@@ -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
|
||||
@@ -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 "";
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
@@ -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];
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
/* Host-only page fixtures use the real diagnostic renderer, without native
|
||||
* startup, filesystem access or report writes. Unused paths are discarded by
|
||||
* the art tool's section garbage collection. */
|
||||
#include "../launcher/diag.c"
|
||||
|
||||
void c4fDiagPreviewPage(int page, const char *text)
|
||||
{
|
||||
if (page < 0 || page >= C4F_DIAG_PAGES) return;
|
||||
char *copy = strdup(text ? text : "");
|
||||
if (!copy) return;
|
||||
pthread_mutex_lock(&c4fLock);
|
||||
free(c4fPages[page]);
|
||||
c4fPages[page] = copy;
|
||||
c4fGeneration++;
|
||||
pthread_mutex_unlock(&c4fLock);
|
||||
}
|
||||
@@ -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; });
|
||||
@@ -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 };
|
||||
|
||||
@@ -0,0 +1,569 @@
|
||||
/* Launcher input and navigation using the real modules and simulated native
|
||||
* readers. No controller service, network or console is contacted. */
|
||||
#include <assert.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include "autorun.h"
|
||||
#include "input.h"
|
||||
#include "navigation.h"
|
||||
#include "screen.h"
|
||||
|
||||
typedef struct {
|
||||
int handle, owned, openError, readError, connected;
|
||||
int opens, reads, closes;
|
||||
uint32_t buttons;
|
||||
} FakeSource;
|
||||
|
||||
typedef struct {
|
||||
int initialResult, initialCalls;
|
||||
int32_t user;
|
||||
FakeSource source[2];
|
||||
} FakeInput;
|
||||
|
||||
static int fakeInitial(void *context, int32_t *user)
|
||||
{
|
||||
FakeInput *fake = context;
|
||||
fake->initialCalls++;
|
||||
*user = fake->user;
|
||||
return fake->initialResult;
|
||||
}
|
||||
|
||||
static int fakeOpen(void *context, int32_t user, int port, int *owned)
|
||||
{
|
||||
FakeInput *fake = context;
|
||||
int index;
|
||||
if (port == C4F_INPUT_PORT_STANDARD) {
|
||||
assert(user == fake->user && user >= 0);
|
||||
index = C4F_INPUT_STANDARD;
|
||||
} else {
|
||||
assert(port == C4F_INPUT_PORT_REMOTE && user == C4F_INPUT_SYSTEM_USER);
|
||||
index = C4F_INPUT_REMOTE;
|
||||
}
|
||||
FakeSource *source = &fake->source[index];
|
||||
source->opens++;
|
||||
*owned = source->owned;
|
||||
return source->openError ? -1 : source->handle;
|
||||
}
|
||||
|
||||
static FakeSource *fakeHandle(FakeInput *fake, int handle)
|
||||
{
|
||||
for (int i = 0; i < 2; i++) if (fake->source[i].handle == handle) return &fake->source[i];
|
||||
assert(!"unknown handle");
|
||||
return NULL;
|
||||
}
|
||||
|
||||
static int fakeRead(void *context, int handle, uint32_t *buttons, int *connected)
|
||||
{
|
||||
FakeSource *source = fakeHandle(context, handle);
|
||||
source->reads++;
|
||||
/* Poison even a failed sample: a failed native call must never leak it. */
|
||||
*buttons = source->buttons;
|
||||
*connected = source->connected;
|
||||
return source->readError ? -1 : 0;
|
||||
}
|
||||
|
||||
static void fakeClose(void *context, int handle)
|
||||
{
|
||||
FakeSource *source = fakeHandle(context, handle);
|
||||
assert(source->owned);
|
||||
source->closes++;
|
||||
assert(source->closes <= source->opens);
|
||||
}
|
||||
|
||||
static void reader(FakeInput *fake, C4fInput *input)
|
||||
{
|
||||
memset(fake, 0, sizeof(*fake));
|
||||
fake->user = 42;
|
||||
fake->source[C4F_INPUT_STANDARD] = (FakeSource){ .handle = 11, .owned = 1, .connected = 1 };
|
||||
fake->source[C4F_INPUT_REMOTE] = (FakeSource){ .handle = 22, .owned = 1, .connected = 1 };
|
||||
C4fInputOps ops = { fake, fakeInitial, fakeOpen, fakeRead, fakeClose };
|
||||
c4fInputInit(input, &ops);
|
||||
}
|
||||
|
||||
static C4fInputFrame poll(C4fInput *input, uint64_t now)
|
||||
{
|
||||
C4fInputFrame frame;
|
||||
/* Poll must fill both sources even if one cannot be opened. */
|
||||
memset(&frame, 0xff, sizeof(frame));
|
||||
c4fInputPoll(input, now, &frame);
|
||||
return frame;
|
||||
}
|
||||
|
||||
static void remoteKeyNormalization(void)
|
||||
{
|
||||
/* These are Sony remote packet key codes, not HDMI-CEC wire opcodes.
|
||||
* The native wrapper's remote-port gate is reviewed separately in main;
|
||||
* this host test checks only the helper and its downstream reader contract. */
|
||||
const uint32_t raw = UINT32_C(0x25a500f0);
|
||||
assert(c4fInputRemoteButtons(raw, 13) == (raw | C4F_INPUT_BUTTON_CROSS));
|
||||
assert(c4fInputRemoteButtons(raw, 15) == (raw | C4F_INPUT_BUTTON_CIRCLE));
|
||||
assert(c4fInputRemoteButtons(raw, 18) == (raw | C4F_INPUT_BUTTON_OPTIONS));
|
||||
assert(c4fInputRemoteButtons(C4F_INPUT_BUTTON_CROSS, 13) == C4F_INPUT_BUTTON_CROSS);
|
||||
for (unsigned code = 0; code <= UINT8_MAX; code++) {
|
||||
if (code == 13 || code == 15 || code == 18) continue;
|
||||
assert(c4fInputRemoteButtons(raw, (uint8_t)code) == raw);
|
||||
assert(c4fInputRemoteButtons(0, (uint8_t)code) == 0);
|
||||
}
|
||||
assert(c4fInputRemoteButtons(C4F_INPUT_BUTTON_INTERCEPTED, 13) == C4F_INPUT_BUTTON_INTERCEPTED);
|
||||
assert(c4fInputRemoteButtons(C4F_INPUT_BUTTON_INTERCEPTED, 15) == C4F_INPUT_BUTTON_INTERCEPTED);
|
||||
assert(c4fInputRemoteButtons(C4F_INPUT_BUTTON_INTERCEPTED, 18) == C4F_INPUT_BUTTON_INTERCEPTED);
|
||||
puts("PASS Sony remote key normalization preserves raw buttons, ignores unknown codes and respects interception");
|
||||
}
|
||||
|
||||
static void remoteOnly(void)
|
||||
{
|
||||
FakeInput fake;
|
||||
C4fInput input;
|
||||
reader(&fake, &input);
|
||||
fake.source[C4F_INPUT_STANDARD].openError = 1;
|
||||
fake.source[C4F_INPUT_REMOTE].buttons = C4F_INPUT_BUTTON_CROSS;
|
||||
C4fInputFrame frame = poll(&input, 0);
|
||||
assert(!frame.standard.available && !frame.standard.buttons && !frame.standard.pressed);
|
||||
assert(frame.remote.available && !frame.remote.pressed);
|
||||
fake.source[C4F_INPUT_REMOTE].buttons = 0;
|
||||
poll(&input, 10);
|
||||
fake.source[C4F_INPUT_REMOTE].buttons = C4F_INPUT_BUTTON_CROSS;
|
||||
frame = poll(&input, 20);
|
||||
assert(frame.remote.pressed == C4F_INPUT_BUTTON_CROSS);
|
||||
assert(!poll(&input, 30).remote.pressed);
|
||||
assert(fake.source[C4F_INPUT_STANDARD].opens == 1);
|
||||
c4fInputClose(&input);
|
||||
assert(!fake.source[C4F_INPUT_STANDARD].closes && fake.source[C4F_INPUT_REMOTE].closes == 1);
|
||||
c4fInputClose(&input);
|
||||
assert(fake.source[C4F_INPUT_REMOTE].closes == 1);
|
||||
puts("PASS remote-only launch, held OK baseline, no repeated activation and owned-handle cleanup");
|
||||
}
|
||||
|
||||
static void sourceEdges(void)
|
||||
{
|
||||
FakeInput fake;
|
||||
C4fInput input;
|
||||
reader(&fake, &input);
|
||||
C4fInputFrame frame = poll(&input, 0);
|
||||
assert(frame.standard.available && frame.remote.available);
|
||||
fake.source[C4F_INPUT_STANDARD].buttons = C4F_INPUT_BUTTON_CROSS;
|
||||
frame = poll(&input, 10);
|
||||
assert(frame.standard.pressed == C4F_INPUT_BUTTON_CROSS && !frame.remote.pressed);
|
||||
fake.source[C4F_INPUT_REMOTE].buttons = C4F_INPUT_BUTTON_CROSS;
|
||||
frame = poll(&input, 20);
|
||||
assert(!frame.standard.pressed && frame.remote.pressed == C4F_INPUT_BUTTON_CROSS);
|
||||
fake.source[C4F_INPUT_STANDARD].buttons = 0;
|
||||
frame = poll(&input, 30);
|
||||
assert(!frame.standard.buttons && frame.remote.buttons == C4F_INPUT_BUTTON_CROSS);
|
||||
assert(!frame.standard.pressed && !frame.remote.pressed);
|
||||
fake.source[C4F_INPUT_STANDARD].buttons = C4F_INPUT_BUTTON_CROSS;
|
||||
frame = poll(&input, 40);
|
||||
assert(frame.standard.pressed == C4F_INPUT_BUTTON_CROSS && !frame.remote.pressed);
|
||||
fake.source[C4F_INPUT_REMOTE].buttons = 0;
|
||||
poll(&input, 50);
|
||||
fake.source[C4F_INPUT_REMOTE].buttons = C4F_INPUT_BUTTON_CROSS;
|
||||
frame = poll(&input, 60);
|
||||
assert(!frame.standard.pressed && frame.remote.pressed == C4F_INPUT_BUTTON_CROSS);
|
||||
c4fInputClose(&input);
|
||||
assert(fake.source[0].closes == 1 && fake.source[1].closes == 1);
|
||||
puts("PASS independent controller/remote edges while either source holds or releases the same button");
|
||||
}
|
||||
|
||||
static void remoteOpenRetry(void)
|
||||
{
|
||||
FakeInput fake;
|
||||
C4fInput input;
|
||||
reader(&fake, &input);
|
||||
fake.source[1].openError = 1;
|
||||
C4fInputFrame frame = poll(&input, 0);
|
||||
assert(frame.standard.available && !frame.remote.available);
|
||||
fake.source[0].buttons = C4F_INPUT_BUTTON_CROSS;
|
||||
frame = poll(&input, 10);
|
||||
assert(frame.standard.pressed == C4F_INPUT_BUTTON_CROSS && !frame.remote.buttons);
|
||||
fake.source[1].openError = 0;
|
||||
fake.source[1].buttons = C4F_INPUT_BUTTON_CROSS;
|
||||
poll(&input, 999);
|
||||
assert(fake.source[1].opens == 1);
|
||||
frame = poll(&input, 1000);
|
||||
assert(frame.remote.available && !frame.remote.buttons && !frame.remote.pressed);
|
||||
assert(fake.source[1].opens == 2 && !frame.standard.pressed);
|
||||
fake.source[1].buttons = 0;
|
||||
poll(&input, 1010);
|
||||
fake.source[1].buttons = C4F_INPUT_BUTTON_CROSS;
|
||||
assert(poll(&input, 1020).remote.pressed == C4F_INPUT_BUTTON_CROSS);
|
||||
c4fInputClose(&input);
|
||||
assert(fake.source[0].closes == 1 && fake.source[1].closes == 1);
|
||||
puts("PASS unavailable remote leaves DS4 operational and retry waits for a new neutral baseline");
|
||||
}
|
||||
|
||||
static void readerRecovery(void)
|
||||
{
|
||||
FakeInput fake;
|
||||
C4fInput input;
|
||||
reader(&fake, &input);
|
||||
poll(&input, 0);
|
||||
fake.source[0].buttons = C4F_INPUT_BUTTON_CROSS;
|
||||
fake.source[1].buttons = C4F_INPUT_BUTTON_RIGHT;
|
||||
poll(&input, 10);
|
||||
fake.source[1].readError = 1;
|
||||
C4fInputFrame frame = poll(&input, 20);
|
||||
assert(frame.standard.available && frame.standard.buttons == C4F_INPUT_BUTTON_CROSS);
|
||||
assert(!frame.remote.available && !frame.remote.buttons && !frame.remote.pressed);
|
||||
assert(fake.source[1].closes == 1);
|
||||
fake.source[1].readError = 0;
|
||||
poll(&input, 1019);
|
||||
assert(fake.source[1].opens == 1);
|
||||
frame = poll(&input, 1020);
|
||||
assert(fake.source[1].opens == 2 && frame.remote.available && !frame.remote.pressed);
|
||||
fake.source[1].buttons = 0;
|
||||
poll(&input, 1030);
|
||||
fake.source[1].buttons = C4F_INPUT_BUTTON_RIGHT;
|
||||
assert(poll(&input, 1040).remote.pressed == C4F_INPUT_BUTTON_RIGHT);
|
||||
fake.source[0].connected = 0;
|
||||
frame = poll(&input, 1050);
|
||||
assert(!frame.standard.available && !frame.standard.buttons && !frame.standard.pressed);
|
||||
assert(frame.remote.available && frame.remote.buttons == C4F_INPUT_BUTTON_RIGHT);
|
||||
assert(fake.source[0].closes == 1);
|
||||
fake.source[0].connected = 1;
|
||||
poll(&input, 2049);
|
||||
assert(fake.source[0].opens == 1);
|
||||
frame = poll(&input, 2050);
|
||||
assert(frame.standard.available && !frame.standard.pressed && fake.source[0].opens == 2);
|
||||
fake.source[0].buttons = 0;
|
||||
poll(&input, 2060);
|
||||
fake.source[0].buttons = C4F_INPUT_BUTTON_CROSS;
|
||||
assert(poll(&input, 2070).standard.pressed == C4F_INPUT_BUTTON_CROSS);
|
||||
c4fInputReset(&input, 2080);
|
||||
frame = poll(&input, 2080);
|
||||
assert(!frame.standard.pressed && !frame.remote.pressed);
|
||||
fake.source[0].buttons = fake.source[1].buttons = 0;
|
||||
poll(&input, 4000);
|
||||
fake.source[0].buttons = C4F_INPUT_BUTTON_CROSS;
|
||||
assert(poll(&input, 4010).standard.pressed == C4F_INPUT_BUTTON_CROSS);
|
||||
c4fInputClose(&input);
|
||||
assert(fake.source[0].closes == fake.source[0].opens);
|
||||
assert(fake.source[1].closes == fake.source[1].opens);
|
||||
puts("PASS read failures/disconnects clear stale activity, bounded retry and neutral-gated recovery/reset");
|
||||
}
|
||||
|
||||
static void unavailableUserAndBorrowedHandle(void)
|
||||
{
|
||||
FakeInput fake;
|
||||
C4fInput input;
|
||||
reader(&fake, &input);
|
||||
fake.initialResult = -1;
|
||||
C4fInputFrame frame = poll(&input, 0);
|
||||
assert(!frame.standard.available && frame.remote.available);
|
||||
assert(fake.initialCalls == 1 && !fake.source[0].opens);
|
||||
fake.initialResult = 0;
|
||||
fake.user = -1;
|
||||
poll(&input, 1000);
|
||||
assert(fake.initialCalls == 2 && !fake.source[0].opens);
|
||||
fake.user = 42;
|
||||
fake.source[0].owned = 0; /* get-handle result owned by another subsystem */
|
||||
frame = poll(&input, 2000);
|
||||
assert(frame.standard.available && fake.source[0].opens == 1);
|
||||
fake.source[0].readError = 1;
|
||||
frame = poll(&input, 2010);
|
||||
assert(!frame.standard.available && !frame.standard.buttons);
|
||||
assert(!fake.source[0].closes && frame.remote.available);
|
||||
fake.source[0].readError = 0;
|
||||
frame = poll(&input, 3010);
|
||||
assert(frame.standard.available && fake.source[0].opens == 2);
|
||||
c4fInputClose(&input);
|
||||
assert(!fake.source[0].closes && fake.source[1].closes == 1);
|
||||
puts("PASS unavailable/invalid starting user does not block remote, borrowed handles remain open");
|
||||
}
|
||||
|
||||
static C4fLauncherScreen stopped(void)
|
||||
{
|
||||
C4fLauncherScreen screen;
|
||||
memset(&screen, 0, sizeof(screen));
|
||||
screen.autorun = C4F_AUTORUN_ON;
|
||||
return screen;
|
||||
}
|
||||
|
||||
static int navigate(C4fLauncherScreen *screen, uint32_t standard, uint32_t remote, int diagnostic)
|
||||
{
|
||||
uint32_t standardUnhandled = 0, remoteUnhandled = 0;
|
||||
return c4fNavigate(screen, standard, remote, diagnostic, &standardUnhandled, &remoteUnhandled);
|
||||
}
|
||||
|
||||
static int remoteFrame(FakeInput *fake, C4fInput *input, C4fLauncherScreen *screen,
|
||||
uint64_t now, uint32_t buttons)
|
||||
{
|
||||
fake->source[1].buttons = buttons;
|
||||
C4fInputFrame frame = poll(input, now);
|
||||
return navigate(screen, frame.standard.pressed, frame.remote.pressed, 0);
|
||||
}
|
||||
|
||||
static int remoteKeyFrame(FakeInput *fake, C4fInput *input, C4fLauncherScreen *screen,
|
||||
uint64_t now, uint8_t keyCode, int diagnostic)
|
||||
{
|
||||
/* The native reader supplies this normalized snapshot only for the remote
|
||||
* port. Keep the fixture's standard controller snapshots unchanged. */
|
||||
fake->source[C4F_INPUT_REMOTE].buttons = c4fInputRemoteButtons(0, keyCode);
|
||||
C4fInputFrame frame = poll(input, now);
|
||||
return navigate(screen, frame.standard.pressed, frame.remote.pressed, diagnostic);
|
||||
}
|
||||
|
||||
static void extendedRemoteNavigation(int diagnostic)
|
||||
{
|
||||
FakeInput fake;
|
||||
C4fInput input;
|
||||
reader(&fake, &input);
|
||||
fake.source[C4F_INPUT_STANDARD].openError = 1;
|
||||
C4fLauncherScreen screen = stopped();
|
||||
assert(remoteKeyFrame(&fake, &input, &screen, 0, 13, diagnostic) == C4F_ACTION_NONE);
|
||||
assert(!screen.actionMenu);
|
||||
assert(remoteKeyFrame(&fake, &input, &screen, 10, 13, diagnostic) == C4F_ACTION_NONE);
|
||||
assert(!screen.actionMenu);
|
||||
remoteKeyFrame(&fake, &input, &screen, 20, 0, diagnostic);
|
||||
assert(remoteKeyFrame(&fake, &input, &screen, 30, 13, diagnostic) == C4F_ACTION_NONE);
|
||||
assert(screen.actionMenu && screen.actionFocus == C4F_ACTION_START);
|
||||
assert(remoteKeyFrame(&fake, &input, &screen, 40, 13, diagnostic) == C4F_ACTION_NONE);
|
||||
assert(screen.actionMenu);
|
||||
remoteKeyFrame(&fake, &input, &screen, 50, 0, diagnostic);
|
||||
assert(remoteKeyFrame(&fake, &input, &screen, 60, 13, diagnostic) == C4F_ACTION_START);
|
||||
assert(!screen.actionMenu);
|
||||
assert(remoteKeyFrame(&fake, &input, &screen, 70, 13, diagnostic) == C4F_ACTION_NONE);
|
||||
remoteKeyFrame(&fake, &input, &screen, 80, 0, diagnostic);
|
||||
remoteKeyFrame(&fake, &input, &screen, 90, 13, diagnostic);
|
||||
remoteKeyFrame(&fake, &input, &screen, 100, 0, diagnostic);
|
||||
assert(remoteKeyFrame(&fake, &input, &screen, 110, 15, diagnostic) == C4F_ACTION_RESUME);
|
||||
assert(!screen.actionMenu);
|
||||
assert(remoteKeyFrame(&fake, &input, &screen, 120, 15, diagnostic) == C4F_ACTION_NONE);
|
||||
remoteKeyFrame(&fake, &input, &screen, 130, 0, diagnostic);
|
||||
assert(remoteKeyFrame(&fake, &input, &screen, 140, 18, diagnostic) == C4F_ACTION_NONE);
|
||||
assert(screen.actionMenu);
|
||||
assert(remoteKeyFrame(&fake, &input, &screen, 150, 18, diagnostic) == C4F_ACTION_NONE);
|
||||
assert(screen.actionMenu);
|
||||
remoteKeyFrame(&fake, &input, &screen, 160, 0, diagnostic);
|
||||
assert(remoteKeyFrame(&fake, &input, &screen, 170, 15, diagnostic) == C4F_ACTION_RESUME);
|
||||
|
||||
fake.source[C4F_INPUT_REMOTE].connected = 0;
|
||||
assert(remoteKeyFrame(&fake, &input, &screen, 180, 13, diagnostic) == C4F_ACTION_NONE);
|
||||
assert(!screen.actionMenu && fake.source[C4F_INPUT_REMOTE].closes == 1);
|
||||
fake.source[C4F_INPUT_REMOTE].connected = 1;
|
||||
assert(remoteKeyFrame(&fake, &input, &screen, 1179, 13, diagnostic) == C4F_ACTION_NONE);
|
||||
assert(fake.source[C4F_INPUT_REMOTE].opens == 1);
|
||||
assert(remoteKeyFrame(&fake, &input, &screen, 1180, 13, diagnostic) == C4F_ACTION_NONE);
|
||||
assert(fake.source[C4F_INPUT_REMOTE].opens == 2 && !screen.actionMenu);
|
||||
assert(remoteKeyFrame(&fake, &input, &screen, 1181, 13, diagnostic) == C4F_ACTION_NONE);
|
||||
remoteKeyFrame(&fake, &input, &screen, 1190, 0, diagnostic);
|
||||
assert(remoteKeyFrame(&fake, &input, &screen, 1200, 13, diagnostic) == C4F_ACTION_NONE);
|
||||
assert(screen.actionMenu);
|
||||
remoteKeyFrame(&fake, &input, &screen, 1210, 0, diagnostic);
|
||||
assert(remoteKeyFrame(&fake, &input, &screen, 1220, 15, diagnostic) == C4F_ACTION_RESUME);
|
||||
assert(!screen.actionMenu);
|
||||
c4fInputClose(&input);
|
||||
assert(fake.source[C4F_INPUT_REMOTE].closes == 2);
|
||||
puts(diagnostic ? "PASS extended remote OK/Back/Options, held-key safety and reconnect in diagnostics"
|
||||
: "PASS extended remote OK/Back/Options, held-key safety and reconnect in the launcher");
|
||||
}
|
||||
|
||||
static void heldRemoteConfirmation(void)
|
||||
{
|
||||
FakeInput fake;
|
||||
C4fInput input;
|
||||
reader(&fake, &input);
|
||||
fake.source[0].openError = 1;
|
||||
C4fLauncherScreen screen = stopped();
|
||||
screen.running = 1;
|
||||
assert(remoteFrame(&fake, &input, &screen, 0, 0) == C4F_ACTION_NONE);
|
||||
assert(remoteFrame(&fake, &input, &screen, 10, C4F_INPUT_BUTTON_CROSS) == C4F_ACTION_NONE);
|
||||
assert(screen.actionMenu);
|
||||
assert(remoteFrame(&fake, &input, &screen, 20, C4F_INPUT_BUTTON_CROSS) == C4F_ACTION_NONE);
|
||||
assert(screen.actionMenu);
|
||||
remoteFrame(&fake, &input, &screen, 30, 0);
|
||||
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);
|
||||
assert(screen.confirmStop && !screen.confirmChoice);
|
||||
assert(remoteFrame(&fake, &input, &screen, 90, C4F_INPUT_BUTTON_CROSS) == C4F_ACTION_NONE);
|
||||
assert(screen.confirmStop && !screen.confirmChoice);
|
||||
remoteFrame(&fake, &input, &screen, 100, 0);
|
||||
remoteFrame(&fake, &input, &screen, 110, C4F_INPUT_BUTTON_RIGHT);
|
||||
assert(screen.confirmChoice);
|
||||
remoteFrame(&fake, &input, &screen, 120, 0);
|
||||
assert(remoteFrame(&fake, &input, &screen, 130, C4F_INPUT_BUTTON_CROSS) == C4F_ACTION_STOP);
|
||||
assert(remoteFrame(&fake, &input, &screen, 140, C4F_INPUT_BUTTON_CROSS) == C4F_ACTION_NONE);
|
||||
c4fInputClose(&input);
|
||||
puts("PASS combined native-reader/navigation flow never selects or confirms Stop from held OK");
|
||||
}
|
||||
|
||||
static void menuActions(void)
|
||||
{
|
||||
C4fLauncherScreen screen = stopped();
|
||||
assert(navigate(&screen, 0, C4F_INPUT_BUTTON_CROSS, 0) == C4F_ACTION_NONE);
|
||||
assert(screen.actionMenu && screen.actionFocus == C4F_ACTION_START);
|
||||
assert(navigate(&screen, 0, 0, 0) == C4F_ACTION_NONE && screen.actionMenu);
|
||||
assert(navigate(&screen, 0, C4F_INPUT_BUTTON_CROSS, 0) == C4F_ACTION_START);
|
||||
assert(!screen.actionMenu);
|
||||
|
||||
assert(navigate(&screen, 0, C4F_INPUT_BUTTON_CROSS, 0) == C4F_ACTION_NONE);
|
||||
assert(navigate(&screen, 0, C4F_INPUT_BUTTON_DOWN, 0) == C4F_ACTION_NONE);
|
||||
assert(screen.actionFocus == C4F_ACTION_AUTORUN);
|
||||
assert(navigate(&screen, 0, C4F_INPUT_BUTTON_CROSS, 0) == C4F_ACTION_AUTORUN);
|
||||
assert(!screen.actionMenu);
|
||||
assert(navigate(&screen, 0, C4F_INPUT_BUTTON_CROSS, 0) == C4F_ACTION_NONE);
|
||||
assert(navigate(&screen, 0, C4F_INPUT_BUTTON_UP, 0) == C4F_ACTION_NONE);
|
||||
assert(screen.actionFocus == C4F_ACTION_RESUME);
|
||||
assert(navigate(&screen, 0, C4F_INPUT_BUTTON_CROSS, 0) == C4F_ACTION_RESUME);
|
||||
assert(!screen.actionMenu);
|
||||
|
||||
screen.running = 1;
|
||||
assert(navigate(&screen, 0, C4F_INPUT_BUTTON_CROSS, 0) == C4F_ACTION_NONE);
|
||||
assert(screen.actionMenu && screen.actionFocus == C4F_ACTION_RESUME);
|
||||
assert(navigate(&screen, 0, C4F_INPUT_BUTTON_UP, 0) == C4F_ACTION_NONE);
|
||||
assert(screen.actionFocus == C4F_ACTION_CLOSE);
|
||||
assert(navigate(&screen, 0, C4F_INPUT_BUTTON_CROSS, 0) == C4F_ACTION_CLOSE);
|
||||
assert(!screen.actionMenu);
|
||||
assert(navigate(&screen, 0, C4F_INPUT_BUTTON_CIRCLE, 0) == C4F_ACTION_CLOSE);
|
||||
screen = stopped();
|
||||
navigate(&screen, 0, C4F_INPUT_BUTTON_CROSS, 0);
|
||||
assert(navigate(&screen, 0, C4F_INPUT_BUTTON_CIRCLE, 0) == C4F_ACTION_RESUME);
|
||||
assert(!screen.actionMenu);
|
||||
puts("PASS remote arrows/OK reach Start, Auto-start, Resume and Close; opening OK is consumed");
|
||||
}
|
||||
|
||||
static void stopConfirmation(void)
|
||||
{
|
||||
C4fLauncherScreen screen = stopped();
|
||||
screen.running = 1;
|
||||
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);
|
||||
assert(navigate(&screen, 0, 0, 0) == C4F_ACTION_NONE && screen.confirmStop);
|
||||
assert(navigate(&screen, 0, C4F_INPUT_BUTTON_CROSS, 0) == C4F_ACTION_RESUME);
|
||||
assert(!screen.confirmStop);
|
||||
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);
|
||||
assert(navigate(&screen, 0, C4F_INPUT_BUTTON_CROSS, 0) == C4F_ACTION_STOP);
|
||||
assert(!screen.confirmStop);
|
||||
|
||||
/* Existing DS4 shortcuts keep their separate confirmation flow. */
|
||||
assert(navigate(&screen, C4F_INPUT_BUTTON_SQUARE, 0, 0) == C4F_ACTION_NONE);
|
||||
assert(screen.confirmStop && !screen.confirmMenu);
|
||||
assert(navigate(&screen, C4F_INPUT_BUTTON_CROSS, 0, 0) == C4F_ACTION_STOP);
|
||||
assert(!screen.confirmStop);
|
||||
navigate(&screen, C4F_INPUT_BUTTON_SQUARE, 0, 0);
|
||||
assert(navigate(&screen, C4F_INPUT_BUTTON_CIRCLE, 0, 0) == C4F_ACTION_RESUME);
|
||||
assert(!screen.confirmStop);
|
||||
navigate(&screen, C4F_INPUT_BUTTON_SQUARE, 0, 0);
|
||||
assert(navigate(&screen, 0, C4F_INPUT_BUTTON_CROSS, 0) == C4F_ACTION_NONE);
|
||||
assert(screen.confirmStop && screen.confirmMenu && !screen.confirmChoice);
|
||||
assert(navigate(&screen, 0, C4F_INPUT_BUTTON_CROSS, 0) == C4F_ACTION_RESUME);
|
||||
assert(!screen.confirmStop);
|
||||
navigate(&screen, C4F_INPUT_BUTTON_SQUARE, 0, 0);
|
||||
assert(navigate(&screen, C4F_INPUT_BUTTON_CROSS, C4F_INPUT_BUTTON_CROSS, 0) == C4F_ACTION_NONE);
|
||||
assert(screen.confirmStop && screen.confirmMenu && !screen.confirmChoice);
|
||||
assert(navigate(&screen, C4F_INPUT_BUTTON_CROSS, 0, 0) == C4F_ACTION_RESUME);
|
||||
navigate(&screen, C4F_INPUT_BUTTON_SQUARE, 0, 0);
|
||||
assert(navigate(&screen, C4F_INPUT_BUTTON_LEFT, 0, 0) == C4F_ACTION_NONE);
|
||||
assert(screen.confirmStop && screen.confirmMenu && !screen.confirmChoice);
|
||||
assert(navigate(&screen, C4F_INPUT_BUTTON_CROSS, 0, 0) == C4F_ACTION_RESUME);
|
||||
puts("PASS remote Stop requires a separate fresh confirmation, defaults Cancel and preserves DS4 shortcuts");
|
||||
}
|
||||
|
||||
static void unavailableActions(void)
|
||||
{
|
||||
C4fLauncherScreen screen = stopped();
|
||||
screen.autorun = C4F_AUTORUN_UNKNOWN;
|
||||
assert(!c4fActionAvailable(&screen, C4F_ACTION_AUTORUN, 0));
|
||||
assert(navigate(&screen, C4F_INPUT_BUTTON_TRIANGLE, 0, 0) == C4F_ACTION_NONE);
|
||||
screen.locked = 1;
|
||||
assert(!c4fActionAvailable(&screen, C4F_ACTION_START, 0));
|
||||
assert(navigate(&screen, C4F_INPUT_BUTTON_CROSS, 0, 0) == C4F_ACTION_NONE);
|
||||
assert(navigate(&screen, C4F_INPUT_BUTTON_CIRCLE, 0, 0) == C4F_ACTION_CLOSE);
|
||||
screen.busy = 1;
|
||||
assert(navigate(&screen, C4F_INPUT_BUTTON_CROSS | C4F_INPUT_BUTTON_TRIANGLE | C4F_INPUT_BUTTON_SQUARE,
|
||||
C4F_INPUT_BUTTON_CROSS, 0) == C4F_ACTION_NONE);
|
||||
assert(screen.actionMenu && screen.actionFocus == C4F_ACTION_RESUME);
|
||||
assert(navigate(&screen, C4F_INPUT_BUTTON_CIRCLE, C4F_INPUT_BUTTON_CIRCLE, 0) == C4F_ACTION_RESUME);
|
||||
assert(navigate(&screen, C4F_INPUT_BUTTON_CIRCLE, C4F_INPUT_BUTTON_CIRCLE, 0) == C4F_ACTION_NONE);
|
||||
screen = stopped();
|
||||
navigate(&screen, 0, C4F_INPUT_BUTTON_CROSS, 0);
|
||||
assert(screen.actionFocus == C4F_ACTION_START);
|
||||
screen.running = 1; /* a status refresh while the user is choosing */
|
||||
assert(navigate(&screen, 0, C4F_INPUT_BUTTON_CROSS, 0) == C4F_ACTION_NONE);
|
||||
assert(screen.actionMenu && screen.actionFocus != C4F_ACTION_START);
|
||||
/* A direction and OK arriving together must only move the focus. */
|
||||
screen = stopped();
|
||||
navigate(&screen, 0, C4F_INPUT_BUTTON_CROSS, 0);
|
||||
assert(navigate(&screen, 0, C4F_INPUT_BUTTON_DOWN | C4F_INPUT_BUTTON_CROSS, 0) == C4F_ACTION_NONE);
|
||||
assert(screen.actionMenu && screen.actionFocus == C4F_ACTION_AUTORUN);
|
||||
assert(navigate(&screen, C4F_INPUT_BUTTON_UP, C4F_INPUT_BUTTON_DOWN | C4F_INPUT_BUTTON_CROSS, 0)
|
||||
== C4F_ACTION_NONE);
|
||||
assert(screen.actionMenu && screen.actionFocus == C4F_ACTION_AUTORUN);
|
||||
assert(navigate(&screen, 0, C4F_INPUT_BUTTON_UP | C4F_INPUT_BUTTON_DOWN | C4F_INPUT_BUTTON_CROSS, 0)
|
||||
== C4F_ACTION_NONE);
|
||||
assert(screen.actionMenu && screen.actionFocus == C4F_ACTION_AUTORUN);
|
||||
screen.running = 1;
|
||||
screen.actionMenu = 0;
|
||||
screen.confirmStop = screen.confirmMenu = screen.confirmChoice = 1;
|
||||
assert(navigate(&screen, C4F_INPUT_BUTTON_UP, C4F_INPUT_BUTTON_DOWN | C4F_INPUT_BUTTON_CROSS, 0)
|
||||
== C4F_ACTION_NONE);
|
||||
assert(screen.confirmStop && screen.confirmChoice);
|
||||
puts("PASS unavailable/busy actions, status-change focus repair and simultaneous direction/OK safety");
|
||||
}
|
||||
|
||||
static void diagnosticNavigation(void)
|
||||
{
|
||||
C4fLauncherScreen screen = stopped();
|
||||
uint32_t standardUnhandled, remoteUnhandled;
|
||||
uint32_t directions = C4F_INPUT_BUTTON_UP | C4F_INPUT_BUTTON_RIGHT;
|
||||
assert(c4fNavigate(&screen, directions, directions, 1, &standardUnhandled, &remoteUnhandled) == C4F_ACTION_NONE);
|
||||
assert(standardUnhandled == directions && remoteUnhandled == directions);
|
||||
assert(!screen.actionMenu);
|
||||
assert(c4fNavigate(&screen, C4F_INPUT_BUTTON_OPTIONS, 0, 1,
|
||||
&standardUnhandled, &remoteUnhandled) == C4F_ACTION_NONE);
|
||||
assert(standardUnhandled == C4F_INPUT_BUTTON_OPTIONS && !remoteUnhandled);
|
||||
assert(!c4fActionAvailable(&screen, C4F_ACTION_CAPTURE, 0));
|
||||
assert(c4fActionAvailable(&screen, C4F_ACTION_CAPTURE, 1));
|
||||
navigate(&screen, 0, C4F_INPUT_BUTTON_CROSS, 1);
|
||||
/* The action list defines display order; navigating through every row
|
||||
* ensures capture is reachable using only the TV remote. */
|
||||
C4fLauncherAction actions[8];
|
||||
int count = c4fActionList(&screen, 1, actions, 8), reached = 0;
|
||||
assert(count > 0 && count <= 8);
|
||||
for (int i = 0; i <= count; i++) {
|
||||
if (screen.actionFocus == C4F_ACTION_CAPTURE) { reached = 1; break; }
|
||||
assert(c4fNavigate(&screen, 0, C4F_INPUT_BUTTON_DOWN, 1,
|
||||
&standardUnhandled, &remoteUnhandled) == C4F_ACTION_NONE);
|
||||
assert(!remoteUnhandled);
|
||||
}
|
||||
assert(reached);
|
||||
assert(navigate(&screen, 0, C4F_INPUT_BUTTON_CROSS, 1) == C4F_ACTION_CAPTURE);
|
||||
assert(!screen.actionMenu);
|
||||
puts("PASS diagnostic arrows remain page/scroll inputs outside Actions; remote can choose Capture");
|
||||
}
|
||||
|
||||
int main(void)
|
||||
{
|
||||
remoteKeyNormalization();
|
||||
remoteOnly();
|
||||
sourceEdges();
|
||||
remoteOpenRetry();
|
||||
readerRecovery();
|
||||
unavailableUserAndBorrowedHandle();
|
||||
menuActions();
|
||||
extendedRemoteNavigation(0);
|
||||
extendedRemoteNavigation(1);
|
||||
heldRemoteConfirmation();
|
||||
stopConfirmation();
|
||||
unavailableActions();
|
||||
diagnosticNavigation();
|
||||
return 0;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
+24
-1
@@ -47,6 +47,20 @@ def one_reply(address, reply, check=None):
|
||||
|
||||
def main():
|
||||
web.build()
|
||||
# The launcher's own input reader and action navigation, with only the
|
||||
# native PS4 pad calls replaced. This also exercises a remote-only launch.
|
||||
for diagnostic in (False, True):
|
||||
binary = 'build/launcher-input-diag-host-test' if diagnostic else 'build/launcher-input-host-test'
|
||||
subprocess.run(['clang-18', '-std=gnu11', '-D_DEFAULT_SOURCE', '-Wall', '-Wextra', '-Werror',
|
||||
*(['-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',
|
||||
@@ -177,12 +191,21 @@ def main():
|
||||
sys.path.insert(0, str(ROOT / 'tools'))
|
||||
import package_launcher as package
|
||||
package.build_art_tool()
|
||||
for state in ('setup', 'running', 'stopped', 'outdated', 'mismatch', 'confirm', 'busy', 'locked', 'unknown'):
|
||||
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',
|
||||
'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
|
||||
package.build_art_tool(diagnostic=True)
|
||||
for state in ('remote-actions-diag', 'remote-confirm-diag', 'remote-input-diag', 'payloader-unavailable-diag'):
|
||||
png = ROOT / f'build/launcher-preview-{state}.png'
|
||||
package.render(['screen', state], png, 1920, 1080, diagnostic=True)
|
||||
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'
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+94
-6
@@ -1,13 +1,19 @@
|
||||
/* Renders launcher artwork on the build machine with the launcher's own drawing
|
||||
* code: the package icon, and screen states for review. Writes raw RGBA.
|
||||
* launcher_art icon <size> <out.rgba>
|
||||
* launcher_art screen <setup|running|stopped|outdated|confirm|busy|locked|unknown> <out.rgba> */
|
||||
* launcher_art screen <state> <out.rgba>
|
||||
* Includes TV-remote action and confirmation states. */
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include "autorun.h"
|
||||
#include "logo.h"
|
||||
#include "screen.h"
|
||||
#ifdef C4F_DIAG
|
||||
#include "diag.h"
|
||||
/* Host preview fixture; the native application does not link it. */
|
||||
void c4fDiagPreviewPage(int page, const char *text);
|
||||
#endif
|
||||
|
||||
static int c4fWrite(const char *path, const uint32_t *pixels, size_t count)
|
||||
{
|
||||
@@ -37,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();
|
||||
@@ -54,18 +104,27 @@ 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");
|
||||
if (!strcmp(state, "outdated")) s.autorun = C4F_AUTORUN_OUTDATED;
|
||||
if (!strcmp(state, "mismatch")) snprintf(s.runningVersion, sizeof(s.runningVersion), "older build");
|
||||
message = "Ready. Open the address on your phone or PC.";
|
||||
} else if (!strcmp(state, "setup")) {
|
||||
s.autorun = C4F_AUTORUN_OFF; message = "Not running.";
|
||||
} else if (!strcmp(state, "stopped")) {
|
||||
message = "Stopped. Press Cross to start it again.";
|
||||
} else if (!strcmp(state, "busy")) {
|
||||
} else if (!strcmp(state, "stopped") || !strcmp(state, "remote-actions-stopped") ||
|
||||
!strcmp(state, "remote-actions-diag") || !strcmp(state, "remote-input-diag")) {
|
||||
message = "Stopped. Ready to start again.";
|
||||
} else if (!strcmp(state, "payloader-unavailable") || !strcmp(state, "payloader-unavailable-diag")) {
|
||||
message = "Auto-start is on. PayLoader did not answer (errno 61). Enable PayLoader Server in GoldHEN > Server Settings, then retry Start.";
|
||||
} else if (!strcmp(state, "busy") || !strcmp(state, "remote-actions-busy")) {
|
||||
s.running = -1; s.busy = 1; s.autorun = C4F_AUTORUN_UNKNOWN; message = "Setting up Control4Free. Please wait...";
|
||||
} else if (!strcmp(state, "locked")) {
|
||||
} else if (!strcmp(state, "locked") || !strcmp(state, "remote-actions-unavailable")) {
|
||||
s.running = -1; s.locked = 1;
|
||||
message = "Could not initialize launcher. Close with PS and retry.";
|
||||
} else if (!strcmp(state, "unknown")) {
|
||||
@@ -75,9 +134,38 @@ int main(int argc, char **argv)
|
||||
} else {
|
||||
return 2;
|
||||
}
|
||||
if (!strncmp(state, "remote-actions-", 15)) {
|
||||
s.actionMenu = 1;
|
||||
s.actionFocus = !strcmp(state, "remote-actions-running") ? C4F_ACTION_STOP :
|
||||
!strcmp(state, "remote-actions-stopped") ? C4F_ACTION_START :
|
||||
!strcmp(state, "remote-actions-diag") ? C4F_ACTION_CAPTURE : C4F_ACTION_RESUME;
|
||||
} else if (!strncmp(state, "remote-confirm-", 15)) {
|
||||
s.confirmStop = s.confirmMenu = 1;
|
||||
s.confirmChoice = !strcmp(state, "remote-confirm-stop");
|
||||
}
|
||||
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),
|
||||
"DS4: disconnected (0x00000000); TV remote: ready (0x00000000), last key 0x0d / raw 0x00000000");
|
||||
char summary[768];
|
||||
snprintf(summary, sizeof(summary),
|
||||
">> Example input data for layout review.\n\nConsole\n"
|
||||
" system software: example\n\nThis app\n version %s\n"
|
||||
" input: %s\n\nService (port 4264)\n not running\n\n"
|
||||
"Auto-start\n state: on\n\nLast start from this app\n message: %s\n",
|
||||
C4F_LAUNCHER_VERSION, s.inputNote, s.message);
|
||||
c4fDiagPreviewPage(0, summary);
|
||||
}
|
||||
c4fDrawDiag(pixels, &s);
|
||||
#else
|
||||
c4fDrawLauncher(pixels, &s);
|
||||
#endif
|
||||
return c4fWrite(argv[3], pixels, (size_t)C4F_SCREEN_WIDTH * C4F_SCREEN_HEIGHT);
|
||||
}
|
||||
@@ -31,17 +31,31 @@ def embed_fonts():
|
||||
f'c4fFontLight={sources[0]}', f'c4fFontRegular={sources[1]}'], check=True)
|
||||
|
||||
|
||||
def build_art_tool():
|
||||
def build_art_tool(diagnostic=False):
|
||||
"""Host build of the launcher's drawing code, for the icon and previews."""
|
||||
embed_fonts()
|
||||
art_version = VERSION + ('-diag' if diagnostic else '')
|
||||
flags = ['clang-18', '-std=gnu11', '-O2', '-D_DEFAULT_SOURCE', f'-I{ROOT}/launcher',
|
||||
f'-I{ROOT}/vendor/stb', f'-I{ROOT}/vendor/qrcodegen',
|
||||
f'-DC4F_LAUNCHER_VERSION="{VERSION}"']
|
||||
f'-DC4F_LAUNCHER_VERSION="{art_version}"']
|
||||
extra_sources = []
|
||||
extra_flags = []
|
||||
art = ART
|
||||
if diagnostic:
|
||||
# Preview only: discard native startup/storage paths and retain the real
|
||||
# diagnostic drawing function. Existing host declarations provide types;
|
||||
# no console APIs or diagnostic file operations are called by this tool.
|
||||
flags += ['-DC4F_DIAG', '-ffunction-sections', '-fdata-sections', f'-I{ROOT}/tests/include']
|
||||
extra_sources = [str(ROOT / 'tests/diag_preview.c')]
|
||||
extra_flags = ['-Wl,--gc-sections', '-pthread']
|
||||
art = ART.with_name(ART.name + '-diag')
|
||||
stb = OUT / 'stb_truetype-host.o'
|
||||
subprocess.run([*flags, '-w', '-c', str(ROOT / 'launcher/stb_truetype.c'), '-o', str(stb)], check=True)
|
||||
subprocess.run([*flags, '-Wall', '-Wextra', '-Werror', str(ROOT / 'tools/launcher_art.c'),
|
||||
str(ROOT / 'launcher/screen.c'), str(ROOT / 'launcher/draw.c'), str(ROOT / 'launcher/logo.c'),
|
||||
str(ROOT / 'vendor/qrcodegen/qrcodegen.c'), str(FONTS), str(stb), '-lm', '-o', str(ART)],
|
||||
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)
|
||||
|
||||
|
||||
@@ -86,10 +100,11 @@ def write_png(path, width, height, rgba, alpha=False):
|
||||
chunk(b'IDAT', zlib.compress(b''.join(rows), 9)) + chunk(b'IEND', b''))
|
||||
|
||||
|
||||
def render(args, path, width, height):
|
||||
def render(args, path, width, height, diagnostic=False):
|
||||
"""Draw with the art tool (build_art_tool first) and save a PNG."""
|
||||
raw = OUT / 'art.rgba'
|
||||
subprocess.run([str(ART), *args, str(raw)], check=True)
|
||||
raw = OUT / ('art-diag.rgba' if diagnostic else 'art.rgba')
|
||||
art = ART.with_name(ART.name + '-diag') if diagnostic else ART
|
||||
subprocess.run([str(art), *args, str(raw)], check=True)
|
||||
write_png(path, width, height, raw.read_bytes())
|
||||
raw.unlink()
|
||||
|
||||
|
||||
Reference in new issue
Block a user