// N separate Chromium instances, one page each: the co-op check the reliable way. // Usage: node cdp_many.mjs [...] [--seconds N] [--shot prefix] import { spawn } from 'node:child_process' import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' const args = process.argv.slice(2) const chromeBin = args[0] const urls = args.slice(1).filter((value) => value.startsWith('http')) const arg = (name, fallback) => { const i = process.argv.indexOf(name) return i === -1 ? fallback : process.argv[i + 1] } const seconds = Number(arg('--seconds', '14')) const shotPrefix = arg('--shot', null) const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) /** Launch Chromium on a URL and attach to its only tab. */ async function connect(url) { const userDataDir = mkdtempSync(join(tmpdir(), 'cdp-pair-')) const chrome = spawn(chromeBin, [ '--headless=new', '--no-sandbox', '--disable-dev-shm-usage', '--use-angle=swiftshader', '--use-gl=angle', '--enable-unsafe-swiftshader', '--window-size=1000,700', `--user-data-dir=${userDataDir}`, '--remote-debugging-port=0', url, ], { stdio: ['ignore', 'ignore', 'pipe'] }) let stderr = '' chrome.stderr.on('data', (d) => { stderr += d }) let port = null for (let i = 0; i < 120 && !port; i++) { await sleep(200) try { port = readFileSync(join(userDataDir, 'DevToolsActivePort'), 'utf8').trim().split('\n')[0] } catch {} } if (!port) throw new Error(`no devtools port; ${stderr.slice(-300)}`) let page = null for (let i = 0; i < 40 && page === null; i++) { await sleep(200) try { const list = await (await fetch(`http://127.0.0.1:${port}/json`)).json() page = list.find((entry) => entry.type === 'page') ?? null } catch {} } if (page === null) throw new Error(`no page target; ${stderr.slice(-300)}`) const ws = new WebSocket(page.webSocketDebuggerUrl) await new Promise((resolve, reject) => { ws.onopen = resolve; ws.onerror = reject }) let id = 0 const pending = new Map() ws.onmessage = (event) => { const message = JSON.parse(event.data) if (message.id && pending.has(message.id)) { pending.get(message.id)(message); pending.delete(message.id) } } const send = (method, params = {}) => new Promise((resolve) => { const msgId = ++id pending.set(msgId, resolve) ws.send(JSON.stringify({ id: msgId, method, params })) }) await send('Runtime.enable') await send('Page.enable') return { send, ws, chrome, stderr: () => stderr } } const pages = await Promise.all(urls.map((url) => connect(url))) // Collect page-level errors: an exception thrown inside a socket listener is // invisible from the outside, and is exactly how a "no messages arrive" bug // actually looks. for (const page of pages) { await page.send('Runtime.evaluate', { expression: `(() => { window.__errs = []; window.addEventListener('error', e => window.__errs.push(String(e.message))); window.addEventListener('unhandledrejection', e => window.__errs.push('rejection: ' + String(e.reason))); return 'ok'; })()`, returnByValue: true }) } await sleep(4500) const evaluate = async (page, expression, awaitPromise = false) => { const result = await page.send('Runtime.evaluate', { expression, returnByValue: true, awaitPromise }) if (result.result?.exceptionDetails) return { __error: JSON.stringify(result.result.exceptionDetails).slice(0, 400) } return result.result?.result?.value ?? null } console.log('where:', JSON.stringify(await Promise.all(pages.map(page => evaluate(page, 'location.href'))))) /** Press and hold a key, then release it. */ async function hold(page, code, key, vk, ms) { const base = { code, key, windowsVirtualKeyCode: vk, nativeVirtualKeyCode: vk } await page.send('Input.dispatchKeyEvent', { type: 'keyDown', ...base }) await sleep(ms) await page.send('Input.dispatchKeyEvent', { type: 'keyUp', ...base }) } const tap = async (page, code, key, vk) => { const base = { code, key, windowsVirtualKeyCode: vk, nativeVirtualKeyCode: vk } await page.send('Input.dispatchKeyEvent', { type: 'keyDown', ...base }) await sleep(700) await page.send('Input.dispatchKeyEvent', { type: 'keyUp', ...base }) } // Each page walks on its own axis, so a fighter that moves only under its own // page's keys is visible in the final positions. const KEYS = [['KeyD', 'd', 68], ['KeyS', 's', 83], ['KeyA', 'a', 65], ['KeyW', 'w', 87]] const drive = (async () => { for (let round = 0; round < 5; round += 1) { await Promise.all(pages.map((page, index) => { const [code, key, vk] = KEYS[index % KEYS.length] return hold(page, code, key, vk, 800) })) await Promise.all(pages.map((page) => tap(page, 'Space', ' ', 32))) await sleep(300) } })() await Promise.race([drive, sleep(seconds * 1000)]) const states = [] for (const page of pages) { const value = await evaluate(page, 'JSON.stringify(window.__d2webNet ?? null)') states.push(typeof value === 'string' ? JSON.parse(value) : value) } if (shotPrefix !== null) { for (let index = 0; index < pages.length; index += 1) { const shot = await pages[index].send('Page.captureScreenshot', { format: 'png' }) const data = shot.result?.data if (typeof data === 'string') writeFileSync(`${shotPrefix}-${index}.png`, Buffer.from(data, 'base64')) } } // Pixel statistics, read straight from the framebuffer: a page whose world is // correct but whose canvas is empty has failed in a way the numbers cannot see. let pixels = [] for (const page of pages) { const value = await evaluate(page, `(() => { const c = document.querySelector('#view'); const gl = c.getContext('webgl2'); const px = new Uint8Array(c.width * c.height * 4); gl.readPixels(0, 0, c.width, c.height, gl.RGBA, gl.UNSIGNED_BYTE, px); let nonBlack = 0; const seen = new Set(); for (let i = 0; i < px.length; i += 4) { if (px[i] + px[i+1] + px[i+2] > 24) nonBlack++; seen.add((px[i] >> 3) + ',' + (px[i+1] >> 3) + ',' + (px[i+2] >> 3)); } return JSON.stringify({ nonBlack, total: c.width * c.height, distinctColours: seen.size, glError: gl.getError() }); })()`) pixels.push(typeof value === 'string' ? JSON.parse(value) : value) } const errors = [] for (const page of pages) { const value = await evaluate(page, 'JSON.stringify(window.__errs ?? null)') errors.push(typeof value === 'string' ? JSON.parse(value) : value) } console.log(JSON.stringify({ states, pixels, errors }, null, 2)) for (const page of pages) { page.ws.close(); page.chrome.kill() } process.exit(0)