mirror of
https://github.com/whscullin/apple2js.git
synced 2024-01-12 14:14:38 +00:00
d67f3d8086
* Add tests for Applesoft compiler in preparation for refactoring While refactoring the compiler, I found several small bugs: * Lower-case letters in strings and REM statements were converted to upper-case. * Lines are stored in the order received, not sorted by line number. * Does not prefer `ATN` to `AT`. * Does not prefer `TO` to `AT`. * `DATA` statements don't preserve spaces. * `DATA` statements don't preserve lowercase. These will be fixed in the upcoming refactoring. * Refactor the Applesoft Compiler Before, the compiler had a few bugs that were not trivial to solve because the implementation was in one heavily-nested function. In this refactoring of the compiler, things like tokenization have been split into separate methods which makes them a bit easier to understand. This refactoring also passes all of the tests. * Set `PRGEND` when compiling to memory Before, `PRGEND` was not adjusted which made round-tripping from the Applesoft compiler to the decompiler not work. This change now updates `PRGEND` with the end-of-program + 2 bytes which seems to be the most frequent value that I have observed. * Fix two compiler bugs In debugging the decompiler, I noticed two bugs in the compiler: * The first character after a line number was skipped. * `?` was not accepted as a shortcut for `PRINT`. This change fixes these two problems and adds tests. * Ignore spaces more aggressively It turns out that Applesoft happily accepts 'T H E N' for `THEN` but the parser did not. This change fixes that and adds tests for some odd cases. Interestingly, this means that there are some valid statements that Applesoft can never parse correctly because it is greedy and ignores (most) spaces. For example, `NOT RACE` will always parse as `NOTRACE` even though `NOT RACE` is a valid expression. * Move tokens into a separate file Because the token lists are just maps in opposite directions, put them in the same file. In the future, maybe we can build one automatically. * Fix `apple2.ts` I had neglected to actually update `apple2.ts` to use the new compiler and decompiler. They now do. Also, the decompiler can be created from `Memory`. It assumes, though, that the zero page pointers to the start and end of the program are correct. * Address comments * No more `as const` for tokens. * Extracted zero page constants to their own file. Co-authored-by: Will Scullin <scullin@scullin.com>
970 lines
28 KiB
TypeScript
970 lines
28 KiB
TypeScript
import MicroModal from 'micromodal';
|
|
|
|
import { base64_json_parse, base64_json_stringify } from '../base64';
|
|
import { Audio, SOUND_ENABLED_OPTION } from './audio';
|
|
import DriveLights from './drive_lights';
|
|
import { includes, word } from '../types';
|
|
import {
|
|
BLOCK_FORMATS,
|
|
DISK_FORMATS,
|
|
DiskDescriptor,
|
|
DriveNumber,
|
|
DRIVE_NUMBERS,
|
|
MassStorage,
|
|
NIBBLE_FORMATS,
|
|
JSONBinaryImage,
|
|
JSONDisk,
|
|
BlockFormat
|
|
} from '../formats/types';
|
|
import { initGamepad } from './gamepad';
|
|
import KeyBoard from './keyboard';
|
|
import Tape, { TAPE_TYPES } from './tape';
|
|
|
|
import ApplesoftDecompiler from '../applesoft/decompiler';
|
|
import ApplesoftCompiler from '../applesoft/compiler';
|
|
|
|
import { debug } from '../util';
|
|
import { Apple2, Stats, State as Apple2State } from '../apple2';
|
|
import DiskII from '../cards/disk2';
|
|
import CPU6502 from '../cpu6502';
|
|
import { VideoModes } from '../videomodes';
|
|
import Apple2IO from '../apple2io';
|
|
import Printer from './printer';
|
|
|
|
import { OptionsModal } from './options_modal';
|
|
import { Screen, SCREEN_FULL_PAGE } from './screen';
|
|
import { JoyStick } from './joystick';
|
|
import { System } from './system';
|
|
import { Options } from '../options';
|
|
|
|
let paused = false;
|
|
|
|
let startTime = Date.now();
|
|
let lastCycles = 0;
|
|
let lastFrames = 0;
|
|
let lastRenderedFrames = 0;
|
|
|
|
let hashtag = document.location.hash;
|
|
|
|
const options = new Options();
|
|
const optionsModal = new OptionsModal(options);
|
|
|
|
type DiskCollection = {
|
|
[name: string]: DiskDescriptor[];
|
|
};
|
|
|
|
const CIDERPRESS_EXTENSION = /#([0-9a-f]{2})([0-9a-f]{4})$/i;
|
|
const BIN_TYPES = ['bin'];
|
|
|
|
const KNOWN_FILE_TYPES = [
|
|
...DISK_FORMATS,
|
|
...TAPE_TYPES,
|
|
...BIN_TYPES,
|
|
] as readonly string[];
|
|
|
|
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 _massStorage: MassStorage<BlockFormat>;
|
|
let _printer: Printer;
|
|
let audio: Audio;
|
|
let screen: Screen;
|
|
let joystick: JoyStick;
|
|
let system: System;
|
|
let keyboard: KeyBoard;
|
|
let io: Apple2IO;
|
|
let _currentDrive: DriveNumber = 1;
|
|
let _e: boolean;
|
|
|
|
let ready: Promise<[void, void]>;
|
|
|
|
export const driveLights = new DriveLights();
|
|
|
|
/** Start of program (word) */
|
|
const TXTTAB = 0x67;
|
|
|
|
export function dumpAppleSoftProgram() {
|
|
const decompiler = ApplesoftDecompiler.decompilerFromMemory(cpu);
|
|
debug(decompiler.list({apple2: _e ? 'e' : 'plus'}));
|
|
}
|
|
|
|
export function compileAppleSoftProgram(program: string) {
|
|
const start = cpu.read(TXTTAB) + (cpu.read(TXTTAB + 1) << 8);
|
|
ApplesoftCompiler.compileToMemory(cpu, program, start);
|
|
dumpAppleSoftProgram();
|
|
}
|
|
|
|
export function openLoad(driveString: string, event: MouseEvent) {
|
|
const drive = parseInt(driveString, 10) as DriveNumber;
|
|
_currentDrive = drive;
|
|
if (event.metaKey && includes(DRIVE_NUMBERS, drive)) {
|
|
openLoadHTTP();
|
|
} else {
|
|
if (disk_cur_cat[drive]) {
|
|
const element = document.querySelector<HTMLSelectElement>('#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 storageData = _disk2.getBinary(drive);
|
|
const a = document.querySelector<HTMLAnchorElement>('#local_save_link')!;
|
|
|
|
if (!storageData) {
|
|
alert(`No data from drive ${drive}`);
|
|
return;
|
|
}
|
|
|
|
const { data } = storageData;
|
|
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<HTMLInputElement>('#save_name')!;
|
|
saveName.value = driveLights.label(drive);
|
|
MicroModal.show('save-modal');
|
|
}
|
|
}
|
|
|
|
export function openAlert(msg: string) {
|
|
const el = document.querySelector<HTMLDivElement>('#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 = hup().split('|');
|
|
parts[drive - 1] = url;
|
|
document.location.hash = parts.join('|');
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function loadingStart() {
|
|
const meter = document.querySelector<HTMLDivElement>('#loading-modal .meter')!;
|
|
meter.style.display = 'none';
|
|
MicroModal.show('loading-modal');
|
|
}
|
|
|
|
function loadingProgress(current: number, total: number) {
|
|
if (total) {
|
|
const meter = document.querySelector<HTMLDivElement>('#loading-modal .meter')!;
|
|
const progress = document.querySelector<HTMLDivElement>('#loading-modal .progress')!;
|
|
meter.style.display = 'block';
|
|
progress.style.width = `${current / total * meter.clientWidth}px`;
|
|
}
|
|
}
|
|
|
|
function loadingStop() {
|
|
MicroModal.close('loading-modal');
|
|
|
|
if (!paused) {
|
|
ready.then(() => {
|
|
_apple2.run();
|
|
}).catch(console.error);
|
|
}
|
|
}
|
|
|
|
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 );
|
|
} else if (includes(DISK_FORMATS, data.type)) {
|
|
loadDisk(drive, data);
|
|
}
|
|
initGamepad(data.gamepad);
|
|
loadingStop();
|
|
}).catch(function (error: Error) {
|
|
loadingStop();
|
|
openAlert(error.message);
|
|
console.error(error);
|
|
});
|
|
}
|
|
|
|
export function doLoad(event: MouseEvent|KeyboardEvent) {
|
|
MicroModal.close('load-modal');
|
|
const select = document.querySelector<HTMLSelectElement>('#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<HTMLInputElement>('#local_file')!;
|
|
const files = localFile.files;
|
|
if (files && files.length === 1) {
|
|
const runOnLoad = event.shiftKey;
|
|
doLoadLocal(_currentDrive, files[0], { runOnLoad });
|
|
} else if (url) {
|
|
let filename;
|
|
MicroModal.close('load-modal');
|
|
if (url.slice(0, 6) === 'local:') {
|
|
filename = url.slice(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 = hup().split('|');
|
|
parts[_currentDrive - 1] = filename;
|
|
document.location.hash = parts.join('|');
|
|
}
|
|
}
|
|
}
|
|
|
|
export function doSave() {
|
|
const saveName = document.querySelector<HTMLInputElement>('#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);
|
|
}
|
|
}
|
|
|
|
interface LoadOptions {
|
|
address?: word;
|
|
runOnLoad?: boolean;
|
|
}
|
|
|
|
function doLoadLocal(drive: DriveNumber, file: File, options: Partial<LoadOptions> = {}) {
|
|
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) {
|
|
const auxAddress = aux !== undefined ? { address: parseInt(aux, 16) } : {};
|
|
doLoadBinary(file, { ...options, ...auxAddress });
|
|
} else {
|
|
const addressInput = document.querySelector<HTMLInputElement>('#local_file_address');
|
|
const addressStr = addressInput?.value;
|
|
if (addressStr) {
|
|
const address = parseInt(addressStr, 16);
|
|
if (isNaN(address)) {
|
|
openAlert('Invalid address: ' + addressStr);
|
|
return;
|
|
}
|
|
doLoadBinary(file, { address, ...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;
|
|
address = address ?? 0x2000;
|
|
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(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 = hup().split('|');
|
|
files[drive - 1] = '';
|
|
document.location.hash = files.join('|');
|
|
|
|
if (includes(DISK_FORMATS, ext)) {
|
|
if (result.byteLength >= 800 * 1024) {
|
|
if (
|
|
includes(BLOCK_FORMATS, ext) &&
|
|
_massStorage.setBinary(drive, name, ext, result)
|
|
) {
|
|
initGamepad();
|
|
} else {
|
|
openAlert(`Unable to load ${name}`);
|
|
}
|
|
} else {
|
|
if (
|
|
includes(NIBBLE_FORMATS, ext) &&
|
|
_disk2.setBinary(drive, name, ext, result)
|
|
) {
|
|
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<HTMLInputElement>('#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<ArrayBufferLike> {
|
|
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 (includes(DISK_FORMATS, ext)) {
|
|
if (data.byteLength >= 800 * 1024) {
|
|
if (includes(BLOCK_FORMATS, ext)) {
|
|
_massStorage.setBinary(drive, name, ext, data);
|
|
initGamepad();
|
|
}
|
|
} else {
|
|
if (
|
|
includes(NIBBLE_FORMATS, ext) &&
|
|
_disk2.setBinary(drive, name, ext, data)
|
|
) {
|
|
initGamepad();
|
|
}
|
|
}
|
|
} else {
|
|
throw new Error(`Extension ${ext} not recognized.`);
|
|
}
|
|
loadingStop();
|
|
}).catch((error: Error) => {
|
|
loadingStop();
|
|
openAlert(error.message);
|
|
console.error(error);
|
|
});
|
|
}
|
|
}
|
|
|
|
function openLoadHTTP() {
|
|
MicroModal.show('http-modal');
|
|
}
|
|
|
|
function openManage() {
|
|
MicroModal.show('manage-modal');
|
|
}
|
|
|
|
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<HTMLDivElement>('#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 toggleSound() {
|
|
const on = !audio.isEnabled();
|
|
options.setOption(SOUND_ENABLED_OPTION, on);
|
|
updateSoundButton(on);
|
|
}
|
|
|
|
function initSoundToggle() {
|
|
updateSoundButton(audio.isEnabled());
|
|
}
|
|
|
|
function updateSoundButton(on: boolean) {
|
|
const label = document.querySelector<HTMLDivElement>('#toggle-sound i')!;
|
|
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('<pre>');
|
|
wind.document.write(_disk2.getJSON(drive, true));
|
|
wind.document.write('</pre>');
|
|
wind.document.close();
|
|
}
|
|
|
|
export function reset() {
|
|
_apple2.reset();
|
|
}
|
|
|
|
function loadBinary(bin: JSONBinaryImage) {
|
|
const maxLen = Math.min(bin.length, 0x10000 - bin.start);
|
|
for (let idx = 0; idx < maxLen; 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<HTMLSelectElement>('#disk_select')!;
|
|
const categorySelect = document.querySelector<HTMLSelectElement>('#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<HTMLInputElement>('#local_file')!;
|
|
localFile.value = '';
|
|
}
|
|
|
|
export function clickDisk(event: MouseEvent|KeyboardEvent) {
|
|
doLoad(event);
|
|
}
|
|
|
|
/** 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.getItem('diskIndex') || '{}') as LocalDiskIndex;
|
|
const names = Object.keys(diskIndex);
|
|
|
|
const cat: DiskDescriptor[] = disk_categories['Local Saves'] = [];
|
|
const contentDiv = document.querySelector<HTMLDivElement>('#manage-modal-content')!;
|
|
contentDiv.innerHTML = '';
|
|
|
|
names.forEach(function (name) {
|
|
cat.push({
|
|
'category': 'Local Saves',
|
|
'name': name,
|
|
'filename': 'local:' + name
|
|
});
|
|
contentDiv.innerHTML =
|
|
'<span class="local_save">' +
|
|
name +
|
|
' <a href="#" onclick="Apple2.doDelete(\'' +
|
|
name +
|
|
'\')">Delete</a><br /></span>';
|
|
});
|
|
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.getItem('diskIndex') || '{}') as LocalDiskIndex;
|
|
|
|
const json = _disk2.getJSON(drive);
|
|
diskIndex[name] = json;
|
|
|
|
window.localStorage.setItem('diskIndex', JSON.stringify(diskIndex));
|
|
|
|
driveLights.label(drive, name);
|
|
driveLights.dirty(drive, false);
|
|
updateLocalStorage();
|
|
}
|
|
|
|
function deleteLocalStorage(name: string) {
|
|
const diskIndex = JSON.parse(window.localStorage.getItem('diskIndex') || '{}') as LocalDiskIndex;
|
|
if (diskIndex[name]) {
|
|
delete diskIndex[name];
|
|
openAlert('Deleted');
|
|
}
|
|
window.localStorage.setItem('diskIndex', JSON.stringify(diskIndex));
|
|
updateLocalStorage();
|
|
}
|
|
|
|
function loadLocalStorage(drive: DriveNumber, name: string) {
|
|
const diskIndex = JSON.parse(window.localStorage.getItem('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<HTMLElement>('.disksave');
|
|
nodes.forEach(function (el) {
|
|
el.style.display = 'inline-block';
|
|
});
|
|
}
|
|
|
|
const categorySelect = document.querySelector<HTMLSelectElement>('#category_select')!;
|
|
|
|
declare global {
|
|
interface Window {
|
|
disk_index: DiskDescriptor[];
|
|
}
|
|
}
|
|
|
|
function buildDiskIndex() {
|
|
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 && !_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');
|
|
}
|
|
}
|
|
}
|
|
|
|
export function updateUI() {
|
|
if (document.location.hash !== hashtag) {
|
|
hashtag = document.location.hash;
|
|
const hash = hup();
|
|
if (hash) {
|
|
processHash(hash);
|
|
}
|
|
}
|
|
}
|
|
|
|
export function pauseRun() {
|
|
const label = document.querySelector<HTMLElement>('#pause-run i')!;
|
|
if (paused) {
|
|
ready.then(() => {
|
|
_apple2.run();
|
|
}).catch(console.error);
|
|
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 openOptions() {
|
|
optionsModal.openModal();
|
|
}
|
|
|
|
export function openPrinterModal() {
|
|
const mimeType = 'application/octet-stream';
|
|
const data = _printer.getRawOutput();
|
|
const a = document.querySelector<HTMLAnchorElement>('#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;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Returns the value of a query parameter or the empty string if it does not
|
|
* exist.
|
|
* @param name the parameter name. Note that `name` must not have any RegExp
|
|
* meta-characters except '[' and ']' or it will fail.
|
|
*/
|
|
|
|
function gup(name: string) {
|
|
const params = new URLSearchParams(window.location.search);
|
|
return params.get(name);
|
|
}
|
|
|
|
/** Returns the URL fragment. */
|
|
function hup() {
|
|
const regex = new RegExp('#(.*)');
|
|
const hash = decodeURIComponent(window.location.hash);
|
|
const results = regex.exec(hash);
|
|
if (!results)
|
|
return '';
|
|
else
|
|
return results[1];
|
|
}
|
|
|
|
function onLoaded(
|
|
apple2: Apple2,
|
|
disk2: DiskII,
|
|
massStorage: MassStorage<BlockFormat>,
|
|
printer: Printer, e: boolean
|
|
) {
|
|
_apple2 = apple2;
|
|
cpu = _apple2.getCPU();
|
|
io = _apple2.getIO();
|
|
stats = apple2.getStats();
|
|
vm = apple2.getVideoModes();
|
|
tape = new Tape(io);
|
|
_disk2 = disk2;
|
|
_massStorage = massStorage;
|
|
_printer = printer;
|
|
_e = e;
|
|
|
|
system = new System(io, e);
|
|
options.addOptions(system);
|
|
|
|
joystick = new JoyStick(io);
|
|
options.addOptions(joystick);
|
|
|
|
screen = new Screen(vm);
|
|
options.addOptions(screen);
|
|
|
|
audio = new Audio(io);
|
|
options.addOptions(audio);
|
|
initSoundToggle();
|
|
|
|
ready = Promise.all([audio.ready, apple2.ready]);
|
|
|
|
MicroModal.init();
|
|
|
|
keyboard = new KeyBoard(cpu, io, e);
|
|
keyboard.create('#keyboard');
|
|
keyboard.setFunction('F1', () => cpu.reset());
|
|
keyboard.setFunction('F2', (event) => {
|
|
if (event.shiftKey) { // Full window, but not full screen
|
|
options.setOption(
|
|
SCREEN_FULL_PAGE,
|
|
!options.getOption(SCREEN_FULL_PAGE)
|
|
);
|
|
} else {
|
|
screen.enterFullScreen();
|
|
}
|
|
});
|
|
keyboard.setFunction('F3', () => io.keyDown(0x1b)); // Escape
|
|
keyboard.setFunction('F4', optionsModal.openModal);
|
|
keyboard.setFunction('F6', () => {
|
|
window.localStorage.setItem('state', base64_json_stringify(_apple2.getState()));
|
|
});
|
|
keyboard.setFunction('F9', () => {
|
|
const localState = window.localStorage.getItem('state');
|
|
if (localState) {
|
|
_apple2.setState(base64_json_parse(localState) as Apple2State);
|
|
}
|
|
});
|
|
|
|
buildDiskIndex();
|
|
|
|
/*
|
|
* Input Handling
|
|
*/
|
|
|
|
window.addEventListener('paste', (event: Event) => {
|
|
const paste = (event.clipboardData || window.clipboardData)!.getData('text');
|
|
io.setKeyBuffer(paste);
|
|
event.preventDefault();
|
|
});
|
|
|
|
window.addEventListener('copy', (event: Event) => {
|
|
event.clipboardData!.setData('text/plain', vm.getText());
|
|
event.preventDefault();
|
|
});
|
|
|
|
if (navigator.standalone) {
|
|
document.body.classList.add('standalone');
|
|
}
|
|
|
|
cpu.reset();
|
|
setInterval(updateKHz, 1000);
|
|
initGamepad();
|
|
|
|
// Check for disks in hashtag
|
|
|
|
const hash = gup('disk') || hup();
|
|
if (hash) {
|
|
_apple2.stop();
|
|
processHash(hash);
|
|
} else {
|
|
ready.then(() => {
|
|
_apple2.run();
|
|
}).catch(console.error);
|
|
}
|
|
|
|
document.querySelector<HTMLInputElement>('#local_file')?.addEventListener(
|
|
'change',
|
|
(event: Event) => {
|
|
const target = event.target as HTMLInputElement;
|
|
const address = document.querySelector<HTMLInputElement>('#local_file_address_input')!;
|
|
const parts = target.value.split('.');
|
|
const ext = parts[parts.length - 1];
|
|
|
|
if (KNOWN_FILE_TYPES.includes(ext)) {
|
|
address.style.display = 'none';
|
|
} else {
|
|
address.style.display = 'inline-block';
|
|
}
|
|
}
|
|
);
|
|
}
|
|
|
|
export function initUI(
|
|
apple2: Apple2,
|
|
disk2: DiskII,
|
|
massStorage: MassStorage<BlockFormat>,
|
|
printer: Printer, e: boolean) {
|
|
window.addEventListener('load', () => {
|
|
onLoaded(apple2, disk2, massStorage, printer, e);
|
|
});
|
|
}
|