PHP Classes

File: scripts/dosbox-bridge/emulator-adapters.js

Recommend this page to a friend!
  Packages of Matthew Asham   Binkterm PHP   scripts/dosbox-bridge/emulator-adapters.js   Download  
File: scripts/dosbox-bridge/emulator-adapters.js
Role: Auxiliary data
Content type: text/plain
Description: Auxiliary data
Class: Binkterm PHP
Bulletin board system based on the Web
Author: By
Last change:
Date: 5 days ago
Size: 27,843 bytes
 

Contents

Class file image Download
/** * Emulator Adapter Pattern * Supports multiple DOS emulators (DOSBox, DOSEMU) with a common interface */ const net = require('net'); const fs = require('fs'); const path = require('path'); const { spawn } = require('child_process'); const pty = require('node-pty'); /** * Base Emulator Adapter * Defines the interface all emulators must implement */ class EmulatorAdapter { constructor(basePath) { this.basePath = basePath; } /** * Launch the emulator with the door game * @param {Object} session - Session object * @param {Object} sessionData - Session data from database * @returns {Promise<Object>} - { process, pid, connection } */ async launch(session, sessionData) { throw new Error('launch() must be implemented by subclass'); } /** * Set up data handlers (emulator -> WebSocket) * @param {Function} onData - Callback for data from emulator */ onData(onData) { throw new Error('onData() must be implemented by subclass'); } /** * Set up exit handler (called when emulator process exits) * @param {Function} onExit - Callback for process exit (code, signal) */ onExit(onExit) { this.exitCallback = onExit; } /** * Write data to emulator (WebSocket -> emulator) * @param {Buffer} data - Data to write */ write(data) { throw new Error('write() must be implemented by subclass'); } /** * Close/cleanup the emulator connection */ close() { throw new Error('close() must be implemented by subclass'); } /** * Resize the terminal (no-op for adapters that don't support it) * @param {number} cols * @param {number} rows */ resize(cols, rows) { // No-op by default; overridden by NativeAdapter } /** * Get emulator name for logging */ getName() { return 'Unknown'; } } /** * DOSBox Adapter * Uses TCP nullmodem connection */ class DOSBoxAdapter extends EmulatorAdapter { constructor(basePath) { super(basePath); this.tcpServer = null; this.socket = null; this.process = null; } getName() { return 'DOSBox'; } async launch(session, sessionData) { const { session_id, node_number, door_id, session_path } = sessionData; const slog = this.slog || console; // Allocate TCP port (handled by caller, passed in session.tcpPort) const tcpPort = session.tcpPort; slog.log(`[${this.getName()}] Launching for session ${session_id} on port ${tcpPort}`); // Generate DOSBox config const configPath = this.generateConfig(sessionData, session_path, tcpPort); slog.log(`[${this.getName()}] Generated config: ${configPath}`); // Find DOSBox executable const dosboxExe = this.findExecutable(); if (!dosboxExe) { throw new Error('DOSBox executable not found'); } // Build launch arguments const headless = process.env.DOSDOOR_HEADLESS !== 'false'; const isLinux = process.platform !== 'win32'; let args; if (headless) { args = isLinux ? ['-noconsole', '-conf', configPath, '-exit'] : ['-nogui', '-conf', configPath, '-exit']; } else { args = ['-conf', configPath, '-exit']; } // Spawn options const spawnOptions = { cwd: this.basePath, detached: false, stdio: 'ignore' }; // Linux headless: set SDL_VIDEODRIVER=dummy if (isLinux && headless) { spawnOptions.env = { ...process.env, SDL_VIDEODRIVER: 'dummy' }; } slog.log(`[${this.getName()}] Spawning: ${dosboxExe} ${args.join(' ')}`); // Launch DOSBox this.process = spawn(dosboxExe, args, spawnOptions); this.process.on('error', (err) => { slog.error(`[${this.getName()}] Process error:`, err.message); }); this.process.on('exit', (code, signal) => { slog.log(`[${this.getName()}] Process exited: code=${code}, signal=${signal}`); if (this.exitCallback) { this.exitCallback(code, signal); } }); return { process: this.process, pid: this.process.pid, tcpPort: tcpPort }; } /** * Set up TCP server to accept DOSBox connection */ async createTCPListener(tcpPort, onConnection) { const slog = this.slog || console; return new Promise((resolve, reject) => { this.tcpServer = net.createServer((socket) => { slog.log(`[${this.getName()}] Connection received on port ${tcpPort}`); this.socket = socket; socket.setNoDelay(true); onConnection(socket); }); this.tcpServer.listen(tcpPort, '127.0.0.1', () => { slog.log(`[${this.getName()}] TCP listener ready on port ${tcpPort}`); resolve(); }); this.tcpServer.on('error', (err) => { slog.error(`[${this.getName()}] TCP server error:`, err.message); reject(err); }); }); } onData(callback) { if (this.socket) { this.socket.on('data', callback); } } write(data) { if (this.socket && !this.socket.destroyed) { this.socket.write(data); } } close() { const slog = this.slog || console; // Close TCP socket first to simulate carrier loss (lost carrier event) // This allows the door game to detect the disconnection and exit gracefully if (this.socket) { slog.log(`[${this.getName()}] Closing TCP socket (simulating carrier loss)`); this.socket.destroy(); this.socket = null; } if (this.tcpServer) { this.tcpServer.close(); this.tcpServer = null; } // Don't kill process immediately - let it detect carrier loss // The process exit handler or removeSession will handle killing if needed // Process will be killed by timeout if it doesn't exit on carrier loss } generateConfig(sessionData, sessionPath, tcpPort) { const { door_id, node_number } = sessionData; // Read base config template const headless = process.env.DOSDOOR_HEADLESS !== 'false'; const configTemplate = headless ? path.join(this.basePath, 'dosbox-bridge', 'dosbox-bridge-production.conf') : path.join(this.basePath, 'dosbox-bridge', 'dosbox-bridge-test.conf'); let config = fs.readFileSync(configTemplate, 'utf8'); // Replace port placeholder config = config.replace(/port:5000/g, `port:${tcpPort}`); // Get door manifest to build launch command // DOORS directory is uppercase - Linux filesystem is case-sensitive const manifestPath = path.join(this.basePath, 'dosbox-bridge', 'dos', 'DOORS', door_id.toUpperCase(), 'dosdoor.jsn'); const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); // Build door launch command const doorDir = manifest.door.directory.replace('dosbox-bridge/dos', '').replace(/\//g, '\\'); // Use custom dropfile_path if specified, otherwise use node-based default const dropDir = manifest.door.dropfile_path ? manifest.door.dropfile_path : `\\DROPS\\NODE${node_number}`; let launchCmd = manifest.door.launch_command || `call ${manifest.door.executable}`; launchCmd = launchCmd.replace('{node}', node_number); launchCmd = launchCmd.replace('{dropfile}', 'DOOR.SYS'); launchCmd = launchCmd.replace('{user_number}', String(sessionData.user_id || '')); // Check if door requires FOSSIL driver (default true for backwards compatibility) const fossilRequired = manifest.door.fossil_required !== false; const fossilCmd = fossilRequired ? '\\FOSSIL\\BNU.COM\n' : ''; // Only copy DOOR.SYS if drop directory is different from door directory // (when using custom dropfile_path, they may be the same) const copyCmd = (dropDir !== doorDir) ? `copy ${dropDir}\\DOOR.SYS ${doorDir}\\DOOR.SYS\n` : ''; // Build autoexec commands const autoexecCommands = `${fossilCmd}${copyCmd}cd ${doorDir}\n${launchCmd}\necho.\necho Door exited\nexit`; // Replace autoexec placeholder config = config.replace('# Door-specific commands will be appended here', autoexecCommands); // Write session-specific config const configPath = path.join(sessionPath, 'dosbox.conf'); fs.writeFileSync(configPath, config); return configPath; } findExecutable() { const slog = this.slog || console; const envPath = process.env.DOSBOX_EXECUTABLE; if (envPath && fs.existsSync(envPath)) { slog.log(`[${this.getName()}] Using configured executable: ${envPath}`); return envPath; } const candidates = process.platform === 'win32' ? ['dosbox.exe', 'dosbox-x.exe', 'c:\\dosbox\\dosbox.exe', 'c:\\dosbox-x\\dosbox-x.exe'] : ['dosbox', 'dosbox-x']; for (const candidate of candidates) { try { const result = require('child_process').spawnSync( process.platform === 'win32' ? 'where' : 'which', [candidate], { encoding: 'utf8' } ); if (result.status === 0 && result.stdout.trim()) { const exePath = result.stdout.trim().split('\n')[0]; slog.log(`[${this.getName()}] Found executable: ${exePath}`); return candidate; } } catch (e) { // Command not found, try next } } slog.log(`[${this.getName()}] No executable found in PATH, trying "dosbox"`); return 'dosbox'; } } /** * DOSEMU Adapter * Uses PTY (pseudo-terminal) connection */ class DOSEMUAdapter extends EmulatorAdapter { constructor(basePath) { super(basePath); this.pty = null; this.process = null; } getName() { return 'DOSEMU'; } async launch(session, sessionData) { const { session_id, node_number, door_id, session_path } = sessionData; const slog = this.slog || console; const tcpPort = session.tcpPort; // Port allocated by bridge slog.log(`[${this.getName()}] Launching for session ${session_id} on port ${tcpPort}`); // Generate DOSEMU config with TCP port const configPath = this.generateConfigWithPort(sessionData, session_path, tcpPort); slog.log(`[${this.getName()}] Generated config: ${configPath}`); // Find DOSEMU executable const dosemuExe = this.findExecutable(); if (!dosemuExe) { throw new Error('DOSEMU executable not found'); } // Build DOSEMU command const doorScript = this.generateDoorScript(session_id, door_id, node_number, session_path); const args = [ '-f', configPath, // Use our config file '-E', doorScript // Execute script ]; slog.log(`[${this.getName()}] Spawning: ${dosemuExe} ${args.join(' ')}`); // Spawn DOSEMU with normal spawn (not PTY) const dosemuCwd = path.join(this.basePath, 'dosbox-bridge', 'dos'); this.process = spawn(dosemuExe, args, { cwd: dosemuCwd, env: process.env, stdio: 'ignore' }); this.process.on('exit', (code, signal) => { slog.log(`[${this.getName()}] Process exited: code=${code}, signal=${signal}`); if (this.exitCallback) { this.exitCallback(code, signal); } }); return { process: this.process, pid: this.process.pid, tcpPort: tcpPort }; } /** * Create TCP listener (same as DOSBox) */ async createTCPListener(tcpPort, onConnection) { const slog = this.slog || console; return new Promise((resolve, reject) => { this.tcpServer = net.createServer((socket) => { slog.log(`[${this.getName()}] Connection received on port ${tcpPort}`); this.socket = socket; socket.setNoDelay(true); onConnection(socket); }); this.tcpServer.listen(tcpPort, '127.0.0.1', () => { slog.log(`[${this.getName()}] TCP listener ready on port ${tcpPort}`); resolve(); }); this.tcpServer.on('error', (err) => { slog.error(`[${this.getName()}] TCP server error:`, err.message); reject(err); }); }); } onData(callback) { if (this.socket) { this.socket.on('data', callback); } } write(data) { if (this.socket && !this.socket.destroyed) { this.socket.write(data); } } close() { const slog = this.slog || console; // Close TCP socket first to simulate carrier loss (lost carrier event) // This allows the door game to detect the disconnection and exit gracefully if (this.socket) { slog.log(`[${this.getName()}] Closing TCP socket (simulating carrier loss)`); this.socket.destroy(); this.socket = null; } if (this.tcpServer) { this.tcpServer.close(); this.tcpServer = null; } // Don't kill process immediately - let it detect carrier loss // The process exit handler or removeSession will handle killing if needed // Process will be killed by timeout if it doesn't exit on carrier loss } generateConfigWithPort(sessionData, sessionPath, tcpPort) { const configPath = path.join(sessionPath, 'dosemu.conf'); const dosDir = path.join(this.basePath, 'dosbox-bridge', 'dos'); // DOSEMU config with TCP serial port const config = ` # DOSEMU Configuration for Door Session $_cpu = "80486" $_hogthreshold = (10) $_external_charset = "utf8" $_internal_charset = "cp437" # Allow lredir to access our directory $_lredir_paths = [ "${dosDir}" ] # Serial port configuration - TCP connection (like DOSBox) $_com1 = "tcp:127.0.0.1:${tcpPort}" # Video settings $_console = "0" $_graphics = "0" $_X = "0" # Disable sound $_sound = "0" `; fs.writeFileSync(configPath, config); return configPath; } generateConfig(sessionData, sessionPath) { const configPath = path.join(sessionPath, 'dosemu.conf'); // Path to our door files const dosDir = path.join(this.basePath, 'dosbox-bridge', 'dos'); // DOSEMU config - allow lredir to our directory const config = ` # DOSEMU Configuration for Door Session $_cpu = "80486" $_hogthreshold = (10) $_external_charset = "utf8" $_internal_charset = "cp437" # Allow lredir to access our directory $_lredir_paths = [ "${dosDir}" ] # Serial port configuration - use TCP for COM1 (same as DOSBox) # This will be replaced with actual port number $_com1 = "tcp:127.0.0.1:__PORT__" # Video settings $_console = "0" $_graphics = "0" $_X = "0" # Disable sound $_sound = "0" `; fs.writeFileSync(configPath, config); return configPath; } generateDoorScript(sessionId, doorId, nodeNumber, sessionPath) { const door_id = doorId; const node_number = nodeNumber; // Get door manifest // DOORS directory is uppercase - Linux filesystem is case-sensitive const manifestPath = path.join(this.basePath, 'dosbox-bridge', 'dos', 'DOORS', door_id.toUpperCase(), 'dosdoor.jsn'); const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); // Build door launch command const doorDir = manifest.door.directory.replace('dosbox-bridge/dos/', ''); const dropDir = `DROPS/NODE${node_number}`; let launchCmd = manifest.door.launch_command || manifest.door.executable; launchCmd = launchCmd.replace('{node}', node_number); launchCmd = launchCmd.replace('{dropfile}', 'DOOR.SYS'); launchCmd = launchCmd.replace('{user_number}', String(sessionData.user_id || '')); // Create DOS batch file to launch door // First use lredir to map our Linux directory, then run door commands const dosDir = path.join(this.basePath, 'dosbox-bridge', 'dos'); const scriptContent = `@echo off lredir c: linux\\fs${dosDir} c: copy ${dropDir}\\DOOR.SYS ${doorDir}\\DOOR.SYS cd ${doorDir} ${launchCmd} `; // Put script in dosbox-bridge/dos so DOSEMU finds it as C:\launch.bat const scriptPath = path.join(this.basePath, 'dosbox-bridge', 'dos', `launch-${sessionId}.bat`); fs.writeFileSync(scriptPath, scriptContent); // Return DOS path for DOSEMU return `C:\\launch-${sessionId}.bat`; } findExecutable() { const slog = this.slog || console; const envPath = process.env.DOSEMU_EXECUTABLE; if (envPath && fs.existsSync(envPath)) { slog.log(`[${this.getName()}] Using configured executable: ${envPath}`); return envPath; } const candidates = ['/usr/bin/dosemu', '/usr/local/bin/dosemu', 'dosemu']; for (const candidate of candidates) { if (fs.existsSync(candidate)) { slog.log(`[${this.getName()}] Found executable: ${candidate}`); return candidate; } try { const result = require('child_process').spawnSync('which', [candidate], { encoding: 'utf8' }); if (result.status === 0 && result.stdout.trim()) { const exePath = result.stdout.trim(); slog.log(`[${this.getName()}] Found executable: ${exePath}`); return exePath; } } catch (e) { // Command not found, try next } } return null; } } /** * Native Linux Door Adapter * Runs native Linux binaries directly via PTY (pseudo-terminal) * User data is passed via DOOR.SYS drop file and environment variables */ class NativeAdapter extends EmulatorAdapter { constructor(basePath) { super(basePath); this.ptyProcess = null; this.outputEncoding = 'utf8'; } getName() { return 'Native'; } async launch(session, sessionData) { const { session_id, node_number, door_id } = sessionData; const slog = this.slog || console; slog.log(`[${this.getName()}] Launching door '${door_id}' for session ${session_id} on node ${node_number}`); // Load nativedoor.json manifest const manifestPath = path.join(this.basePath, 'native-doors', 'doors', door_id, 'nativedoor.json'); if (!fs.existsSync(manifestPath)) { throw new Error(`Native door manifest not found: ${manifestPath}`); } const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); // Build drop file path const dropPath = path.join(this.basePath, 'native-doors', 'drops', `NODE${node_number}`); // Determine drop file filename from manifest format const dropfileFormat = (manifest.door && manifest.door.dropfile_format) || 'DOOR.SYS'; const dropfileName = dropfileFormat === 'DOOR32.SYS' ? 'DOOR32.SYS' : 'DOOR.SYS'; const dropfileFull = path.join(dropPath, dropfileName); // Determine output encoding (cp437 for legacy DOS-style doors, utf8 for modern) this.outputEncoding = (manifest.door && manifest.door.output_encoding) || 'utf8'; const ptyEncoding = this.outputEncoding === 'cp437' ? 'binary' : 'utf8'; // Build launch command - replace {node}, {dropfile}, and {user_number} placeholders // On Windows, prefer launch_command_windows (e.g. WSL via wsl.exe) over the Linux bash command const isWindows = process.platform === 'win32'; let launchCmd = (isWindows && manifest.door.launch_command_windows) ? manifest.door.launch_command_windows : (manifest.door.launch_command || manifest.door.executable); launchCmd = launchCmd.replace(/\{node\}/g, String(node_number)); launchCmd = launchCmd.replace(/\{dropfile\}/g, dropfileFull); launchCmd = launchCmd.replace(/\{user_number\}/g, String(sessionData.user_id || '')); // Resolve ${VAR:-default} environment variable references in the launch command. // This lets manifest commands reference .env variables without shell interpretation. launchCmd = launchCmd.replace(/\$\{([A-Z_][A-Z0-9_]*):-([^}]*)\}/g, (_, varName, defaultVal) => { return process.env[varName] || defaultVal; }); // Shell-style tokenizer: respects single/double-quoted segments so paths // with spaces (e.g. basePath or substituted {dropfile}) are kept intact. const argv = []; let token = ''; let inSingle = false; let inDouble = false; for (let i = 0; i < launchCmd.length; i++) { const ch = launchCmd[i]; if (inSingle) { if (ch === "'") inSingle = false; else token += ch; } else if (inDouble) { if (ch === '"') inDouble = false; else token += ch; } else if (ch === "'") { inSingle = true; } else if (ch === '"') { inDouble = true; } else if (ch === ' ' || ch === '\t') { if (token.length > 0) { argv.push(token); token = ''; } } else { token += ch; } } if (token.length > 0) argv.push(token); let cmd = argv.shift(); const args = argv; const doorDir = path.join(this.basePath, 'native-doors', 'doors', door_id); // On Windows, node-pty doesn't search PATH reliably ? resolve bare command // names to absolute paths using `where.exe`. Skip if the command already // contains a path separator (absolute or relative path). if (isWindows && !cmd.includes('/') && !cmd.includes('\\')) { try { const whereResult = require('child_process').spawnSync('where', [cmd], { encoding: 'utf8' }); if (whereResult.status === 0) { cmd = whereResult.stdout.split(/\r?\n/)[0].trim(); } } catch (e) { slog.warn(`[${this.getName()}] Could not resolve path for '${cmd}':`, e.message); } } // Parse user data for environment variables const userData = typeof sessionData.user_data === 'string' ? JSON.parse(sessionData.user_data) : (sessionData.user_data || {}); const env = { ...process.env, DOOR_USER_NAME: userData.handle || userData.username || 'Guest', DOOR_USER_REAL_NAME: userData.real_name || 'Guest', DOOR_USER_NUMBER: String(sessionData.user_id || ''), DOOR_NODE: String(node_number), DOOR_BBS_NAME: userData.bbs_name || 'BinktermPHP BBS', DOOR_DROPFILE: dropfileFull, DOOR_ANSI: '1', TERM: 'xterm-256color' }; slog.log(`[${this.getName()}] Spawning: ${cmd} ${args.join(' ')} in ${doorDir} (output_encoding=${this.outputEncoding})`); // Read terminal size from admin config (config/nativedoors.json) let initCols = 80, initRows = 25; const doorConfigPath = path.join(this.basePath, 'config', 'nativedoors.json'); try { if (fs.existsSync(doorConfigPath)) { const doorConfigs = JSON.parse(fs.readFileSync(doorConfigPath, 'utf8')); const doorCfg = doorConfigs[door_id] || {}; const sizeStr = doorCfg.terminal_size || '80x25'; if (sizeStr !== 'autofit') { const sizeParts = sizeStr.split('x').map(Number); if (sizeParts[0] > 0) initCols = Math.max(20, Math.min(500, sizeParts[0])); if (sizeParts[1] > 0) initRows = Math.max(5, Math.min(200, sizeParts[1])); } } } catch (_) {} slog.log(`[${this.getName()}] Spawning pty at ${initCols}x${initRows}`); this.ptyProcess = pty.spawn(cmd, args, { name: 'xterm-256color', cols: initCols, rows: initRows, cwd: doorDir, // node-pty on Windows (conpty) does not support encoding ? omit it ...(isWindows ? {} : { encoding: ptyEncoding }), env }); this.ptyProcess.onExit(({ exitCode, signal }) => { slog.log(`[${this.getName()}] Process exited: code=${exitCode}, signal=${signal}`); if (this.exitCallback) { this.exitCallback(exitCode, signal); } }); return { process: this.ptyProcess, pid: this.ptyProcess.pid }; } onData(callback) { if (this.ptyProcess) { this.ptyProcess.onData(callback); } } write(data) { if (this.ptyProcess) { this.ptyProcess.write(data.toString('utf8')); } } resize(cols, rows) { const slog = this.slog || console; if (this.ptyProcess) { slog.log(`[${this.getName()}] Resizing pty to ${cols}x${rows}`); this.ptyProcess.resize(cols, rows); } else { slog.log(`[${this.getName()}] resize() called but ptyProcess is null`); } } close() { const slog = this.slog || console; if (this.ptyProcess) { slog.log(`[${this.getName()}] Killing PTY process`); this.ptyProcess.kill(); this.ptyProcess = null; } } } /** * Factory function to select appropriate emulator * * @param {string} basePath - Base path for BinktermPHP * @param {string} [doorType='dos'] - Door type: 'dos' or 'native' */ function createEmulatorAdapter(basePath, doorType) { // Native Linux doors use PTY directly - no emulator needed if (doorType === 'native') { console.log('[EMULATOR] Door type: native, using NativeAdapter'); return new NativeAdapter(basePath); } // DOS doors: check environment variable for emulator preference const preferredEmulator = process.env.DOOR_EMULATOR || 'auto'; // Windows: only DOSBox supported if (process.platform === 'win32') { console.log('[EMULATOR] Platform: Windows, using DOSBox'); return new DOSBoxAdapter(basePath); } // Linux: check preference if (preferredEmulator === 'dosemu') { // DOSEMU explicitly requested const dosemuAdapter = new DOSEMUAdapter(basePath); if (dosemuAdapter.findExecutable()) { console.log('[EMULATOR] Using DOSEMU (explicitly requested)'); return dosemuAdapter; } else { console.log('[EMULATOR] DOSEMU requested but not found, falling back to DOSBox'); } } // Default to DOSBox (reliable, proven) console.log('[EMULATOR] Using DOSBox (default)'); return new DOSBoxAdapter(basePath); } module.exports = { EmulatorAdapter, DOSBoxAdapter, DOSEMUAdapter, NativeAdapter, createEmulatorAdapter };