#!/usr/bin/env node 'use strict'; /** * A stand-in for the SteelSeries GameSense server. * * It speaks enough of the real API to exercise the client, and it resolves the * bound handlers against each incoming frame to render the keyboard as ANSI * colour in the terminal. That makes the lighting logic verifiable without an * Apex 7 (or Windows) in front of you. * * node tools/fake-gamesense.js --port 51000 * node bin/apex7-scale.js --address 127.0.0.1:51000 --root D --scale dorian */ const http = require('http'); const { HID } = require('../src/hid'); const args = process.argv.slice(2); const portArg = args.indexOf('--port'); const PORT = portArg >= 0 ? Number(args[portArg + 1]) : 51000; const QUIET = args.includes('--quiet'); const ONCE = args.includes('--once'); // exit after the first rendered frame // hid -> [label, width] const LAYOUT = [ [['esc', HID.escape], null, ['f1', HID.f1], ['f2', HID.f2], ['f3', HID.f3], ['f4', HID.f4], null, ['f5', HID.f5], ['f6', HID.f6], ['f7', HID.f7], ['f8', HID.f8], null, ['f9', HID.f9], ['f10', HID.f10], ['f11', HID.f11], ['f12', HID.f12], null, ['prt', HID.printScreen], ['scr', HID.scrollLock], ['brk', HID.pause]], [['`', HID.backquote], ['1', HID['1']], ['2', HID['2']], ['3', HID['3']], ['4', HID['4']], ['5', HID['5']], ['6', HID['6']], ['7', HID['7']], ['8', HID['8']], ['9', HID['9']], ['0', HID['0']], ['-', HID.minus], ['=', HID.equal], ['bsp', HID.backspace], null, ['ins', HID.insert], ['hom', HID.home], ['pup', HID.pageUp], null, ['num', HID.numLock], ['/', HID.numpadDivide], ['*', HID.numpadMultiply], ['-', HID.numpadSubtract]], [['tab', HID.tab], ['q', HID.q], ['w', HID.w], ['e', HID.e], ['r', HID.r], ['t', HID.t], ['y', HID.y], ['u', HID.u], ['i', HID.i], ['o', HID.o], ['p', HID.p], ['[', HID.bracketLeft], [']', HID.bracketRight], ['\\', HID.backslash], null, ['del', HID.delete], ['end', HID.end], ['pdn', HID.pageDown], null, ['7', HID.numpad7], ['8', HID.numpad8], ['9', HID.numpad9], ['+', HID.numpadAdd]], [['cap', HID.capsLock], ['a', HID.a], ['s', HID.s], ['d', HID.d], ['f', HID.f], ['g', HID.g], ['h', HID.h], ['j', HID.j], ['k', HID.k], ['l', HID.l], [';', HID.semicolon], ["'", HID.quote], ['ent', HID.enter], null, null, null, null, null, null, ['4', HID.numpad4], ['5', HID.numpad5], ['6', HID.numpad6]], [['sft', HID.shiftLeft], ['z', HID.z], ['x', HID.x], ['c', HID.c], ['v', HID.v], ['b', HID.b], ['n', HID.n], ['m', HID.m], [',', HID.comma], ['.', HID.period], ['/', HID.slash], ['sft', HID.shiftRight], null, null, null, null, ['up', HID.arrowUp], null, null, ['1', HID.numpad1], ['2', HID.numpad2], ['3', HID.numpad3], ['ent', HID.numpadEnter]], [['ctl', HID.controlLeft], ['win', HID.metaLeft], ['alt', HID.altLeft], ['spc', HID.space], ['alt', HID.altRight], ['win', HID.metaRight], ['men', HID.application], ['ctl', HID.controlRight], null, null, null, null, null, ['lft', HID.arrowLeft], ['dn', HID.arrowDown], ['rgt', HID.arrowRight], null, ['0', HID.numpad0], ['.', HID.numpadDecimal]], ]; const state = { games: new Map(), // game -> { metadata, events: Map } lastColors: new Map(), // hid -> {red,green,blue} }; function cell(label, color) { const text = ` ${String(label).slice(0, 3).padEnd(3)} `; if (!color) return `\x1b[2m${text}\x1b[0m`; const { red, green, blue } = color; // Relative luminance, to keep the label readable on any background. const luma = 0.2126 * red + 0.7152 * green + 0.0722 * blue; const fg = luma > 140 ? '30' : '97'; return `\x1b[48;2;${red};${green};${blue}m\x1b[${fg}m${text}\x1b[0m`; } function render() { const lines = LAYOUT.map((row) => row.map((key) => (key ? cell(key[0], state.lastColors.get(key[1])) : ' ')).join('') ); console.log(`\n${lines.join('\n')}\n`); } /** Resolve bound handlers against an incoming frame -> hid => color. */ function applyFrame(handlers, frame) { const colors = new Map(); for (const handler of handlers) { if (handler['device-type'] !== 'rgb-per-key-zones') continue; let color = null; if (handler.mode === 'context-color') { color = frame[handler['context-frame-key']] || null; } else if (handler.color) { color = handler.color; } if (!color) continue; for (const hid of handler['custom-zone-keys'] || []) colors.set(hid, color); } return colors; } function readBody(req) { return new Promise((resolve, reject) => { const chunks = []; req.on('data', (c) => chunks.push(c)); req.on('end', () => { const text = Buffer.concat(chunks).toString('utf8'); try { resolve(text ? JSON.parse(text) : {}); } catch (err) { reject(err); } }); req.on('error', reject); }); } function log(...parts) { if (!QUIET) console.log(...parts); } const server = http.createServer(async (req, res) => { let body; try { body = await readBody(req); } catch (err) { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: `bad JSON: ${err.message}` })); return; } const reply = (code, payload) => { res.writeHead(code, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(payload)); }; const game = body.game; const entry = () => { if (!state.games.has(game)) { state.games.set(game, { metadata: null, events: new Map() }); } return state.games.get(game); }; switch (req.url) { case '/game_metadata': if (!game) return reply(400, { error: 'missing game' }); entry().metadata = body; log(`[metadata] ${game} "${body.game_display_name}" by ${body.developer}`); return reply(200, { game }); case '/bind_game_event': { if (!game || !body.event) return reply(400, { error: 'missing game or event' }); const handlers = body.handlers || []; entry().events.set(body.event, handlers); const zoneKeys = handlers.reduce( (n, h) => n + (h['custom-zone-keys'] || []).length, 0 ); log( `[bind] ${game}/${body.event}: ${handlers.length} handlers, ${zoneKeys} keys addressed` ); return reply(200, { game, event: body.event }); } case '/game_event': { const known = state.games.get(game); const handlers = known && known.events.get(body.event); if (!handlers) return reply(404, { error: `no handler for ${game}/${body.event}` }); const frame = (body.data && body.data.frame) || {}; const missing = handlers .filter((h) => h.mode === 'context-color' && !(h['context-frame-key'] in frame)) .map((h) => h['context-frame-key']); if (missing.length) { log(`[warn] frame is missing keys: ${missing.join(', ')}`); } const colors = applyFrame(handlers, frame); state.lastColors = colors; log(`[event] ${game}/${body.event} value=${body.data && body.data.value}`); render(); if (ONCE) setTimeout(() => process.exit(0), 50); return reply(200, { game, event: body.event }); } case '/game_heartbeat': log(`[heartbeat] ${game}`); return reply(200, { game }); case '/remove_game': state.games.delete(game); state.lastColors = new Map(); log(`[remove] ${game}`); return reply(200, { game }); default: return reply(404, { error: `unhandled endpoint ${req.url}` }); } }); server.listen(PORT, '127.0.0.1', () => { console.log(`fake GameSense server on 127.0.0.1:${PORT}`); console.log(` GAMESENSE_ADDRESS=127.0.0.1:${PORT} node bin/apex7-scale.js --root C --scale major\n`); });