diff --git a/js/apple2.ts b/js/apple2.ts index 35ff4a9..bb6f619 100644 --- a/js/apple2.ts +++ b/js/apple2.ts @@ -37,6 +37,11 @@ export interface Apple2Options { tick: () => void, } +export interface Stats { + frames: number, + renderedFrames: number, +} + interface State { cpu: CpuState, vm: VideoModesState, @@ -66,7 +71,7 @@ export class Apple2 implements Restorable, DebuggerContainer { private tick: () => void; - private stats = { + private stats: Stats = { frames: 0, renderedFrames: 0 }; @@ -215,7 +220,7 @@ export class Apple2 implements Restorable, DebuggerContainer { this.cpu.reset(); } - getStats() { + getStats(): Stats { return this.stats; } diff --git a/js/applesoft/compiler.js b/js/applesoft/compiler.js deleted file mode 100644 index a08ea84..0000000 --- a/js/applesoft/compiler.js +++ /dev/null @@ -1,287 +0,0 @@ -export default function ApplesoftCompiler(mem) -{ - var _mem = mem; - - var LOMEM = 0x69; - var ARRAY_START = 0x6B; - var ARRAY_END = 0x6D; - var PROGRAM_START = 0x801; - - var TOKENS = { - 'END': 0x80, - 'FOR': 0x81, - 'NEXT': 0x82, - 'DATA': 0x83, - 'INPUT': 0x84, - 'DEL': 0x85, - 'DIM': 0x86, - 'READ': 0x87, - 'GR': 0x88, - 'TEXT': 0x89, - 'PR#': 0x8a, - 'IN#': 0x8b, - 'CALL': 0x8c, - 'PLOT': 0x8d, - 'HLIN': 0x8e, - 'VLIN': 0x8f, - 'HGR2': 0x90, - 'HGR': 0x91, - 'HCOLOR=': 0x92, - 'HPLOT': 0x93, - 'DRAW': 0x94, - 'XDRAW': 0x95, - 'HTAB': 0x96, - 'HOME': 0x97, - 'ROT=': 0x98, - 'SCALE=': 0x99, - 'SHLOAD': 0x9a, - 'TRACE': 0x9b, - 'NOTRACE': 0x9c, - 'NORMAL': 0x9d, - 'INVERSE': 0x9e, - 'FLASH': 0x9f, - 'COLOR=': 0xa0, - 'POP=': 0xa1, - 'VTAB': 0xa2, - 'HIMEM:': 0xa3, - 'LOMEM:': 0xa4, - 'ONERR': 0xa5, - 'RESUME': 0xa6, - 'RECALL': 0xa7, - 'STORE': 0xa8, - 'SPEED=': 0xa9, - 'LET': 0xaa, - 'GOTO': 0xab, - 'RUN': 0xac, - 'IF': 0xad, - 'RESTORE': 0xae, - '&': 0xaf, - 'GOSUB': 0xb0, - 'RETURN': 0xb1, - 'REM': 0xb2, - 'STOP': 0xb3, - 'ON': 0xb4, - 'WAIT': 0xb5, - 'LOAD': 0xb6, - 'SAVE': 0xb7, - 'DEF': 0xb8, - 'POKE': 0xb9, - 'PRINT': 0xba, - 'CONT': 0xbb, - 'LIST': 0xbc, - 'CLEAR': 0xbd, - 'GET': 0xbe, - 'NEW': 0xbf, - 'TAB(': 0xc0, - 'TO': 0xc1, - 'FN': 0xc2, - 'SPC(': 0xc3, - 'THEN': 0xc4, - 'AT': 0xc5, - 'NOT': 0xc6, - 'STEP': 0xc7, - '+': 0xc8, - '-': 0xc9, - '*': 0xca, - '/': 0xcb, - '^': 0xcc, - 'AND': 0xcd, - 'OR': 0xce, - '>': 0xcf, - '=': 0xd0, - '<': 0xd1, - 'SGN': 0xd2, - 'INT': 0xd3, - 'ABS': 0xd4, - 'USR': 0xd5, - 'FRE': 0xd6, - 'SCRN(': 0xd7, - 'PDL': 0xd8, - 'POS': 0xd9, - 'SQR': 0xda, - 'RND': 0xdb, - 'LOG': 0xdc, - 'EXP': 0xdd, - 'COS': 0xde, - 'SIN': 0xdf, - 'TAN': 0xe0, - 'ATN': 0xe1, - 'PEEK': 0xe2, - 'LEN': 0xe3, - 'STR$': 0xe4, - 'VAL': 0xe5, - 'ASC': 0xe6, - 'CHR$': 0xe7, - 'LEFT$': 0xe8, - 'RIGHT$': 0xe9, - 'MID$': 0xea - }; - - var STATES = { - NORMAL: 0, - STRING: 1, - COMMENT: 2, - DATA: 3 - }; - - function writeByte(addr, val) { - var page = addr >> 8, - off = addr & 0xff; - - return _mem.write(page, off, val); - } - - function writeWord(addr, val) { - var lsb = val & 0xff; - var msb = val >> 8; - - writeByte(addr, lsb); - writeByte(addr + 1, msb); - } - - return { - compile: function(program) { - var lineNos = {}; - - function compileLine(line, offset) { - if (!line) { - return []; - } - - var state = STATES.NORMAL; - var result = [0, 0, 0, 0]; - var curChar = 0; - var character; - var lineNoStr = ''; - - while (line.length) { - character = line.charAt(curChar); - if (/\d/.test(character)) { - lineNoStr += character; - curChar++; - } else { - break; - } - } - - while (curChar < line.length) { - character = line.charAt(curChar).toUpperCase(); - switch (state) { - case STATES.NORMAL: - if (character !== ' ') { - if (character === '"') { - result.push(character.charCodeAt(0)); - state = STATES.STRING; - curChar++; - } else { - var foundToken = ''; - for (var possibleToken in TOKENS) { - if (possibleToken.charAt(0) == character) { - var tokenIdx = curChar + 1; - var idx = 1; - while (idx < possibleToken.length) { - if (line.charAt(tokenIdx) !== ' ') { - if (line.charAt(tokenIdx).toUpperCase() !== possibleToken.charAt(idx)) { - break; - } - idx++; - } - tokenIdx++; - } - if (idx === possibleToken.length) { - // Found a token - if (possibleToken === 'AT') { - var lookAhead = line.charAt(tokenIdx + 1).toUpperCase(); - // ATN takes precedence over AT - if (lookAhead === 'N') { - foundToken = 'ATN'; - tokenIdx++; - } - // TO takes precedence over AT - if (lookAhead === 'O') { - result.push(lookAhead.charCodeAt(0)); - foundToken = 'TO'; - tokenIdx++; - } - } - foundToken = possibleToken; - } - } - if (foundToken) { - break; - } - } - if (foundToken) { - result.push(TOKENS[foundToken]); - curChar = tokenIdx; - if (foundToken === 'REM') { - state = STATES.COMMENT; - } - } else { - result.push(character.charCodeAt(0)); - curChar++; - } - } - } else { - curChar++; - } - break; - case STATES.COMMENT: - result.push(character.charCodeAt(0)); - curChar++; - break; - case STATES.STRING: - result.push(character.charCodeAt(0)); - if (character == '"') { - state = STATES.NORMAL; - } - curChar++; - break; - } - } - - if (lineNoStr.length) { - var lineNo = parseInt(lineNoStr, 10); - if (lineNo < 0 || lineNo > 65535) { - throw new Error('Line number out of range'); - } - if (lineNos[lineNoStr]) { - throw new Error('Duplicate line number'); - } - lineNos[lineNoStr] = result; - - // Next line pointer - result.push(0); - var nextLine = offset + result.length; - result[0] = nextLine & 0xff; - result[1] = nextLine >> 8; - - // Line number - result[2] = lineNo & 0xff; - result[3] = lineNo >> 8; - } else { - throw new Error('Missing line number'); - } - - return result; - } - - var compiled = []; - var lines = program.split(/[\r\n]+/g); - - while (lines.length) { - var line = lines.shift(); - var compiledLine = compileLine(line, PROGRAM_START + compiled.length); - compiled = compiled.concat(compiledLine); - } - compiled.push(0, 0); - - for (var idx = 0; idx < compiled.length; idx++) { - writeByte(PROGRAM_START + idx, compiled[idx]); - } - writeWord(LOMEM, PROGRAM_START + compiled.length); - writeWord(ARRAY_START, PROGRAM_START + compiled.length); - writeWord(ARRAY_END, PROGRAM_START + compiled.length); - } - }; -} diff --git a/js/applesoft/compiler.ts b/js/applesoft/compiler.ts new file mode 100644 index 0000000..5ffba6c --- /dev/null +++ b/js/applesoft/compiler.ts @@ -0,0 +1,288 @@ +import { byte, KnownKeys, KnownValues, Memory, word } from '../types'; + +/** Map from keyword to token. */ +const TOKENS = { + 'END': 0x80, + 'FOR': 0x81, + 'NEXT': 0x82, + 'DATA': 0x83, + 'INPUT': 0x84, + 'DEL': 0x85, + 'DIM': 0x86, + 'READ': 0x87, + 'GR': 0x88, + 'TEXT': 0x89, + 'PR#': 0x8a, + 'IN#': 0x8b, + 'CALL': 0x8c, + 'PLOT': 0x8d, + 'HLIN': 0x8e, + 'VLIN': 0x8f, + 'HGR2': 0x90, + 'HGR': 0x91, + 'HCOLOR=': 0x92, + 'HPLOT': 0x93, + 'DRAW': 0x94, + 'XDRAW': 0x95, + 'HTAB': 0x96, + 'HOME': 0x97, + 'ROT=': 0x98, + 'SCALE=': 0x99, + 'SHLOAD': 0x9a, + 'TRACE': 0x9b, + 'NOTRACE': 0x9c, + 'NORMAL': 0x9d, + 'INVERSE': 0x9e, + 'FLASH': 0x9f, + 'COLOR=': 0xa0, + 'POP=': 0xa1, + 'VTAB': 0xa2, + 'HIMEM:': 0xa3, + 'LOMEM:': 0xa4, + 'ONERR': 0xa5, + 'RESUME': 0xa6, + 'RECALL': 0xa7, + 'STORE': 0xa8, + 'SPEED=': 0xa9, + 'LET': 0xaa, + 'GOTO': 0xab, + 'RUN': 0xac, + 'IF': 0xad, + 'RESTORE': 0xae, + '&': 0xaf, + 'GOSUB': 0xb0, + 'RETURN': 0xb1, + 'REM': 0xb2, + 'STOP': 0xb3, + 'ON': 0xb4, + 'WAIT': 0xb5, + 'LOAD': 0xb6, + 'SAVE': 0xb7, + 'DEF': 0xb8, + 'POKE': 0xb9, + 'PRINT': 0xba, + 'CONT': 0xbb, + 'LIST': 0xbc, + 'CLEAR': 0xbd, + 'GET': 0xbe, + 'NEW': 0xbf, + 'TAB(': 0xc0, + 'TO': 0xc1, + 'FN': 0xc2, + 'SPC(': 0xc3, + 'THEN': 0xc4, + 'AT': 0xc5, + 'NOT': 0xc6, + 'STEP': 0xc7, + '+': 0xc8, + '-': 0xc9, + '*': 0xca, + '/': 0xcb, + '^': 0xcc, + 'AND': 0xcd, + 'OR': 0xce, + '>': 0xcf, + '=': 0xd0, + '<': 0xd1, + 'SGN': 0xd2, + 'INT': 0xd3, + 'ABS': 0xd4, + 'USR': 0xd5, + 'FRE': 0xd6, + 'SCRN(': 0xd7, + 'PDL': 0xd8, + 'POS': 0xd9, + 'SQR': 0xda, + 'RND': 0xdb, + 'LOG': 0xdc, + 'EXP': 0xdd, + 'COS': 0xde, + 'SIN': 0xdf, + 'TAN': 0xe0, + 'ATN': 0xe1, + 'PEEK': 0xe2, + 'LEN': 0xe3, + 'STR$': 0xe4, + 'VAL': 0xe5, + 'ASC': 0xe6, + 'CHR$': 0xe7, + 'LEFT$': 0xe8, + 'RIGHT$': 0xe9, + 'MID$': 0xea +} as const; + +const LOMEM = 0x69; +const ARRAY_START = 0x6B; +const ARRAY_END = 0x6D; +const PROGRAM_START = 0x801; + +const STATES = { + NORMAL: 0, + STRING: 1, + COMMENT: 2, + DATA: 3 +} as const; + +export default class ApplesoftCompiler { + constructor(private mem: Memory) { } + + private writeByte(addr: word, val: byte) { + const page = addr >> 8; + const off = addr & 0xff; + + return this.mem.write(page, off, val); + } + + private writeWord(addr: word, val: byte) { + const lsb = val & 0xff; + const msb = val >> 8; + + this.writeByte(addr, lsb); + this.writeByte(addr + 1, msb); + } + + compile(program: string) { + const lineNos: { [line: string]: byte[]} = {}; + + function compileLine(line: string | null | undefined, offset: number) { + if (!line) { + return []; + } + + let state: KnownValues = STATES.NORMAL; + const result = [0, 0, 0, 0]; + let curChar = 0; + let character; + let lineNoStr = ''; + + while (line.length) { + character = line.charAt(curChar); + if (/\d/.test(character)) { + lineNoStr += character; + curChar++; + } else { + break; + } + } + + while (curChar < line.length) { + character = line.charAt(curChar).toUpperCase(); + switch (state) { + case STATES.NORMAL: + if (character !== ' ') { + if (character === '"') { + result.push(character.charCodeAt(0)); + state = STATES.STRING; + curChar++; + } else { + let foundToken = ''; + let tokenIdx = -1; + for (const possibleToken in TOKENS) { + if (possibleToken.charAt(0) == character) { + tokenIdx = curChar + 1; + let idx = 1; + while (idx < possibleToken.length) { + if (line.charAt(tokenIdx) !== ' ') { + if (line.charAt(tokenIdx).toUpperCase() !== possibleToken.charAt(idx)) { + break; + } + idx++; + } + tokenIdx++; + } + if (idx === possibleToken.length) { + // Found a token + if (possibleToken === 'AT') { + const lookAhead = line.charAt(tokenIdx + 1).toUpperCase(); + // ATN takes precedence over AT + if (lookAhead === 'N') { + foundToken = 'ATN'; + tokenIdx++; + } + // TO takes precedence over AT + if (lookAhead === 'O') { + result.push(lookAhead.charCodeAt(0)); + foundToken = 'TO'; + tokenIdx++; + } + } + foundToken = possibleToken; + } + } + if (foundToken) { + break; + } + } + if (foundToken) { + result.push(TOKENS[foundToken as KnownKeys]); + curChar = tokenIdx; + if (foundToken === 'REM') { + state = STATES.COMMENT; + } + } else { + result.push(character.charCodeAt(0)); + curChar++; + } + } + } else { + curChar++; + } + break; + case STATES.COMMENT: + result.push(character.charCodeAt(0)); + curChar++; + break; + case STATES.STRING: + result.push(character.charCodeAt(0)); + if (character == '"') { + state = STATES.NORMAL; + } + curChar++; + break; + } + } + + if (lineNoStr.length) { + const lineNo = parseInt(lineNoStr, 10); + if (lineNo < 0 || lineNo > 65535) { + throw new Error('Line number out of range'); + } + if (lineNos[lineNoStr]) { + throw new Error('Duplicate line number'); + } + lineNos[lineNoStr] = result; + + // Next line pointer + result.push(0); + const nextLine = offset + result.length; + result[0] = nextLine & 0xff; + result[1] = nextLine >> 8; + + // Line number + result[2] = lineNo & 0xff; + result[3] = lineNo >> 8; + } else { + throw new Error('Missing line number'); + } + + return result; + } + + let compiled: number[] = []; + const lines = program.split(/[\r\n]+/g); + + while (lines.length) { + const line = lines.shift(); + const compiledLine = compileLine(line, PROGRAM_START + compiled.length); + compiled = compiled.concat(compiledLine); + } + compiled.push(0, 0); + + for (let idx = 0; idx < compiled.length; idx++) { + this.writeByte(PROGRAM_START + idx, compiled[idx]); + } + this.writeWord(LOMEM, PROGRAM_START + compiled.length); + this.writeWord(ARRAY_START, PROGRAM_START + compiled.length); + this.writeWord(ARRAY_END, PROGRAM_START + compiled.length); + } +} diff --git a/js/applesoft/decompiler.js b/js/applesoft/decompiler.js deleted file mode 100644 index b78f899..0000000 --- a/js/applesoft/decompiler.js +++ /dev/null @@ -1,173 +0,0 @@ -export default function ApplesoftDump(mem) -{ - var _mem = mem; - - var LETTERS = - ' ' + - ' !"#$%&\'()*+,-./0123456789:;<=>?' + - '@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_' + - '`abcdefghijklmnopqrstuvwxyz{|}~ '; - var TOKENS = { - 0x80: 'END', - 0x81: 'FOR', - 0x82: 'NEXT', - 0x83: 'DATA', - 0x84: 'INPUT', - 0x85: 'DEL', - 0x86: 'DIM', - 0x87: 'READ', - 0x88: 'GR', - 0x89: 'TEXT', - 0x8a: 'PR#', - 0x8b: 'IN#', - 0x8c: 'CALL', - 0x8d: 'PLOT', - 0x8e: 'HLIN', - 0x8f: 'VLIN', - 0x90: 'HGR2', - 0x91: 'HGR', - 0x92: 'HCOLOR=', - 0x93: 'HPLOT', - 0x94: 'DRAW', - 0x95: 'XDRAW', - 0x96: 'HTAB', - 0x97: 'HOME', - 0x98: 'ROT=', - 0x99: 'SCALE=', - 0x9a: 'SHLOAD', - 0x9b: 'TRACE', - 0x9c: 'NOTRACE', - 0x9d: 'NORMAL', - 0x9e: 'INVERSE', - 0x9f: 'FLASH', - 0xa0: 'COLOR=', - 0xa1: 'POP=', - 0xa2: 'VTAB', - 0xa3: 'HIMEM:', - 0xa4: 'LOMEM:', - 0xa5: 'ONERR', - 0xa6: 'RESUME', - 0xa7: 'RECALL', - 0xa8: 'STORE', - 0xa9: 'SPEED=', - 0xaa: 'LET', - 0xab: 'GOTO', - 0xac: 'RUN', - 0xad: 'IF', - 0xae: 'RESTORE', - 0xaf: '&', - 0xb0: 'GOSUB', - 0xb1: 'RETURN', - 0xb2: 'REM', - 0xb3: 'STOP', - 0xb4: 'ON', - 0xb5: 'WAIT', - 0xb6: 'LOAD', - 0xb7: 'SAVE', - 0xb8: 'DEF', - 0xb9: 'POKE', - 0xba: 'PRINT', - 0xbb: 'CONT', - 0xbc: 'LIST', - 0xbd: 'CLEAR', - 0xbe: 'GET', - 0xbf: 'NEW', - 0xc0: 'TAB(', - 0xc1: 'TO', - 0xc2: 'FN', - 0xc3: 'SPC(', - 0xc4: 'THEN', - 0xc5: 'AT', - 0xc6: 'NOT', - 0xc7: 'STEP', - 0xc8: '+', - 0xc9: '-', - 0xca: '*', - 0xcb: '/', - 0xcc: '^', - 0xcd: 'AND', - 0xce: 'OR', - 0xcf: '>', - 0xd0: '=', - 0xd1: '<', - 0xd2: 'SGN', - 0xd3: 'INT', - 0xd4: 'ABS', - 0xd5: 'USR', - 0xd6: 'FRE', - 0xd7: 'SCRN(', - 0xd8: 'PDL', - 0xd9: 'POS', - 0xda: 'SQR', - 0xdb: 'RND', - 0xdc: 'LOG', - 0xdd: 'EXP', - 0xde: 'COS', - 0xdf: 'SIN', - 0xe0: 'TAN', - 0xe1: 'ATN', - 0xe2: 'PEEK', - 0xe3: 'LEN', - 0xe4: 'STR$', - 0xe5: 'VAL', - 0xe6: 'ASC', - 0xe7: 'CHR$', - 0xe8: 'LEFT$', - 0xe9: 'RIGHT$', - 0xea: 'MID$' - }; - - function readByte(addr) { - var page = addr >> 8, - off = addr & 0xff; - - return _mem.read(page, off); - } - - function readWord(addr) { - var lsb, msb; - - lsb = readByte(addr); - msb = readByte(addr + 1); - - return (msb << 8) | lsb; - } - - return { - toString: function () { - var str = ''; - var start = readWord(0x67); // Start - var end = readWord(0xaf); // End of program - var addr = start; - do { - var line = ''; - var next = readWord(addr); - addr += 2; - var lineno = readWord(addr); - addr += 2; - - line += lineno; - line += ' '; - var val = false; - do { - if (addr < start || addr > end) - return str; - - val = readByte(addr++); - if (val >= 0x80) { - line += ' '; - line += TOKENS[val]; - line += ' '; - } - else - line += LETTERS[val]; - } while (val); - line += '\n'; - str += line; - addr = next; - } while (addr && addr >= start && addr < end); - - return str; - } - }; -} diff --git a/js/applesoft/decompiler.ts b/js/applesoft/decompiler.ts new file mode 100644 index 0000000..d06d5e5 --- /dev/null +++ b/js/applesoft/decompiler.ts @@ -0,0 +1,171 @@ +import { byte, KnownKeys, Memory, word } from '../types'; + +const LETTERS = + ' ' + + ' !"#$%&\'()*+,-./0123456789:;<=>?' + + '@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_' + + '`abcdefghijklmnopqrstuvwxyz{|}~ '; + +const TOKENS = { + 0x80: 'END', + 0x81: 'FOR', + 0x82: 'NEXT', + 0x83: 'DATA', + 0x84: 'INPUT', + 0x85: 'DEL', + 0x86: 'DIM', + 0x87: 'READ', + 0x88: 'GR', + 0x89: 'TEXT', + 0x8a: 'PR#', + 0x8b: 'IN#', + 0x8c: 'CALL', + 0x8d: 'PLOT', + 0x8e: 'HLIN', + 0x8f: 'VLIN', + 0x90: 'HGR2', + 0x91: 'HGR', + 0x92: 'HCOLOR=', + 0x93: 'HPLOT', + 0x94: 'DRAW', + 0x95: 'XDRAW', + 0x96: 'HTAB', + 0x97: 'HOME', + 0x98: 'ROT=', + 0x99: 'SCALE=', + 0x9a: 'SHLOAD', + 0x9b: 'TRACE', + 0x9c: 'NOTRACE', + 0x9d: 'NORMAL', + 0x9e: 'INVERSE', + 0x9f: 'FLASH', + 0xa0: 'COLOR=', + 0xa1: 'POP=', + 0xa2: 'VTAB', + 0xa3: 'HIMEM:', + 0xa4: 'LOMEM:', + 0xa5: 'ONERR', + 0xa6: 'RESUME', + 0xa7: 'RECALL', + 0xa8: 'STORE', + 0xa9: 'SPEED=', + 0xaa: 'LET', + 0xab: 'GOTO', + 0xac: 'RUN', + 0xad: 'IF', + 0xae: 'RESTORE', + 0xaf: '&', + 0xb0: 'GOSUB', + 0xb1: 'RETURN', + 0xb2: 'REM', + 0xb3: 'STOP', + 0xb4: 'ON', + 0xb5: 'WAIT', + 0xb6: 'LOAD', + 0xb7: 'SAVE', + 0xb8: 'DEF', + 0xb9: 'POKE', + 0xba: 'PRINT', + 0xbb: 'CONT', + 0xbc: 'LIST', + 0xbd: 'CLEAR', + 0xbe: 'GET', + 0xbf: 'NEW', + 0xc0: 'TAB(', + 0xc1: 'TO', + 0xc2: 'FN', + 0xc3: 'SPC(', + 0xc4: 'THEN', + 0xc5: 'AT', + 0xc6: 'NOT', + 0xc7: 'STEP', + 0xc8: '+', + 0xc9: '-', + 0xca: '*', + 0xcb: '/', + 0xcc: '^', + 0xcd: 'AND', + 0xce: 'OR', + 0xcf: '>', + 0xd0: '=', + 0xd1: '<', + 0xd2: 'SGN', + 0xd3: 'INT', + 0xd4: 'ABS', + 0xd5: 'USR', + 0xd6: 'FRE', + 0xd7: 'SCRN(', + 0xd8: 'PDL', + 0xd9: 'POS', + 0xda: 'SQR', + 0xdb: 'RND', + 0xdc: 'LOG', + 0xdd: 'EXP', + 0xde: 'COS', + 0xdf: 'SIN', + 0xe0: 'TAN', + 0xe1: 'ATN', + 0xe2: 'PEEK', + 0xe3: 'LEN', + 0xe4: 'STR$', + 0xe5: 'VAL', + 0xe6: 'ASC', + 0xe7: 'CHR$', + 0xe8: 'LEFT$', + 0xe9: 'RIGHT$', + 0xea: 'MID$' +} as const; + +export default class ApplesoftDump { + constructor(private mem: Memory) { } + + private readByte(addr: word): byte { + const page = addr >> 8; + const off = addr & 0xff; + + return this.mem.read(page, off); + } + + private readWord(addr: word): word { + const lsb = this.readByte(addr); + const msb = this.readByte(addr + 1); + + return (msb << 8) | lsb; + } + + toString() { + let str = ''; + const start = this.readWord(0x67); // Start + const end = this.readWord(0xaf); // End of program + let addr = start; + do { + let line = ''; + const next = this.readWord(addr); + addr += 2; + const lineno = this.readWord(addr); + addr += 2; + + line += lineno; + line += ' '; + let val = 0; + do { + if (addr < start || addr > end) + return str; + + val = this.readByte(addr++); + if (val >= 0x80) { + line += ' '; + line += TOKENS[val as KnownKeys]; + line += ' '; + } + else + line += LETTERS[val]; + } while (val); + line += '\n'; + str += line; + addr = next; + } while (addr && addr >= start && addr < end); + + return str; + } +} diff --git a/js/cards/disk2.ts b/js/cards/disk2.ts index be81b4e..ede4450 100644 --- a/js/cards/disk2.ts +++ b/js/cards/disk2.ts @@ -787,7 +787,7 @@ export default class DiskII implements Card { this.callbacks.label(drive, name); } - getJSON(drive: DriveNumber, pretty: boolean) { + getJSON(drive: DriveNumber, pretty: boolean = false) { const cur = this.drives[drive - 1]; if (!isNibbleDrive(cur)) { throw new Error('Can\'t save WOZ disks to JSON'); @@ -801,7 +801,7 @@ export default class DiskII implements Card { return true; } - setBinary(drive: DriveNumber, name: string, fmt: DiskFormat, rawData: memory) { + setBinary(drive: DriveNumber, name: string, fmt: DiskFormat, rawData: ArrayBuffer) { let disk; const cur = this.drives[drive - 1]; const readOnly = false; diff --git a/js/cards/smartport.ts b/js/cards/smartport.ts index a9116b8..04a8827 100644 --- a/js/cards/smartport.ts +++ b/js/cards/smartport.ts @@ -456,6 +456,7 @@ export default class SmartPort implements Card, Restorable { for (let idx = 0; idx < data.byteLength; idx += 512) { this.disks[drive].push(new Uint8Array(data.slice(idx, idx + 512))); } + return true; } setDisk(drive: number, json: BlockDevice) { diff --git a/js/formats/format_utils.ts b/js/formats/format_utils.ts index a138429..6b73008 100644 --- a/js/formats/format_utils.ts +++ b/js/formats/format_utils.ts @@ -12,6 +12,7 @@ import { byte, DiskFormat, memory } from '../types'; import { base64_decode, base64_encode } from '../base64'; import { bytify, debug, toHex } from '../util'; +import { GamepadConfiguration } from '../ui/gamepad'; export interface Disk { format: DiskFormat @@ -27,8 +28,12 @@ export interface Disk { export class JSONDiskBase { type: DiskFormat name: string + disk?: number + category?: string + writeProtected?: boolean volume: byte readOnly: boolean + gamepad?: GamepadConfiguration } /** diff --git a/js/gl.ts b/js/gl.ts index 2d960a3..6c142d0 100644 --- a/js/gl.ts +++ b/js/gl.ts @@ -668,7 +668,7 @@ export class VideoModesGL implements VideoModes { private _displayConfig: screenEmu.DisplayConfiguration; private _monoMode: boolean = false; - ready: Promise + public ready: Promise constructor( gr: LoresPage, diff --git a/js/types.ts b/js/types.ts index 213e83a..eb5dbc3 100644 --- a/js/types.ts +++ b/js/types.ts @@ -31,6 +31,25 @@ export type KnownKeys = { [K in keyof T]: string extends K ? never : number extends K ? never : K } extends { [_ in keyof T]: infer U } ? U : never; +/** + * Extracts the declared values of a constant object. + */ +export type KnownValues = T extends { + [_ in keyof T]: infer U } ? U : never; + +/** + * Replacement for `includes` on constant types that is also a type assertion. + * + * @example + * const SOME_VALUES = [1, 2, 'a'] as const; + * let n: number = 1; + * let r = includes(SOME_VALUES, n); // r === true, n is 1 | 2 | 'a' + * n = 5; + * r = includes(SOME_VALUES, n); // r === false, n is number + */ +export function includes(a: ReadonlyArray, v: T): v is S { + return (a as ReadonlyArray).includes(v); +} /** A bit. */ export type bit = 0 | 1; diff --git a/js/ui/apple2.js b/js/ui/apple2.js deleted file mode 100644 index 8719a94..0000000 --- a/js/ui/apple2.js +++ /dev/null @@ -1,937 +0,0 @@ -import MicroModal from 'micromodal'; - -import { base64_json_parse, base64_json_stringify } from '../base64'; -import Audio from './audio'; -import DriveLights from './drive_lights'; -import { DISK_FORMATS } from '../types'; -import { gamepad, configGamepad, initGamepad } from './gamepad'; -import KeyBoard from './keyboard'; -import Tape, { TAPE_TYPES } from './tape'; - -import ApplesoftDump from '../applesoft/decompiler'; -import ApplesoftCompiler from '../applesoft/compiler'; - -import { debug, gup, hup } from '../util'; -import Prefs from '../prefs'; - -var paused = false; - -var focused = false; -var startTime = Date.now(); -var lastCycles = 0; -var lastFrames = 0; -var lastRenderedFrames = 0; - -var hashtag = document.location.hash; - -var disk_categories = {'Local Saves': []}; -var disk_sets = {}; -var disk_cur_name = []; -var disk_cur_cat = []; - -var _apple2; -var cpu; -var stats; -var vm; -var tape; -var _disk2; -var _smartPort; -var _printer; -var audio; -var keyboard; -var io; -var _currentDrive = 1; - -export const driveLights = new DriveLights(); - -export function dumpAppleSoftProgram() { - var dumper = new ApplesoftDump(cpu); - debug(dumper.toString()); -} - -export function compileAppleSoftProgram(program) { - var compiler = new ApplesoftCompiler(cpu); - compiler.compile(program); -} - -export function openLoad(drive, event) { - _currentDrive = parseInt(drive, 10); - if (event.metaKey) { - openLoadHTTP(drive); - } else { - if (disk_cur_cat[drive]) { - document.querySelector('#category_select').value = disk_cur_cat[drive]; - selectCategory(); - } - MicroModal.show('load-modal'); - } -} - -export function openSave(drive, event) { - _currentDrive = parseInt(drive, 10); - - var mimeType = 'application/octet-stream'; - var data = _disk2.getBinary(drive); - var a = document.querySelector('#local_save_link'); - - var blob = new Blob([data], { 'type': mimeType }); - a.href = window.URL.createObjectURL(blob); - a.download = driveLights.label(drive) + '.dsk'; - - if (event.metaKey) { - dumpDisk(drive); - } else { - document.querySelector('#save_name').value = driveLights.label(drive); - MicroModal.show('save-modal'); - } -} - -export function openAlert(msg) { - var el = document.querySelector('#alert-modal .message'); - el.innerText = msg; - MicroModal.show('alert-modal'); -} - -/******************************************************************** - * - * Drag and Drop - */ - -export function handleDragOver(drive, event) { - event.preventDefault(); - event.dataTransfer.dropEffect = 'copy'; -} - -export function handleDragEnd(drive, event) { - var dt = event.dataTransfer; - if (dt.items) { - for (var i = 0; i < dt.items.length; i++) { - dt.items.remove(i); - } - } else { - event.dataTransfer.clearData(); - } -} - -export function handleDrop(drive, event) { - event.preventDefault(); - event.stopPropagation(); - - if (drive < 1) { - if (!_disk2.getMetadata(1)) { - drive = 1; - } else if (!_disk2.getMetadata(2)) { - drive = 2; - } else { - drive = 1; - } - } - var dt = event.dataTransfer; - if (dt.files.length == 1) { - var runOnLoad = event.shiftKey; - doLoadLocal(drive, dt.files[0], { runOnLoad }); - } else if (dt.files.length == 2) { - doLoadLocal(1, dt.files[0]); - doLoadLocal(2, dt.files[1]); - } else { - for (var idx = 0; idx < dt.items.length; idx++) { - if (dt.items[idx].type === 'text/uri-list') { - dt.items[idx].getAsString(function(url) { - var parts = document.location.hash.split('|'); - parts[drive - 1] = url; - document.location.hash = parts.join('|'); - }); - } - } - } -} - -function loadingStart () { - var meter = document.querySelector('#loading-modal .meter'); - meter.style.display = 'none'; - MicroModal.show('loading-modal'); -} - -function loadingProgress (current, total) { - if (total) { - var meter = document.querySelector('#loading-modal .meter'); - var progress = document.querySelector('#loading-modal .progress'); - meter.style.display = 'block'; - progress.style.width = current / total * meter.clientWidth + 'px'; - } -} - -function loadingStop () { - MicroModal.close('loading-modal'); - - if (!paused) { - vm.ready.then(() => { - _apple2.run(); - }); - } -} - -export function loadAjax(drive, url) { - loadingStart(); - - fetch(url).then(function(response) { - if (response.ok) { - return response.json(); - } else { - throw new Error('Error loading: ' + response.statusText); - } - }).then(function(data) { - if (data.type == 'binary') { - loadBinary(drive, data); - } else if (DISK_FORMATS.indexOf(data.type) > -1) { - loadDisk(drive, data); - } - initGamepad(data.gamepad); - loadingStop(); - }).catch(function(error) { - loadingStop(); - openAlert(error.message); - }); -} - -export function doLoad() { - MicroModal.close('load-modal'); - var urls = document.querySelector('#disk_select').value, url; - if (urls && urls.length) { - if (typeof(urls) == 'string') { - url = urls; - } else { - url = urls[0]; - } - } - - var files = document.querySelector('#local_file').files; - if (files.length == 1) { - doLoadLocal(_currentDrive, files[0]); - } else if (url) { - var filename; - MicroModal.close('load-modal'); - if (url.substr(0,6) == 'local:') { - filename = url.substr(6); - if (filename == '__manage') { - openManage(); - } else { - loadLocalStorage(_currentDrive, filename); - } - } else { - var r1 = /json\/disks\/(.*).json$/.exec(url); - if (r1) { - filename = r1[1]; - } else { - filename = url; - } - var parts = document.location.hash.split('|'); - parts[_currentDrive - 1] = filename; - document.location.hash = parts.join('|'); - } - } -} - -export function doSave() { - var name = document.querySelector('#save_name').value; - saveLocalStorage(_currentDrive, name); - MicroModal.close('save-modal'); - window.setTimeout(() => openAlert('Saved'), 0); -} - -export function doDelete(name) { - if (window.confirm('Delete ' + name + '?')) { - deleteLocalStorage(name); - } -} - -const CIDERPRESS_EXTENSION = /#([0-9a-f]{2})([0-9a-f]{4})$/i; -const BIN_TYPES = ['bin']; - -function doLoadLocal(drive, file, options = {}) { - var parts = file.name.split('.'); - var ext = parts[parts.length - 1].toLowerCase(); - var matches = file.name.match(CIDERPRESS_EXTENSION); - var type, aux; - if (matches && matches.length === 3) { - [, type, aux] = matches; - } - if (DISK_FORMATS.includes(ext)) { - doLoadLocalDisk(drive, file); - } else if (TAPE_TYPES.includes(ext)) { - tape.doLoadLocalTape(file); - } else if (BIN_TYPES.includes(ext) || type === '06' || options.address) { - doLoadBinary(file, { address: parseInt(aux || '2000', 16), ...options }); - } else { - openAlert('Unknown file type: ' + ext); - } -} - -function doLoadBinary(file, options) { - loadingStart(); - - var fileReader = new FileReader(); - fileReader.onload = function() { - let { address } = options; - const bytes = new Uint8Array(this.result); - for (let idx = 0; idx < this.result.byteLength; idx++) { - cpu.write(address >> 8, address & 0xff, bytes[idx]); - address++; - } - if (options.runOnLoad) { - cpu.reset(); - cpu.setPC(options.address); - } - loadingStop(); - }; - fileReader.readAsArrayBuffer(file); -} - -function doLoadLocalDisk(drive, file) { - loadingStart(); - var fileReader = new FileReader(); - fileReader.onload = function() { - var parts = file.name.split('.'); - var ext = parts.pop().toLowerCase(); - var name = parts.join('.'); - - // Remove any json file reference - var files = document.location.hash.split('|'); - files[drive - 1] = ''; - document.location.hash = files.join('|'); - - if (this.result.byteLength >= 800 * 1024) { - if (_smartPort.setBinary(drive, name, ext, this.result)) { - focused = false; - initGamepad(); - } - } else { - if (_disk2.setBinary(drive, name, ext, this.result)) { - focused = false; - initGamepad(); - } - } - loadingStop(); - }; - fileReader.readAsArrayBuffer(file); -} - -export function doLoadHTTP(drive, _url) { - if (!_url) { - MicroModal.close('http-modal'); - } - - loadingStart(); - var url = _url || document.querySelector('#http_url').value; - if (url) { - fetch(url).then(function(response) { - if (response.ok) { - var reader = response.body.getReader(); - var received = 0; - var chunks = []; - var contentLength = parseInt(response.headers.get('content-length'), 10); - - return reader.read().then(function readChunk(result) { - if (result.done) { - var data = new Uint8Array(received); - var offset = 0; - for (var idx = 0; idx < chunks.length; idx++) { - data.set(chunks[idx], offset); - offset += chunks[idx].length; - } - return data.buffer; - } - - received += result.value.length; - if (contentLength) { - loadingProgress(received, contentLength); - } - chunks.push(result.value); - - return reader.read().then(readChunk); - }); - } else { - throw new Error('Error loading: ' + response.statusText); - } - }).then(function(data) { - var urlParts = url.split('/'); - var file = urlParts.pop(); - var fileParts = file.split('.'); - var ext = fileParts.pop().toLowerCase(); - var name = decodeURIComponent(fileParts.join('.')); - if (data.byteLength >= 800 * 1024) { - if (_smartPort.setBinary(drive, name, ext, data)) { - initGamepad(); - } - } else { - if (_disk2.setBinary(drive, name, ext, data)) { - initGamepad(); - } - } - loadingStop(); - }).catch(function(error) { - loadingStop(); - openAlert(error.message); - }); - } -} - -function openLoadHTTP(drive) { - _currentDrive = parseInt(drive, 10); - MicroModal.show('http-modal'); -} - -function openManage() { - MicroModal.show('manage-modal'); -} - -var prefs = new Prefs(); -var showStats = 0; - -export function updateKHz() { - var now = Date.now(); - var ms = now - startTime; - var cycles = cpu.getCycles(); - var delta; - var fps; - var khz; - - switch (showStats) { - case 0: { - delta = cycles - lastCycles; - khz = parseInt(delta/ms); - document.querySelector('#khz').innerText = khz + ' kHz'; - break; - } - case 1: { - delta = stats.renderedFrames - lastRenderedFrames; - fps = parseInt(delta/(ms/1000), 10); - document.querySelector('#khz').innerText = fps + ' rps'; - break; - } - default: { - delta = stats.frames - lastFrames; - fps = parseInt(delta/(ms/1000), 10); - document.querySelector('#khz').innerText = fps + ' fps'; - } - } - - startTime = now; - lastCycles = cycles; - lastRenderedFrames = stats.renderedFrames; - lastFrames = stats.frames; -} - -export function toggleShowFPS() { - showStats = ++showStats % 3; -} - -export function updateSound() { - var on = document.querySelector('#enable_sound').checked; - var label = document.querySelector('#toggle-sound i'); - audio.enable(on); - if (on) { - label.classList.remove('fa-volume-off'); - label.classList.add('fa-volume-up'); - } else { - label.classList.remove('fa-volume-up'); - label.classList.add('fa-volume-off'); - } -} - -function dumpDisk(drive) { - var wind = window.open('', '_blank'); - wind.document.title = driveLights.label(drive); - wind.document.write('
');
-    wind.document.write(_disk2.getJSON(drive, true));
-    wind.document.write('
'); - wind.document.close(); -} - -export function reset() { - _apple2.reset(); -} - -function loadBinary(bin) { - for (var idx = 0; idx < bin.length; idx++) { - var pos = bin.start + idx; - cpu.write(pos >> 8, pos & 0xff, bin.data[idx]); - } - cpu.reset(); - cpu.setPC(bin.start); -} - -export function selectCategory() { - document.querySelector('#disk_select').innerHTML = ''; - var cat = disk_categories[document.querySelector('#category_select').value]; - if (cat) { - for (var idx = 0; idx < cat.length; idx++) { - var file = cat[idx], name = file.name; - if (file.disk) { - name += ' - ' + file.disk; - } - var option = document.createElement('option'); - option.value = file.filename; - option.innerText = name; - document.querySelector('#disk_select').append(option); - if (disk_cur_name[_currentDrive] == name) { - option.selected = true; - } - } - } -} - -export function selectDisk() { - document.querySelector('#local_file').value = ''; -} - -export function clickDisk() { - doLoad(); -} - -function loadDisk(drive, disk) { - var name = disk.name; - var category = disk.category; - - if (disk.disk) { - name += ' - ' + disk.disk; - } - - disk_cur_cat[drive] = category; - disk_cur_name[drive] = name; - - _disk2.setDisk(drive, disk); - initGamepad(disk.gamepad); -} - -/* - * LocalStorage Disk Storage - */ - -function updateLocalStorage() { - var diskIndex = JSON.parse(window.localStorage.diskIndex || '{}'); - var names = Object.keys(diskIndex), cat; - - cat = disk_categories['Local Saves'] = []; - document.querySelector('#manage-modal-content').innerHTML = ''; - - names.forEach(function(name) { - cat.push({ - 'category': 'Local Saves', - 'name': name, - 'filename': 'local:' + name - }); - document.querySelector('#manage-modal-content').innerHTML = - '' + - name + - ' Delete
'; - }); - cat.push({ - 'category': 'Local Saves', - 'name': 'Manage Saves...', - 'filename': 'local:__manage' - }); -} - -function saveLocalStorage(drive, name) { - var diskIndex = JSON.parse(window.localStorage.diskIndex || '{}'); - - var json = _disk2.getJSON(drive); - diskIndex[name] = json; - - window.localStorage.diskIndex = JSON.stringify(diskIndex); - - driveLights.label(drive, name); - driveLights.dirty(drive, false); - updateLocalStorage(); -} - -function deleteLocalStorage(name) { - var diskIndex = JSON.parse(window.localStorage.diskIndex || '{}'); - if (diskIndex[name]) { - delete diskIndex[name]; - openAlert('Deleted'); - } - window.localStorage.diskIndex = JSON.stringify(diskIndex); - updateLocalStorage(); -} - -function loadLocalStorage(drive, name) { - var diskIndex = JSON.parse(window.localStorage.diskIndex || '{}'); - if (diskIndex[name]) { - _disk2.setJSON(drive, diskIndex[name]); - driveLights.label(drive, name); - driveLights.dirty(drive, false); - } -} - -if (window.localStorage !== undefined) { - document.querySelectorAll('.disksave').forEach(function (el) { el.style.display = 'inline-block';}); -} - -var oldcat = ''; -var option; -for (var idx = 0; idx < window.disk_index.length; idx++) { - var file = window.disk_index[idx]; - var cat = file.category; - var name = file.name, disk = file.disk; - if (file.e && !window.e) { - continue; - } - if (cat != oldcat) { - option = document.createElement('option'); - option.value = cat; - option.innerText = cat; - document.querySelector('#category_select').append(option); - - disk_categories[cat] = []; - oldcat = cat; - } - disk_categories[cat].push(file); - if (disk) { - if (!disk_sets[name]) { - disk_sets[name] = []; - } - disk_sets[name].push(file); - } -} -option = document.createElement('option'); -option.innerText = 'Local Saves'; -document.querySelector('#category_select').append(option); - -updateLocalStorage(); - -function processHash(hash) { - var files = hash.split('|'); - for (var idx = 0; idx < files.length; idx++) { - var file = files[idx]; - if (file.indexOf('://') > 0) { - var parts = file.split('.'); - var ext = parts[parts.length - 1].toLowerCase(); - if (ext == 'json') { - loadAjax(idx + 1, file); - } else { - doLoadHTTP(idx + 1, file); - } - } else if (file) { - loadAjax(idx + 1, 'json/disks/' + file + '.json'); - } - } -} - - -/* - * Keyboard/Gamepad routines - */ - -function _keydown(evt) { - if (!focused && (!evt.metaKey || evt.ctrlKey || window.e)) { - evt.preventDefault(); - - var key = keyboard.mapKeyEvent(evt); - if (key !== 0xff) { - io.keyDown(key); - } - } - if (evt.keyCode === 112) { // F1 - Reset - cpu.reset(); - evt.preventDefault(); // prevent launching help - } else if (evt.keyCode === 113) { // F2 - Full Screen - var elem = document.getElementById('screen'); - if (evt.shiftKey) { // Full window, but not full screen - document.body.classList.toggle('full-page'); - } else if (document.webkitCancelFullScreen) { - if (document.webkitIsFullScreen) { - document.webkitCancelFullScreen(); - } else { - if (Element.ALLOW_KEYBOARD_INPUT) { - elem.webkitRequestFullScreen(Element.ALLOW_KEYBOARD_INPUT); - } else { - elem.webkitRequestFullScreen(); - } - } - } else if (document.mozCancelFullScreen) { - if (document.mozIsFullScreen) { - document.mozCancelFullScreen(); - } else { - elem.mozRequestFullScreen(); - } - } - } else if (evt.keyCode === 114) { // F3 - io.keyDown(0x1b); - } else if (evt.keyCode === 117) { // F6 Quick Save - window.localStorage.state = base64_json_stringify(_apple2.getState()); - } else if (evt.keyCode === 120) { // F9 Quick Restore - if (window.localStorage.state) { - _apple2.setState(base64_json_parse(window.localStorage.state)); - } - } else if (evt.keyCode == 16) { // Shift - keyboard.shiftKey(true); - } else if (evt.keyCode == 20) { // Caps lock - keyboard.capslockKey(); - } else if (evt.keyCode == 17) { // Control - keyboard.controlKey(true); - } else if (evt.keyCode == 91 || evt.keyCode == 93) { // Command - keyboard.commandKey(true); - } else if (evt.keyCode == 18) { // Alt - if (evt.location == 1) { - keyboard.commandKey(true); - } else { - keyboard.optionKey(true); - } - } -} - -function _keyup(evt) { - if (!focused) - io.keyUp(); - - if (evt.keyCode == 16) { // Shift - keyboard.shiftKey(false); - } else if (evt.keyCode == 17) { // Control - keyboard.controlKey(false); - } else if (evt.keyCode == 91 || evt.keyCode == 93) { // Command - keyboard.commandKey(false); - } else if (evt.keyCode == 18) { // Alt - if (evt.location == 1) { - keyboard.commandKey(false); - } else { - keyboard.optionKey(false); - } - } -} - -export function updateScreen() { - var mono = document.querySelector('#mono_screen').checked; - var scanlines = document.querySelector('#show_scanlines').checked; - var gl = document.querySelector('#gl_canvas').checked; - - var screen = document.querySelector('#screen'); - var overscan = document.querySelector('.overscan'); - if (scanlines && !gl) { - overscan.classList.add('scanlines'); - } else { - overscan.classList.remove('scanlines'); - } - if (mono && !gl) { - screen.classList.add('mono'); - } else { - screen.classList.remove('mono'); - } - vm.mono(mono); -} - -export function updateCPU() { - var accelerated = document.querySelector('#accelerator_toggle').checked; - var kHz = accelerated ? 4092 : 1023; - io.updateKHz(kHz); -} - -export function updateUI() { - if (document.location.hash != hashtag) { - hashtag = document.location.hash; - var hash = hup(); - if (hash) { - processHash(hash); - } - } -} - -var disableMouseJoystick = false; -var flipX = false; -var flipY = false; -var swapXY = false; - -export function updateJoystick() { - disableMouseJoystick = document.querySelector('#disable_mouse').checked; - flipX = document.querySelector('#flip_x').checked; - flipY = document.querySelector('#flip_y').checked; - swapXY = document.querySelector('#swap_x_y').checked; - configGamepad(flipX, flipY); - - if (disableMouseJoystick) { - io.paddle(0, 0.5); - io.paddle(1, 0.5); - return; - } -} - -function _mousemove(evt) { - if (gamepad || disableMouseJoystick) { - return; - } - - var s = document.querySelector('#screen'); - var offset = s.getBoundingClientRect(); - var x = (evt.pageX - offset.left) / s.clientWidth, - y = (evt.pageY - offset.top) / s.clientHeight, - z = x; - - if (swapXY) { - x = y; - y = z; - } - - io.paddle(0, flipX ? 1 - x : x); - io.paddle(1, flipY ? 1 - y : y); -} - -export function pauseRun() { - var label = document.querySelector('#pause-run i'); - if (paused) { - vm.ready.then(() => { - _apple2.run(); - }); - label.classList.remove('fa-play'); - label.classList.add('fa-pause'); - } else { - _apple2.stop(); - label.classList.remove('fa-pause'); - label.classList.add('fa-play'); - } - paused = !paused; -} - -export function toggleSound() { - var enableSound = document.querySelector('#enable_sound'); - enableSound.checked = !enableSound.checked; - updateSound(); -} - -export function openOptions() { - MicroModal.show('options-modal'); -} - -export function openPrinterModal() { - const mimeType = 'application/octet-stream'; - const data = _printer.getRawOutput(); - const a = document.querySelector('#raw_printer_output'); - - const blob = new Blob([data], { 'type': mimeType}); - a.href = window.URL.createObjectURL(blob); - a.download = 'raw_printer_output.bin'; - MicroModal.show('printer-modal'); -} - -export function clearPrinterPaper() { - _printer.clear(); -} - -function onLoaded(apple2, disk2, smartPort, printer, e) { - _apple2 = apple2; - cpu = _apple2.getCPU(); - io = _apple2.getIO(); - stats = apple2.getStats(); - vm = apple2.getVideoModes(); - tape = new Tape(io); - _disk2 = disk2; - _smartPort = smartPort; - _printer = printer; - - keyboard = new KeyBoard(cpu, io, e); - keyboard.create('#keyboard'); - audio = new Audio(io); - - MicroModal.init(); - - /* - * Input Handling - */ - - window.addEventListener('keydown', _keydown); - window.addEventListener('keyup', _keyup); - - window.addEventListener('keydown', audio.autoStart); - if (window.ontouchstart !== undefined) { - window.addEventListener('touchstart', audio.autoStart); - } - window.addEventListener('mousedown', audio.autoStart); - - window.addEventListener('paste', (event) => { - var paste = (event.clipboardData || window.clipboardData).getData('text'); - io.setKeyBuffer(paste); - event.preventDefault(); - }); - - window.addEventListener('copy', (event) => { - event.clipboardData.setData('text/plain', vm.getText()); - event.preventDefault(); - }); - - - document.querySelectorAll('canvas').forEach(function(canvas) { - canvas.addEventListener('mousedown', function(evt) { - if (!gamepad) { - io.buttonDown(evt.which == 1 ? 0 : 1); - } - evt.preventDefault(); - }); - canvas.addEventListener('mouseup', function(evt) { - if (!gamepad) { - io.buttonUp(evt.which == 1 ? 0 : 1); - } - }); - canvas.addEventListener('contextmenu', function(evt) { - evt.preventDefault(); - }); - }); - - document.body.addEventListener('mousemove', _mousemove); - - document.querySelectorAll('input,textarea').forEach(function(input) { - input.addEventListener('focus', function() { focused = true; }); - input.addEventListener('blur', function() { focused = false; }); - }); - - if (prefs.havePrefs()) { - document.querySelectorAll('#options-modal input[type=checkbox]').forEach(function(el) { - var val = prefs.readPref(el.id); - if (val) { - el.checked = JSON.parse(val); - } - el.addEventListener('change', function() { - prefs.writePref(el.id, JSON.stringify(el.checked)); - }); - }); - document.querySelectorAll('#options-modal select').forEach(function(el) { - var val = prefs.readPref(el.id); - if (val) { - el.value = val; - } - el.addEventListener('change', function() { - prefs.writePref(el.id, el.value); - }); - }); - } - - if (navigator.standalone) { - document.body.classList.add('standalone'); - } - - cpu.reset(); - setInterval(updateKHz, 1000); - updateSound(); - updateScreen(); - updateCPU(); - initGamepad(); - - // Check for disks in hashtag - - var hash = gup('disk') || hup(); - if (hash) { - _apple2.stop(); - processHash(hash); - } else { - vm.ready.then(() => { - _apple2.run(); - }); - } -} - -export function initUI(apple2, disk2, smartPort, printer, e) { - window.addEventListener('load', () => { - onLoaded(apple2, disk2, smartPort, printer, e); - }); -} diff --git a/js/ui/apple2.ts b/js/ui/apple2.ts new file mode 100644 index 0000000..6e791eb --- /dev/null +++ b/js/ui/apple2.ts @@ -0,0 +1,1053 @@ +import MicroModal from 'micromodal'; + +import { base64_json_parse, base64_json_stringify } from '../base64'; +import Audio from './audio'; +import DriveLights from './drive_lights'; +import { byte, DISK_FORMATS, includes, word } from '../types'; +import { gamepad, configGamepad, initGamepad, GamepadConfiguration } from './gamepad'; +import KeyBoard from './keyboard'; +import Tape, { TAPE_TYPES } from './tape'; + +import ApplesoftDump from '../applesoft/decompiler'; +import ApplesoftCompiler from '../applesoft/compiler'; + +import { debug, gup, hup } from '../util'; +import Prefs from '../prefs'; +import { Apple2, Stats } from '../apple2'; +import DiskII, { DriveNumber, DRIVE_NUMBERS } from '../cards/disk2'; +import SmartPort from '../cards/smartport'; +import CPU6502 from '../cpu6502'; +import { VideoModes } from '../videomodes'; +import Apple2IO from '../apple2io'; +import { JSONDisk } from '../formats/format_utils'; +import Printer from './printer'; + +let paused = false; + +let focused = false; +let startTime = Date.now(); +let lastCycles = 0; +let lastFrames = 0; +let lastRenderedFrames = 0; + +let hashtag = document.location.hash; + +interface DiskDescriptor { + name: string; + disk?: number; + filename: string; + e?: boolean; + category: string; +} + +type DiskCollection = { + [name: string]: DiskDescriptor[] +}; + +const disk_categories: DiskCollection = { 'Local Saves': [] }; +const disk_sets: DiskCollection = {}; +// Disk names +const disk_cur_name: string[] = []; +// Disk categories +const disk_cur_cat: string[] = []; + +let _apple2: Apple2; +let cpu: CPU6502; +let stats: Stats; +let vm: VideoModes; +let tape: Tape; +let _disk2: DiskII; +let _smartPort: SmartPort; +let _printer: Printer; +let audio: Audio; +let keyboard: KeyBoard; +let io: Apple2IO; +let _currentDrive: DriveNumber = 1; + +export const driveLights = new DriveLights(); + +export function dumpAppleSoftProgram() { + const dumper = new ApplesoftDump(cpu); + debug(dumper.toString()); +} + +export function compileAppleSoftProgram(program: string) { + const compiler = new ApplesoftCompiler(cpu); + compiler.compile(program); +} + +export function openLoad(driveString: string, event: MouseEvent) { + const drive = parseInt(driveString, 10); + if (event.metaKey && includes(DRIVE_NUMBERS, drive)) { + openLoadHTTP(drive); + } else { + if (disk_cur_cat[drive]) { + const element = document.querySelector('#category_select')!; + element.value = disk_cur_cat[drive]; + selectCategory(); + } + MicroModal.show('load-modal'); + } +} + +export function openSave(driveString: string, event: MouseEvent) { + const drive = parseInt(driveString, 10) as DriveNumber; + + const mimeType = 'application/octet-stream'; + const data = _disk2.getBinary(drive); + const a = document.querySelector('#local_save_link')!; + + if (!data) { + alert('No data from drive ' + drive); + return; + } + + const blob = new Blob([data], { 'type': mimeType }); + a.href = window.URL.createObjectURL(blob); + a.download = driveLights.label(drive) + '.dsk'; + + if (event.metaKey) { + dumpDisk(drive); + } else { + const saveName = document.querySelector('#save_name')!; + saveName.value = driveLights.label(drive); + MicroModal.show('save-modal'); + } +} + +export function openAlert(msg: string) { + const el = document.querySelector('#alert-modal .message')!; + el.innerText = msg; + MicroModal.show('alert-modal'); +} + +/******************************************************************** + * + * Drag and Drop + */ + +export function handleDragOver(_drive: number, event: DragEvent) { + event.preventDefault(); + event.dataTransfer!.dropEffect = 'copy'; +} + +export function handleDragEnd(_drive: number, event: DragEvent) { + const dt = event.dataTransfer!; + if (dt.items) { + for (let i = 0; i < dt.items.length; i++) { + dt.items.remove(i); + } + } else { + dt.clearData(); + } +} + +export function handleDrop(drive: number, event: DragEvent) { + event.preventDefault(); + event.stopPropagation(); + + if (drive < 1) { + if (!_disk2.getMetadata(1)) { + drive = 1; + } else if (!_disk2.getMetadata(2)) { + drive = 2; + } else { + drive = 1; + } + } + const dt = event.dataTransfer!; + if (dt.files.length == 1) { + const runOnLoad = event.shiftKey; + doLoadLocal(drive as DriveNumber, dt.files[0], { runOnLoad }); + } else if (dt.files.length == 2) { + doLoadLocal(1, dt.files[0]); + doLoadLocal(2, dt.files[1]); + } else { + for (let idx = 0; idx < dt.items.length; idx++) { + if (dt.items[idx].type === 'text/uri-list') { + dt.items[idx].getAsString(function (url) { + const parts = document.location.hash.split('|'); + parts[drive - 1] = url; + document.location.hash = parts.join('|'); + }); + } + } + } +} + +function loadingStart() { + const meter = document.querySelector('#loading-modal .meter')!; + meter.style.display = 'none'; + MicroModal.show('loading-modal'); +} + +function loadingProgress(current: number, total: number) { + if (total) { + const meter = document.querySelector('#loading-modal .meter')!; + const progress = document.querySelector('#loading-modal .progress')!; + meter.style.display = 'block'; + progress.style.width = current / total * meter.clientWidth + 'px'; + } +} + +function loadingStop() { + MicroModal.close('loading-modal'); + + if (!paused) { + vm.ready.then(() => { + _apple2.run(); + }); + } +} + +interface JSONBinaryImage { + type: 'binary', + start: word, + length: word, + data: byte[], + gamepad?: GamepadConfiguration, +} + +export function loadAjax(drive: DriveNumber, url: string) { + loadingStart(); + + fetch(url).then(function (response: Response) { + if (response.ok) { + return response.json(); + } else { + throw new Error('Error loading: ' + response.statusText); + } + }).then(function (data: JSONDisk | JSONBinaryImage) { + if (data.type === 'binary') { + loadBinary(data as JSONBinaryImage); + } else if (includes(DISK_FORMATS, data.type)) { + loadDisk(drive, data); + } + initGamepad(data.gamepad); + loadingStop(); + }).catch(function (error) { + loadingStop(); + openAlert(error.message); + }); +} + +export function doLoad() { + MicroModal.close('load-modal'); + const select = document.querySelector('#disk_select')!; + const urls = select.value; + let url; + if (urls && urls.length) { + if (typeof (urls) == 'string') { + url = urls; + } else { + url = urls[0]; + } + } + + const localFile = document.querySelector('#local_file')!; + const files = localFile.files; + if (files && files.length == 1) { + doLoadLocal(_currentDrive, files[0]); + } else if (url) { + let filename; + MicroModal.close('load-modal'); + if (url.substr(0, 6) == 'local:') { + filename = url.substr(6); + if (filename == '__manage') { + openManage(); + } else { + loadLocalStorage(_currentDrive, filename); + } + } else { + const r1 = /json\/disks\/(.*).json$/.exec(url); + if (r1) { + filename = r1[1]; + } else { + filename = url; + } + const parts = document.location.hash.split('|'); + parts[_currentDrive - 1] = filename; + document.location.hash = parts.join('|'); + } + } +} + +export function doSave() { + const saveName = document.querySelector('#save_name')!; + const name = saveName.value; + saveLocalStorage(_currentDrive, name); + MicroModal.close('save-modal'); + window.setTimeout(() => openAlert('Saved'), 0); +} + +export function doDelete(name: string) { + if (window.confirm('Delete ' + name + '?')) { + deleteLocalStorage(name); + } +} + +const CIDERPRESS_EXTENSION = /#([0-9a-f]{2})([0-9a-f]{4})$/i; +const BIN_TYPES = ['bin']; + +interface LoadOptions { + address: word, + runOnLoad?: boolean, +} + +function doLoadLocal(drive: DriveNumber, file: File, options: Partial = {}) { + const parts = file.name.split('.'); + const ext = parts[parts.length - 1].toLowerCase(); + const matches = file.name.match(CIDERPRESS_EXTENSION); + let type, aux; + if (matches && matches.length === 3) { + [, type, aux] = matches; + } + if (includes(DISK_FORMATS, ext)) { + doLoadLocalDisk(drive, file); + } else if (includes(TAPE_TYPES, ext)) { + tape.doLoadLocalTape(file); + } else if (BIN_TYPES.includes(ext) || type === '06' || options.address) { + doLoadBinary(file, { address: parseInt(aux || '2000', 16), ...options }); + } else { + openAlert('Unknown file type: ' + ext); + } +} + +function doLoadBinary(file: File, options: LoadOptions) { + loadingStart(); + + const fileReader = new FileReader(); + fileReader.onload = function () { + const result = this.result as ArrayBuffer; + let { address } = options; + const bytes = new Uint8Array(result); + for (let idx = 0; idx < result.byteLength; idx++) { + cpu.write(address >> 8, address & 0xff, bytes[idx]); + address++; + } + if (options.runOnLoad) { + cpu.reset(); + cpu.setPC(options.address); + } + loadingStop(); + }; + fileReader.readAsArrayBuffer(file); +} + +function doLoadLocalDisk(drive: DriveNumber, file: File) { + loadingStart(); + const fileReader = new FileReader(); + fileReader.onload = function () { + const result = this.result as ArrayBuffer; + const parts = file.name.split('.'); + const ext = parts.pop()!.toLowerCase(); + const name = parts.join('.'); + + // Remove any json file reference + const files = document.location.hash.split('|'); + files[drive - 1] = ''; + document.location.hash = files.join('|'); + + if (result.byteLength >= 800 * 1024) { + if (_smartPort.setBinary(drive, name, ext, result)) { + focused = false; + initGamepad(); + } + } else { + if (includes(DISK_FORMATS, ext) + && _disk2.setBinary(drive, name, ext, result)) { + focused = false; + initGamepad(); + } else { + openAlert(`Unable to load ${name}`); + } + } + loadingStop(); + }; + fileReader.readAsArrayBuffer(file); +} + +export function doLoadHTTP(drive: DriveNumber, url?: string) { + if (!url) { + MicroModal.close('http-modal'); + } + + loadingStart(); + const input = document.querySelector('#http_url')!; + url = url || input.value; + if (url) { + fetch(url).then(function (response) { + if (response.ok) { + const reader = response!.body!.getReader(); + let received = 0; + const chunks: Uint8Array[] = []; + const contentLength = parseInt(response.headers.get('content-length')!, 10); + + return reader.read().then( + function readChunk(result): Promise { + if (result.done) { + const data = new Uint8Array(received); + let offset = 0; + for (let idx = 0; idx < chunks.length; idx++) { + data.set(chunks[idx], offset); + offset += chunks[idx].length; + } + return Promise.resolve(data.buffer); + } + + received += result.value.length; + if (contentLength) { + loadingProgress(received, contentLength); + } + chunks.push(result.value); + + return reader.read().then(readChunk); + }); + } else { + throw new Error('Error loading: ' + response.statusText); + } + }).then(function (data) { + const urlParts = url!.split('/'); + const file = urlParts.pop()!; + const fileParts = file.split('.'); + const ext = fileParts.pop()!.toLowerCase(); + const name = decodeURIComponent(fileParts.join('.')); + if (data.byteLength >= 800 * 1024) { + if (_smartPort.setBinary(drive, name, ext, data)) { + initGamepad(); + } + } else { + if (includes(DISK_FORMATS, ext) + && _disk2.setBinary(drive, name, ext, data)) { + initGamepad(); + } else { + throw new Error(`Extension ${ext} not recognized.`); + } + } + loadingStop(); + }).catch(function (error) { + loadingStop(); + openAlert(error.message); + }); + } +} + +function openLoadHTTP(drive: DriveNumber) { + _currentDrive = drive; + MicroModal.show('http-modal'); +} + +function openManage() { + MicroModal.show('manage-modal'); +} + +const prefs = new Prefs(); +let showStats = 0; + +export function updateKHz() { + const now = Date.now(); + const ms = now - startTime; + const cycles = cpu.getCycles(); + let delta; + let fps; + let khz; + + const kHzElement = document.querySelector('#khz')!; + switch (showStats) { + case 0: { + delta = cycles - lastCycles; + khz = Math.trunc(delta / ms); + kHzElement.innerText = khz + ' kHz'; + break; + } + case 1: { + delta = stats.renderedFrames - lastRenderedFrames; + fps = Math.trunc(delta / (ms / 1000)); + kHzElement.innerText = fps + ' rps'; + break; + } + default: { + delta = stats.frames - lastFrames; + fps = Math.trunc(delta / (ms / 1000)); + kHzElement.innerText = fps + ' fps'; + } + } + + startTime = now; + lastCycles = cycles; + lastRenderedFrames = stats.renderedFrames; + lastFrames = stats.frames; +} + +export function toggleShowFPS() { + showStats = ++showStats % 3; +} + +export function updateSound() { + const soundCheckbox = document.querySelector('#enable_sound')!; + const on = soundCheckbox.checked; + const label = document.querySelector('#toggle-sound i')!; + audio.enable(on); + if (on) { + label.classList.remove('fa-volume-off'); + label.classList.add('fa-volume-up'); + } else { + label.classList.remove('fa-volume-up'); + label.classList.add('fa-volume-off'); + } +} + +function dumpDisk(drive: DriveNumber) { + const wind = window.open('', '_blank')!; + wind.document.title = driveLights.label(drive); + wind.document.write('
');
+    wind.document.write(_disk2.getJSON(drive, true));
+    wind.document.write('
'); + wind.document.close(); +} + +export function reset() { + _apple2.reset(); +} + +function loadBinary(bin: JSONBinaryImage) { + for (let idx = 0; idx < bin.length; idx++) { + const pos = bin.start + idx; + cpu.write(pos, bin.data[idx]); + } + cpu.reset(); + cpu.setPC(bin.start); +} + +export function selectCategory() { + const diskSelect = document.querySelector('#disk_select')!; + const categorySelect = document.querySelector('#category_select')!; + diskSelect.innerHTML = ''; + const cat = disk_categories[categorySelect.value]; + if (cat) { + for (let idx = 0; idx < cat.length; idx++) { + const file = cat[idx]; + let name = file.name; + if (file.disk) { + name += ' - ' + file.disk; + } + const option = document.createElement('option'); + option.value = file.filename; + option.innerText = name; + diskSelect.append(option); + if (disk_cur_name[_currentDrive] === name) { + option.selected = true; + } + } + } +} + +export function selectDisk() { + const localFile = document.querySelector('#local_file')!; + localFile.value = ''; +} + +export function clickDisk() { + doLoad(); +} + +/** Called to load disks from the local catalog. */ +function loadDisk(drive: DriveNumber, disk: JSONDisk) { + let name = disk.name; + const category = disk.category!; // all disks in the local catalog have a category + + if (disk.disk) { + name += ' - ' + disk.disk; + } + + disk_cur_cat[drive] = category; + disk_cur_name[drive] = name; + + _disk2.setDisk(drive, disk); + initGamepad(disk.gamepad); +} + +/* + * LocalStorage Disk Storage + */ + +function updateLocalStorage() { + const diskIndex = JSON.parse(window.localStorage.diskIndex || '{}'); + const names = Object.keys(diskIndex); + + const cat: DiskDescriptor[] = disk_categories['Local Saves'] = []; + const contentDiv = document.querySelector('#manage-modal-content')!; + contentDiv.innerHTML = ''; + + names.forEach(function (name) { + cat.push({ + 'category': 'Local Saves', + 'name': name, + 'filename': 'local:' + name + }); + contentDiv.innerHTML = + '' + + name + + ' Delete
'; + }); + cat.push({ + 'category': 'Local Saves', + 'name': 'Manage Saves...', + 'filename': 'local:__manage' + }); +} + +type LocalDiskIndex = { + [name: string]: string, +} + +function saveLocalStorage(drive: DriveNumber, name: string) { + const diskIndex = JSON.parse(window.localStorage.diskIndex || '{}') as LocalDiskIndex; + + const json = _disk2.getJSON(drive); + diskIndex[name] = json; + + window.localStorage.diskIndex = JSON.stringify(diskIndex); + + driveLights.label(drive, name); + driveLights.dirty(drive, false); + updateLocalStorage(); +} + +function deleteLocalStorage(name: string) { + const diskIndex = JSON.parse(window.localStorage.diskIndex || '{}') as LocalDiskIndex; + if (diskIndex[name]) { + delete diskIndex[name]; + openAlert('Deleted'); + } + window.localStorage.diskIndex = JSON.stringify(diskIndex); + updateLocalStorage(); +} + +function loadLocalStorage(drive: DriveNumber, name: string) { + const diskIndex = JSON.parse(window.localStorage.diskIndex || '{}') as LocalDiskIndex; + if (diskIndex[name]) { + _disk2.setJSON(drive, diskIndex[name]); + driveLights.label(drive, name); + driveLights.dirty(drive, false); + } +} + +if (window.localStorage !== undefined) { + const nodes = document.querySelectorAll('.disksave'); + nodes.forEach(function (el) { + el.style.display = 'inline-block'; + }); +} + +const categorySelect = document.querySelector('#category_select')!; + +declare global { + interface Window { + disk_index: DiskDescriptor[]; + e: boolean; + } +} + +let oldcat = ''; +let option; +for (let idx = 0; idx < window.disk_index.length; idx++) { + const file = window.disk_index[idx]; + const cat = file.category; + const name = file.name; + const disk = file.disk; + if (file.e && !window.e) { + continue; + } + if (cat != oldcat) { + option = document.createElement('option'); + option.value = cat; + option.innerText = cat; + categorySelect.append(option); + + disk_categories[cat] = []; + oldcat = cat; + } + disk_categories[cat].push(file); + if (disk) { + if (!disk_sets[name]) { + disk_sets[name] = []; + } + disk_sets[name].push(file); + } +} +option = document.createElement('option'); +option.innerText = 'Local Saves'; +categorySelect.append(option); + +updateLocalStorage(); + +/** + * Processes the URL fragment. It is expected to be of the form: + * `disk1|disk2` where each disk is the name of a local image OR + * a URL. + */ +function processHash(hash: string) { + const files = hash.split('|'); + for (let idx = 0; idx < Math.min(2, files.length); idx++) { + const drive = idx + 1; + if (!includes(DRIVE_NUMBERS, drive)) { + break; + } + const file = files[idx]; + if (file.indexOf('://') > 0) { + const parts = file.split('.'); + const ext = parts[parts.length - 1].toLowerCase(); + if (ext == 'json') { + loadAjax(drive, file); + } else { + doLoadHTTP(drive, file); + } + } else if (file) { + loadAjax(drive, 'json/disks/' + file + '.json'); + } + } +} + + +/* + * Keyboard/Gamepad routines + */ +declare global { + interface Document { + webkitCancelFullScreen: () => void; + webkitIsFullScreen: boolean; + mozCancelFullScreen: () => void; + mozIsFullScreen: boolean; + } + interface Element { + webkitRequestFullScreen: (options?: any) => void; + mozRequestFullScreen: () => void; + } +} + + +function _keydown(evt: KeyboardEvent) { + if (!focused && (!evt.metaKey || evt.ctrlKey || window.e)) { + evt.preventDefault(); + + const key = keyboard.mapKeyEvent(evt); + if (key !== 0xff) { + io.keyDown(key); + } + } + if (evt.keyCode === 112) { // F1 - Reset + cpu.reset(); + evt.preventDefault(); // prevent launching help + } else if (evt.keyCode === 113) { // F2 - Full Screen + const elem = document.getElementById('screen')!; + if (evt.shiftKey) { // Full window, but not full screen + document.body.classList.toggle('full-page'); + } else if (document.webkitCancelFullScreen) { + if (document.webkitIsFullScreen) { + document.webkitCancelFullScreen(); + } else { + const allowKeyboardInput = (Element as any).ALLOW_KEYBOARD_INPUT; + if (allowKeyboardInput) { + elem.webkitRequestFullScreen(allowKeyboardInput); + } else { + elem.webkitRequestFullScreen(); + } + } + } else if (document.mozCancelFullScreen) { + if (document.mozIsFullScreen) { + document.mozCancelFullScreen(); + } else { + elem.mozRequestFullScreen(); + } + } + } else if (evt.keyCode === 114) { // F3 + io.keyDown(0x1b); + } else if (evt.keyCode === 117) { // F6 Quick Save + window.localStorage.state = base64_json_stringify(_apple2.getState()); + } else if (evt.keyCode === 120) { // F9 Quick Restore + if (window.localStorage.state) { + _apple2.setState(base64_json_parse(window.localStorage.state)); + } + } else if (evt.keyCode == 16) { // Shift + keyboard.shiftKey(true); + } else if (evt.keyCode == 20) { // Caps lock + keyboard.capslockKey(); + } else if (evt.keyCode == 17) { // Control + keyboard.controlKey(true); + } else if (evt.keyCode == 91 || evt.keyCode == 93) { // Command + keyboard.commandKey(true); + } else if (evt.keyCode == 18) { // Alt + if (evt.location == 1) { + keyboard.commandKey(true); + } else { + keyboard.optionKey(true); + } + } +} + +function _keyup(evt: KeyboardEvent) { + if (!focused) + io.keyUp(); + + if (evt.keyCode == 16) { // Shift + keyboard.shiftKey(false); + } else if (evt.keyCode == 17) { // Control + keyboard.controlKey(false); + } else if (evt.keyCode == 91 || evt.keyCode == 93) { // Command + keyboard.commandKey(false); + } else if (evt.keyCode == 18) { // Alt + if (evt.location == 1) { + keyboard.commandKey(false); + } else { + keyboard.optionKey(false); + } + } +} + +export function updateScreen() { + const mono = document.querySelector('#mono_screen')!.checked; + const scanlines = document.querySelector('#show_scanlines')!.checked; + const gl = document.querySelector('#gl_canvas')!.checked; + + const screen = document.querySelector('#screen')!; + const overscan = document.querySelector('.overscan')!; + if (scanlines && !gl) { + overscan.classList.add('scanlines'); + } else { + overscan.classList.remove('scanlines'); + } + if (mono && !gl) { + screen.classList.add('mono'); + } else { + screen.classList.remove('mono'); + } + vm.mono(mono); +} + +export function updateCPU() { + const accelerated = document.querySelector('#accelerator_toggle')!.checked; + const kHz = accelerated ? 4092 : 1023; + io.updateKHz(kHz); +} + +export function updateUI() { + if (document.location.hash != hashtag) { + hashtag = document.location.hash; + const hash = hup(); + if (hash) { + processHash(hash); + } + } +} + +let disableMouseJoystick = false; +let flipX = false; +let flipY = false; +let swapXY = false; + +export function updateJoystick() { + disableMouseJoystick = document.querySelector('#disable_mouse')!.checked; + flipX = document.querySelector('#flip_x')!.checked; + flipY = document.querySelector('#flip_y')!.checked; + swapXY = document.querySelector('#swap_x_y')!.checked; + configGamepad(flipX, flipY); + + if (disableMouseJoystick) { + io.paddle(0, 0.5); + io.paddle(1, 0.5); + return; + } +} + +function _mousemove(evt: MouseEvent) { + if (gamepad || disableMouseJoystick) { + return; + } + + const s = document.querySelector('#screen')!; + const offset = s.getBoundingClientRect(); + let x = (evt.pageX - offset.left) / s.clientWidth; + let y = (evt.pageY - offset.top) / s.clientHeight; + const z = x; + + if (swapXY) { + x = y; + y = z; + } + + io.paddle(0, flipX ? 1 - x : x); + io.paddle(1, flipY ? 1 - y : y); +} + +export function pauseRun() { + const label = document.querySelector('#pause-run i')!; + if (paused) { + vm.ready.then(() => { + _apple2.run(); + }); + label.classList.remove('fa-play'); + label.classList.add('fa-pause'); + } else { + _apple2.stop(); + label.classList.remove('fa-pause'); + label.classList.add('fa-play'); + } + paused = !paused; +} + +export function toggleSound() { + const enableSound = document.querySelector('#enable_sound')!; + enableSound.checked = !enableSound.checked; + updateSound(); +} + +export function openOptions() { + MicroModal.show('options-modal'); +} + +export function openPrinterModal() { + const mimeType = 'application/octet-stream'; + const data = _printer.getRawOutput(); + const a = document.querySelector('#raw_printer_output')!; + + const blob = new Blob([data], { 'type': mimeType }); + a.href = window.URL.createObjectURL(blob); + a.download = 'raw_printer_output.bin'; + MicroModal.show('printer-modal'); +} + +export function clearPrinterPaper() { + _printer.clear(); +} + +declare global { + interface Window { + clipboardData?: DataTransfer; + } + interface Event { + clipboardData?: DataTransfer; + } + interface Navigator { + standalone?: boolean; + } +} + +function onLoaded(apple2: Apple2, disk2: DiskII, smartPort: SmartPort, printer: Printer, e: boolean) { + _apple2 = apple2; + cpu = _apple2.getCPU(); + io = _apple2.getIO(); + stats = apple2.getStats(); + vm = apple2.getVideoModes(); + tape = new Tape(io); + _disk2 = disk2; + _smartPort = smartPort; + _printer = printer; + + keyboard = new KeyBoard(cpu, io, e); + keyboard.create('#keyboard'); + audio = new Audio(io); + + MicroModal.init(); + + /* + * Input Handling + */ + + window.addEventListener('keydown', _keydown); + window.addEventListener('keyup', _keyup); + + window.addEventListener('keydown', audio.autoStart); + if (window.ontouchstart !== undefined) { + window.addEventListener('touchstart', audio.autoStart); + } + window.addEventListener('mousedown', audio.autoStart); + + window.addEventListener('paste', (event: Event) => { + const paste = (event.clipboardData || window.clipboardData)!.getData('text'); + io.setKeyBuffer(paste); + event.preventDefault(); + }); + + window.addEventListener('copy', (event) => { + event.clipboardData!.setData('text/plain', vm.getText()); + event.preventDefault(); + }); + + + document.querySelectorAll('canvas').forEach(function (canvas) { + canvas.addEventListener('mousedown', function (evt) { + if (!gamepad) { + io.buttonDown(evt.which == 1 ? 0 : 1); + } + evt.preventDefault(); + }); + canvas.addEventListener('mouseup', function (evt) { + if (!gamepad) { + io.buttonUp(evt.which == 1 ? 0 : 1); + } + }); + canvas.addEventListener('contextmenu', function (evt) { + evt.preventDefault(); + }); + }); + + document.body.addEventListener('mousemove', _mousemove); + + document.querySelectorAll('input,textarea').forEach(function (input) { + input.addEventListener('focus', function () { focused = true; }); + input.addEventListener('blur', function () { focused = false; }); + }); + + if (prefs.havePrefs()) { + document.querySelectorAll('#options-modal input[type=checkbox]').forEach(function (el) { + const val = prefs.readPref(el.id); + if (val) { + el.checked = JSON.parse(val); + } + el.addEventListener('change', function () { + prefs.writePref(el.id, JSON.stringify(el.checked)); + }); + }); + document.querySelectorAll('#options-modal select').forEach(function (el) { + const val = prefs.readPref(el.id); + if (val) { + el.value = val; + } + el.addEventListener('change', function () { + prefs.writePref(el.id, el.value); + }); + }); + } + + if (navigator.standalone) { + document.body.classList.add('standalone'); + } + + cpu.reset(); + setInterval(updateKHz, 1000); + updateSound(); + updateScreen(); + updateCPU(); + initGamepad(); + + // Check for disks in hashtag + + const hash = gup('disk') || hup(); + if (hash) { + _apple2.stop(); + processHash(hash); + } else { + vm.ready.then(() => { + _apple2.run(); + }); + } +} + +export function initUI(apple2: Apple2, disk2: DiskII, smartPort: SmartPort, printer: Printer, e: boolean) { + window.addEventListener('load', () => { + onLoaded(apple2, disk2, smartPort, printer, e); + }); +} diff --git a/js/ui/drive_lights.ts b/js/ui/drive_lights.ts index 424fd0f..905b98c 100644 --- a/js/ui/drive_lights.ts +++ b/js/ui/drive_lights.ts @@ -9,11 +9,11 @@ export default class DriveLights implements Callbacks { 'url(css/red-off-16.png)'; } - public dirty() { + public dirty(_drive: DriveNumber, _dirty: boolean) { // document.querySelector('#disksave' + drive).disabled = !dirty; } - public label(drive: DriveNumber, label: string) { + public label(drive: DriveNumber, label?: string) { const labelElement = document.querySelector('#disk-label' + drive)! as HTMLElement; if (label) { diff --git a/js/ui/tape.ts b/js/ui/tape.ts index 497f27e..af7d12e 100644 --- a/js/ui/tape.ts +++ b/js/ui/tape.ts @@ -19,7 +19,7 @@ export const TAPE_TYPES = ['wav', 'aiff', 'aif', 'mp3', 'm4a'] as const; export default class Tape { constructor(private readonly io: Apple2IO) {} - public doLoadLocalTape(file: File, done: () => void) { + public doLoadLocalTape(file: File, done?: () => void) { const kHz = this.io.getKHz(); // Audio Buffer Source @@ -28,7 +28,7 @@ export default class Tape { context = new AudioContext(); } else { window.alert('Not supported by your browser'); - done(); + done && done(); return; } diff --git a/js/videomodes.ts b/js/videomodes.ts index 79a5b50..ee02766 100644 --- a/js/videomodes.ts +++ b/js/videomodes.ts @@ -69,4 +69,10 @@ export interface VideoModes extends Restorable { isMixed(): boolean isPage2(): boolean isText(): boolean + + mono(on: boolean): void + + getText(): string + + ready: Promise } diff --git a/package-lock.json b/package-lock.json index 4dd4f0c..41cb8ac 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "@babel/core": "^7.9.0", "@babel/preset-env": "^7.9.0", "@types/jest": "^26.0.14", + "@types/micromodal": "^0.3.2", "@typescript-eslint/eslint-plugin": "^4.6.1", "@typescript-eslint/parser": "^4.6.1", "ajv": "^6.12.0", @@ -2163,6 +2164,12 @@ "integrity": "sha512-cxWFQVseBm6O9Gbw1IWb8r6OS4OhSt3hPZLkFApLjM8TEXROBuQGLAH2i2gZpcXdLBIrpXuTDhH7Vbm1iXmNGA==", "dev": true }, + "node_modules/@types/micromodal": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@types/micromodal/-/micromodal-0.3.2.tgz", + "integrity": "sha512-rQAprHsGUqtbngygYKWKpgbmWCbdR6injIeLELVjapsI7xXCjKlbaet62haRG+CLYOacXzlUlS0me8H+3RG1UQ==", + "dev": true + }, "node_modules/@types/node": { "version": "14.14.35", "resolved": "https://registry.npmjs.org/@types/node/-/node-14.14.35.tgz", @@ -14938,6 +14945,12 @@ "integrity": "sha512-cxWFQVseBm6O9Gbw1IWb8r6OS4OhSt3hPZLkFApLjM8TEXROBuQGLAH2i2gZpcXdLBIrpXuTDhH7Vbm1iXmNGA==", "dev": true }, + "@types/micromodal": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@types/micromodal/-/micromodal-0.3.2.tgz", + "integrity": "sha512-rQAprHsGUqtbngygYKWKpgbmWCbdR6injIeLELVjapsI7xXCjKlbaet62haRG+CLYOacXzlUlS0me8H+3RG1UQ==", + "dev": true + }, "@types/node": { "version": "14.14.35", "resolved": "https://registry.npmjs.org/@types/node/-/node-14.14.35.tgz", diff --git a/package.json b/package.json index 54ef4f3..97416dd 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "@babel/core": "^7.9.0", "@babel/preset-env": "^7.9.0", "@types/jest": "^26.0.14", + "@types/micromodal": "^0.3.2", "@typescript-eslint/eslint-plugin": "^4.6.1", "@typescript-eslint/parser": "^4.6.1", "ajv": "^6.12.0",