/** * Sealed Sins, 2023. */ import { pick, get, set, merge } from 'lodash'; import { compile as handlebarsCompile } from 'handlebars'; import { parseNumber, parseObject } from './utils'; // Inspired by: // https://www.jmeiners.com/lc3-vm/ /** * VM register type. */ export enum VmReg { PC = 'PC', R0 = 'R0', R1 = 'R1', R2 = 'R2', R3 = 'R3', R4 = 'R4', R5 = 'R5', R6 = 'R6', R7 = 'R7', R8 = 'R8', R9 = 'R9', } /** * VM command type. */ // prettier-ignore export type VmCommand = ( | [ 'let', VmReg, VmVariable ] | [ 'mov', VmReg, VmReg ] | [ 'get', VmReg, VmReg, string ] | [ 'set', VmReg, VmReg, string ] | [ 'mrg', VmReg, VmReg ] | [ 'ldi', VmReg, string ] | [ 'sti', VmReg, string ] | [ 'tag', string ] | [ 'jmp', string ] | [ 'yld' ] | [ 'jeq', VmReg, VmReg, string ] | [ 'jgt', VmReg, VmReg, string ] | [ 'jlt', VmReg, VmReg, string ] | [ 'dbg', string ] | [ 'fmt', VmReg, string ] | [ 'not', VmReg ] | [ 'inc', VmReg ] | [ 'dec', VmReg ] | [ 'add', VmReg, VmReg ] | [ 'sub', VmReg, VmReg ] | [ 'mul', VmReg, VmReg ] | [ 'div', VmReg, VmReg ] ); /** * VM variable type. */ // prettier-ignore export type VmVariable = ( number | string | boolean | null | Array<VmVariable> | { [key: string]: VmVariable } ); /** * VM error with additional metadata. */ export class VmError extends Error { // prettier-ignore constructor(public messageOriginal: string, public lineNumber?: number) { super(`VmError (at ${lineNumber ?? '?'}): ${messageOriginal}`); } } /** * Generic Virtual Machine. */ export class VM { private mem: Record<string, VmVariable>; private reg: Record<VmReg, VmVariable>; constructor(private readonly code: Array<VmCommand> = []) { this.mem = {}; this.reg = { [VmReg.R0]: 0, [VmReg.R1]: 0, [VmReg.R2]: 0, [VmReg.R3]: 0, [VmReg.R4]: 0, [VmReg.R5]: 0, [VmReg.R6]: 0, [VmReg.R7]: 0, [VmReg.R8]: 0, [VmReg.R9]: 0, [VmReg.PC]: 0, }; } /** * Serialize VM state. */ public save() { const data = pick(this, ['reg', 'mem', 'code']); return JSON.stringify(data); } /** * Deserialize VM state. */ public load(state: string) { const data = parseObject(JSON.parse(state)); Object.assign(this, data); } /** * Get register scope. */ public regs() { return this.reg; } /** * Get register. */ public getReg<T extends VmVariable>(reg: VmReg) { return this.reg[reg] as T; } /** * Set register. */ public setReg<T extends VmVariable>(reg: VmReg, value: T) { this.reg[reg] = value; } /** * Get variable scope. */ public vars() { return this.mem; } /** * Get variable. */ public getVar<T extends VmVariable>(key: string) { return (this.mem[key] ?? null) as T; } /** * Set variable. */ public setVar<T extends VmVariable>(key: string, value: T) { this.mem[key] = value; } /** * Format string using data from VM. * Use `reg` to access registers and `mem` to access variable memory. */ public format(fmt: string) { const fscope = { reg: this.regs(), mem: this.vars() }; const render = handlebarsCompile(fmt); return render(fscope); } /** * Jump to the given tag (label). */ public jump(tag: string) { for (const [index, cmd] of this.code.entries()) { if (cmd[0] === 'tag' && cmd[1] === tag) { this.setReg(VmReg.PC, index + 1); return; } } // prettier-ignore throw new VmError( `Invalid jump target: ${tag}` ); } /** * Execute VM commands until the next `yld` command. */ public next() { const pc = this.getReg<number>(VmReg.PC); const cmd = this.code[pc]; if (cmd) { try { this.setReg(VmReg.PC, pc + 1); if (cmd[0] !== 'yld') { this.exec(cmd); this.next(); } } catch (err: any) { const text = err.messageOriginal || err.message || err.toString(); const lineNumber = err.lineNumber ?? pc + 1; throw new VmError(text, lineNumber); } } } /** * Execute given comand within current VM context. */ public exec(cmd: VmCommand) { switch (cmd[0]) { case 'let': { const [_, reg, value] = cmd; this.setReg(reg, value); break; } case 'mov': { const [_, regAcc, regVal] = cmd; const val = this.getReg(regVal); this.setReg(regAcc, val); break; } case 'get': { const [_, regAcc, regObj, path] = cmd; const obj = parseObject(this.getReg(regObj)); const val = get(obj, path, null) as VmVariable; this.setReg(regAcc, val); break; } case 'set': { const [_, regAcc, regObj, path] = cmd; const obj = parseObject(this.getReg(regObj)); const val = this.getReg(regAcc); set(obj, path, val); break; } case 'mrg': { const [_, regAcc, regUpd] = cmd; const acc = parseObject(this.getReg(regAcc)); const upd = parseObject(this.getReg(regUpd)); merge(acc, upd); break; } case 'ldi': { const [_, reg, key] = cmd; this.setReg(reg, this.getVar(key)); break; } case 'sti': { const [_, reg, key] = cmd; this.setVar(key, this.getReg(reg)); break; } case 'tag': { break; // handled by jump() method } case 'jmp': { const [_, tag] = cmd; this.jump(tag); break; } case 'yld': { break; // handled by next() method } case 'jeq': { const [_, regA, regB, tag] = cmd; const a = this.getReg(regA); const b = this.getReg(regB); if (a === b) { this.jump(tag); } break; } case 'jgt': { const [_, regA, regB, tag] = cmd; const a = parseNumber(this.getReg(regA)); const b = parseNumber(this.getReg(regB)); if (a > b) { this.jump(tag); } break; } case 'jlt': { const [_, regA, regB, tag] = cmd; const a = parseNumber(this.getReg(regA)); const b = parseNumber(this.getReg(regB)); if (a < b) { this.jump(tag); } break; } case 'dbg': { const [_, fmt] = cmd; const str = this.format(fmt); console.log(str); break; } case 'fmt': { const [_, regAcc, fmt] = cmd; const str = this.format(fmt); this.setReg(regAcc, str); break; } case 'not': { const [_, regAcc] = cmd; const acc = this.getReg(regAcc); this.setReg(regAcc, !acc); break; } case 'inc': { const [_, regAcc] = cmd; const acc = parseNumber(this.getReg(regAcc)); this.setReg(regAcc, acc + 1); break; } case 'dec': { const [_, regAcc] = cmd; const acc = parseNumber(this.getReg(regAcc)); this.setReg(regAcc, acc - 1); break; } case 'add': { const [_, regAcc, regVal] = cmd; const acc = parseNumber(this.getReg(regAcc)); const val = parseNumber(this.getReg(regVal)); this.setReg(regAcc, acc + val); break; } case 'sub': { const [_, regAcc, regVal] = cmd; const acc = parseNumber(this.getReg(regAcc)); const val = parseNumber(this.getReg(regVal)); this.setReg(regAcc, acc - val); break; } case 'mul': { const [_, regAcc, regVal] = cmd; const acc = parseNumber(this.getReg(regAcc)); const val = parseNumber(this.getReg(regVal)); this.setReg(regAcc, acc * val); break; } case 'div': { const [_, regAcc, regVal] = cmd; const acc = parseNumber(this.getReg(regAcc)); const val = parseNumber(this.getReg(regVal)); this.setReg(regAcc, acc / val); break; } default: { const exhaustiveSwitchTest: never = cmd; throw new VmError(`Unexpected: ${cmd}`); } } } }