From f70c027a8d400c525334e1c169b2dcfc99dd9961 Mon Sep 17 00:00:00 2001 From: p2r3 Date: Thu, 17 Oct 2024 00:36:45 +0300 Subject: [PATCH] chore: make epochtal compatible with spplice-cpp --- defaults/portal2/main.js | 328 ++++++++++++++++++++++------------- defaults/portal2/polyfill.js | 235 +++++++++++++++++++++++++ util/spplice.js | 5 +- 3 files changed, 446 insertions(+), 122 deletions(-) create mode 100644 defaults/portal2/polyfill.js diff --git a/defaults/portal2/main.js b/defaults/portal2/main.js index a8e4cab..9d1a34a 100644 --- a/defaults/portal2/main.js +++ b/defaults/portal2/main.js @@ -1,173 +1,259 @@ -/* - * Due to valve's decision to break most coop commands on orange, a few workarounds are needed to make coop possible again. - * This main.js file is loaded by spplice and feeds off the console output to relay commands to the game. - * Additionally, this file is responisble for communicating the server's state to the game client. +/** + * This file provides some patches for co-op scripts, requests and injects + * server timestamps during runs, and provides a WebSocket interface to + * the Portal 2 console. The code is written for spplice-cpp, and is made + * compatible with Spplice 2 via a polyfill script. */ -const SERVER_ADDRESS = fs.read("address.txt"); +// Polyfills for Spplice 2 +if (!("game" in this)) eval(fs.read("polyfill.js")); -let input = ""; -let pongIgnore = 0; +// Read the server's HTTP address from file +const HTTP_ADDRESS = fs.read("address.txt"); +// Get the WebSocket address from the HTTP address +const WS_ADDRESS = HTTP_ADDRESS.replace("http", "ws"); -let ws = null, wsPrompt = null; - -// Generates a random 4 digit code -function generateAuthCode () { - let code = Math.floor(Math.random() * 10000).toString(); - while (code.length < 4) code = "0" + code; - return code; +/** + * Utility funtion, checks if the given file path exists + * @param {string} path Path to the file or directory, relative to tempcontent + * @returns {boolean} True if the path exists, false otherwise + */ +function pathExists (path) { + try { fs.rename(path, path) } + catch (_) { return false } + return true; } -// Handles received WebSocket events -function wsMessageHandler (event) { +do { // Attempt connection with the game's console + var gameSocket = game.connect(); + sleep(200); +} while (gameSocket === -1); - const data = JSON.parse(event.data); +console.log("Connected to Portal 2 console."); - switch (data.type) { - case "authenticated": { - ws.send(JSON.stringify({ type: "isGame", value: true })); - SendToConsole("clear; echo WebSocket connection established."); - return; - } - case "cmd": return SendToConsole(data.value); - case "ping": return ws.send(JSON.stringify({ type: "pong" })); - } +// Keep track of co-op sync pings/pongs +var pongIgnore = 0; +// Whether an attempt has been made to provide a WebSocket token +var webSocketAttempt = false; +// Store the last partially received console line until it can be processed +var lastLine = ""; -} +/** + * Processes individual lines of Portal 2 console output + * + * @param {string} line A single complete line of console output + */ +function processConsoleLine (line) { + + // Relay commands to the game + if (line.indexOf("[SendToConsole] ") === 0) { + game.send(gameSocket, line.slice(16) + "\n"); + return; + } -// Authenticates and sets up a WebSocket connection -function wsSetup (token) { + // Respond to coop portalgun pings + if (line.indexOf("[coop_portal_ping]") !== -1) { - const protocol = SERVER_ADDRESS.startsWith("https:") ? "wss" : "ws"; - const hostname = SERVER_ADDRESS.split("://")[1].split("/")[0]; + // Hide the message from the onscreen chat and respond with a pong + game.send(gameSocket, "hud_saytext_time 0;say [coop_portal_pong]\n"); + // Up pongIgnore to ignore the next two pings, as they are echos of the same ping on the wrong client + pongIgnore = 2; - if (ws) { - ws.addEventListener("close", () => { - ws = null; - wsSetup(token); - }); - ws.close(); return; } - ws = new WebSocket(`${protocol}://${hostname}/api/events/connect`); + // Respond to coop portalgun pongs + if (line.indexOf("[coop_portal_pong]") !== -1) { - ws.addEventListener("message", wsMessageHandler); + // Make sure we're not listening to our own pongs + if (pongIgnore > 0) { + pongIgnore --; + return; + } + pongIgnore = 1; - ws.addEventListener("open", () => { - ws.send(token); - }); + // Trigger coop script function to update the portals + game.send(gameSocket, "script ::coopUpdatePortals()\n"); - ws.addEventListener("close", (event) => { - ws = null; - SendToConsole("echo \"\""); - SendToConsole("echo WebSocket connection closed."); - if (event.reason === "ERR_TOKEN") { - SendToConsole("echo \"\""); - SendToConsole("echo The token you provided was invalid."); + return; + } + + // Respond to request for server timestamp + if (line.indexOf("[RequestTimestamp]") !== -1 || line.indexOf("Recording to ") === 0) { + + // Request server timestamp from API + const timestamp = download.string(HTTP_ADDRESS + "/api/timestamp/get"); + + if (!timestamp) { + // If the request fails, run a script that informs the player + game.send(gameSocket, "script ::serverUnreachable()\n"); + } else { + // Send a no-op command to log the timestamp in the demo file + game.send(gameSocket, "-alt1 ServerTimestamp " + timestamp + "\n"); } - }); -} + return; + } -// Workaround for lack of fs.exists in Spplice 2's JS API -function pathExists (path) { - try { - fs.rename(path, path); - return true; - } catch (e) { - return false; + // Process WebSocket token + if (line.indexOf("ws : ") === 0) { + if (!webSocketAttempt) { + webSocketAttempt = true; + game.send(gameSocket, "echo Looks like you're trying to connect to an Epochtal Live lobby.\n"); + game.send(gameSocket, "echo Please use the dedicated Epochtal Live Spplice package for that instead, this will not work.\n"); + game.send(gameSocket, "echo \"\"\n"); + game.send(gameSocket, "echo If you know what you're doing, send the command again.\n"); + return; + } + webSocketToken = line.slice(5, -1); + return; } + + // Force quit when SAR unloads + if (line.indexOf("Cya :)") === 0) { + if (webSocket) ws.disconnect(webSocket); + throw "Game closing"; + } + } -// Handle console output -onConsoleOutput(async function (data) { +/** + * Processes output from the Portal 2 console, splitting it up into lines + * and passing those to processConsoleLine + */ +function processConsoleOutput () { + + // Receive 1024 bytes from the game console socket + const buffer = game.read(gameSocket, 1024); + // If we received nothing, don't proceed + if (buffer.length === 0) return; - // Append new data to the input buffer - input += data.toString().replaceAll("\r", ""); + // Add the latest buffer to any partial data we had before + lastLine += buffer; - // Split the input buffer by newline characters - const lines = input.split("\n"); - input = lines.pop(); + // Split up the output into lines + const lines = lastLine.replace(/\r/, "").split("\n"); + // Store the last entry of the array as a partially received line + lastLine = lines.pop(); - // Iterate over each completed new line - for (let i = 0; i < lines.length; i ++) { + // Process each complete line of console output + lines.forEach(processConsoleLine); - // Relay commands to the game - if (lines[i].startsWith("[SendToConsole] ")) { - SendToConsole(lines[i].slice(16)); - continue; - } +} - // Respond to coop portalgun pings - if (lines[i].includes("[coop_portal_ping]")) { +/** + * Processes events sent by the server + * + * @param {Object} data Event data, as parsed from JSON + * @param {string} data.type Event type + */ +function processServerEvent (data) { - // Hide the message from the onscreen chat and respond with a pong - SendToConsole("hud_saytext_time 0;say [coop_portal_pong]"); - // Up pongIgnore to ignore the next two pings, as they are echos of the same ping on the wrong client - pongIgnore = 2; + switch (data.type) { - continue; - } + // Handle authentication success + case "authenticated": { - // Respond to coop portalgun pongs - if (lines[i].includes("[coop_portal_pong]")) { + // Acknowledge success to the user + game.send(gameSocket, "echo Authentication complete.\n"); - // Only respond to the correct pongs - if (pongIgnore > 0) { - pongIgnore --; - continue; + // Send isGame event to inform the server of our role + if (!ws.send(webSocket, '{"type":"isGame","value":"true"}')) { + game.send(gameSocket, "echo Failed to send isGame event. Disconnecting.\n"); + ws.disconnect(webSocket); + webSocket = null; } - pongIgnore = 1; - // Trigger coop vscript to update the portals - SendToConsole("script ::coopUpdatePortals()"); + return; + } + + // Handle server pings + case "ping": { - continue; + // Respond to the ping with a pong + ws.send(webSocket, '{"type":"pong"}'); + + return; } - // Respond to request for server timestamp - if (lines[i].includes("[RequestTimestamp]") || (lines[i].startsWith("Recording to ") && lines[i].endsWith("..."))) { - try { + } - // Request server timestamp from API - const request = await fetch(`${SERVER_ADDRESS}/api/timestamp/get`); - const timestamp = await request.json(); +} - // Send a no-op command to log the timestamp in the demo file - SendToConsole(`-alt1 ServerTimestamp ${timestamp}`); +// Keep track of WebSocket parameters +var webSocket = null; +var webSocketToken = null; - } catch (e) { +/** + * Processes communication with the WebSocket + */ +function processWebSocket () { - // If the request fails, run a script that informs the player - SendToConsole("script ::serverUnreachable()"); + // If we have a token, attempt to use it + if (webSocketToken) { - } - continue; + // Disconnect existing socket, if any + if (webSocket) ws.disconnect(webSocket); + // Start a new WebSocket connection + game.send(gameSocket, "echo Connecting to WebSocket...\n"); + webSocket = ws.connect(WS_ADDRESS + "/api/events/connect"); + + // Clear the token and exit if the connection failed + if (!webSocket) { + game.send(gameSocket, "echo WebSocket connection failed.\n"); + webSocketToken = null; + return; } - // Respond to request for WebSocket connection - if (lines[i].startsWith("ws : ")) { - if (!wsPrompt) { - SendToConsole("echo Looks like you're trying to connect to an Epochtal Live lobby."); - SendToConsole("echo Please use the dedicated Epochtal Live Spplice package for that instead, this will not work."); - SendToConsole("echo \"\""); - SendToConsole("echo If you know what you're doing, send the command again."); - wsPrompt = true; - continue; - } - wsSetup(lines[i].slice(5)); - continue; + // Send the token to authenticate + game.send(gameSocket, "echo Connection established, authenticating...\n"); + const success = ws.send(webSocket, webSocketToken); + webSocketToken = null; + + // On transmission failure, assume the socket is dead + if (!success) { + game.send(gameSocket, "echo Failed to send authentication token.\n"); + ws.disconnect(webSocket); + webSocket = null; + return; } - // Work around an issue in Spplice 2 where the JS interface remains running after game close - if (lines[i].startsWith("Cya :)")) { - if (ws) ws.close(); - throw "Game closing"; + // Shortly after, send a ping to check that we're still connected + sleep(1000); + const pingSuccess = ws.send(webSocket, '{"type":"ping"}'); + + // If this failed, the server probably closed the connection, indicating auth failure + if (!pingSuccess) { + game.send(gameSocket, "echo Authentication failed.\n"); + game.send(gameSocket, 'echo ""\n'); + game.send(gameSocket, "echo The token you provided most likely expired. Tokens are only valid for 30 seconds after being issued.\n"); + + ws.disconnect(webSocket); + webSocket = null; + return; } } -}); + // If a socket has not been instantiated, don't proceed + if (!webSocket) return; -log.appendl("SendToConsole relay enabled!"); + // Process incoming data until the stack is empty + var message; + while ((message = ws.read(webSocket)) !== "") { + + // Attempt to deserialize and process the message + try { processServerEvent(JSON.parse(message)) } + catch (_) { continue } + + } + +} + +// Run each processing function on an interval +while (true) { + processConsoleOutput(); + processWebSocket(); + // Sleep for roughly one tick + sleep(16); +} diff --git a/defaults/portal2/polyfill.js b/defaults/portal2/polyfill.js new file mode 100644 index 0000000..150e45b --- /dev/null +++ b/defaults/portal2/polyfill.js @@ -0,0 +1,235 @@ +/** + * This file provides polyfilled implementations of spplice-cpp features. + * Spplice 2's JS API is mostly asynchronous, whereas spplice-cpp's API + * features thread-blocking functions exclusively, with no async support + * due to the runtime being only ES5 compliant. + * + * However, because of some fundamental differences in the JS environments, + * we also need to modify the original main.js script. These modifications + * are mostly find-and-replace operations for making some functions + * asynchronous, which assume very little about the code being modified. + * + * Ideally, this would all be deprecated ASAP anyway. + */ + +// Save everything received by the console to the buffer +let __pfConsoleData = ""; +onConsoleOutput(data => __pfConsoleData += data); + +// Redirect console output to the Spplice 2 progress log +var console = { + log (...args) { + args.forEach(arg => log.append(arg)); + }, + error (...args) { + args.forEach(arg => log.error(arg)); + } +}; + +var game = { + // Spplice 2 connects before starting the JS interface, so this is a dummy function + connect () { + return 1; + }, + // Treat the console data string as a FIFO buffer + read (_, n = 1024) { + const out = __pfConsoleData.slice(0, n); + __pfConsoleData = __pfConsoleData.slice(n); + return out; + }, + // Basically an alias for SendToConsole, but removes extra \n if any + send (_, d) { + if (d[d.length - 1] === "\n") d = d.slice(0, -1); + SendToConsole(d); + } +}; + +// Store WebSockets in an array, up to 32 at a time +const __pfWebSocketList = []; +const __pfWebSocketMax = 32; + +// Imitate a spplice-cpp WebSocket, with a FIFO message buffer and "closed" state +class __pfWebSocket { + + socket = null; + buffer = []; + closed = false; + opened = false; + + constructor (url, resolve) { + this.socket = new WebSocket(url); + // Push all messages to the buffer + this.socket.addEventListener("message", msg => this.buffer.push(msg.data)); + // Resolve the promise with "true" on connection success + this.socket.onopen = () => { + if (!this.closed) resolve(true); + this.opened = true; + } + // On connection failure, resolve with "false" (if relevant) and set closed state + this.socket.onerror = this.socket.onclose = () => { + if (!this.opened) resolve(false); + this.closed = true; + this.socket.onclose = undefined; + this.socket.onerror = undefined; + }; + } + +} + +var ws = { + // Synchronously connect to the socket, return ID on success or null/throw on failure + async connect (url) { + for (let i = 0; i < __pfWebSocketMax; i ++) { + if (__pfWebSocketList[i] !== undefined) continue; + + const success = await new Promise(function (resolve) { + __pfWebSocketList[i] = new __pfWebSocket(url, resolve); + }); + + if (!success) { + __pfWebSocketList[i] = undefined; + return null; + } + return i + 1; + } + throw "ws.connect: Too many WebSocket connections"; + }, + // Close the socket, remove it from the list + disconnect (s) { + __pfWebSocketList[s - 1].socket.close(); + __pfWebSocketList[s - 1] = undefined; + }, + // Send the given message, return false if socket is no longer open + send (s, m) { + if (__pfWebSocketList[s - 1].closed) return false; + __pfWebSocketList[s - 1].socket.send(m); + return true; + }, + // Treat the socket output as a FIFO structure + read (s, n = 1024) { + if (__pfWebSocketList[s - 1].buffer.length === 0) return ""; + return __pfWebSocketList[s - 1].buffer.shift(); + } +}; + +var download = { + // Fetch text, return empty string on failure + async string (url) { + try { return await (await fetch(url)).text() } + catch (e) { return "" } + }, + // Fetch file, throw error on failure + async file (path, url) { + let taken = true; + try { fs.rename(path, path) } + catch (e) { taken = false } + if (taken) throw "download.file: Path already occupied"; + + try { + const buffer = Buffer.from(await (await fetch(url)).arrayBuffer()); + fs.write(path, buffer); + } catch (e) { + throw "download.file: Download failed"; + } + } +}; + +// This is where we modify the main.js file. We don't actually write it +// out or anything, it gets read into a string and then eval-ed from there. +let __pfMain = fs.read("main.js"); + +// Start a new block to make use of temporary constants +{ + // List of words to replace in input/output pairs + const replaceList = [ + ["ws.connect", "await ws.connect"], // Treat ws.connect as an awaited promise + ["download.string", "await download.string"], // Treat download.string as an awaited promise + ["download.file", "await download.file"], // Treat download.file as an awaited promise + ["sleep", "await sleep"] // Await all sleep() calls + ]; + + // Temporarily substitute all strings to avoid tampering with those + const replacedStrings = []; + let stridx = 0; + + while ((stridx = Math.min(__pfMain.indexOf('"', stridx), __pfMain.indexOf("'", stridx))) !== -1) { + + // Store the character that started the string for reference + const stringChar = __pfMain[stridx]; + + // Look for a matching character that terminates the string + // Since the input is ES5, we don't have to parse template literals + let strend = stridx; + while (++strend < __pfMain.length) { + // There are no multiline strings in ES5, abort!! + if (__pfMain[strend] === "\n") break; + // Search for a string terminator of the same type + if (__pfMain[strend] !== stringChar) continue; + // Count the amount of backslashes + let escapes = 0; + while (__pfMain[strend - escapes - 1] === "\\") escapes ++; + // If it's an odd number, the terminator is escaped, keep looking + if (escapes % 2 === 1) continue; + // Otherwise, we've found it + break; + } + + // Make sure we didn't break from the loop early + if (__pfMain[strend] === stringChar) { + replacedStrings.push(__pfMain.slice(stridx, strend + 1)); + __pfMain = __pfMain.slice(0, stridx) + "__pfReplacedString" + __pfMain.slice(strend + 1); + // Offset new end index by replaced string length minus the length of the placeholder + strend -= (strend - stridx - 1) - 18; + } + + stridx = strend + 1; + + } + + // Replace the first element of each pair with the second + replaceList.forEach(function (pair) { + + // Ensure we only match whole words (not part of larger variable names) + const regex = new RegExp("(? log.error(e))`; + +} + +// Execute the modified script +eval(__pfMain); +// Throw to prevent continuing down to the original main.js file +throw { message: "Polyfilled script started. This is not an error." }; diff --git a/util/spplice.js b/util/spplice.js index fbf81e3..425542c 100644 --- a/util/spplice.js +++ b/util/spplice.js @@ -126,7 +126,10 @@ module.exports = async function (args, context = epochtal) { description, icon: iconLink, file: archiveLink, - weight: weight || 100 + weight: weight || 100, + // Ensures Spplice 3 cache is invalidated on every change + version: Date.now().toString(), + args: [] }); // Save the index to the file if it exists