const fs = require('fs'),
os = require('os'),
process = require('process'),
path = require('path'),
uuid = require('uuid').v4,
childProcess = require('child_process'),
NesRomFile = require('./nes-rom-file'),
getCallingPath = require('../util/get-calling-path'),
mesen = require('../util/mesen');
const RETRY_LIMIT = 200;
// NOTE: YES, the .exe is needed on all operating systems, since it depends on mono.
const mesenExe = mesen.getMesen();
const needsMono = process.platform !== 'win32';
const mesenOptions = ['/DoNotSaveSettings', '/ShowFPS=false', '/ShowLagCounter=false', '/ShowInputDisplay=false']
const tempDir = path.join(os.tmpdir(), 'nes-test'),
luaDir = path.join(tempDir, 'lua');
/**
* Controls a NES emulator, allowing you to run it frame-by-frame, and
* get values out of it. Be aware that almost every method on this is asynchronous, and should
* only be called when prefixed with `await`, or you may run into timing issues and emulator crashes!
*/
class NesEmulator {
romFile = null;
rawResult = null;
currentFrame = 0;
started = false;
testId;
testFile;
useTestRunner = !(process.env.DEBUG_OPEN_MESEN === 'true');
nesRomFileWrapper = null;
currentEventNumber = 1;
emulatorHandle = null;
crashed = false;
callingPath = null;
testDirectory;
/**
* Create a NesEmulator instance, read to be started and run your tests.
* @param {String} romFile The path to a rom file, relative to the working directory nes-test is called from.
* @throws {Error} Error if the rom file cannot be found.
*/
constructor(romFile) {
this.callingPath = getCallingPath();
this.romFile = path.resolve(getCallingPath(), romFile);
this.testId = uuid();
this.testDirectory = path.join(luaDir, this.testId);
if (!fs.existsSync(this.romFile)) {
throw new Error('Rom not found! -- ' + this.romFile);
}
this.nesRomFileWrapper = new NesRomFile(this.romFile);
// Build the temp directories if they do not exist
if (!fs.existsSync(this.testDirectory)) {
try { fs.mkdirSync(path.join(os.tmpdir(), 'nes-test')); } catch (e) { if (e.code !== 'EEXIST') { throw e; } }
try { fs.mkdirSync(luaDir); } catch (e) { if (e.code !== 'EEXIST') { throw e; } }
try { fs.mkdirSync(this.testDirectory); } catch (e) { if (e.code !== 'EEXIST') { throw e; } }
}
this.testFile = path.join(this.testDirectory, `test-` + this.testId + '.lua')
// Generate the lua file
let baseLua = fs.readFileSync(path.join(__dirname, '..', 'lua', 'emulator-controller.lua')).toString();
baseLua = baseLua.replace('-- [nes-test-replacement interopPath]', 'interopPath = "' + this._cleanWinPath(this.testDirectory + path.sep) + '"');
fs.writeFileSync(this.testFile, baseLua);
}
/**
* Run the given lua code, and return any values you set with
* NesTest.writeValue. Note that this is an internal method, and shouldn't be needed for most tests! The documentation of the
* lua core is also pretty lacking right now. If you use this, please try to help document it!
* @param {string} string The lua code to run. This can be spread across multiple lines.
* @returns {object} An object containing some internal state used by the tool. Any
* values you set with NesTest.writeValue() will be available.
*/
async runLua(string) {
if (!this.started) {
throw new Error('Emulator not running!');
}
// Format that the main lua code we use knows how to parse and execute.
// currentEventNumber is passed between this and the lua to make sure events happen in the right order.
const lua = `
local event = {}
function event.doAction()
${string.split('\n').join('\n ')}
end
function event.getNum()
return ${this.currentEventNumber}
end
return event`;
this.currentEventNumber++;
fs.writeFileSync(path.join(this.testDirectory, 'current-event.lua'), lua);
// Wait for the emulator/client lua to update js-status.json with the updated state.
// Try up to the limit of retries, waiting 50ms between each attempt.
let returnState = null;
for (let i = 0; i < RETRY_LIMIT; i++) {
try {
let str = fs.readFileSync(path.join(this.testDirectory, 'js-status.json'));
let state = JSON.parse(str);
if (state && state.eventNum === this.currentEventNumber) {
returnState = state;
break;
}
} catch (e) {
// Do nothing, just try again.
}
await new Promise(resolve => setTimeout(resolve, 50));
}
if (returnState === null) {
throw new Error('Command failed!');
}
if (returnState.log && returnState.log.length > 0) {
returnState.log.split('||').filter(a => a.length > 0).forEach(msg => {
console.info('[Mesen Lua]', msg)
});
}
return returnState;
}
/**
* Run a set number of frames in the emulator before handling further input. This can be used to wait for
* things like title screen rendering, level updates, etc.
* @param {Number} value How many frames to execute
*/
async runCpuFrames(value) {
await this.runLua(`NesTest.waitFrames(${value})`);
}
/**
* Press down one or more buttons for the next frame. If you need to do this for multiple frames,
* you will have to call it multiple times in a loop. Be aware that this command also advances the
* emulator one frame.
* @param {object} value An object with keys for any button on the keyboard, and a true
* or false value. (True is pressed, false is released.) Available keys:
* - a
* - b
* - up
* - down
* - left
* - right
* - start
* - select
* @param {number} controller Which controller to use.
* - 0 (player 1)
* - 1 (player 2)
* @example
* <caption>This will hold the up and a buttons for one frame</caption>
* await emulator.sendInput({up: true, a: true})
*/
async sendInput(value, controller=0) {
await this.runLua(`emu.setInput(${controller}, ${this.getLuaFormat(value)})`);
}
/**
* Take a screenshot of the emulator and store it for later use in tests. There are also two matchers available:
* - {@link JasmineMatchers#toBeSimilarToImage}
* - {@link JasmineMatchers#toBeIdenticalToImage}
* @example
* <caption>Takes a screenshot, saves it to "example.png" and compares it to a local "example.png"</caption>
* // Take a screenshot of the intro screen
* const screenshot = await emulator.takeScreenshot('example.png');
*
* // Do a comparison that they're similar (at least 80% the same)
* expect(screenshot).toBeSimilarToImage('./example.png');
*
* // Also test that they're identical. (You'll generally want to do only one of these tests, but both are provided for the example)
* expect(screenshot).toBeIdenticalToImage('./example.png');
* @param {String} filename The name of a screenshot to use.
* @param {object} options Options for the screenshot
* @param {String} options.copyToLocation Local path to copy the screenshot to, for use after the test ends.
* @returns The full path to the file created
*/
async takeScreenshot(filename, options = {copyToLocation: null}) {
if (filename.indexOf('/') !== -1 || filename.indexOf('\\') !== -1) {
throw new Error('filename for takeScreenshot cannot be a directory. If you want to save the file after the test run completes, provide the copyToLocation argument.');
}
const state = await this.runLua(`
local img = emu.takeScreenshot()
local imgFile, err = io.open("${this._cleanWinPath(path.join(this.testDirectory, filename))}", "wb")
if (err) then
NesTest.log("Failed writing image file" .. err)
NesTest.writeValue('success', 0)
NesTest.writeValue('errorMessage', '"' .. err .. '"')
end
imgFile:write(img)
imgFile:close()
NesTest.writeValue('success', 1)
`);
if (state.errorMessage || !state.success) {
throw new Error(state.errorMessage || 'Unknown error');
}
if (options.copyToLocation) {
fs.copyFileSync(this.getScreenshotPath(filename), path.join(this.callingPath, options.copyToLocation));
}
return this.getScreenshotPath(filename);
}
/**
* Given a string key, this will look up the label/variable mapped to the name and return that numeric value. Numeric values will
* be returned as-is. Everything else will return null.
* @tutorial Accessing Variables By Name
* @param {Number|String} address Either a numeric address or a string representing an address.
* @throws {Error} An error if a string address is not found in the game's debug file. (Or the file is not present)
* @returns {Number} A numeric address somewhere in the rom.
*/
getNumericAddress(address) {
if (typeof address === 'string') {
if (!this.nesRomFileWrapper.symbols) {
throw new Error('Debug file not found next to rom file! Cannot look addresses by name.');
}
if (typeof this.nesRomFileWrapper.symbols.c[address] !== 'undefined') {
return this.nesRomFileWrapper.symbols.c[address];
}
if (typeof this.nesRomFileWrapper.symbols.assembly[address] !== 'undefined') {
return this.nesRomFileWrapper.symbols.assembly[address];
}
// Last ditch effort, try to find a c symbol from assembly, since some variables don't get copied.
// See: https://cc65.github.io/mailarchive/2002-12/1875.html
if (typeof this.nesRomFileWrapper.symbols.assembly['_' + address] !== 'undefined') {
return this.nesRomFileWrapper.symbols.assembly['_' + address];
}
throw new Error('Address name not found in rom: ' + address);
} else if (typeof address === 'number') {
return address;
} else {
return null;
}
}
/**
* Get the value of a byte from within the NES memory using an address or debug symbol.
* @param {Number|String} address Either a numeric address, or a string representing a C or assembly variable
* @returns {Number} The requested byte.
* @tutorial Accessing Variables By Name
*/
async getByteValue(address) {
let numAddress = this.getNumericAddress(address);
const state = await this.runLua(`NesTest.writeValue('thisByte', emu.read(${numAddress}, emu.memType.cpuDebug))`);
return state.thisByte;
}
/**
* Sets a _memory_ address on the NES to the given value. Note this will have no effect on hardcoded memory addresses.
* @param {Number|String} address Either a numeric address, or a string representing a C or assembly variable
* @param {Number} value The value to set the given byte to
* @tutorial Accessing Variables By Name
*/
async setMemoryByteValue(address, value) {
let numAddress = this.getNumericAddress(address);
await this.runLua(`emu.write(${numAddress}, ${value}, emu.memType.cpuDebug)`);
}
/**
* Set a _data_ address on the NES to the given value. This will have no effect on variable memory addresses, only
* on hardcoded PRG data.
* @param {Number|String} address Either a numeric address, or a string representing a C or assembly variable
* @param {Number} value The value to set the given byte to
* @tutorial Accessing Variables By Name
*/
async setPrgByteValue(address, value) {
let numAddress = this.getNumericAddress(address);
await this.runLua(`emu.write(${numAddress}, ${value}, emu.memType.prgRom)`);
}
/**
* Set a byte in ppu memory to a given value.
* @param {Number|String} address Either a numeric address, or a string representing a C or assembly variable
* @param {Number} value The value to set the given byte to.
* @tutorial Accessing Variables By Name
*/
async setPpuByteValue(address, value) {
let numAddress = this.getNumericAddress(address);
await this.runLua(`emu.write(${numAddress}, ${value}, emu.memType.ppuDebug)`);
}
/**
* Get the value of a bytefrom within the PPU memory.
* @param {Number|String} address Either a numeric address, or a string representing a C or assembly variable
* @returns {Number} The requested byte.
* @tutorial Accessing Variables By Name
*/
async getPpuByteValue(address) {
let numAddress = this.getNumericAddress(address);
const state = await this.runLua(`NesTest.writeValue('thisByte', emu.read(${numAddress}, emu.memType.ppuDebug))`);
return state.thisByte;
}
/**
* Get the value of a word (two consecutive bytes) from within the NES memory.
* @param {Number|String} address Either a numeric address, or a string representing a C or assembly variable
* @returns {Number} The requested word.
* @tutorial Accessing Variables By Name
*/
async getWordValue(address) {
let numAddress = this.getNumericAddress(address);
const state = await this.runLua(`NesTest.writeValue('thisWord', emu.readWord(${numAddress}, emu.memType.cpuDebug))`);
return state.thisWord;
}
/**
* Get the value of a word (two consecutive bytes) from within the PPU memory.
* @param {Number|String} address Either a numeric address, or a string representing a C or assembly variable
* @returns {Number} The requested word.
* @tutorial Accessing Variables By Name
*/
async getPpuWordValue(address) {
let numAddress = this.getNumericAddress(address);
const state = await this.runLua(`NesTest.writeValue('thisWord', emu.readWord(${numAddress}, emu.memType.ppuDebug))`);
return state.thisWord;
}
/**
* Sets a _memory_ address on the NES to the given value. Note this will have no effect on hardcoded memory addresses.
* @param {Number|String} address Either a numeric address, or a string representing a C or assembly variable
* @param {Number} value The value to set the given word to
* @tutorial Accessing Variables By Name
*/
async setMemoryWordValue(address, value) {
let numAddress = this.getNumericAddress(address);
await this.runLua(`emu.write(${numAddress}, ${value}, emu.memType.cpuDebug)`);
}
/**
* Set a _data_ address on the NES to the given value. This will have no effect on variable memory addresses, only
* on hardcoded PRG data.
* @param {Number|String} address Either a numeric address, or a string representing a C or assembly variable
* @param {Number} value The value to set the given word to
* @tutorial Accessing Variables By Name
*/
async setPrgWordValue(address, value) {
let numAddress = this.getNumericAddress(address);
await this.runLua(`emu.writeWord(${numAddress}, ${value}, emu.memType.prgRom)`);
}
/**
* Set a word in ppu memory to a given value.
* @param {Number|String} address Either a numeric address, or a string representing a C or assembly variable
* @param {Number} value The value to set the given word to.
* @tutorial Accessing Variables By Name
*/
async setPpuWordValue(address, value) {
let numAddress = this.getNumericAddress(address);
await this.runLua(`emu.writeWord(${numAddress}, ${value}, emu.memType.ppuDebug)`);
}
/**
* Get a sequence of bytes from the game's memory, of the length requested.
* @param {Number|String} address Either a numeric address, or a string representing a C or assembly variable.
* @param {Number} length How many bytes to include in the sequence.
* @returns {Number[]} An array with the requested bytes.
* @tutorial Accessing Variables By Name
*/
async getByteRange(address, length) {
let numAddress = this.getNumericAddress(address);
const state = await this.runLua(`
a = {}
for i=1,${length} do
a[i] = emu.read(${numAddress} + i, emu.memType.cpuDebug)
end
NesTest.writeValue('range', '"' .. table.concat(a, ",") .. '"')
`);
return state.range.split(',').map(i => parseInt(i, 10));
}
/**
* Generates lua format from a simple json object
* @param {@} value A json object
* @returns A tring with the lua of that object
* @ignore
*/
getLuaFormat(value) {
if (typeof value === 'string') {
return '"' + value + '"';
}
if (typeof value !== 'object') {
return value;
}
// Generic object, not of our special type.
const kvLua = Object.keys(value).map((k) => {
const v = this.getLuaFormat(value[k]);
return ' ' + k + ' = ' + v;
});
return `{ ${kvLua} }`;
}
/**
* Start the emulator, so that commands can be run.
*/
async start() {
// Add the "stop" event to wrap things up
this.started = true;
this.currentEventNumber = 1;
this.crashed = false;
// Clean up any event file that might somehow already be in place (Mainly if you're reusing an emulator)
try { fs.rmSync(path.join(luaDir, 'current-event.lua')) } catch (e) {}
// Run mesen, get the result code
this.emulatorHandle = childProcess.spawn(
needsMono ? 'mono' : mesenExe,
[...(needsMono ? [mesenExe] : []), ...(this.useTestRunner ? ['--testrunner'] : []), ...mesenOptions, this.romFile, this.testFile],
{cwd: tempDir}
);
this.emulatorHandle.stdout.on('data', msg => {
console.debug('[Mesen stdout]', msg.toString());
});
this.emulatorHandle.stderr.on('data', msg => {
console.debug('[Mesen stderr]', msg.toString());
});
this.emulatorHandle.on('error', error => {
this.started = false;
this.emulatorHandle = false;
this.crashed = true;
console.error('Emulator crashed!', error);
});
this.emulatorHandle.on('close', code => {
this.started = false;
this.emulatorHandle = false;
if (code !== 0) {
this.crashed = true;
console.warn('Emulator exited with non-zero code:', code);
}
});
}
/**
* Stop the emulator and clean up all temporary test data.
* @param {number} code Optional error code to return from Mesen when it exits. You probbably don't want to set this.
*/
async stop(code=0) {
if (this.started) {
if (this.useTestRunner) {
await this.runLua(`emu.stop(${code})`);
try { fs.rmSync(this.testDirectory, {recursive: true}) } catch (e) { console.error('Failed deleting test data', e); }
} else {
await this.runLua('emu.breakExecution()');
}
}
}
/**
* Get the full path to a test image, generally to compare with a prepared image.
* @param {String} image The name of the image given when the screenshot was taken
* @returns The full path to the image file created, for use in tests.
*/
getScreenshotPath(image) {
return path.join(this.testDirectory, image);
}
/**
* Clean up a windows path so the backslashes don't confuse lua.
* @param {String} string The path
* @returns The same path, but with backslashes escaped (sorta);
* @ignore
*/
_cleanWinPath(string) {
return string.replace(/\\/g, '\\\\');
}
/**
* Download and prepare the emulator, if required. Not needed if running within nes-test.
*/
async ensureEmulatorAvailable() {
return mesen.ensureMesenAvailable();
}
}
module.exports = NesEmulator;