/**
* 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
};
|