Files
2026-10-12 01:39:58 -07:00

712 lines
28 KiB
JavaScript
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// CheatSpecter dev bridge.
//
// Browsers cannot open raw TCP sockets, and the PS5 debugger only speaks raw
// TCP. This small server sits between them on a computer: it serves the web
// app and relays a WebSocket to the debugger's TCP port, byte for byte.
//
// The on-console payload will replace this with the same three things:
// GET / the web app
// GET /api/info { mode, version }
// WS /ws binary frames <-> debugger TCP stream
// WS /events debugger stop reports (the console connects back to us
// on TCP 755 when a debugger attaches; those bytes are
// forwarded here)
//
// Extras that only make sense on a computer: LAN discovery, a local cheats
// folder, and a built-in demo console (--demo).
//
// node bridge/server.mjs [--demo] [--open] [--port 8744]
import http from 'node:http';
import net from 'node:net';
import dgram from 'node:dgram';
import os from 'node:os';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { spawn } from 'node:child_process';
import { WebSocketServer } from 'ws';
import { DEMO_TITLES, demoLibraryResponse } from './demo-library.mjs';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const DIST = path.join(ROOT, 'dist');
const CHEATS_DIR = path.join(ROOT, 'cheats');
const LEARN_DIR = path.join(ROOT, 'learn');
const TABLE_MAX = 4 << 20;
const PREF_MAX = 64 << 10;
const VERSION = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8')).version;
const DEBUGGER_PORT = 744;
const DISCOVERY_PORT = 1010;
const EVENT_PORT = 755;
/** Where the CheatSpecter payload on the console answers. */
const DEFAULT_PAYLOAD_PORT = 8744;
const DISCOVERY_MAGIC = Buffer.from([0xaa, 0xaa, 0xff, 0xff]); // 0xFFFFAAAA little-endian
const MIME = {
'.html': 'text/html; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.svg': 'image/svg+xml',
'.json': 'application/json; charset=utf-8',
'.png': 'image/png',
'.ico': 'image/x-icon',
'.webmanifest': 'application/manifest+json',
'.wasm': 'application/wasm',
};
// ---- which addresses the bridge will relay to --------------------------------
/**
* The bridge only relays to consoles on the local network. Without this it
* would be an open proxy for anything that can reach it.
*/
export function isPrivateAddress(host) {
if (net.isIPv4(host)) {
const [a, b] = host.split('.').map(Number);
return a === 10 || a === 127 || (a === 192 && b === 168) || (a === 172 && b >= 16 && b <= 31) || (a === 169 && b === 254);
}
return false;
}
/**
* A page from another site must not be able to drive the bridge, so the
* WebSocket is only accepted from the app this server itself served.
*/
function sameOrigin(req) {
const origin = req.headers.origin;
if (!origin) return true; // not a browser (tests, scripts on this machine)
try {
return new URL(origin).host === req.headers.host;
} catch {
return false;
}
}
// ---- discovery ---------------------------------------------------------------
function broadcastAddresses() {
const out = new Set(['255.255.255.255']);
for (const list of Object.values(os.networkInterfaces())) {
for (const i of list ?? []) {
if (i.family !== 'IPv4' || i.internal) continue;
const ip = i.address.split('.').map(Number);
const mask = i.netmask.split('.').map(Number);
out.add(ip.map((octet, n) => (octet | (~mask[n] & 0xff)) & 0xff).join('.'));
}
}
return [...out];
}
/** Asks the LAN "is there a debugger here?" and collects whoever echoes. */
function discover(extraProbes, waitMs = 900) {
return new Promise((resolve) => {
const found = new Map();
const sock = dgram.createSocket({ type: 'udp4', reuseAddr: true });
const finish = () => {
try {
sock.close();
} catch {
// already closed
}
resolve([...found.values()]);
};
sock.on('error', finish);
sock.on('message', (msg, rinfo) => {
if (msg.length !== 4 || !msg.equals(DISCOVERY_MAGIC)) return;
const probe = extraProbes.find((p) => p.udpPort === rinfo.port && p.host === rinfo.address);
if (probe) found.set(`${probe.host}:${probe.port}`, { host: probe.host, port: probe.port, label: probe.label });
else if (rinfo.port === DISCOVERY_PORT) found.set(rinfo.address, { host: rinfo.address, port: DEBUGGER_PORT, label: 'PS5' });
});
sock.bind(0, () => {
try {
sock.setBroadcast(true);
} catch {
// some platforms refuse; unicast probes still work
}
for (const addr of broadcastAddresses()) sock.send(DISCOVERY_MAGIC, DISCOVERY_PORT, addr, () => {});
for (const p of extraProbes) sock.send(DISCOVERY_MAGIC, p.udpPort, p.host, () => {});
setTimeout(finish, waitMs);
});
});
}
// ---- http --------------------------------------------------------------------
function sendJson(res, code, body) {
const data = JSON.stringify(body);
res.writeHead(code, { 'content-type': MIME['.json'], 'cache-control': 'no-store' });
res.end(data);
}
function serveFile(res, file, cache = 'no-cache') {
fs.readFile(file, (err, data) => {
if (err) {
res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' });
res.end('Not found');
return;
}
res.writeHead(200, {
'content-type': MIME[path.extname(file).toLowerCase()] ?? 'application/octet-stream',
'cache-control': cache,
});
res.end(data);
});
}
/** Resolves a URL path inside a folder, refusing anything that escapes it. */
function safeJoin(base, urlPath) {
let rel;
try {
rel = decodeURIComponent(urlPath);
} catch {
return null;
}
const full = path.resolve(base, '.' + path.posix.normalize('/' + rel));
return full === base || full.startsWith(base + path.sep) ? full : null;
}
// ---- the demo console's stand-in for the cheat engine ---------------------------
//
// The real engine is C code inside the payload (payload/src/engine.c). The demo
// console has no payload, so GET /api/engine is answered here in the same
// shape, from the cheat file the app saved: which cheats are marked to keep,
// and which of those the engine would turn down, by the engine's own rules.
// It does not touch the demo game, and says so in each cheat's detail.
const DEMO_KEPT_DETAIL = 'The demo console does not keep cheats on by itself. On a real PS5 this would be switched on whenever the game runs.';
const DEMO_REFUSALS = {
review: 'This cheat has not been looked at yet. Open CheatSpecter and review it first.',
pageOnly: 'Only CheatSpecter in the browser can place this cheat, so the PS5 cannot keep it on.',
kind: 'Only patches and held values can be kept on by the PS5.',
notHeld: 'Only a value that is held in place can be kept on. Turn on "Hold it at this value" in CheatSpecter first.',
patchAddress: 'This cheat uses a raw memory address, which changes every time the game starts. Give it a lasting location in CheatSpecter first.',
valueAddress: 'This value uses a raw memory address, which changes every time the game starts. Give it a pointer path in CheatSpecter first.',
};
/** The version as it appears in a file name (fileVersion() in src/lib/tablestore.ts). */
const demoFileVersion = (version) => String(version ?? '').replace(/[^0-9A-Za-z.]/g, '').replace(/^\.+/, '').slice(0, 16) || 'unknown';
/** Why the engine would not run an entry marked to keep, or null when it would. */
function demoRefusal(e) {
if (e.reviewed === false) return DEMO_REFUSALS.review;
if (e.pageOnly === true) return DEMO_REFUSALS.pageOnly;
if (e.type !== 'patch' && e.type !== 'value') return DEMO_REFUSALS.kind;
if (e.type === 'value' && e.freeze !== true) return DEMO_REFUSALS.notHeld;
const places = e.type === 'patch' && Array.isArray(e.more) ? [e, ...e.more] : [e];
if (places.some((p) => p?.at?.kind === 'address')) return e.type === 'patch' ? DEMO_REFUSALS.patchAddress : DEMO_REFUSALS.valueAddress;
return null;
}
/**
* @param {{titleId:string, version:string, pid:number}|null} game the game the demo console is running
* @param {Map<string, Buffer>} tables saved cheat files, keyed "<title id>-<version>"
*/
export function demoEngineStatus(game, tables) {
if (!game) return { running: true, game: null, file: '', cheats: [] };
const status = { running: true, game: { titleId: game.titleId, version: game.version, pid: game.pid }, file: '', cheats: [] };
// This version's own file, else the highest-versioned one for the game.
const own = `${game.titleId}-${demoFileVersion(game.version)}`;
const others = [...tables.keys()].filter((k) => k.startsWith(`${game.titleId}-`)).sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
const key = tables.has(own) ? own : others[others.length - 1];
if (!key) return status;
status.file = `${key}.cspector`;
let doc = null;
try {
doc = JSON.parse(tables.get(key).toString('utf8').replace(/^\uFEFF/, ''));
} catch {
// reported below
}
const legacy = doc?.format === 'cheatspecter/1';
const list = legacy ? doc.entries : doc?.format === 'cheatspecter' ? doc.cheats : null;
const says = (legacy ? doc : doc?.game) ?? {};
status.exact =
key === own &&
(!says.titleId || String(says.titleId).toUpperCase() === game.titleId) &&
(!says.version || demoFileVersion(says.version) === demoFileVersion(game.version));
if (!Array.isArray(list)) {
status.error = 'The cheat file for this game is damaged (it is not valid JSON), so no cheats were switched on. Save your cheats again from CheatSpecter.';
return status;
}
for (const e of list) {
if (!e || typeof e !== 'object' || typeof e.name !== 'string' || e.keep !== true) continue;
if (!['patch', 'value', 'inject', 'call', 'script'].includes(e.type)) continue;
const refusal = demoRefusal(e);
status.cheats.push(refusal ? { name: e.name, state: 'refused', detail: refusal } : { name: e.name, state: 'waiting', detail: DEMO_KEPT_DETAIL });
}
return status;
}
/** Community tables dropped into the cheats/ folder: JSON, SHN or MC4. */
const LOCAL_TABLE_RE = /\.(json|shn|mc4)$/i;
function localCheatIndex() {
let files = [];
try {
files = fs.readdirSync(CHEATS_DIR).filter((f) => LOCAL_TABLE_RE.test(f));
} catch {
return '';
}
const lines = [];
for (const f of files) {
let name = '';
try {
name = String(JSON.parse(fs.readFileSync(path.join(CHEATS_DIR, f), 'utf8').replace(/^/, '')).name ?? '');
} catch {
// listed anyway; the app reports the parse error when it is opened
}
lines.push(`${f}=${name}`);
}
return lines.join('\n');
}
/**
* @param {object} [opts]
* @param {number} [opts.port]
* @param {string} [opts.host]
* @param {{host:string, port:number, udpPort:number, label:string, game?:() => ({titleId:string, version:string, pid:number}|null)}|null} [opts.demo]
* the built-in demo console; `game` says which game it is running, for the stand-in engine status
* @param {number|null} [opts.eventPort] where consoles connect back with debugger events (default 755, null = off)
*/
export function startBridge(opts = {}) {
const demo = opts.demo ?? null;
const PAYLOAD_PORT = opts.payloadPort ?? DEFAULT_PAYLOAD_PORT;
/** The demo console's saved cheat files and settings, kept only while the bridge runs. */
const demoTables = new Map();
const demoPrefs = new Map();
const allowed = (host, port) =>
(isPrivateAddress(host) && port === DEBUGGER_PORT) || (demo && host === demo.host && port === demo.port);
// ---- debugger events ----
// On attach the console opens a TCP connection back to the machine that sent
// the attach, always to port 755. Whatever arrives is passed to the browsers
// that asked for events from that console.
const eventPort = opts.eventPort === undefined ? EVENT_PORT : opts.eventPort;
const eventSubs = new Map(); // console address -> Set<WebSocket>
let eventsError = eventPort === null ? 'Debugger events are turned off' : null;
const eventServer = net.createServer((sock) => {
const from = (sock.remoteAddress ?? '').replace(/^::ffff:/, '');
sock.on('error', () => {});
sock.on('data', (chunk) => {
for (const ws of eventSubs.get(from) ?? []) {
if (ws.readyState === ws.OPEN) ws.send(chunk, { binary: true });
}
});
// The console closes this when the session ends (detach, game exit, error).
sock.on('close', () => {
for (const ws of eventSubs.get(from) ?? []) {
if (ws.readyState === ws.OPEN) ws.close(4005, 'The console ended the debugging session');
}
});
});
const eventsReady = new Promise((resolve) => {
if (eventPort === null) return resolve();
eventServer.once('error', (err) => {
eventsError =
err.code === 'EADDRINUSE'
? `Port ${eventPort} is in use by another program (another debugger tool?)`
: err.code === 'EACCES'
? `This system does not let CheatSpecter listen on port ${eventPort} without administrator rights`
: `Could not listen on port ${eventPort} (${err.code})`;
resolve();
});
eventServer.listen(eventPort, '0.0.0.0', () => resolve());
});
/**
* The game library, game names, "start this game", the cheat files and
* settings saved on the PS5 and the engine that keeps cheats on by itself
* are all handled by the CheatSpecter payload on the console (the debugger
* can do none of them), so these requests are passed on to it.
*/
async function consoleApi(req, res, url) {
const p = url.pathname;
const host = url.searchParams.get('host') ?? '';
const launching = p.startsWith('/api/launch/');
// The engine: GET /api/engine says what it is keeping on, POST /api/engine/reload makes it read the file again.
const engineReload = p === '/api/engine/reload';
const engine = p === '/api/engine' || engineReload;
const table = /^\/api\/tables\/([A-Z]{4}\d{5})(?:\/([0-9A-Za-z][0-9A-Za-z.]{0,15}))?$/.exec(p);
const saving = !!table && !!table[2] && req.method === 'PUT';
const deleting = !!table && !!table[2] && req.method === 'DELETE';
// Cheat files other tools keep on the console: a list for one game, and one file. Read-only.
const consoleCheats = /^\/api\/console-cheats\/([A-Z]{4}\d{5})(?:\/([A-Za-z0-9._()+%\- /]{1,300}))?$/.exec(p);
// Settings: GET and PUT /api/prefs/<name>, the name being lower-case letters, digits and dashes only.
const pref = /^\/api\/prefs\/([a-z0-9-]{1,32})$/.exec(p);
const storing = !!pref && req.method === 'PUT';
const methodOk = launching || engineReload ? req.method === 'POST' : saving || storing || deleting || req.method === 'GET';
if (!methodOk || !sameOrigin(req)) {
res.writeHead(sameOrigin(req) ? 405 : 403);
res.end();
return;
}
if (p.startsWith('/api/prefs/') && !pref) {
res.writeHead(400);
res.end();
return;
}
let body;
if (saving || storing) {
const chunks = [];
const limit = storing ? PREF_MAX : TABLE_MAX;
let size = 0;
for await (const chunk of req) {
size += chunk.length;
// Too much is read to its end and dropped, so the sender gets the answer instead of a cut connection; within reason.
if (size > limit + (16 << 20)) break;
if (size <= limit) chunks.push(chunk);
}
if (size > limit) {
res.writeHead(413);
res.end();
return;
}
body = Buffer.concat(chunks);
}
if (demo && host === demo.host) {
if (engine) {
// The demo console has no engine. The stand-in answers in the engine's shape and takes a reload without doing anything.
if (engineReload) {
res.writeHead(204, { 'cache-control': 'no-store' });
res.end();
} else sendJson(res, 200, demoEngineStatus(demo.game ? demo.game() : null, demoTables));
return;
}
if (pref) {
if (storing) {
let ok = true;
try {
JSON.parse(body.toString('utf8'));
} catch {
ok = false;
}
if (ok) demoPrefs.set(pref[1], body);
res.writeHead(ok ? 204 : 400, { 'cache-control': 'no-store' });
res.end();
} else if (demoPrefs.has(pref[1])) {
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
res.end(demoPrefs.get(pref[1]));
} else {
res.writeHead(404);
res.end();
}
return;
}
if (launching) {
const known = DEMO_TITLES.some((t) => p === `/api/launch/${t.id}`);
const running = p === '/api/launch/PPSA99999';
sendJson(res, running ? 200 : known ? 500 : 404, running
? { ok: true }
: { ok: false, error: known ? 'The demo console only has one game it can actually run' : 'That game is not on this console' });
return;
}
if (consoleCheats) {
// The demo console has no other tools' cheat folders.
if (consoleCheats[2]) {
res.writeHead(404);
res.end();
} else sendJson(res, 200, { files: [] });
return;
}
if (table) {
const [, tid, version] = table;
if (!version) {
const versions = [...demoTables.keys()].filter((k) => k.startsWith(`${tid}-`)).map((k) => k.slice(10));
sendJson(res, 200, { folder: '/data/cheatspecter/cheats', versions });
} else if (saving) {
demoTables.set(`${tid}-${version}`, body);
res.writeHead(204);
res.end();
} else if (deleting) {
const had = demoTables.delete(`${tid}-${version}`);
res.writeHead(had ? 204 : 404);
res.end();
} else if (demoTables.has(`${tid}-${version}`)) {
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
res.end(demoTables.get(`${tid}-${version}`));
} else {
res.writeHead(404);
res.end();
}
return;
}
const answer = demoLibraryResponse(p);
if (!answer) {
res.writeHead(404);
res.end();
return;
}
res.writeHead(200, { 'content-type': answer.type, 'cache-control': 'no-store' });
res.end(answer.body);
return;
}
if (!isPrivateAddress(host) || !(table || engine || pref || consoleCheats || /^\/api\/(library|appmeta\/[A-Z]{4}\d{5}\/[a-z0-9.]+|launch\/[A-Z]{4}\d{5})$/.test(p))) {
res.writeHead(400);
res.end();
return;
}
try {
const upstream = await fetch(`http://${host}:${PAYLOAD_PORT}${p}`, {
method: req.method,
body,
signal: AbortSignal.timeout(launching ? 30_000 : 20_000),
});
const answer = Buffer.from(await upstream.arrayBuffer());
res.writeHead(upstream.status, {
'content-type': upstream.headers.get('content-type') ?? 'application/octet-stream',
'cache-control': upstream.headers.get('cache-control') ?? 'no-store',
'x-content-type-options': 'nosniff',
});
res.end(answer);
} catch {
// Nothing answering on the payload's port: it has not been loaded on this console.
sendJson(res, 502, { ok: false, error: 'payload-missing' });
}
}
const server = http.createServer((req, res) => {
const url = new URL(req.url ?? '/', 'http://localhost');
const p = url.pathname;
if (p.startsWith('/api/tables/') || p.startsWith('/api/console-cheats/') || p === '/api/library' || p.startsWith('/api/appmeta/') || p.startsWith('/api/launch/') || p === '/api/engine' || p === '/api/engine/reload' || p.startsWith('/api/prefs/')) {
void consoleApi(req, res, url);
return;
}
if (req.method !== 'GET' && req.method !== 'HEAD') {
res.writeHead(405);
res.end();
return;
}
if (p === '/api/info') {
sendJson(res, 200, {
mode: 'bridge',
version: VERSION,
events: eventsError === null,
...(demo ? { demo: { host: demo.host, port: demo.port, label: demo.label } } : {}),
});
return;
}
if (p === '/api/discover') {
discover(demo ? [demo] : []).then((list) => sendJson(res, 200, list));
return;
}
if (p === '/api/local-cheats') {
res.writeHead(200, { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' });
res.end(localCheatIndex());
return;
}
if (p.startsWith('/local-cheats/')) {
const file = safeJoin(CHEATS_DIR, p.slice('/local-cheats/'.length));
if (!file || !LOCAL_TABLE_RE.test(file)) {
res.writeHead(404);
res.end();
return;
}
serveFile(res, file, 'no-store');
return;
}
// The Learn section's lessons normally come from GitHub. With the demo
// console (which is also how `npm run dev` runs) the checkout's own copy is
// served, so lessons can be written and tried before they are published.
if (demo && p.startsWith('/learn/')) {
const lesson = safeJoin(LEARN_DIR, p.slice('/learn/'.length));
if (!lesson || !lesson.endsWith('.json') || !fs.existsSync(lesson)) {
res.writeHead(404);
res.end();
return;
}
serveFile(res, lesson, 'no-store');
return;
}
const file = safeJoin(DIST, p === '/' ? 'index.html' : p.slice(1));
if (!file) {
res.writeHead(404);
res.end();
return;
}
if (!fs.existsSync(DIST)) {
res.writeHead(503, { 'content-type': 'text/plain; charset=utf-8' });
res.end('The web app has not been built yet. Run "npm run build" (or "npm start", which does it for you).');
return;
}
// Hashed asset names never change content, so they can be cached hard.
serveFile(res, file, p.startsWith('/assets/') ? 'public, max-age=31536000, immutable' : 'no-cache');
});
const wss = new WebSocketServer({ noServer: true, perMessageDeflate: false });
server.on('upgrade', (req, socket, head) => {
const url = new URL(req.url ?? '/', 'http://localhost');
if ((url.pathname !== '/ws' && url.pathname !== '/events') || !sameOrigin(req)) {
socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
socket.destroy();
return;
}
wss.handleUpgrade(req, socket, head, (ws) => (url.pathname === '/ws' ? relay(ws, url) : subscribe(ws, url)));
});
function subscribe(ws, url) {
const host = url.searchParams.get('host') ?? '';
if (!isPrivateAddress(host)) {
ws.close(4003, 'That address is not a console on your local network');
return;
}
if (eventsError) {
ws.close(4004, eventsError);
return;
}
if (!eventSubs.has(host)) eventSubs.set(host, new Set());
eventSubs.get(host).add(ws);
ws.on('close', () => eventSubs.get(host)?.delete(ws));
ws.on('error', () => {});
}
function relay(ws, url) {
const host = url.searchParams.get('host') ?? '';
const port = Number(url.searchParams.get('port') ?? DEBUGGER_PORT);
if (!allowed(host, port)) {
ws.close(4003, 'That address is not a console on your local network');
return;
}
const tcp = net.connect({ host, port });
tcp.setNoDelay(true);
let open = false;
const pending = [];
const timer = setTimeout(() => {
if (!open) tcp.destroy(new Error('timeout'));
}, 5000);
tcp.on('connect', () => {
open = true;
clearTimeout(timer);
for (const chunk of pending) tcp.write(chunk);
pending.length = 0;
});
tcp.on('data', (chunk) => {
if (ws.readyState !== ws.OPEN) return;
ws.send(chunk, { binary: true });
// Slow browser, fast console: stop reading until the socket drains.
if (ws.bufferedAmount > 8 * 1024 * 1024) {
tcp.pause();
const resume = () => {
if (ws.readyState !== ws.OPEN) return;
if (ws.bufferedAmount < 1024 * 1024) tcp.resume();
else setTimeout(resume, 20);
};
setTimeout(resume, 20);
}
});
tcp.on('error', (err) => {
clearTimeout(timer);
const reason = !open
? err.code === 'ECONNREFUSED'
? 'The console refused the connection. Is the ps5debug payload loaded?'
: 'Could not reach the console. Check that it is on and on the same network.'
: 'Lost the connection to the console';
if (ws.readyState === ws.OPEN || ws.readyState === ws.CONNECTING) ws.close(4001, reason);
});
tcp.on('close', () => {
clearTimeout(timer);
if (ws.readyState === ws.OPEN) ws.close(4002, 'The console closed the connection');
});
ws.on('message', (data, isBinary) => {
if (!isBinary) return;
if (open) tcp.write(data);
else pending.push(data);
});
ws.on('close', () => tcp.destroy());
ws.on('error', () => tcp.destroy());
}
const ready = eventsReady.then(
() =>
new Promise((resolve, reject) => {
server.once('error', reject);
server.listen(opts.port ?? 8744, opts.host ?? '0.0.0.0', () => resolve(server.address().port));
}),
);
return {
ready,
server,
close() {
for (const c of wss.clients) c.terminate();
server.closeAllConnections?.();
if (eventServer.listening) eventServer.close();
return new Promise((resolve) => server.close(() => resolve()));
},
};
}
// ---- command line ------------------------------------------------------------
function lanAddresses() {
const out = [];
for (const list of Object.values(os.networkInterfaces())) {
for (const i of list ?? []) if (i.family === 'IPv4' && !i.internal) out.push(i.address);
}
return out;
}
function openBrowser(url) {
const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'cmd' : 'xdg-open';
const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url];
try {
spawn(cmd, args, { stdio: 'ignore', detached: true }).on('error', () => {}).unref();
} catch {
// the URL is printed either way
}
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
const args = process.argv.slice(2);
const flag = (name) => args.includes(name);
const portArg = args.indexOf('--port');
const port = portArg >= 0 ? Number(args[portArg + 1]) : Number(process.env.PORT ?? 8744);
let demo = null;
// In demo mode nothing real will connect back, so use an unprivileged port
// that works on every system.
const DEMO_EVENT_PORT = 17755 + Number(process.env.CS_PORT_OFFSET ?? 0);
if (flag('--demo')) {
const { startMock, MOCK_TCP_PORT, MOCK_UDP_PORT, GAME } = await import('../mock/mock-ps5.mjs');
// A real console takes a while to scan; the pause makes the demo show progress too.
const mock = startMock({ scanDelayMs: 250, eventPort: DEMO_EVENT_PORT });
await mock.ready;
demo = {
host: '127.0.0.1',
port: MOCK_TCP_PORT,
udpPort: MOCK_UDP_PORT,
label: 'Demo console',
game: () => (mock.state.foreground ? { titleId: GAME.titleId, version: GAME.version, pid: GAME.pid } : null),
};
}
const bridge = startBridge({ port, demo, eventPort: demo ? DEMO_EVENT_PORT : EVENT_PORT });
bridge.ready
.then((actual) => {
const local = `http://localhost:${actual}`;
console.log(`\n CheatSpecter ${VERSION}${demo ? ' (demo console running)' : ''}\n`);
console.log(` On this computer: ${local}`);
for (const ip of lanAddresses()) console.log(` Phone or tablet: http://${ip}:${actual}`);
console.log('\n Leave this window open while you use it. Close it to stop.\n');
if (flag('--open')) openBrowser(local);
})
.catch((err) => {
if (err.code === 'EADDRINUSE') {
console.error(`\n Port ${port} is already in use. CheatSpecter may already be running: http://localhost:${port}\n`);
if (flag('--open')) openBrowser(`http://localhost:${port}`);
} else {
console.error(err);
}
process.exit(1);
});
}