Merge branch 'main' into master

This commit is contained in:
brainrecall 2022-09-25 20:05:27 -04:00 committed by GitHub
commit 7b902e3b4c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
222 changed files with 21469 additions and 5724 deletions

View File

@ -1,3 +1,4 @@
dist
json/disks/index.js
node_modules
tmp

View File

@ -1,6 +1,11 @@
{
// Global
"root": true,
"parser": "@typescript-eslint/parser",
"plugins": ["@typescript-eslint/eslint-plugin"],
"extends": [
"eslint:recommended",
"plugin:jest/recommended"
],
"rules": {
"indent": [
"error",
@ -17,32 +22,15 @@
"error",
"unix"
],
"eqeqeq": [
"error",
"smart"
],
"prefer-const": [
"error"
],
"semi": [
"error",
"always"
],
"no-var": "error",
"no-use-before-define": "off",
"@typescript-eslint/no-floating-promises": "error",
"@typescript-eslint/no-use-before-define": [
"error",
{
"functions": false,
"classes": false
}
],
"no-unused-vars": "off",
"@typescript-eslint/no-unused-vars": [
"error",
{
"argsIgnorePattern": "^_"
}
],
"no-redeclare": "off",
"@typescript-eslint/no-redeclare": ["error"],
"no-dupe-class-members": "off",
"no-console": [
"error",
{
@ -52,6 +40,17 @@
"error"
]
}
],
// Jest configuration
"jest/expect-expect": [
"error",
{
"assertFunctionNames": [
"expect*",
"checkImageData",
"testCode"
]
}
]
},
"env": {
@ -59,20 +58,101 @@
"browser": true,
"es6": true
},
"parserOptions": {
"sourceType": "module",
"project": "./tsconfig.json"
},
"extends": "eslint:recommended",
"overrides": [
// All overrides matching a file are applied in-order, with the last
// taking precedence.
//
// TypeScript/TSX-specific configuration
{
"files": [
"**/*.ts"
"*.ts",
"*.tsx"
],
"plugins": [
"@typescript-eslint/eslint-plugin"
],
"extends": [
"plugin:react/recommended",
"plugin:react-hooks/recommended",
"plugin:@typescript-eslint/recommended",
"plugin:@typescript-eslint/recommended-requiring-type-checking"
],
"rules": {
"no-var": "error"
// recommended is just "warn"
"@typescript-eslint/no-explicit-any": "error",
// enforce semicolons at ends of statements
"semi": "off",
"@typescript-eslint/semi": [
"error",
"always"
],
// enforce semicolons to separate members
"@typescript-eslint/member-delimiter-style": [
"error",
{
"multiline": {
"delimiter": "semi",
"requireLast": true
},
"singleline": {
"delimiter": "semi",
"requireLast": false
}
}
],
// definitions must come before uses for variables
"@typescript-eslint/no-use-before-define": [
"error",
{
"functions": false,
"classes": false
}
],
// no used variables
"no-unused-vars": "off",
"@typescript-eslint/no-unused-vars": [
"error",
{
"argsIgnorePattern": "^_"
}
],
// no redeclaration of classes, members or variables
"no-redeclare": "off",
"@typescript-eslint/no-redeclare": [
"error"
],
// allow empty interface definitions and empty extends
"@typescript-eslint/no-empty-interface": "off",
// allow explicit type declaration
"@typescript-eslint/no-inferrable-types": "off",
// allow some non-string types in templates
"@typescript-eslint/restrict-template-expressions": [
"error",
{
"allowNumber": true,
"allowBoolean": true
}
],
// react rules
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "error"
},
"parserOptions": {
"sourceType": "module",
"project": "./tsconfig.json"
}
},
// UI elements
{
"files": [
"js/ui/**.ts"
],
"rules": {
// allow non-null assertions since these classes reference the DOM
"@typescript-eslint/no-non-null-assertion": "off"
}
},
// JS Node configuration
{
"files": [
"bin/*",
@ -88,6 +168,7 @@
"browser": false
}
},
// Test configuration
{
"files": [
"test/**/*"
@ -101,16 +182,18 @@
"no-console": 0
}
},
// Entry point configuration
{
"files": [
"js/entry2.js",
"js/entry2e.js",
"js/entry2.ts",
"js/entry2e.ts",
"jest.config.js"
],
"env": {
"commonjs": true
}
},
// Worker configuration
{
"files": [
"workers/*"
@ -120,5 +203,13 @@
}
}
],
"ignorePatterns": ["coverage/**/*"]
"ignorePatterns": [
"coverage/**/*"
],
"settings": {
"react": {
"pragma": "h",
"version": "16"
}
}
}

View File

@ -9,7 +9,7 @@ jobs:
strategy:
matrix:
node-version: [12.x]
node-version: [14.x]
steps:
- uses: actions/checkout@v2

2
.stylelintignore Normal file
View File

@ -0,0 +1,2 @@
css/apple2.css
coverage

10
.stylelintrc.json Normal file
View File

@ -0,0 +1,10 @@
{
"extends": [
"stylelint-config-standard",
"stylelint-config-css-modules"
],
"rules": {
"indentation": 4,
"selector-class-pattern": "^[a-z][a-zA-Z0-9_-]+$"
}
}

View File

@ -1,6 +1,6 @@
The MIT License (MIT)
Copyright (c) 2013-2019 Will Scullin
Copyright (c) 2010-2021 Will Scullin and contributors
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in

View File

@ -1,6 +1,6 @@
<!DOCTYPE html>
<!--
Copyright 2010-2019 Will Scullin <scullin@scullinsteel.com>
Copyright 2010-2021 Will Scullin and contributors
Permission to use, copy, modify, distribute, and sell this software and its
documentation for any purpose is hereby granted without fee, provided that
@ -46,7 +46,7 @@
</div>
<div id="display">
<div class="overscan">
<canvas id="screen" width="592" height="416"></canvas>
<canvas id="screen" width="592" height="416" tabindex="-1"></canvas>
</div>
</div>
<div class="inset">
@ -96,7 +96,7 @@
<button onclick="window.open('https://github.com/whscullin/apple2js#readme', 'blank')" title="About">
<i class="fas fa-info"></i>
</button>
<button onclick="Apple2.openOptions()" title="Options">
<button onclick="Apple2.openOptions()" title="Options (F4)">
<i class="fas fa-cog"></i>
</button>
</div>
@ -265,7 +265,7 @@
</button>
</header>
<main class="modal__content" id="printer-modal-content">
<div class="paper"></div>
<div class="paper" tabindex="-1"></div>
</main>
<footer class="modal__footer">
<a id="raw_printer_output" class="button">Download Raw Output</a>

View File

@ -1,6 +1,6 @@
<!DOCTYPE html>
<!--
Copyright 2010-2019 Will Scullin <scullin@scullinsteel.com>
Copyright 2010-2021 Will Scullin and contributors
Permission to use, copy, modify, distribute, and sell this software and its
documentation for any purpose is hereby granted without fee, provided that
@ -46,7 +46,7 @@
</div>
<div id="display">
<div class="overscan">
<canvas id="screen" width="592" height="416"></canvas>
<canvas id="screen" width="592" height="416" tabindex="-1"></canvas>
</div>
</div>
<div class="inset">
@ -97,7 +97,7 @@
<button onclick="window.open('https://github.com/whscullin/apple2js#readme', 'blank')" title="About">
<i class="fas fa-info"></i>
</button>
<button onclick="Apple2.openOptions()" title="Options">
<button onclick="Apple2.openOptions()" title="Options (F4)">
<i class="fas fa-cog"></i>
</button>
</div>
@ -270,7 +270,7 @@
</button>
</header>
<main class="modal__content" id="printer-modal-content">
<div class="paper"></div>
<div class="paper" tabindex="-1"></div>
</main>
<footer class="modal__footer">
<a id="raw_printer_output" class="button">Download Raw Output</a>

88
asm/mouse.s Normal file
View File

@ -0,0 +1,88 @@
;
; Minimal mouse support. Only firmware routines are supported, no
; I/O hooks or softswitches. This is enough to work with titles
; that follow the documentation's recommendations to use the
; firmware routines like Dazzle Draw and Apple II DeskTop
;
ORG $C700
; Constants for future reference
CLAMP_X_LOW EQU $478
CLAMP_Y_LOW EQU $4F8
CLAMP_X_HIGH EQU $578
CLAMP_Y_HIGH EQU $5F8
X_LOW EQU $478
Y_LOW EQU $4F8
X_HIGH EQU $578
Y_HIGH EQU $5F8
RESERVED1 EQU $678
RESERVED2 EQU $67F
STATUS EQU $778
MODE EQU $7F8
STATUS_DOWN EQU $80
STATUS_LAST EQU $40
INT_SCREEN EQU $08
INT_BUTTON EQU $04
INT_MOUSE EQU $02
ROMRTS EQU $FF58
DFB $00 ; $00
DFB $00 ; $01
DFB $00 ; $02
DFB $00 ; $03
DFB $00 ; $04
; Cx05 - Pascal ID byte
DFB $38 ; $05
DFB $00 ; $06
; Cx07 - Pascal ID byte
DFB $18 ; $07
DFB $00 ; $08
DFB $00 ; $09
DFB $00 ; $0A
; Cx0B - Generic signature byte of firmware cards
DFB $01 ; $0B
; Cx0C - 2 = X-Y pointing device; 0 = identification code
ID1 DFB $20 ; $0C
DFB $00 ; $0D
DFB $00 ; $0E
DFB $00 ; $0F
DFB $00 ; $10
DFB $00 ; $11
; The firmware routines point to individual RTS opcodes
; that are intercepted by the card implementation which
; manipulates memory and processor state directly
DFB $20 ; $12 SETMOUSE
DFB $21 ; $13 SERVEMOUSE
DFB $22 ; $14 READMOUSE
DFB $23 ; $15 CLEARMOUSE
DFB $24 ; $16 POSMOUSE
DFB $25 ; $17 CLAMPMOUSE
DFB $26 ; $18 HOMEMOUSE
DFB $27 ; $19 INITMOUSE
DFB $00 ; $1A
DFB $00 ; $1B
DFB $00 ; $1C
DFB $00 ; $1D
DFB $00 ; $1E
DFB $00 ; $1F
RTS ; $20 SETMOUSE
RTS ; $21 SERVEMOUSE
RTS ; $22 READMOUSE
RTS ; $23 CLEARMOUSE
RTS ; $24 POSMOUSE
RTS ; $25 CLAMPMOUSE
RTS ; $26 HOMEMOUSE
RTS ; $27 INITMOUSE
PADDING DS $C7FB - PADDING
ORG $C7FB
; CxFB - A mouse identification byte
ID2 DFB $D6 ; $FB
DFB $00 ; $FC
DFB $00 ; $FD
DFB $00 ; $FE
DFB $00 ; $FF
END

View File

@ -9,5 +9,19 @@ module.exports = {
},
},
],
[
'@babel/typescript',
{
jsxPragma: 'h'
}
],
],
plugins: [
[
'@babel/plugin-transform-react-jsx', {
pragma: 'h',
pragmaFrag: 'Fragment',
}
]
]
};

View File

@ -14,6 +14,7 @@ for (const fileName of dir.sort()) {
const json = fs.readFileSync(path.resolve(diskPath, fileName));
const data = JSON.parse(json);
if (data.private) { continue; }
if (!data.name || !data.category) { continue; }
const entry = {
filename: `json/disks/${fileName}`,
@ -26,7 +27,7 @@ for (const fileName of dir.sort()) {
}
}
index.sort((x,y) => {
index.sort((x, y) => {
const xc = x.category.toLowerCase();
const yc = y.category.toLowerCase();
const xn = x.name.toLowerCase();
@ -49,3 +50,8 @@ fs.writeFileSync(
path.resolve(diskPath, 'index.js'),
`disk_index = ${JSON.stringify(index, null, 2)};`
);
fs.writeFileSync(
path.resolve(diskPath, 'index.json'),
JSON.stringify(index, null, 2)
);

View File

@ -1,5 +1,3 @@
/* Copyright 2010-2019 Will Scullin */
#header {
width: 580px;
margin: auto;
@ -11,6 +9,7 @@ img {
#badge {
cursor: pointer;
user-select: none;
}
#subtitle {
@ -20,6 +19,7 @@ img {
font-family: "Adobe Garamond Pro",Garamond,Times;
font-size: 13px;
font-weight: normal;
user-select: none;
}
.motter {
@ -124,6 +124,10 @@ body {
height: 16px;
}
.disk-light.on {
background-image: url(red-on-16.png);
}
.disk-label {
color: #000;
font-family: sans-serif;
@ -133,6 +137,7 @@ body {
overflow: hidden;
white-space: nowrap;
flex-grow: 1;
user-select: none;
}
.code {
@ -162,9 +167,6 @@ th {
.inset button {
min-width: 36px;
}
.inset button {
margin: 0 2px;
}
@ -181,11 +183,13 @@ th {
color: #0f0;
border: 2px inset #888;
border-radius: 4px;
user-select: none;
}
canvas {
display: block;
float: left;
image-rendering: pixelated;
}
.mono {
@ -194,7 +198,8 @@ canvas {
.scanlines:after {
display: block;
background-image: repeating-linear-gradient(to bottom, transparent 0, transparent 1px, #000 1px, #000 2px);
pointer-events: none;
background-image: repeating-linear-gradient(to bottom, transparent 0, transparent 1px, rgba(0,0,0,0.5) 1px, rgba(0,0,0,0.5) 2px);
content: '';
position: absolute;
top: 0;
@ -203,6 +208,10 @@ canvas {
right: 0;
}
.full-page .scanlines:after {
background-image: repeating-linear-gradient(to bottom, transparent 0, transparent 0.25vh, rgba(0,0,0,0.5) 0.25vh, rgba(0,0,0,0.5) 0.5vh);
}
#screen {
cursor: crosshair;
-moz-image-rendering: -moz-crisp-edges;
@ -210,14 +219,20 @@ canvas {
image-rendering: optimizeSpeed;
width: 592px;
height: 416px;
touch-action: manipulation;
user-select: none;
}
#screen.mouseMode {
cursor: none;
}
#screen:-webkit-full-screen {
background-color: black;
top: 0;
left: 0;
width: 100%;
height: 100%;
width: 100vw;
height: 100vh;
}
#about {
@ -297,6 +312,7 @@ canvas {
padding: 5px 11px;
border: 1px outset #66594E;
border-radius: 3px;
user-select: none;
}
.modal__title {
@ -327,28 +343,34 @@ canvas {
.modal__footer {
text-align: right;
user-select: none;
}
button {
button,
a.button {
background: #44372C;
color: #fff;
padding: 2px 8px;
border: 1px outset #66594E;
border-radius: 3px;
font-size: 15px;
text-decoration: none;
}
button:hover {
button:hover,
a.button:hover {
background-color: #55473D;
border: 1px outset #66594E;
}
button:active {
button:active,
a.button:active {
background-color: #22150A;
border: 1px outset #44372C;
}
button:focus {
button:focus,
a.button:hover {
outline: none;
}
@ -576,6 +598,7 @@ button:focus {
#reset-row .inset {
margin: 0;
width: 604px;
}
#reset {
@ -592,6 +615,9 @@ button:focus {
padding: 0;
margin-left: 10px;
width: 42px;
display: flex;
justify-content: center;
align-items: center;
}
#reset:hover {
@ -600,8 +626,11 @@ button:focus {
}
#reset:active {
background: #22150A;
border: 5px outset #44372C;
background-color: #22150A;
border-left: 3px solid #44372C;
border-top: 3px solid #44372C;
border-right: 3px solid #000000;
border-bottom: 3px solid #000000;
}
#keyboard .key-OPEN_APPLE.active div {
@ -643,7 +672,6 @@ button:focus {
#options-modal {
width: 300px;
line-height: 1.75em;
}
#options-modal h3 {

BIN
css/green-off-16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 784 B

BIN
css/green-off-32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

BIN
css/green-on-16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 919 B

BIN
css/green-on-32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

0
css/red-off-16.png Executable file → Normal file
View File

Before

Width:  |  Height:  |  Size: 764 B

After

Width:  |  Height:  |  Size: 764 B

BIN
css/red-off-32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

0
css/red-on-16.png Executable file → Normal file
View File

Before

Width:  |  Height:  |  Size: 869 B

After

Width:  |  Height:  |  Size: 869 B

BIN
css/red-on-32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

26
index.html Normal file
View File

@ -0,0 +1,26 @@
<!DOCTYPE html>
<head>
<title>PreApple II</title>
<meta name="viewport" content="width=640, user-scalable=no" />
<meta name="apple-mobile-web-app-capable" content="yes">
<meta charset="utf-8" />
<link rel="apple-touch-icon" href="img/webapp-iphone.png" />
<link rel="apple-touch-icon" size="72x72" href="img/webapp-ipad.png" />
<link rel="shortcut icon" href="img/logoicon.png" />
<link rel="stylesheet" href="https://use.fontawesome.com/releases/v6.1.1/css/all.css" />
</head>
<body>
<div id="app"></div>
<svg width="0" height="0" xmlns="http://www.w3.org/2000/svg">
<filter id="green">
<feColorMatrix type="matrix" values="0.0 0.0 0.0 0.0 0
0.0 1.0 0.0 0.0 0
0.0 0.0 0.5 0.0 0
0.0 0.0 0.0 1.0 0" />
</filter>
</svg>
<script src="dist/preact.bundle.js"></script>
</body>

View File

@ -2,21 +2,22 @@ module.exports = {
'moduleNameMapper': {
'^js/(.*)': '<rootDir>/js/$1',
'^test/(.*)': '<rootDir>/test/$1',
'\\.css$': 'identity-obj-proxy',
},
'roots': [
'js/',
'test/',
],
'testMatch': [
'**/?(*.)+(spec|test).+(ts|js)'
'**/?(*.)+(spec|test).+(ts|js|tsx)'
],
'transform': {
'^.+\\.js$': 'babel-jest',
'^.+\\.ts$': 'ts-jest'
'^.+\\.ts$': 'ts-jest',
'^.*\\.tsx$': 'ts-jest',
},
'setupFilesAfterEnv': [
'<rootDir>/test/jest-setup.js'
'<rootDir>/test/jest-setup.ts'
],
'coveragePathIgnorePatterns': [
'/node_modules/',

View File

@ -17,43 +17,48 @@ import {
} from './gl';
import ROM from './roms/rom';
import { Apple2IOState } from './apple2io';
import CPU6502, { CpuState } from './cpu6502';
import CPU6502, {
CpuState,
FLAVOR_6502,
FLAVOR_ROCKWELL_65C02,
} from './cpu6502';
import MMU, { MMUState } from './mmu';
import RAM, { RAMState } from './ram';
import SYMBOLS from './symbols';
import Debugger, { DebuggerContainer } from './debugger';
import { Restorable, rom } from './types';
import { ReadonlyUint8Array, Restorable, rom } from './types';
import { processGamepad } from './ui/gamepad';
export interface Apple2Options {
characterRom: string;
enhanced: boolean,
e: boolean,
gl: boolean,
rom: string,
canvas: HTMLCanvasElement,
tick: () => void,
enhanced: boolean;
e: boolean;
gl: boolean;
rom: string;
canvas: HTMLCanvasElement;
tick: () => void;
}
export interface Stats {
frames: number,
renderedFrames: number,
cycles: number;
frames: number;
renderedFrames: number;
}
interface State {
cpu: CpuState,
vm: VideoModesState,
io: Apple2IOState,
mmu?: MMUState,
ram?: RAMState[],
export interface State {
cpu: CpuState;
vm: VideoModesState;
io: Apple2IOState;
mmu: MMUState | undefined;
ram: RAMState[] | undefined;
}
export class Apple2 implements Restorable<State>, DebuggerContainer {
private paused = false;
private theDebugger?: Debugger;
private theDebugger: Debugger | undefined;
private runTimer: number | null = null;
private runAnimationFrame: number | null = null;
@ -74,25 +79,28 @@ export class Apple2 implements Restorable<State>, DebuggerContainer {
private tick: () => void;
private stats: Stats = {
cycles: 0,
frames: 0,
renderedFrames: 0
};
public ready: Promise<void>
public ready: Promise<void>;
constructor(options: Apple2Options) {
this.ready = this.init(options);
}
async init(options: Apple2Options) {
const romImportPromise = import(`./roms/system/${options.rom}`);
const characterRomImportPromise = import(`./roms/character/${options.characterRom}`);
const romImportPromise = import(`./roms/system/${options.rom}`) as Promise<{ default: new () => ROM }>;
const characterRomImportPromise = import(`./roms/character/${options.characterRom}`) as Promise<{ default: ReadonlyUint8Array }>;
const LoresPage = options.gl ? LoresPageGL : LoresPage2D;
const HiresPage = options.gl ? HiresPageGL : HiresPage2D;
const VideoModes = options.gl ? VideoModesGL : VideoModes2D;
this.cpu = new CPU6502({ '65C02': options.enhanced });
this.cpu = new CPU6502({
flavor: options.enhanced ? FLAVOR_ROCKWELL_65C02 : FLAVOR_6502
});
this.vm = new VideoModes(options.canvas, options.e);
const [{ default: Apple2ROM }, { default: characterRom }] = await Promise.all([
@ -144,7 +152,7 @@ export class Apple2 implements Restorable<State>, DebuggerContainer {
return; // already running
}
this.theDebugger = new Debugger(this);
this.theDebugger = new Debugger(this.cpu, this);
this.theDebugger.addSymbols(SYMBOLS);
const interval = 30;
@ -180,6 +188,7 @@ export class Apple2 implements Restorable<State>, DebuggerContainer {
this.stats.renderedFrames++;
}
}
this.stats.cycles = this.cpu.getCycles();
this.stats.frames++;
this.io.tick();
this.tick();
@ -208,6 +217,10 @@ export class Apple2 implements Restorable<State>, DebuggerContainer {
this.runAnimationFrame = null;
}
isRunning() {
return !this.paused;
}
getState(): State {
const state: State = {
cpu: this.cpu.getState(),

View File

@ -1,34 +1,18 @@
/* Copyright 2010-2019 Will Scullin <scullin@scullinsteel.com>
*
* Permission to use, copy, modify, distribute, and sell this software and its
* documentation for any purpose is hereby granted without fee, provided that
* the above copyright notice appear in all copies and that both that
* copyright notice and this permission notice appear in supporting
* documentation. No representations are made about the suitability of this
* software for any purpose. It is provided "as is" without express or
* implied warranty.
*/
import CPU6502 from './cpu6502';
import { Card, Memory, MemoryPages, TapeData, byte, Restorable } from './types';
import { debug, garbage } from './util';
import { VideoModes } from './videomodes';
type slot = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7;
type button = 0 | 1 | 2;
type paddle = 0 | 1 | 2 | 3;
type annunciator = 0 | 1 | 2 | 3;
export type slot = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7;
export type button = 0 | 1 | 2;
export type paddle = 0 | 1 | 2 | 3;
export type annunciator = 0 | 1 | 2 | 3;
interface Annunciators {
0: boolean,
1: boolean,
2: boolean,
3: boolean,
}
type Annunciators = Record<annunciator, boolean>;
export interface Apple2IOState {
annunciators: Annunciators;
cards: Array<any | null>
cards: Array<unknown | null>;
}
export type SampleListener = (sample: number[]) => void;
@ -68,7 +52,7 @@ const LOC = {
};
export default class Apple2IO implements MemoryPages, Restorable<Apple2IOState> {
private _slot: Array<Card | null> = new Array(7).fill(null);
private _slot: Array<Card | null> = new Array<Card | null>(7).fill(null);
private _auxRom: Memory | null = null;
private _khz = 1023;
@ -97,7 +81,7 @@ export default class Apple2IO implements MemoryPages, Restorable<Apple2IOState>
private _annunciators: Annunciators = [false, false, false, false];
private _tape: TapeData = [];
private _tapeOffset = 0;
private _tapeOffset: number = 0;
private _tapeNext: number = 0;
private _tapeCurrent = false;
@ -109,7 +93,7 @@ export default class Apple2IO implements MemoryPages, Restorable<Apple2IOState>
this._calcSampleRate();
}
_debug(..._args: any[]) {
_debug(..._args: unknown[]) {
// debug.apply(this, arguments);
}
@ -122,7 +106,7 @@ export default class Apple2IO implements MemoryPages, Restorable<Apple2IOState>
if (this._audioListener) {
this._audioListener(this._sample);
}
this._sample = new Array(this._sample_size);
this._sample = new Array<number>(this._sample_size);
this._sampleIdx = 0;
}
}
@ -233,7 +217,7 @@ export default class Apple2IO implements MemoryPages, Restorable<Apple2IOState>
}
break;
case LOC.TAPEIN:
if (this._tapeOffset == -1) {
if (this._tapeOffset === -1) {
this._tapeOffset = 0;
this._tapeNext = now;
}
@ -242,7 +226,7 @@ export default class Apple2IO implements MemoryPages, Restorable<Apple2IOState>
this._tapeCurrent = this._tape[this._tapeOffset][1];
while (now >= this._tapeNext) {
if ((this._tapeOffset % 1000) === 0) {
debug('Read ' + (this._tapeOffset / 1000));
debug(`Read ${this._tapeOffset / 1000}`);
}
this._tapeCurrent = this._tape[this._tapeOffset][1];
this._tapeNext += this._tape[this._tapeOffset++][0];
@ -264,7 +248,7 @@ export default class Apple2IO implements MemoryPages, Restorable<Apple2IOState>
}
if (this._buffer.length > 0) {
let val = this._buffer.shift() as string;
if (val == '\n') {
if (val === '\n') {
val = '\r';
}
this._key = val.charCodeAt(0) | 0x80;
@ -359,7 +343,7 @@ export default class Apple2IO implements MemoryPages, Restorable<Apple2IOState>
case 0xc7:
slot = page & 0x0f;
card = this._slot[slot];
if (this._auxRom != card) {
if (this._auxRom !== card) {
// _debug('Setting auxRom to slot', slot);
this._auxRom = card;
}
@ -395,7 +379,7 @@ export default class Apple2IO implements MemoryPages, Restorable<Apple2IOState>
case 0xc7:
slot = page & 0x0f;
card = this._slot[slot];
if (this._auxRom != card) {
if (this._auxRom !== card) {
// _debug('Setting auxRom to slot', slot);
this._auxRom = card;
}
@ -428,6 +412,10 @@ export default class Apple2IO implements MemoryPages, Restorable<Apple2IOState>
this._slot[slot] = card;
}
getSlot(slot: slot): Card | null {
return this._slot[slot];
}
keyDown(ascii: byte) {
this._keyDown = true;
this._key = ascii | 0x80;
@ -437,8 +425,8 @@ export default class Apple2IO implements MemoryPages, Restorable<Apple2IOState>
this._keyDown = false;
}
buttonDown(b: button) {
this._button[b] = true;
buttonDown(b: button, state = true) {
this._button[b] = state;
}
buttonUp(b: button) {
@ -467,7 +455,7 @@ export default class Apple2IO implements MemoryPages, Restorable<Apple2IOState>
}
setTape(tape: TapeData) {
debug('Tape length: ' + tape.length);
debug(`Tape length: ${tape.length}`);
this._tape = tape;
this._tapeOffset = -1;
}
@ -475,7 +463,7 @@ export default class Apple2IO implements MemoryPages, Restorable<Apple2IOState>
sampleRate(rate: number, sample_size: number) {
this._rate = rate;
this._sample_size = sample_size;
this._sample = new Array(this._sample_size);
this._sample = new Array<number>(this._sample_size);
this._sampleIdx = 0;
this._calcSampleRate();
}

View File

@ -1,288 +1,319 @@
import { byte, KnownKeys, KnownValues, Memory, word } from '../types';
import { byte, KnownValues, Memory, word } from '../types';
import { STRING_TO_TOKEN } from './tokens';
import { TXTTAB, PRGEND, VARTAB, ARYTAB, STREND } from './zeropage';
/** 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;
/** Default address for program start */
const PROGRAM_START = 0x801;
const STATES = {
NORMAL: 0,
STRING: 1,
COMMENT: 2,
DATA: 3
} as const;
/** Parse states. Starts in `NORMAL`. */
enum STATES {
/**
* Tries to tokenize the input. Transitions:
* * `"`: `STRING`
* * `REM`: `COMMENT`
* * `DATA`: `DATA`
*/
NORMAL = 0,
/**
* Stores the input exactly. Tranistions:
* * `"`: `NORMAL`
*/
STRING = 1,
/** Stores the input exactly up until the end of the line. No transitions. */
COMMENT = 2,
/**
* Stores the input exactly. Transitions:
* * `:`: `NORMAL`
* * `"`: `DATA_QUOTE`
*/
DATA = 3,
/**
* Stores the input exactly. Transitions:
* * `"`: `DATA`
*/
DATA_QUOTE = 4,
}
export default class ApplesoftCompiler {
constructor(private mem: Memory) { }
function writeByte(mem: Memory, addr: word, val: byte) {
const page = addr >> 8;
const off = addr & 0xff;
private writeByte(addr: word, val: byte) {
const page = addr >> 8;
const off = addr & 0xff;
return mem.write(page, off, val);
}
return this.mem.write(page, off, val);
function writeWord(mem: Memory, addr: word, val: byte) {
const lsb = val & 0xff;
const msb = val >> 8;
writeByte(mem, addr, lsb);
writeByte(mem, addr + 1, msb);
}
class LineBuffer implements IterableIterator<string> {
private prevChar: number = 0;
constructor(private readonly line: string, private curChar: number = 0) { }
[Symbol.iterator](): IterableIterator<string> {
return this;
}
private writeWord(addr: word, val: byte) {
const lsb = val & 0xff;
const msb = val >> 8;
clone(): LineBuffer {
return new LineBuffer(this.line, this.curChar);
}
this.writeByte(addr, lsb);
this.writeByte(addr + 1, msb);
next(): IteratorResult<string, string | undefined> {
if (this.atEnd()) {
return { done: true, value: undefined };
}
this.prevChar = this.curChar;
return { done: false, value: this.line[this.curChar++] };
}
/**
* Tries to match the input token at the current buffer location. If
* the token matches, the current buffer location is advanced passed
* the token and this method returns `true`. Otherwise, this method
* returns `false`.
*
* The input is assumed to be an all-uppercase string and the tokens
* in the buffer are uppercased before the comparison.
*
* @param token An all-uppercase string to match.
*/
lookingAtToken(token: string): boolean {
const oldCurChar = this.curChar;
const oldPrevChar = this.prevChar;
let possibleToken = '';
for (const char of this) {
if (char === ' ') {
continue;
}
possibleToken += char;
if (possibleToken.length === token.length) {
break;
}
}
if (possibleToken.toUpperCase() === token) {
// Matched; set prevChar to before the match.
this.prevChar = oldCurChar;
return true;
}
// No match; restore state.
this.curChar = oldCurChar;
this.prevChar = oldPrevChar;
return false;
}
backup() {
this.curChar = this.prevChar;
}
peek(): string {
if (this.atEnd()) {
throw new RangeError(`Reading past the end of ${this.line}`);
}
return this.line[this.curChar];
}
atEnd(): boolean {
return this.curChar >= this.line.length;
}
}
export default class ApplesoftCompiler {
private lines: Map<number, byte[]> = new Map();
/**
* Loads an Applesoft BASIC program into memory.
*
* @param mem Memory, including zero page, into which the program is
* loaded.
* @param program A string with a BASIC program to compile (tokenize).
* @param programStart Optional start address of the program. Defaults to
* standard Applesoft program address, 0x801.
*/
static compileToMemory(mem: Memory, program: string, programStart: word = PROGRAM_START) {
const compiler = new ApplesoftCompiler();
compiler.compile(program);
const compiledProgram: Uint8Array = compiler.program(programStart);
for (let i = 0; i < compiledProgram.byteLength; i++) {
writeByte(mem, programStart + i, compiledProgram[i]);
}
// Set zero page locations. Applesoft is weird because, when a line
// is inserted, PRGEND is copied to VARTAB in the beginning, but then
// VARTAB is manipulated to make space for the line, then PRGEND is
// set from VARTAB. There's also a bug in NEW at D657 where the carry
// flag is not cleared, so it can add 2 or 3. The upshot, though, is
// that PRGEND and VARTAB end up being 1 or 2 bytes past the end of
// the program. From my tests is the emulator, it's usually 1, so
// that's what we're going with here.
const prgend = programStart + compiledProgram.byteLength + 1;
writeWord(mem, TXTTAB, programStart);
writeWord(mem, PRGEND, prgend);
writeWord(mem, VARTAB, prgend);
writeWord(mem, ARYTAB, prgend);
writeWord(mem, STREND, prgend);
}
private readLineNumber(lineBuffer: LineBuffer): number {
let lineNoStr = '';
for (const character of lineBuffer) {
if (/\d/.test(character)) {
lineNoStr += character;
} else {
lineBuffer.backup();
break;
}
}
if (lineNoStr.length === 0) {
throw new Error('Missing line number');
}
return parseInt(lineNoStr, 10);
}
private readToken(lineBuffer: LineBuffer): byte {
// Try to match a token
for (const possibleToken in STRING_TO_TOKEN) {
if (lineBuffer.lookingAtToken(possibleToken)) {
// NOTE(flan): This special token-preference
// logic is straight from the Applesoft BASIC
// code (D5BE-D5CA in the Apple //e ROM).
// Found a token
if (possibleToken === 'AT' && !lineBuffer.atEnd()) {
const lookAhead = lineBuffer.peek();
// ATN takes precedence over AT
if (lookAhead === 'N') {
lineBuffer.next();
return STRING_TO_TOKEN['ATN'];
}
// TO takes precedence over AT
if (lookAhead === 'O') {
// Backup to before the token
lineBuffer.backup();
// and emit the 'A' (upper- or lower-case)
return lineBuffer.next().value?.charCodeAt(0) ?? 0;
}
}
return STRING_TO_TOKEN[possibleToken];
}
}
// If not a token, output the character upper-cased
return lineBuffer.next().value?.toUpperCase().charCodeAt(0) ?? 0;
}
private compileLine(line: string | null | undefined) {
const result: byte[] = [];
if (!line) {
return;
}
const lineBuffer = new LineBuffer(line);
let state: KnownValues<typeof STATES> = STATES.NORMAL;
const lineNumber = this.readLineNumber(lineBuffer);
if (lineNumber < 0 || lineNumber > 65535) {
throw new Error('Line number out of range');
}
// Read the rest of the line
for (const character of lineBuffer) {
const charCode = character.charCodeAt(0);
switch (state) {
case STATES.NORMAL:
// Skip spaces
if (character === ' ') {
break;
}
// Transition to parsing a string
if (character === '"') {
result.push(charCode);
state = STATES.STRING;
break;
}
// Shorthand for PRINT (D580 in Apple //e ROM)
if (character === '?') {
result.push(STRING_TO_TOKEN['PRINT']);
break;
}
// Try to parse a token or character
lineBuffer.backup();
{
const token = this.readToken(lineBuffer);
if (token === STRING_TO_TOKEN['REM']) {
state = STATES.COMMENT;
}
if (token === STRING_TO_TOKEN['DATA']) {
state = STATES.DATA;
}
result.push(token);
}
break;
case STATES.COMMENT:
result.push(character.charCodeAt(0));
break;
case STATES.STRING:
if (character === '"') {
state = STATES.NORMAL;
}
result.push(character.charCodeAt(0));
break;
case STATES.DATA:
if (character === ':') {
state = STATES.NORMAL;
}
if (character === '"') {
state = STATES.DATA_QUOTE;
}
result.push(character.charCodeAt(0));
break;
case STATES.DATA_QUOTE:
if (character === '"') {
state = STATES.DATA;
}
result.push(character.charCodeAt(0));
break;
}
}
this.lines.set(lineNumber, result);
}
compile(program: string) {
const lineNos: { [line: string]: byte[]} = {};
function compileLine(line: string | null | undefined, offset: number) {
if (!line) {
return [];
}
let state: KnownValues<typeof STATES> = 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<typeof TOKENS>]);
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);
this.compileLine(line);
}
compiled.push(0, 0);
}
for (let idx = 0; idx < compiled.length; idx++) {
this.writeByte(PROGRAM_START + idx, compiled[idx]);
/** Returns the compiled program at the given start address. */
program(programStart: word = PROGRAM_START): Uint8Array {
const result: byte[] = [];
// Lines can be inserted out of order, but they should be in order
// when tokenized.
const lineNumbers = [...this.lines.keys()].sort();
for (const lineNo of lineNumbers) {
const lineBytes = this.lines.get(lineNo) || [];
const nextLineAddr = programStart + result.length + 4
+ lineBytes.length + 1; // +1 for the zero at end of line
result.push(nextLineAddr & 0xff, nextLineAddr >> 8);
result.push(lineNo & 0xff, lineNo >> 8);
result.push(...lineBytes);
result.push(0x00);
}
this.writeWord(LOMEM, PROGRAM_START + compiled.length);
this.writeWord(ARRAY_START, PROGRAM_START + compiled.length);
this.writeWord(ARRAY_END, PROGRAM_START + compiled.length);
result.push(0x00, 0x00);
return new Uint8Array(result);
}
}

View File

@ -1,4 +1,7 @@
import { byte, KnownKeys, Memory, word } from '../types';
import { byte, word, ReadonlyUint8Array, Memory } from '../types';
import { toHex } from 'js/util';
import { TOKEN_TO_STRING, STRING_TO_TOKEN } from './tokens';
import { TXTTAB, PRGEND } from './zeropage';
const LETTERS =
' ' +
@ -6,166 +9,334 @@ const LETTERS =
'@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;
/**
* Resolves a token value to a token string or character.
*
* @param token
* @returns string representing token
*/
const resolveToken = (token: byte) => {
let tokenString;
if (token >= 0x80 && token <= 0xea) {
tokenString = TOKEN_TO_STRING[token];
} else if (LETTERS[token] !== undefined) {
tokenString = LETTERS[token];
} else {
tokenString = `[${toHex(token)}]`;
}
return tokenString;
};
export default class ApplesoftDump {
constructor(private mem: Memory) { }
interface ListOptions {
apple2: 'e' | 'plus';
columns: number; // usually 40 or 80
}
private readByte(addr: word): byte {
const page = addr >> 8;
const off = addr & 0xff;
const DEFAULT_LIST_OPTIONS: ListOptions = {
apple2: 'e',
columns: 40,
};
return this.mem.read(page, off);
interface DecompileOptions {
style: 'compact' | 'pretty';
}
const DEFAULT_DECOMPILE_OPTIONS: DecompileOptions = {
style: 'pretty',
};
const MAX_LINES = 32768;
export default class ApplesoftDecompiler {
/**
* Returns a decompiler for the program in the given memory.
*
* The memory is assumed to have set `TXTTAB` and `PRGEND` correctly.
*/
static decompilerFromMemory(ram: Memory): ApplesoftDecompiler {
const program: byte[] = [];
const start = ram.read(0x00, TXTTAB) + (ram.read(0x00, TXTTAB + 1) << 8);
const end = ram.read(0x00, PRGEND) + (ram.read(0x00, PRGEND + 1) << 8);
if (start >= 0xc000 || end >= 0xc000) {
throw new Error(`Program memory ${toHex(start, 4)}-${toHex(end, 4)} out of range`);
}
for (let addr = start; addr <= end; addr++) {
program.push(ram.read(addr >> 8, addr & 0xff));
}
return new ApplesoftDecompiler(new Uint8Array(program), start);
}
private readWord(addr: word): word {
const lsb = this.readByte(addr);
const msb = this.readByte(addr + 1);
return (msb << 8) | lsb;
/**
* Constructs a decompiler for the given program data. The data is
* assumed to be a dump of memory beginning at `base`. If the data
* does not cover the whole program, attempting to decompile will
* fail.
*
* @param program The program bytes.
* @param base Address of start of program, or 0 to compute start
*/
constructor(
private readonly program: ReadonlyUint8Array,
private readonly base: word = 0x801
) {
if (this.base === 0) {
// Signals that we're loading a file from disk, and
// addresses are arbitrarily absolute, so we compute
// base by taking the next line address and adjusting it to
// the actual beginning of the next line.
const nextLine = this.wordAt(0);
// Start at beginning of first line
let nextLineIndex = 4;
// Find 0 at end of line
while (program[nextLineIndex]) {
nextLineIndex++;
}
// Move to beginning of next line
nextLineIndex++;
// Adjust base
this.base = nextLine - nextLineIndex;
}
}
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;
/** Returns the 2-byte word at the given offset. */
private wordAt(offset: word): word {
return this.program[offset] + (this.program[offset + 1] << 8);
}
line += lineno;
line += ' ';
let val = 0;
do {
if (addr < start || addr > end)
return str;
/**
* Iterates through the lines of the given program in the order of
* the linked list of lines, starting from the first line. This
* does _not_ mean that all lines in memory will
*
* @param from First line for which to call the callback.
* @param to Last line for which to call the callback.
* @param callback A function to call for each line. The first parameter
* is the offset of the line number of the line; the tokens follow.
*/
private forEachLine(
from: number, to: number,
callback: (offset: word) => void): void {
let count = 0;
let offset = 0;
let nextLineAddr = this.wordAt(offset);
let nextLineNo = this.wordAt(offset + 2);
while (nextLineAddr !== 0 && nextLineNo < from) {
if (++count > MAX_LINES) {
throw new Error('Loop detected in listing');
}
offset = nextLineAddr;
nextLineAddr = this.wordAt(offset);
nextLineNo = this.wordAt(offset + 2);
}
while (nextLineAddr !== 0 && nextLineNo <= to) {
if (++count > MAX_LINES) {
throw new Error('Loop detected in listing');
}
callback(offset + 2);
offset = nextLineAddr - this.base;
nextLineAddr = this.wordAt(offset);
nextLineNo = this.wordAt(offset + 2);
}
}
val = this.readByte(addr++);
if (val >= 0x80) {
line += ' ';
line += TOKENS[val as KnownKeys<typeof TOKENS>];
line += ' ';
}
else
line += LETTERS[val];
} while (val);
line += '\n';
str += line;
addr = next;
} while (addr && addr >= start && addr < end);
/** Lists a single line like an Apple II. */
listLine(offset: word, options: ListOptions): string {
const lines: string[] = [];
let line = '';
return str;
const lineNo = this.wordAt(offset);
// The Apple //e prints a space before each line number to make
// it easier to edit the lines. The change is at the subroutine
// called at D6F9: on the //e it is SPCLIN (F7AA), on the ][+ it
// is LINPRT (ED24).
if (options.apple2 === 'e') {
line += ' '; // D6F9: JSR SPCLIN
}
line += `${lineNo} `; // D6FC, always 1 space after line number
offset += 2;
// In the original ROM, the line length is checked immediately
// after the line number is printed. For simplicity, this method
// always assumes that there is space for one token—which would
// have been the case on a realy Apple.
while (this.program[offset] !== 0) {
if (offset >= this.program.length) {
lines.unshift('Unterminated line: ');
break;
}
const token = this.program[offset];
if (token >= 0x80 && token <= 0xea) {
line += ' '; // D750, always put a space in front of token
line += resolveToken(token);
line += ' '; // D762, always put a trailing space
} else {
line += resolveToken(token);
}
offset++;
// The Apple //e and ][+ differ in how they choose to break
// long lines. In the ][+, D705 prints a newline if the
// current column (MON_CH, $24) is greater than or equal to
// 33. In the //e, control is passed to GETCH (F7B4), which
// uses column 33 in 40-column mode and column 73 in 80-column
// mode.
//
// The ][+ behaves more like a //e when there is an 80-column
// card active (programs wrap at column 73). From what I can
// tell (using Virtual ]['s inspector), the 80-column card
// keeps MON_CH at zero until the actual column is >= 71, when
// it sets it to the actual cursor position - 40. In the
// Videx Videoterm ROM, this fixup happens in BASOUT (CBCD) at
// CBE6 by getting the 80-column horizontal cursor position and
// subtracting 0x47 (71). If the result is less than zero, then
// 0x00 is stored in MON_CH, otherwise 31 is added back and the
// result is stored in MON_CH. (The manual is archived at
// http://www.apple-iigs.info/doc/fichiers/videoterm.pdf, among
// other places.)
//
// For out purposes, we're just going to use the number of
// columns - 7.
if (line.length >= options.columns - 7) {
line += '\n';
lines.push(line);
line = ' ';
}
}
lines.push(line + '\n');
return lines.join('');
}
/**
* Lists the program in the same format that an Apple II prints to the
* screen.
*
* This method also accepts a starting and ending line number. Like on
* an Apple II, this will print all of the lines between `from` and `to`
* (inclusive) regardless of the actual line numbers between them.
*
* To list a single line, pass the same number for both `from` and `to`.
*
* @param options The options for formatting the output.
* @param from The first line to print (default 0).
* @param to The last line to print (default end of program).
*/
list(options: Partial<ListOptions> = {},
from: number = 0, to: number = 65536): string {
const allOptions = { ...DEFAULT_LIST_OPTIONS, ...options };
let result = '';
this.forEachLine(from, to, offset => {
result += this.listLine(offset, allOptions);
});
return result;
}
/**
* Returns a single line for the given compiled line in as little
* space as possible.
*/
compactLine(offset: word): string {
let result = '';
let spaceIf: (nextToken: string) => boolean = () => false;
const lineNo = this.wordAt(offset);
result += lineNo;
spaceIf = (nextToken: string) => /^\d/.test(nextToken);
offset += 2;
while (this.program[offset] !== 0) {
if (offset >= this.program.length) {
return 'Unterminated line: ' + result;
}
const token = this.program[offset];
let tokenString = resolveToken(token);
if (tokenString === 'PRINT') {
tokenString = '?';
}
if (spaceIf(tokenString)) {
result += ' ';
}
result += tokenString;
spaceIf = () => false;
if (token === STRING_TO_TOKEN['AT']) {
spaceIf = (nextToken) => nextToken.toUpperCase().startsWith('N');
}
offset++;
}
return result;
}
/**
* Returns a single line for the compiled line, but with even spacing:
* * space after line number (not before)
* * space before and after colons (`:`)
* * space around equality and assignment operators (`=`, `<=`, etc.)
* * space after tokens, unless it looks like a function call
* * space after commas, but not before
*/
prettyLine(offset: word): string {
let result = '';
let inString = false;
let spaceIf: (char: byte) => boolean = () => false;
const lineNo = this.wordAt(offset);
result += `${lineNo} `;
offset += 2;
while (this.program[offset] !== 0) {
if (offset >= this.program.length) {
return 'Unterminated line: ' + result;
}
const token = this.program[offset];
const tokenString = resolveToken(token);
if (tokenString === '"') {
inString = !inString;
}
if (spaceIf(token) || (!inString && tokenString === ':')) {
result += ' ';
}
result += tokenString;
if (!inString && tokenString === ':') {
spaceIf = () => true;
} else if (!inString && tokenString === ',') {
spaceIf = () => true;
} else if (token >= 0xcf && token <= 0xd1) {
// For '<', '=', '>', don't add a space between them.
spaceIf = (token: byte) => token < 0xcf || token > 0xd1;
} else if (token > 0x80 && token < 0xea) {
// By default, if a token is followed by an open paren, don't
// add a space.
spaceIf = (token: byte) => token !== 0x28;
} else {
// By default, if a literal is followed by a token, add a space.
spaceIf = (token: byte) => token >= 0x80 && token <= 0xea;
}
offset++;
}
return result;
}
/**
* Decompiles the program based on the given options.
*/
decompile(options: Partial<DecompileOptions> = {},
from: number = 0, to: number = 65536): string {
const allOptions = { ...DEFAULT_DECOMPILE_OPTIONS, ...options };
const results: string[] = [];
this.forEachLine(from, to, offset => {
results.push(allOptions.style === 'compact' ? this.compactLine(offset) : this.prettyLine(offset));
});
return results.join('\n');
}
}

200
js/applesoft/heap.ts Normal file
View File

@ -0,0 +1,200 @@
import { byte, word, Memory } from 'js/types';
import { toHex } from 'js/util';
import {
CURLINE,
ARG,
FAC,
ARYTAB,
STREND,
TXTTAB,
VARTAB
} from './zeropage';
export type ApplesoftValue = word | number | string | ApplesoftArray;
export type ApplesoftArray = Array<ApplesoftValue>;
export enum VariableType {
Float = 0,
String = 1,
Function = 2,
Integer = 3
}
export interface ApplesoftVariable {
name: string;
sizes?: number[];
type: VariableType;
value: ApplesoftValue | undefined;
}
export class ApplesoftHeap {
constructor(private mem: Memory) {}
private readByte(addr: word): byte {
const page = addr >> 8;
const off = addr & 0xff;
if (page >= 0xc0) {
throw new Error(`Address ${toHex(page)} out of range`);
}
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;
}
private readInt(addr: word): word {
const msb = this.readByte(addr);
const lsb = this.readByte(addr + 1);
return (msb << 8) | lsb;
}
private readFloat(addr: word, { unpacked } = { unpacked: false }): number {
let exponent = this.readByte(addr);
if (exponent === 0) {
return 0;
}
exponent = (exponent & 0x80 ? 1 : -1) * ((exponent & 0x7F) - 1);
let msb = this.readByte(addr + 1);
const sb3 = this.readByte(addr + 2);
const sb2 = this.readByte(addr + 3);
const lsb = this.readByte(addr + 4);
let sign;
if (unpacked) {
const sb = this.readByte(addr + 5);
sign = sb & 0x80 ? -1 : 1;
} else {
sign = msb & 0x80 ? -1 : 1;
}
msb &= 0x7F;
const mantissa = (msb << 24) | (sb3 << 16) | (sb2 << 8) | lsb;
return sign * (1 + mantissa / 0x80000000) * Math.pow(2, exponent);
}
private readString(len: byte, addr: word): string {
let str = '';
for (let idx = 0; idx < len; idx++) {
str += String.fromCharCode(this.readByte(addr + idx) & 0x7F);
}
return str;
}
private readVar(addr: word) {
const firstByte = this.readByte(addr);
const lastByte = this.readByte(addr + 1);
const firstLetter = firstByte & 0x7F;
const lastLetter = lastByte & 0x7F;
const name =
String.fromCharCode(firstLetter) +
(lastLetter ? String.fromCharCode(lastLetter) : '');
const type = (lastByte & 0x80) >> 7 | (firstByte & 0x80) >> 6;
return { name, type };
}
private readArray(addr: word, type: byte, sizes: number[]): ApplesoftArray {
let strLen, strAddr;
let value;
const ary = [];
const len = sizes[0];
for (let idx = 0; idx < len; idx++) {
if (sizes.length > 1) {
value = this.readArray(addr, type, sizes.slice(1));
} else {
switch (type) {
case 0: // Real
value = this.readFloat(addr);
addr += 5;
break;
case 1: // String
strLen = this.readByte(addr);
strAddr = this.readWord(addr + 1);
value = this.readString(strLen, strAddr);
addr += 3;
break;
case 3: // Integer
default:
value = this.readInt(addr);
addr += 2;
break;
}
}
ary[idx] = value;
}
return ary;
}
dumpInternals() {
return {
txttab: this.readWord(TXTTAB),
fac: this.readFloat(FAC, { unpacked: true }),
arg: this.readFloat(ARG, { unpacked: true }),
curline: this.readWord(CURLINE),
};
}
dumpVariables() {
const simpleVariableTable = this.readWord(VARTAB);
const arrayVariableTable = this.readWord(ARYTAB);
const variableStorageEnd = this.readWord(STREND);
// var stringStorageStart = readWord(0x6F);
let addr;
const vars: ApplesoftVariable[] = [];
let value;
let strLen, strAddr;
for (addr = simpleVariableTable; addr < arrayVariableTable; addr += 7) {
const { name, type } = this.readVar(addr);
switch (type) {
case VariableType.Float:
value = this.readFloat(addr + 2);
break;
case VariableType.String:
strLen = this.readByte(addr + 2);
strAddr = this.readWord(addr + 3);
value = this.readString(strLen, strAddr);
break;
case VariableType.Function:
value = toHex(this.readWord(addr + 2));
value += ',' + toHex(this.readWord(addr + 4));
break;
case VariableType.Integer:
value = this.readInt(addr + 2);
break;
}
vars.push({ name, type, value });
}
while (addr < variableStorageEnd) {
const { name, type } = this.readVar(addr);
const off = this.readWord(addr + 2);
const dim = this.readByte(addr + 4);
const sizes = [];
for (let idx = 0; idx < dim; idx++) {
sizes[idx] = this.readInt(addr + 5 + idx * 2);
}
value = this.readArray(addr + 5 + dim * 2, type, sizes);
vars.push({ name, sizes, type, value });
if (off < 1) {
break;
}
addr += off;
}
return vars;
}
}

223
js/applesoft/tokens.ts Normal file
View File

@ -0,0 +1,223 @@
import { byte } from 'js/types';
/** Map from token to keyword */
export const TOKEN_TO_STRING: Record<byte, string> = {
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$'
};
/** Map from keyword to token. */
export const STRING_TO_TOKEN: Record<string, byte> = {
'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
};

27
js/applesoft/zeropage.ts Normal file
View File

@ -0,0 +1,27 @@
/*
* Zero page locations used by Applesoft. The names come from
* the commented decompilation produced by the Merlin Pro
* assembler, revision 4/27/84. There is evidence from
* https://www.pagetable.com/?p=774 that the original Microsoft
* BASIC source code used these names as well.
*/
/** Start of program (word) */
export const TXTTAB = 0x67;
/** Start of variables (word) */
export const VARTAB = 0x69;
/** Start of arrays (word) */
export const ARYTAB = 0x6B;
/** End of strings (word). (Strings are allocated down from HIMEM.) */
export const STREND = 0x6D;
/** Current line */
export const CURLINE = 0x75;
/** Floating Point accumulator (float) */
export const FAC = 0x9D;
/** Floating Point arguments (float) */
export const ARG = 0xA5;
/**
* End of program (word). This is actually 1 or 2 bytes past the three
* zero bytes that end the program.
*/
export const PRGEND = 0xAF;

View File

@ -111,10 +111,10 @@ export function base64_decode(data: string | null | undefined): memory | undefin
o3 = bits & 0xff;
tmp_arr[ac++] = o1;
if (h3 != 64) {
if (h3 !== 64) {
tmp_arr[ac++] = o2;
}
if (h4 != 64) {
if (h4 !== 64) {
tmp_arr[ac++] = o3;
}
} while (i < data.length);
@ -124,8 +124,8 @@ export function base64_decode(data: string | null | undefined): memory | undefin
const DATA_URL_PREFIX = 'data:application/octet-stream;base64,';
export function base64_json_parse(json: string) {
const reviver = (_key: string, value: any) => {
export function base64_json_parse(json: string): unknown {
const reviver = (_key: string, value: unknown) => {
if (typeof value ==='string' && value.startsWith(DATA_URL_PREFIX)) {
return base64_decode(value.slice(DATA_URL_PREFIX.length));
}
@ -135,8 +135,8 @@ export function base64_json_parse(json: string) {
return JSON.parse(json, reviver);
}
export function base64_json_stringify(json: any) {
const replacer = (_key: string, value: any) => {
export function base64_json_stringify(json: unknown) {
const replacer = (_key: string, value: unknown) => {
if (value instanceof Uint8Array) {
return DATA_URL_PREFIX + base64_encode(value);
}

View File

@ -1,14 +1,3 @@
/* Copyright 2010-2021 Will Scullin <scullin@scullinsteel.com>
*
* Permission to use, copy, modify, distribute, and sell this software and its
* documentation for any purpose is hereby granted without fee, provided that
* the above copyright notice appear in all copies and that both that
* copyright notice and this permission notice appear in supporting
* documentation. No representations are made about the suitability of this
* software for any purpose. It is provided "as is" without express or
* implied warranty.
*/
import { byte, Color, memory, MemoryPages, rom } from './types';
import { allocMemPages } from './util';
import {
@ -122,9 +111,9 @@ export class LoresPage2D implements LoresPage {
private _refreshing = false;
private _blink = false;
private highColorTextMode = false
private highColorTextMode = false;
dirty: Region = {...notDirty}
dirty: Region = { ...notDirty };
imageData: ImageData;
constructor(
@ -159,7 +148,7 @@ export class LoresPage2D implements LoresPage {
let inverse = false;
if (this.e) {
if (!this.vm._80colMode && !this.vm.altCharMode) {
inverse = ((val & 0xc0) == 0x40) && this._blink;
inverse = ((val & 0xc0) === 0x40) && this._blink;
}
} else {
inverse = !((val & 0x80) || (val & 0x40) && this._blink);
@ -203,7 +192,7 @@ export class LoresPage2D implements LoresPage {
const base = addr & 0x3FF;
let fore, back;
if (this._buffer[bank][base] == val && !this._refreshing) {
if (this._buffer[bank][base] === val && !this._refreshing) {
return;
}
this._buffer[bank][base] = val;
@ -299,7 +288,7 @@ export class LoresPage2D implements LoresPage {
let color;
if (colorMode) {
if (b & 0x80) {
if ((b & 0x1c0) != 0x80) {
if ((b & 0x1c0) !== 0x80) {
color = whiteCol;
} else {
color = odd ? violetCol : greenCol;
@ -406,7 +395,7 @@ export class LoresPage2D implements LoresPage {
this._blink = !this._blink;
for (let idx = 0; idx < 0x400; idx++, addr++) {
const b = this._buffer[0][idx];
if ((b & 0xC0) == 0x40) {
if ((b & 0xC0) === 0x40) {
this._write(addr >> 8, addr & 0xff, this._buffer[0][idx], 0);
}
}
@ -496,7 +485,7 @@ export class LoresPage2D implements LoresPage {
export class HiresPage2D implements HiresPage {
public imageData: ImageData;
dirty: Region = {...notDirty};
dirty: Region = { ...notDirty };
private _buffer: memory[] = [];
private _refreshing = false;
@ -586,7 +575,7 @@ export class HiresPage2D implements HiresPage {
const addr = (page << 8) | off;
const base = addr & 0x1FFF;
if (this._buffer[bank][base] == val && !this._refreshing) {
if (this._buffer[bank][base] === val && !this._refreshing) {
return;
}
this._buffer[bank][base] = val;
@ -604,7 +593,7 @@ export class HiresPage2D implements HiresPage {
const data = this.imageData.data;
let dx, dy;
if ((rowa < 24) && (col < 40) && this.vm.hiresMode) {
if ((rowa < 24) && (col < 40) && (this.vm.hiresMode || this._refreshing)) {
let y = rowa << 4 | rowb << 1;
if (y < this.dirty.top) { this.dirty.top = y; }
y += 1;
@ -615,7 +604,7 @@ export class HiresPage2D implements HiresPage {
if (x > this.dirty.right) { this.dirty.right = x; }
dy = rowa << 4 | rowb << 1;
let bz, b0, b1, b2, b3, b4, c, hb;
let bz, b0, b1, b2, b3, b4, c;
if (this.oneSixtyMode && !this.vm.monoMode) {
// 1 byte = two pixels, but 3:4 ratio
const c3 = val & 0xf;
@ -654,8 +643,8 @@ export class HiresPage2D implements HiresPage {
((b2 & 0x40) >> 6) | ((b3 & 0x07) << 1), // 5
((b3 & 0x78) >> 3), // 6
0
], // 7
hb = [
]; // 7
const hb = [
0,
b0 & 0x80, // 0
b0 & 0x80, // 1
@ -706,16 +695,16 @@ export class HiresPage2D implements HiresPage {
} else if (this.colorDHRMode) {
this._drawHalfPixel(data, offset, dcolor);
} else if (
((c[idx] != c[idx - 1]) && (c[idx] != c[idx + 1])) &&
(((bits & 0x1c) == 0x1c) ||
((bits & 0x70) == 0x70) ||
((bits & 0x38) == 0x38))
((c[idx] !== c[idx - 1]) && (c[idx] !== c[idx + 1])) &&
(((bits & 0x1c) === 0x1c) ||
((bits & 0x70) === 0x70) ||
((bits & 0x38) === 0x38))
) {
this._drawHalfPixel(data, offset, whiteCol);
} else if (
(bits & 0x38) ||
(c[idx] == c[idx + 1]) ||
(c[idx] == c[idx - 1])
(c[idx] === c[idx + 1]) ||
(c[idx] === c[idx - 1])
) {
this._drawHalfPixel(data, offset, dcolor);
} else if (bits & 0x28) {
@ -797,9 +786,9 @@ export class HiresPage2D implements HiresPage {
refresh() {
this.highColorHGRMode = !this.vm.an3State && this.vm.hiresMode && !this.vm._80colMode;
this.oneSixtyMode = this.vm.flag == 1 && this.vm.doubleHiresMode;
this.mixedDHRMode = this.vm.flag == 2 && this.vm.doubleHiresMode;
this.monoDHRMode = this.vm.flag == 3 && this.vm.doubleHiresMode;
this.oneSixtyMode = this.vm.flag === 1 && this.vm.doubleHiresMode;
this.mixedDHRMode = this.vm.flag === 2 && this.vm.doubleHiresMode;
this.monoDHRMode = this.vm.flag === 3 && this.vm.doubleHiresMode;
let addr = 0x2000 * this.page;
this._refreshing = true;
@ -927,13 +916,21 @@ export class VideoModes2D implements VideoModes {
this._hgrs[page - 1] = hires;
}
getLoresPage(page: pageNo) {
return this._grs[page - 1];
}
getHiresPage(page: pageNo) {
return this._hgrs[page - 1];
}
text(on: boolean) {
const old = this.textMode;
this.textMode = on;
if (on) {
this.flag = 0;
}
if (old != on) {
if (old !== on) {
this._refresh();
}
}
@ -944,7 +941,7 @@ export class VideoModes2D implements VideoModes {
const old = this._80colMode;
this._80colMode = on;
if (old != on) {
if (old !== on) {
this._refresh();
}
}
@ -954,7 +951,7 @@ export class VideoModes2D implements VideoModes {
const old = this.altCharMode;
this.altCharMode = on;
if (old != on) {
if (old !== on) {
this._refresh();
}
}
@ -966,7 +963,7 @@ export class VideoModes2D implements VideoModes {
this.flag = 0;
}
if (old != on) {
if (old !== on) {
this._refresh();
}
}
@ -981,7 +978,7 @@ export class VideoModes2D implements VideoModes {
this.flag = ((this.flag << 1) | (this._80colMode ? 0x0 : 0x1)) & 0x3;
}
if (old != on) {
if (old !== on) {
this._refresh();
}
}
@ -993,7 +990,7 @@ export class VideoModes2D implements VideoModes {
mixed(on: boolean) {
const old = this.mixedMode;
this.mixedMode = on;
if (old != on) {
if (old !== on) {
this._refresh();
}
}
@ -1001,7 +998,7 @@ export class VideoModes2D implements VideoModes {
page(pageNo: pageNo) {
const old = this.pageMode;
this.pageMode = pageNo;
if (old != pageNo) {
if (old !== pageNo) {
this._refresh();
}
}
@ -1015,7 +1012,7 @@ export class VideoModes2D implements VideoModes {
}
isPage2() {
return this.pageMode == 2;
return this.pageMode === 2;
}
isHires() {
@ -1095,8 +1092,8 @@ export class VideoModes2D implements VideoModes {
gr.imageData, gr.dirty
);
}
hgr.dirty = {...notDirty};
gr.dirty = {...notDirty};
hgr.dirty = { ...notDirty };
gr.dirty = { ...notDirty };
return blitted;
}

View File

@ -1,26 +1,15 @@
/* Copyright 2010-2019 Will Scullin <scullin@scullinsteel.com>
*
* Permission to use, copy, modify, distribute, and sell this software and its
* documentation for any purpose is hereby granted without fee, provided that
* the above copyright notice appear in all copies and that both that
* copyright notice and this permission notice appear in supporting
* documentation. No representations are made about the suitability of this
* software for any purpose. It is provided "as is" without express or
* implied warranty.
*/
import type { byte, Card, Restorable } from '../types';
import { debug, toHex } from '../util';
import { rom as readOnlyRom } from '../roms/cards/cffa';
import { read2MGHeader } from '../formats/2mg';
import { create2MGFromBlockDisk, HeaderData, read2MGHeader } from '../formats/2mg';
import { ProDOSVolume } from '../formats/prodos';
import createBlockDisk from '../formats/block';
import { dump } from '../formats/prodos/utils';
import {
BlockDisk,
BlockFormat,
ENCODING_BLOCK,
MassStorage,
MassStorageData,
} from 'js/formats/types';
const rom = new Uint8Array(readOnlyRom);
@ -93,11 +82,10 @@ const IDENTITY = {
};
export interface CFFAState {
disks: Array<BlockDisk | null>
disks: Array<BlockDisk | null>;
}
type Partition = ReturnType<typeof ProDOSVolume>
export default class CFFA implements Card, MassStorage, Restorable<CFFAState> {
export default class CFFA implements Card, MassStorage<BlockFormat>, Restorable<CFFAState> {
// CFFA internal Flags
@ -134,7 +122,7 @@ export default class CFFA implements Card, MassStorage, Restorable<CFFAState> {
// Disk data
private _partitions: Array<Partition|null> = [
private _partitions: Array<ProDOSVolume|null> = [
// Drive 1
null,
// Drive 2
@ -148,6 +136,9 @@ export default class CFFA implements Card, MassStorage, Restorable<CFFAState> {
[]
];
private _name: string[] = [];
private _metadata: Array<HeaderData|null> = [];
constructor() {
debug('CFFA');
@ -164,7 +155,7 @@ export default class CFFA implements Card, MassStorage, Restorable<CFFAState> {
// Verbose debug method
private _debug(..._args: any[]) {
private _debug(..._args: unknown[]) {
// debug.apply(this, arguments);
}
@ -398,8 +389,9 @@ export default class CFFA implements Card, MassStorage, Restorable<CFFAState> {
(block) => new Uint8Array(block)
),
encoding: ENCODING_BLOCK,
format: disk.format,
readOnly: disk.readOnly,
name: disk.name,
metadata: { ...disk.metadata },
};
}
return result;
@ -424,6 +416,8 @@ export default class CFFA implements Card, MassStorage, Restorable<CFFAState> {
drive = drive - 1;
this._sectors[drive] = [];
this._name[drive] = '';
this._metadata[drive] = null;
this._identity[drive][IDENTITY.SectorCountHigh] = 0;
this._identity[drive][IDENTITY.SectorCountLow] = 0;
@ -446,9 +440,9 @@ export default class CFFA implements Card, MassStorage, Restorable<CFFAState> {
this._identity[drive][IDENTITY.SectorCountHigh] = this._sectors[0].length & 0xffff;
this._identity[drive][IDENTITY.SectorCountLow] = this._sectors[0].length >> 16;
const prodos = ProDOSVolume(disk);
dump(prodos);
const prodos = new ProDOSVolume(disk);
this._name[drive] = disk.metadata.name;
this._partitions[drive] = prodos;
if (drive) {
@ -466,8 +460,12 @@ export default class CFFA implements Card, MassStorage, Restorable<CFFAState> {
const readOnly = false;
if (ext === '2mg') {
const { bytes, offset } = read2MGHeader(rawData);
const headerData = read2MGHeader(rawData);
const { bytes, offset } = headerData;
this._metadata[drive - 1] = headerData;
rawData = rawData.slice(offset, offset + bytes);
} else {
this._metadata[drive - 1] = null;
}
const options = {
rawData,
@ -475,8 +473,37 @@ export default class CFFA implements Card, MassStorage, Restorable<CFFAState> {
volume,
readOnly
};
const disk = createBlockDisk(options);
const disk = createBlockDisk(ext, options);
return this.setBlockVolume(drive, disk);
}
getBinary(drive: number): MassStorageData | null {
drive = drive - 1;
const blockDisk = this._partitions[drive]?.disk();
if (!blockDisk) {
return null;
}
const { blocks, readOnly } = blockDisk;
const { name } = blockDisk.metadata;
let ext: '2mg' | 'po';
let data: ArrayBuffer;
if (this._metadata[drive]) {
ext = '2mg';
data = create2MGFromBlockDisk(this._metadata[drive - 1], blockDisk);
} else {
ext = 'po';
const dataArray = new Uint8Array(blocks.length * 512);
for (let idx = 0; idx < blocks.length; idx++) {
dataArray.set(blocks[idx], idx * 512);
}
data = dataArray.buffer;
}
return {
metadata: { name },
ext,
data,
readOnly,
};
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,14 +1,3 @@
/* Copyright 2010-2019 Will Scullin <scullin@scullinsteel.com>
*
* Permission to use, copy, modify, distribute, and sell this software and its
* documentation for any purpose is hereby granted without fee, provided that
* the above copyright notice appear in all copies and that both that
* copyright notice and this permission notice appear in supporting
* documentation. No representations are made about the suitability of this
* software for any purpose. It is provided "as is" without express or
* implied warranty.
*/
import RAM, { RAMState } from '../ram';
import { debug } from '../util';
import { Card, Memory, byte, Restorable } from '../types';
@ -29,10 +18,10 @@ export default class LanguageCard implements Card, Restorable<LanguageCardState>
private bank2: RAM;
private ram: RAM;
private readbsr = false;
private writebsr = false;
private bsr2 = false;
private prewrite = false;
private _readbsr = false;
private _writebsr = false;
private _bsr2 = false;
private _prewrite = false;
private read1: Memory;
private read2: Memory;
@ -54,21 +43,21 @@ export default class LanguageCard implements Card, Restorable<LanguageCardState>
this.read2 = this.rom;
}
private debug(..._args: any[]) {
private debug(..._args: unknown[]) {
// debug.apply(null, args);
}
private updateBanks() {
if (this.readbsr) {
this.read1 = this.bsr2 ? this.bank2 : this.bank1;
if (this._readbsr) {
this.read1 = this._bsr2 ? this.bank2 : this.bank1;
this.read2 = this.ram;
} else {
this.read1 = this.rom;
this.read2 = this.rom;
}
if (this.writebsr) {
this.write1 = this.bsr2 ? this.bank2 : this.bank1;
if (this._writebsr) {
this.write1 = this._bsr2 ? this.bank2 : this.bank1;
this.write2 = this.ram;
} else {
this.write1 = this.rom;
@ -101,35 +90,35 @@ export default class LanguageCard implements Card, Restorable<LanguageCardState>
if (writeSwitch) { // $C081, $C083, $C089, $C08B
if (readMode) {
this.writebsr = this.prewrite;
this._writebsr = this._prewrite;
}
this.prewrite = readMode;
this._prewrite = readMode;
if (offSwitch) { // $C083, $C08B
this.readbsr = true;
this._readbsr = true;
rwStr = 'Read/Write';
} else { // $C081, $C089
this.readbsr = false;
this._readbsr = false;
rwStr = 'Write';
}
} else { // $C080, $C082, $C088, $C08A
this.writebsr = false;
this.prewrite = false;
this._writebsr = false;
this._prewrite = false;
if (offSwitch) { // $C082, $C08A
this.readbsr = false;
this._readbsr = false;
rwStr = 'Off';
} else { // $C080, $C088
this.readbsr = true;
this._readbsr = true;
rwStr = 'Read';
}
}
if (bank1Switch) { // C08[8-C]
this.bsr2 = false;
this._bsr2 = false;
bankStr = 'Bank 1';
} else { // C08[0-3]
this.bsr2 = true;
this._bsr2 = true;
bankStr = 'Bank 2';
}
@ -169,12 +158,24 @@ export default class LanguageCard implements Card, Restorable<LanguageCardState>
}
}
public get bsr2() {
return this._bsr2;
}
public get readbsr() {
return this._readbsr;
}
public get writebsr() {
return this._writebsr;
}
getState() {
return {
readbsr: this.readbsr,
writebsr: this.writebsr,
bsr2: this.bsr2,
prewrite: this.prewrite,
prewrite: this._prewrite,
ram: this.ram.getState(),
bank1: this.bank1.getState(),
bank2: this.bank2.getState()
@ -182,10 +183,10 @@ export default class LanguageCard implements Card, Restorable<LanguageCardState>
}
setState(state: LanguageCardState) {
this.readbsr = state.readbsr;
this.writebsr = state.writebsr;
this.bsr2 = state.bsr2;
this.prewrite = state.prewrite;
this._readbsr = state.readbsr;
this._writebsr = state.writebsr;
this._bsr2 = state.bsr2;
this._prewrite = state.prewrite;
this.ram.setState(state.ram);
this.bank1.setState(state.bank1);
this.bank2.setState(state.bank2);

324
js/cards/mouse.ts Normal file
View File

@ -0,0 +1,324 @@
import { Card, byte, word, Restorable } from '../types';
import CPU6502, { CpuState } from '../cpu6502';
import { debug } from '../util';
import { rom } from '../roms/cards/mouse';
const CLAMP_MIN_LOW = 0x478;
const CLAMP_MAX_LOW = 0x4F8;
const CLAMP_MIN_HIGH = 0x578;
const CLAMP_MAX_HIGH = 0x5F8;
const X_LOW = 0x478;
const Y_LOW = 0x4F8;
const X_HIGH = 0x578;
const Y_HIGH = 0x5F8;
const STATUS = 0x778;
const MODE = 0x7F8;
const STATUS_DOWN = 0x80;
const STATUS_LAST = 0x40;
const STATUS_MOVED = 0x20;
const INT_SCREEN = 0x08;
const INT_PRESS = 0x04;
const INT_MOVE = 0x02;
const MODE_ON = 0x01;
const MODE_INT_MOVE = 0x02;
const MODE_INT_PRESS = 0x04;
const MODE_INT_VBL = 0x08;
/**
* Firmware routine offset pointers
*/
const ENTRIES = {
SET_MOUSE: 0x12,
SERVE_MOUSE: 0x13,
READ_MOUSE: 0x14,
CLEAR_MOUSE: 0x15,
POS_MOUSE: 0x16,
CLAMP_MOUSE: 0x17,
HOME_MOUSE: 0x18,
INIT_MOUSE: 0x19
};
interface MouseState {
clampXMin: word;
clampYMin: word;
clampXMax: word;
clampYMax: word;
x: word;
y: word;
mode: byte;
down: boolean;
lastDown: boolean;
lastX: word;
lastY: word;
serve: byte;
shouldIntMove: boolean;
shouldIntPress: boolean;
slot: byte;
}
export default class Mouse implements Card, Restorable<MouseState> {
/** Lowest mouse X */
private clampXMin: word = 0;
/** Lowest mouse Y */
private clampYMin: word = 0;
/** Highest mouse X */
private clampXMax: word = 0x3FF;
/** Highest mouse Y */
private clampYMax: word = 0x3FF;
/** Mouse X position */
private x: word = 0;
/** Mouse Y position */
private y: word = 0;
/** Mouse mode */
private mode: byte = 0;
/** Mouse button down state */
private down = false;
/** Last mouse button down state */
private lastDown = false;
/** Last mouse Y Position */
private lastX: word = 0;
/** Last mouse X position */
private lastY: word = 0;
/** Interrupt service flags */
private serve: byte = 0;
/** Move happened since last refresh */
private shouldIntMove = false;
/** Button press happened since last refresh */
private shouldIntPress = false;
/** Slot for screen hole indexing */
private slot = 0;
constructor(
private cpu: CPU6502,
private cbs: {
setMouse: (mouse: Mouse) => void;
mouseMode: (on: boolean) => void;
}
) {
this.cbs.setMouse(this);
}
ioSwitch(_off: byte, _val?: byte) {
return undefined;
}
read(_page: byte, off: byte) {
let state = this.cpu.getState();
const holeWrite = (addr: word, val: byte) => {
this.cpu.write(addr >> 8, (addr & 0xff) + this.slot, val);
};
const holeRead = (addr: word) => {
return this.cpu.read(addr >> 8, addr & 0xff);
};
const clearCarry = (state: CpuState) => {
state.s &= 0xFE;
return state;
};
if (this.cpu.getSync()) {
switch (off) {
case rom[ENTRIES.SET_MOUSE]:
{
this.mode = state.a;
this.cbs.mouseMode(!!(this.mode & MODE_ON));
state = clearCarry(state);
// debug(
// 'setMouse ',
// (_mode & MODE_ON ? 'Mouse on ' : 'Mouse off '),
// (_mode & MODE_INT_MOVE ? 'Move interrupt ' : '') +
// (_mode & MODE_INT_PRESS ? 'Move press ' : '') +
// (_mode & MODE_INT_VBL ? 'Move VBL ' : '')
// );
}
break;
case rom[ENTRIES.SERVE_MOUSE]:
// debug('serveMouse');
holeWrite(STATUS, this.serve);
state = clearCarry(state);
this.serve = 0;
break;
case rom[ENTRIES.READ_MOUSE]:
{
const moved = (this.lastX !== this.x) || (this.lastY !== this.y);
const status =
(this.down ? STATUS_DOWN : 0) |
(this.lastDown ? STATUS_LAST : 0) |
(moved ? STATUS_MOVED : 0);
const mouseXLow = this.x & 0xff;
const mouseYLow = this.y & 0xff;
const mouseXHigh = this.x >> 8;
const mouseYHigh = this.y >> 8;
// debug({ mouseXLow, mouseYLow, mouseXHigh, mouseYHigh });
holeWrite(X_LOW, mouseXLow);
holeWrite(Y_LOW, mouseYLow);
holeWrite(X_HIGH, mouseXHigh);
holeWrite(Y_HIGH, mouseYHigh);
holeWrite(STATUS, status);
holeWrite(MODE, this.mode);
this.lastDown = this.down;
this.lastX = this.x;
this.lastY = this.y;
state = clearCarry(state);
}
break;
case rom[ENTRIES.CLEAR_MOUSE]:
debug('clearMouse');
state = clearCarry(state);
break;
case rom[ENTRIES.POS_MOUSE]:
debug('posMouse');
state = clearCarry(state);
break;
case rom[ENTRIES.CLAMP_MOUSE]:
{
const clampY = state.a;
if (clampY) {
this.clampYMin = holeRead(CLAMP_MIN_LOW) | (holeRead(CLAMP_MIN_HIGH) << 8);
this.clampYMax = holeRead(CLAMP_MAX_LOW) | (holeRead(CLAMP_MAX_HIGH) << 8);
debug('clampMouse Y', this.clampYMin, this.clampYMax);
} else {
this.clampXMin = holeRead(CLAMP_MIN_LOW) | (holeRead(CLAMP_MIN_HIGH) << 8);
this.clampXMax = holeRead(CLAMP_MAX_LOW) | (holeRead(CLAMP_MAX_HIGH) << 8);
debug('clampMouse X', this.clampXMin, this.clampXMax);
}
state = clearCarry(state);
}
break;
case rom[ENTRIES.HOME_MOUSE]:
{
debug('homeMouse');
this.x = this.clampXMin;
this.y = this.clampYMin;
state = clearCarry(state);
}
break;
case rom[ENTRIES.INIT_MOUSE]:
{
this.slot = state.y >> 4;
debug('initMouse slot', this.slot);
state = clearCarry(state);
}
break;
}
this.cpu.setState(state);
}
return rom[off];
}
write() {
// not writable
}
/**
* Triggers interrupts based on activity since the last tick
*/
tick() {
if (this.mode & MODE_INT_VBL) {
this.serve |= INT_SCREEN;
}
if ((this.mode & MODE_INT_PRESS) && this.shouldIntPress) {
this.serve |= INT_PRESS;
}
if ((this.mode & MODE_INT_MOVE) && this.shouldIntMove) {
this.serve |= INT_MOVE;
}
if (this.serve) {
this.cpu.irq();
}
this.shouldIntMove = false;
this.shouldIntPress = false;
}
/**
* Scales mouse position and clamps to min and max,and flags
* potential mouse state change interrupt
*
* @param x Client mouse X position
* @param y Client mouse Y position
* @param w Client width
* @param h Client height
*/
setMouseXY(x: number, y: number, w: number, h: number) {
const rangeX = this.clampXMax - this.clampXMin;
const rangeY = this.clampYMax - this.clampYMin;
this.x = (x * rangeX / w + this.clampXMin) & 0xffff;
this.y = (y * rangeY / h + this.clampYMin) & 0xffff;
this.shouldIntMove = true;
}
/**
* Tracks mouse button state and flags potential
* mouse state change interrupt
*
* @param down Mouse button down state
*/
setMouseDown(down: boolean) {
this.shouldIntPress = this.down !== down;
this.down = down;
}
/**
* Restores saved state
*
* @param state stored state
*/
setState(state: MouseState) {
this.clampXMin = state.clampXMin;
this.clampYMin = state.clampYMin;
this.clampXMax = state.clampXMax;
this.clampYMax = state.clampYMax;
this.x = state.x;
this.y = state.y;
this.mode = state.mode;
this.down = state.down;
this.lastDown = state.lastDown;
this.lastX = state.lastX;
this.lastY = state.lastY;
this.serve = state.serve;
this.shouldIntMove = state.shouldIntMove;
this.shouldIntPress = state.shouldIntPress;
this.slot = state.slot;
}
/**
* Saves state for restoration
*
* @returns restorable state
*/
getState(): MouseState {
return {
clampXMin: this.clampXMin,
clampYMin: this.clampYMin,
clampXMax: this.clampXMax,
clampYMax: this.clampYMax,
x: this.x,
y: this.y,
mode: this.mode,
down: this.down,
lastDown: this.lastDown,
lastX: this.lastX,
lastY: this.lastY,
serve: this.serve,
shouldIntMove: this.shouldIntMove,
shouldIntPress: this.shouldIntPress,
slot: this.slot
};
}
}

View File

@ -1,126 +0,0 @@
/* Copyright 2010-2019 Will Scullin <scullin@scullinsteel.com>
*
* Permission to use, copy, modify, distribute, and sell this software and its
* documentation for any purpose is hereby granted without fee, provided that
* the above copyright notice appear in all copies and that both that
* copyright notice and this permission notice appear in supporting
* documentation. No representations are made about the suitability of this
* software for any purpose. It is provided "as is" without express or
* implied warranty.
*/
import { debug } from '../util';
export default function NoSlotClock(rom)
{
var PATTERN = [
0xC5, 0x3A, 0xA3, 0x5C, 0xC5, 0x3A, 0xA3, 0x5C
];
var A0 = 0x01;
var A2 = 0x04;
function _init() {
debug('NoSlotClock');
}
var _bits = [];
var _pattern = new Array(64);
var _patternIdx = 0;
function _patternMatch() {
for (var idx = 0; idx < 8; idx++) {
var byte = 0;
for (var jdx = 0; jdx < 8; jdx++) {
byte >>= 1;
byte |= _pattern.shift() ? 0x80 : 0x00;
}
if (byte !== PATTERN[idx]) {
return false;
}
}
return true;
}
function _calcBits() {
function shift(val) {
for (var idx = 0; idx < 4; idx++) {
_bits.push(val & 0x08 ? 0x01 : 0x00);
val <<= 1;
}
}
function shiftBCD(val) {
shift(parseInt(val / 10, 10));
shift(parseInt(val % 10, 10));
}
var now = new Date();
var year = now.getFullYear() % 100;
var day = now.getDate();
var weekday = now.getDay() + 1;
var month = now.getMonth() + 1;
var hour = now.getHours();
var minutes = now.getMinutes();
var seconds = now.getSeconds();
var hundredths = (now.getMilliseconds() / 10);
_bits = [];
shiftBCD(year);
shiftBCD(month);
shiftBCD(day);
shiftBCD(weekday);
shiftBCD(hour);
shiftBCD(minutes);
shiftBCD(seconds);
shiftBCD(hundredths);
}
_init();
function _access(off) {
if (off & A2) {
_patternIdx = 0;
} else {
var bit = off & A0;
_pattern[_patternIdx++] = bit;
if (_patternIdx === 64) {
if (_patternMatch()) {
_calcBits();
}
_patternIdx = 0;
}
}
}
return {
start: function nsc_start() {
return rom.start();
},
end: function nsc_end() {
return rom.end();
},
read: function nsc_read(page, off) {
if (_bits.length > 0) {
var bit = _bits.pop();
return bit;
} else {
_access(off);
}
return rom.read(page, off);
},
write: function nsc_write(page, off, val) {
_access(off);
rom.write(page, off, val);
},
getState() {
return {};
},
setState(_) {
}
};
}

116
js/cards/nsc.ts Normal file
View File

@ -0,0 +1,116 @@
import ROM from 'js/roms/rom';
import { bit, byte } from 'js/types';
import { debug } from '../util';
const PATTERN = [
0xC5, 0x3A, 0xA3, 0x5C, 0xC5, 0x3A, 0xA3, 0x5C
];
const A0 = 0x01;
const A2 = 0x04;
export default class NoSlotClock {
bits: bit[] = [];
pattern = new Array(64);
patternIdx: number = 0;
constructor(private rom: ROM) {
debug('NoSlotClock');
}
private patternMatch() {
for (let idx = 0; idx < 8; idx++) {
let byte = 0;
for (let jdx = 0; jdx < 8; jdx++) {
byte >>= 1;
byte |= this.pattern.shift() ? 0x80 : 0x00;
}
if (byte !== PATTERN[idx]) {
return false;
}
}
return true;
}
private calcBits() {
const shift = (val: byte) => {
for (let idx = 0; idx < 4; idx++) {
this.bits.push(val & 0x08 ? 0x01 : 0x00);
val <<= 1;
}
};
const shiftBCD = (val: byte) => {
shift(Math.floor(val / 10));
shift(Math.floor(val % 10));
};
const now = new Date();
const year = now.getFullYear() % 100;
const day = now.getDate();
const weekday = now.getDay() + 1;
const month = now.getMonth() + 1;
const hour = now.getHours();
const minutes = now.getMinutes();
const seconds = now.getSeconds();
const hundredths = (now.getMilliseconds() / 10);
this.bits = [];
shiftBCD(year);
shiftBCD(month);
shiftBCD(day);
shiftBCD(weekday);
shiftBCD(hour);
shiftBCD(minutes);
shiftBCD(seconds);
shiftBCD(hundredths);
}
access(off: byte) {
if (off & A2) {
this.patternIdx = 0;
} else {
const bit = off & A0;
this.pattern[this.patternIdx++] = bit;
if (this.patternIdx === 64) {
if (this.patternMatch()) {
this.calcBits();
}
this.patternIdx = 0;
}
}
}
start() {
return this.rom.start();
}
end() {
return this.rom.end();
}
read(page: byte, off: byte) {
if (this.bits.length > 0) {
const bit = this.bits.pop();
return bit;
} else {
this.access(off);
}
return this.rom.read(page, off);
}
write(_page: byte, off: byte, _val: byte) {
this.access(off);
this.rom.write();
}
getState() {
return {};
}
setState(_: unknown) {
// Setting the state makes no sense.
}
}

View File

@ -1,14 +1,3 @@
/* Copyright 2010-2019 Will Scullin <scullin@scullinsteel.com>
*
* Permission to use, copy, modify, distribute, and sell this software and its
* documentation for any purpose is hereby granted without fee, provided that
* the above copyright notice appear in all copies and that both that
* copyright notice and this permission notice appear in supporting
* documentation. No representations are made about the suitability of this
* software for any purpose. It is provided "as is" without express or
* implied warranty.
*/
import { debug } from '../util';
import { Card, Restorable, byte } from '../types';
import { rom } from '../roms/cards/parallel';
@ -48,11 +37,15 @@ export default class Parallel implements Card, Restorable<ParallelState> {
return rom[off];
}
write() {}
write() {
// not writable
}
getState() {
return {};
}
setState(_state: ParallelState) {}
setState(_state: ParallelState) {
// can't set the state
}
}

View File

@ -1,14 +1,3 @@
/* Copyright 2010-2019 Will Scullin <scullin@scullinsteel.com>
*
* Permission to use, copy, modify, distribute, and sell this software and its
* documentation for any purpose is hereby granted without fee, provided that
* the above copyright notice appear in all copies and that both that
* copyright notice and this permission notice appear in supporting
* documentation. No representations are made about the suitability of this
* software for any purpose. It is provided "as is" without express or
* implied warranty.
*/
import { allocMem, debug } from '../util';
import { Card, Restorable, byte, memory } from '../types';
import { rom } from '../roms/cards/ramfactor';
@ -27,9 +16,9 @@ const LOC = {
} as const;
export class RAMFactorState {
loc: number
firmware: byte
mem: memory
loc: number;
firmware: byte;
mem: memory;
}
export default class RAMFactor implements Card, Restorable<RAMFactorState> {
@ -137,7 +126,9 @@ export default class RAMFactor implements Card, Restorable<RAMFactorState> {
return rom[this.firmware << 12 | (page - 0xC0) << 8 | off];
}
write() {}
write() {
// not writable
}
reset() {
this.firmware = 0;

View File

@ -1,30 +1,28 @@
/* Copyright 2010-2019 Will Scullin <scullin@scullinsteel.com>
*
* Permission to use, copy, modify, distribute, and sell this software and its
* documentation for any purpose is hereby granted without fee, provided that
* the above copyright notice appear in all copies and that both that
* copyright notice and this permission notice appear in supporting
* documentation. No representations are made about the suitability of this
* software for any purpose. It is provided "as is" without express or
* implied warranty.
*/
import { debug, toHex } from '../util';
import { rom as smartPortRom } from '../roms/cards/smartport';
import { Card, Restorable, byte, word, rom } from '../types';
import { MassStorage, BlockDisk, ENCODING_BLOCK } from '../formats/types';
import { MassStorage, BlockDisk, ENCODING_BLOCK, BlockFormat, MassStorageData, DiskFormat } from '../formats/types';
import CPU6502, { CpuState, flags } from '../cpu6502';
import { read2MGHeader } from '../formats/2mg';
import { create2MGFromBlockDisk, HeaderData, read2MGHeader } from '../formats/2mg';
import createBlockDisk from '../formats/block';
import { DriveNumber } from '../formats/types';
const ID = 'SMARTPORT.J.S';
export interface SmartPortState {
disks: BlockDisk[]
disks: BlockDisk[];
}
export interface SmartPortOptions {
block: boolean;
}
export interface Callbacks {
driveLight: (drive: DriveNumber, on: boolean) => void;
dirty: (drive: DriveNumber, dirty: boolean) => void;
label: (drive: DriveNumber, name?: string, side?: string) => void;
}
class Address {
lo: byte;
hi: byte;
@ -107,14 +105,40 @@ const DEVICE_OFFLINE = 0x2F;
// const BAD_BUFFER_ADDRESS = 0x56;
// const DUPLICATE_VOLUME_ONLINE = 0x57;
export default class SmartPort implements Card, MassStorage, Restorable<SmartPortState> {
// Type: Device
// $00: Memory Expansion Card (RAM disk)
// $01: 3.5" disk
// $02: ProFile-type hard disk
// $03: Generic SCSI
// $04: ROM disk
// $05: SCSI CD-ROM
// $06: SCSI tape or other SCSI sequential device
// $07: SCSI hard disk
const DEVICE_TYPE_SCSI_HD = 0x07;
// $08: Reserved
// $09: SCSI printer
// $0A: 5-1/4" disk
// $0B: Reserved
// $0C: Reserved
// $0D: Printer
// $0E: Clock
// $0F: Modem
export default class SmartPort implements Card, MassStorage<BlockFormat>, Restorable<SmartPortState> {
private rom: rom;
private disks: BlockDisk[] = [];
private busy: boolean[] = [];
private busyTimeout: ReturnType<typeof setTimeout>[] = [];
private ext: DiskFormat[] = [];
private metadata: Array<HeaderData | null> = [];
constructor(private cpu: CPU6502, options: SmartPortOptions) {
constructor(
private cpu: CPU6502,
private callbacks: Callbacks | null,
options: SmartPortOptions
) {
if (options?.block) {
const dumbPortRom = new Uint8Array(smartPortRom);
const dumbPortRom = new Uint8Array(smartPortRom);
dumbPortRom[0x07] = 0x3C;
this.rom = dumbPortRom;
debug('DumbPort card');
@ -124,15 +148,27 @@ export default class SmartPort implements Card, MassStorage, Restorable<SmartPor
}
}
private debug(..._args: any[]) {
private debug(..._args: unknown[]) {
// debug.apply(this, arguments);
}
private driveLight(drive: DriveNumber) {
if (!this.busy[drive]) {
this.busy[drive] = true;
this.callbacks?.driveLight(drive, true);
}
clearTimeout(this.busyTimeout[drive]);
this.busyTimeout[drive] = setTimeout(() => {
this.busy[drive] = false;
this.callbacks?.driveLight(drive, false);
}, 100);
}
/*
* dumpBlock
*/
dumpBlock(drive: number, block: number) {
dumpBlock(drive: DriveNumber, block: number) {
let result = '';
let b;
let jdx;
@ -141,7 +177,7 @@ export default class SmartPort implements Card, MassStorage, Restorable<SmartPor
result += toHex(idx << 4, 4) + ': ';
for (jdx = 0; jdx < 16; jdx++) {
b = this.disks[drive].blocks[block][idx * 16 + jdx];
if (jdx == 8) {
if (jdx === 8) {
result += ' ';
}
result += toHex(b) + ' ';
@ -149,7 +185,7 @@ export default class SmartPort implements Card, MassStorage, Restorable<SmartPor
result += ' ';
for (jdx = 0; jdx < 16; jdx++) {
b = this.disks[drive].blocks[block][idx * 16 + jdx] & 0x7f;
if (jdx == 8) {
if (jdx === 8) {
result += ' ';
}
if (b >= 0x20 && b < 0x7f) {
@ -167,7 +203,7 @@ export default class SmartPort implements Card, MassStorage, Restorable<SmartPor
* getDeviceInfo
*/
getDeviceInfo(state: CpuState, drive: number) {
getDeviceInfo(state: CpuState, drive: DriveNumber) {
if (this.disks[drive]) {
const blocks = this.disks[drive].blocks.length;
state.x = blocks & 0xff;
@ -185,10 +221,10 @@ export default class SmartPort implements Card, MassStorage, Restorable<SmartPor
* readBlock
*/
readBlock(state: CpuState, drive: number, block: number, buffer: Address) {
this.debug('read drive=' + drive);
this.debug('read buffer=' + buffer);
this.debug('read block=$' + toHex(block));
readBlock(state: CpuState, drive: DriveNumber, block: number, buffer: Address) {
this.debug(`read drive=${drive}`);
this.debug(`read buffer=${buffer.toString()}`);
this.debug(`read block=$${toHex(block)}`);
if (!this.disks[drive]?.blocks.length) {
debug('Drive', drive, 'is empty');
@ -198,6 +234,7 @@ export default class SmartPort implements Card, MassStorage, Restorable<SmartPor
}
// debug('read', '\n' + dumpBlock(drive, block));
this.driveLight(drive);
for (let idx = 0; idx < 512; idx++) {
buffer.writeByte(this.disks[drive].blocks[block][idx]);
@ -212,10 +249,10 @@ export default class SmartPort implements Card, MassStorage, Restorable<SmartPor
* writeBlock
*/
writeBlock(state: CpuState, drive: number, block: number, buffer: Address) {
this.debug('write drive=' + drive);
this.debug('write buffer=' + buffer);
this.debug('write block=$' + toHex(block));
writeBlock(state: CpuState, drive: DriveNumber, block: number, buffer: Address) {
this.debug(`write drive=${drive}`);
this.debug(`write buffer=${buffer.toString()}`);
this.debug(`write block=$${toHex(block)}`);
if (!this.disks[drive]?.blocks.length) {
debug('Drive', drive, 'is empty');
@ -232,20 +269,21 @@ export default class SmartPort implements Card, MassStorage, Restorable<SmartPor
}
// debug('write', '\n' + dumpBlock(drive, block));
this.driveLight(drive);
for (let idx = 0; idx < 512; idx++) {
this.disks[drive].blocks[block][idx] = buffer.readByte();
buffer = buffer.inc(1);
}
state.a = 0;
state.s &= flags.C;
state.s &= ~flags.C;
}
/*
* formatDevice
*/
formatDevice(state: CpuState, drive: number) {
formatDevice(state: CpuState, drive: DriveNumber) {
if (!this.disks[drive]?.blocks.length) {
debug('Drive', drive, 'is empty');
state.a = DEVICE_OFFLINE;
@ -321,11 +359,11 @@ export default class SmartPort implements Card, MassStorage, Restorable<SmartPor
buffer = bufferAddr.readAddress();
block = blockAddr.readWord();
this.debug('cmd=' + cmd);
this.debug(`cmd=${cmd}`);
this.debug('unit=$' + toHex(unit));
this.debug('slot=' + driveSlot + ' drive=' + drive);
this.debug('buffer=' + buffer + ' block=$' + toHex(block));
this.debug(`slot=${driveSlot} drive=${drive}`);
this.debug(`buffer=${buffer.toString()} block=$${toHex(block)}`);
switch (cmd) {
case 0: // INFO
@ -341,47 +379,48 @@ export default class SmartPort implements Card, MassStorage, Restorable<SmartPor
break;
case 3: // FORMAT
this.formatDevice(state, unit);
this.formatDevice(state, drive);
break;
}
} else if (off == smartOff && this.cpu.getSync()) {
} else if (off === smartOff && this.cpu.getSync()) {
this.debug('smartport entry');
const stackAddr = new Address(this.cpu, state.sp + 1, 0x01);
let blocks;
const retVal = stackAddr.readAddress();
this.debug('return=' + retVal);
this.debug(`return=${retVal.toString()}`);
const cmdBlockAddr = retVal.inc(1);
cmd = cmdBlockAddr.readByte();
const cmdListAddr = cmdBlockAddr.inc(1).readAddress();
this.debug('cmd=' + cmd);
this.debug('cmdListAddr=' + cmdListAddr);
this.debug(`cmd=${cmd}`);
this.debug(`cmdListAddr=${cmdListAddr.toString()}`);
stackAddr.writeAddress(retVal.inc(3));
const parameterCount = cmdListAddr.readByte();
unit = cmdListAddr.inc(1).readByte();
const drive = unit ? 2 : 1;
buffer = cmdListAddr.inc(2).readAddress();
let status;
this.debug('parameterCount=' + parameterCount);
this.debug(`parameterCount=${parameterCount}`);
switch (cmd) {
case 0x00: // INFO
status = cmdListAddr.inc(4).readByte();
this.debug('info unit=' + unit);
this.debug('info buffer=' + buffer);
this.debug('info status=' + status);
this.debug(`info unit=${unit}`);
this.debug(`info buffer=${buffer.toString()}`);
this.debug(`info status=${status}`);
switch (unit) {
case 0:
switch (status) {
case 0:
buffer.writeByte(2); // two devices
buffer.inc(1).writeByte(1 << 6); // no interrupts
buffer.inc(2).writeByte(0); // reserved
buffer.inc(3).writeByte(0); // reserved
buffer.inc(2).writeByte(0x2); // Other vendor
buffer.inc(3).writeByte(0x0); // Other vendor
buffer.inc(4).writeByte(0); // reserved
buffer.inc(5).writeByte(0); // reserved
buffer.inc(6).writeByte(0); // reserved
@ -396,7 +435,7 @@ export default class SmartPort implements Card, MassStorage, Restorable<SmartPor
default: // Unit 1
switch (status) {
case 0:
blocks = this.disks[unit].blocks.length;
blocks = this.disks[unit]?.blocks.length ?? 0;
buffer.writeByte(0xf0); // W/R Block device in drive
buffer.inc(1).writeByte(blocks & 0xff); // 1600 blocks
buffer.inc(2).writeByte((blocks & 0xff00) >> 8);
@ -406,6 +445,24 @@ export default class SmartPort implements Card, MassStorage, Restorable<SmartPor
state.a = 0;
state.s &= ~flags.C;
break;
case 3:
blocks = this.disks[unit]?.blocks.length ?? 0;
buffer.writeByte(0xf0); // W/R Block device in drive
buffer.inc(1).writeByte(blocks & 0xff); // Blocks low byte
buffer.inc(2).writeByte((blocks & 0xff00) >> 8); // Blocks middle byte
buffer.inc(3).writeByte((blocks & 0xff0000) >> 16); // Blocks high byte
buffer.inc(4).writeByte(ID.length); // Vendor ID length
for (let idx = 0; idx < ID.length; idx++) { // Vendor ID
buffer.inc(5 + idx).writeByte(ID.charCodeAt(idx));
}
buffer.inc(21).writeByte(DEVICE_TYPE_SCSI_HD); // Device Type
buffer.inc(22).writeByte(0x0); // Device Subtype
buffer.inc(23).writeWord(0x0101); // Version
state.x = 24;
state.y = 0;
state.a = 0;
state.s &= ~flags.C;
break;
}
break;
}
@ -415,16 +472,16 @@ export default class SmartPort implements Card, MassStorage, Restorable<SmartPor
case 0x01: // READ BLOCK
block = cmdListAddr.inc(4).readWord();
this.readBlock(state, unit, block, buffer);
this.readBlock(state, drive, block, buffer);
break;
case 0x02: // WRITE BLOCK
block = cmdListAddr.inc(4).readWord();
this.writeBlock(state, unit, block, buffer);
this.writeBlock(state, drive, block, buffer);
break;
case 0x03: // FORMAT
this.formatDevice(state, unit);
this.formatDevice(state, drive);
break;
case 0x04: // CONTROL
@ -453,6 +510,7 @@ export default class SmartPort implements Card, MassStorage, Restorable<SmartPor
}
write() {
// not writable
}
getState() {
@ -464,8 +522,9 @@ export default class SmartPort implements Card, MassStorage, Restorable<SmartPor
(block) => new Uint8Array(block)
),
encoding: ENCODING_BLOCK,
format: disk.format,
readOnly: disk.readOnly,
name: disk.name,
metadata: { ...disk.metadata },
};
return result;
}
@ -481,20 +540,27 @@ export default class SmartPort implements Card, MassStorage, Restorable<SmartPor
(block) => new Uint8Array(block)
),
encoding: ENCODING_BLOCK,
format: disk.format,
readOnly: disk.readOnly,
name: disk.name,
metadata: { ...disk.metadata },
};
return result;
}
);
}
setBinary(drive: number, name: string, fmt: string, rawData: ArrayBuffer) {
const volume = 254;
const readOnly = false;
if (fmt == '2mg') {
const { bytes, offset } = read2MGHeader(rawData);
setBinary(drive: DriveNumber, name: string, fmt: BlockFormat, rawData: ArrayBuffer) {
let volume = 254;
let readOnly = false;
if (fmt === '2mg') {
const header = read2MGHeader(rawData);
this.metadata[drive] = header;
const { bytes, offset } = header;
volume = header.volume;
readOnly = header.readOnly;
rawData = rawData.slice(offset, offset + bytes);
} else {
this.metadata[drive] = null;
}
const options = {
rawData,
@ -503,8 +569,37 @@ export default class SmartPort implements Card, MassStorage, Restorable<SmartPor
volume,
};
this.disks[drive] = createBlockDisk(options);
this.ext[drive] = fmt;
this.disks[drive] = createBlockDisk(fmt, options);
this.callbacks?.label(drive, name);
return true;
}
getBinary(drive: number): MassStorageData | null {
if (!this.disks[drive]) {
return null;
}
const disk = this.disks[drive];
const ext = this.ext[drive];
const { readOnly } = disk;
const { name } = disk.metadata;
let data: ArrayBuffer;
if (ext === '2mg') {
data = create2MGFromBlockDisk(this.metadata[drive], disk);
} else {
const { blocks } = disk;
const byteArray = new Uint8Array(blocks.length * 512);
for (let idx = 0; idx < blocks.length; idx++) {
byteArray.set(blocks[idx], idx * 512);
}
data = byteArray.buffer;
}
return {
metadata: { name },
ext,
data,
readOnly,
};
}
}

View File

@ -1,14 +1,3 @@
/* Copyright 2010-2019 Will Scullin <scullin@scullinsteel.com>
*
* Permission to use, copy, modify, distribute, and sell this software and its
* documentation for any purpose is hereby granted without fee, provided that
* the above copyright notice appear in all copies and that both that
* copyright notice and this permission notice appear in supporting
* documentation. No representations are made about the suitability of this
* software for any purpose. It is provided "as is" without express or
* implied warranty.
*/
import { debug, toHex } from '../util';
import { Card, Restorable, byte } from '../types';
import { rom } from '../roms/cards/thunderclock';
@ -46,7 +35,7 @@ export default class Thunderclock implements Card, Restorable<ThunderclockState>
private bits: boolean[] = [];
private command: byte = COMMANDS.REGHOLD;
private debug(..._args: any[]) {
private debug(..._args: unknown[]) {
// debug.apply(this, arguments);
}
@ -149,6 +138,7 @@ export default class Thunderclock implements Card, Restorable<ThunderclockState>
}
write() {
// not writable
}
ioSwitch(off: byte, val?: byte) {
@ -159,5 +149,7 @@ export default class Thunderclock implements Card, Restorable<ThunderclockState>
return {};
}
setState() {}
setState(_state: ThunderclockState) {
// can't set the state
}
}

View File

@ -1,25 +1,14 @@
/* Copyright 2017 Will Scullin <scullin@scullinsteel.com>
*
* Permission to use, copy, modify, distribute, and sell this software and its
* documentation for any purpose is hereby granted without fee, provided that
* the above copyright notice appear in all copies and that both that
* copyright notice and this permission notice appear in supporting
* documentation. No representations are made about the suitability of this
* software for any purpose. It is provided "as is" without express or
* implied warranty.
*/
import { allocMemPages, debug } from '../util';
import { Card, Restorable, byte, Color, memory, word } from '../types';
import { ROM, VIDEO_ROM } from '../roms/cards/videoterm';
interface VideotermState {
curReg: byte,
startPos: word,
cursorPos: word,
bank: byte,
buffer: memory,
regs: byte[],
curReg: byte;
startPos: word;
cursorPos: word;
bank: byte;
buffer: memory;
regs: byte[];
}
const LOC = {
@ -175,7 +164,7 @@ export default class Videoterm implements Card, Restorable<VideotermState> {
const startPos =
this.regs[REGS.STARTPOS_HI] << 8 |
this.regs[REGS.STARTPOS_LO];
if (this.startPos != startPos) {
if (this.startPos !== startPos) {
this.startPos = startPos;
this.shouldRefresh = true;
}

37
js/components/App.tsx Normal file
View File

@ -0,0 +1,37 @@
import 'preact/debug';
import { h } from 'preact';
import { Header } from './Header';
import { Apple2 } from './Apple2';
import { usePrefs } from './hooks/usePrefs';
import { SYSTEM_TYPE_APPLE2E } from '../ui/system';
import { SCREEN_GL } from '../ui/screen';
import { defaultSystem, systemTypes } from './util/systems';
import styles from './css/App.module.css';
/**
* Top level application component, provides the parameters
* needed by the Apple2 component to bootstrap itself.
*
* @returns Application component
*/
export const App = () => {
const prefs = usePrefs();
const systemType = prefs.readPref(SYSTEM_TYPE_APPLE2E, 'apple2enh');
const gl = prefs.readPref(SCREEN_GL, 'true') === 'true';
const system = {
...defaultSystem,
...(systemTypes[systemType] || {})
};
return (
<div className={styles.container}>
<Header e={system.e} />
<Apple2
gl={gl}
{...system}
/>
</div>
);
};

171
js/components/Apple2.tsx Normal file
View File

@ -0,0 +1,171 @@
import { h } from 'preact';
import cs from 'classnames';
import { useCallback, useEffect, useMemo, useRef, useState } from 'preact/hooks';
import { Apple2 as Apple2Impl } from '../apple2';
import { ControlStrip } from './ControlStrip';
import { Debugger } from './debugger/Debugger';
import { ErrorModal } from './ErrorModal';
import { Inset } from './Inset';
import { Keyboard } from './Keyboard';
import { LanguageCard } from './LanguageCard';
import { Mouse } from './Mouse';
import { Screen } from './Screen';
import { Drives } from './Drives';
import { Slinky } from './Slinky';
import { ThunderClock } from './ThunderClock';
import { Videoterm } from './Videoterm';
import { spawn, Ready } from './util/promises';
import styles from './css/Apple2.module.css';
import { SupportedSectors } from 'js/formats/types';
declare global {
interface Window {
apple2: Apple2Impl;
}
}
/**
* Interface for the Apple2 component.
*/
export interface Apple2Props {
characterRom: string;
enhanced: boolean;
e: boolean;
gl: boolean;
rom: string;
sectors: SupportedSectors;
}
/**
* Component to bind various UI components together to form
* the application layout. Includes the screen, drives,
* emulator controls and keyboard. Bootstraps the core
* Apple2 emulator.
*
* @param props Apple2 initialization props
* @returns
*/
export const Apple2 = (props: Apple2Props) => {
const { e, enhanced, sectors } = props;
const screenRef = useRef<HTMLCanvasElement>(null);
const [apple2, setApple2] = useState<Apple2Impl>();
const [error, setError] = useState<unknown>();
const [ready, setReady] = useState(false);
const [showDebug, setShowDebug] = useState(false);
const drivesReady = useMemo(() => new Ready(setError), []);
const io = apple2?.getIO();
const cpu = apple2?.getCPU();
const vm = apple2?.getVideoModes();
const rom = apple2?.getROM();
const doPaste = useCallback((event: Event) => {
if (
(document.activeElement !== screenRef.current) &&
(document.activeElement !== document.body)
) {
return;
}
if (io) {
const paste = (event.clipboardData || window.clipboardData)?.getData('text');
if (paste) {
io.setKeyBuffer(paste);
}
}
event.preventDefault();
}, [io]);
const doCopy = useCallback((event: Event) => {
if (
(document.activeElement !== screenRef.current) &&
(document.activeElement !== document.body)
) {
return;
}
if (vm) {
event.clipboardData?.setData('text/plain', vm.getText());
}
event.preventDefault();
}, [vm]);
useEffect(() => {
if (screenRef.current) {
const options = {
canvas: screenRef.current,
tick: () => { /* do nothing */ },
...props,
};
const apple2 = new Apple2Impl(options);
const controller = spawn(async (signal) => {
try {
await apple2.ready;
if (signal.aborted) {
return;
}
setApple2(apple2);
await drivesReady.ready;
if (signal.aborted) {
setApple2(undefined);
return;
}
apple2.reset();
apple2.run();
} catch (e) {
setError(e);
}
setReady(true);
});
window.apple2 = apple2;
return () => controller.abort();
}
}, [props, drivesReady]);
useEffect(() => {
const { current } = screenRef;
window.addEventListener('paste', doPaste);
window.addEventListener('copy', doCopy);
current?.addEventListener('paste', doPaste);
current?.addEventListener('copy', doCopy);
return () => {
window.removeEventListener('paste', doPaste);
window.removeEventListener('copy', doCopy);
current?.removeEventListener('paste', doPaste);
current?.removeEventListener('copy', doCopy);
};
}, [doCopy, doPaste]);
const toggleDebugger = useCallback(() => {
setShowDebug((on) => !on);
}, []);
return (
<div className={styles.container}>
<div
className={cs(styles.outer, { apple2e: e, [styles.ready]: ready })}
>
<Screen screenRef={screenRef} />
{!e ? <LanguageCard cpu={cpu} io={io} rom={rom} slot={0} /> : null}
<Slinky io={io} slot={2} />
{!e ? <Videoterm io={io} slot={3} /> : null}
<Mouse cpu={cpu} screenRef={screenRef} io={io} slot={4} />
<ThunderClock io={io} slot={5} />
<Inset>
<Drives cpu={cpu} io={io} sectors={sectors} enhanced={enhanced} ready={drivesReady} />
</Inset>
<ControlStrip apple2={apple2} e={e} toggleDebugger={toggleDebugger} />
<Inset>
<Keyboard apple2={apple2} e={e} />
</Inset>
<ErrorModal error={error} setError={setError} />
</div>
{showDebug ? <Debugger apple2={apple2} /> : null}
</div>
);
};

View File

@ -0,0 +1,51 @@
import { h } from 'preact';
import { useCallback, useContext, useEffect, useState } from 'preact/hooks';
import { ControlButton } from './ControlButton';
import { OptionsContext } from './OptionsContext';
import { Audio, SOUND_ENABLED_OPTION } from '../ui/audio';
import { Apple2 as Apple2Impl } from '../apple2';
/**
* AudioControl component properties.
*/
export interface AudioControlProps {
apple2: Apple2Impl | undefined;
}
/**
* Control that instantiates the Audio object and provides
* a control to mute and unmute audio.
*
* @param apple2 The Apple2 object
* @returns AudioControl component
*/
export const AudioControl = ({ apple2 }: AudioControlProps) => {
const [audioEnabled, setAudioEnabled] = useState(false);
const [audio, setAudio] = useState<Audio>();
const options = useContext(OptionsContext);
useEffect(() => {
if (apple2) {
const io = apple2.getIO();
const audio = new Audio(io);
options.addOptions(audio);
setAudio(audio);
setAudioEnabled(audio.isEnabled());
}
}, [apple2, options]);
const doToggleSound = useCallback(() => {
const on = !audio?.isEnabled();
options.setOption(SOUND_ENABLED_OPTION, on);
setAudioEnabled(on);
}, [audio, options]);
return (
<ControlButton
onClick={doToggleSound}
title="Toggle Sound"
disabled={!audio}
icon={audioEnabled ? 'volume-up' : 'volume-off'}
/>
);
};

101
js/components/BlockDisk.tsx Normal file
View File

@ -0,0 +1,101 @@
import { h } from 'preact';
import { useCallback, useState } from 'preact/hooks';
import cs from 'classnames';
import { BLOCK_FORMATS } from 'js/formats/types';
import SmartPort from '../cards/smartport';
import { BlockFileModal } from './BlockFileModal';
import { DiskDragTarget } from './DiskDragTarget';
import { DownloadModal } from './DownloadModal';
import { ErrorModal } from './ErrorModal';
import styles from './css/BlockDisk.module.css';
/**
* Storage structure for drive state returned via callbacks.
*/
export interface BlockDiskData {
number: 1 | 2;
on: boolean;
name?: string;
}
/**
* Interface for BlockDisk.
*/
export interface BlockDiskProps extends BlockDiskData {
smartPort: SmartPort;
}
/**
* BlockDisk component
*
* Includes drive light, disk name and side, and UI for loading disks.
*
* @param smartPort SmartPort object
* @param number Drive 1 or 2
* @param on Active state
* @param name Disk name identifier
* @param side Disk side identifier
* @returns BlockDisk component
*/
export const BlockDisk = ({ smartPort, number, on, name }: BlockDiskProps) => {
const [modalOpen, setModalOpen] = useState(false);
const [downloadModalOpen, setDownloadModalOpen] = useState(false);
const [error, setError] = useState<unknown>();
const doClose = useCallback(() => {
setModalOpen(false);
}, []);
const onOpenModal = useCallback(() => {
setModalOpen(true);
}, []);
const doCloseDownload = useCallback(() => {
setDownloadModalOpen(false);
}, []);
const onOpenDownloadModal = useCallback(() => {
setDownloadModalOpen(true);
}, []);
return (
<DiskDragTarget
className={styles.disk}
storage={smartPort}
drive={number}
formats={BLOCK_FORMATS}
onError={setError}
>
<ErrorModal error={error} setError={setError} />
<BlockFileModal
smartPort={smartPort}
number={number}
onClose={doClose}
isOpen={modalOpen}
/>
<DownloadModal
number={number}
massStorage={smartPort}
isOpen={downloadModalOpen}
onClose={doCloseDownload}
/>
<div
id={`disk${number}`}
className={cs(styles.diskLight, { [styles.on]: on })}
/>
<button title="Load Disk" onClick={onOpenModal}>
<i className="fas fa-folder-open" />
</button>
<button title="Save Disk" onClick={onOpenDownloadModal}>
<i className="fas fa-save" />
</button>
<div
id={`disk-label${number}`}
className={styles.diskLabel}
>
{name}
</div>
</DiskDragTarget>
);
};

View File

@ -0,0 +1,77 @@
import { h, Fragment } from 'preact';
import { useCallback, useState } from 'preact/hooks';
import { DriveNumber, BLOCK_FORMATS } from '../formats/types';
import { ErrorModal } from './ErrorModal';
import { FileChooser } from './FileChooser';
import { Modal, ModalContent, ModalFooter } from './Modal';
import { loadLocalBlockFile, getHashParts, setHashParts } from './util/files';
import SmartPort from 'js/cards/smartport';
import { useHash } from './hooks/useHash';
import { noAwait } from './util/promises';
import styles from './css/BlockFileModal.module.css';
const DISK_TYPES: FilePickerAcceptType[] = [
{
description: 'Disk Images',
accept: { 'application/octet-stream': BLOCK_FORMATS.map(x => '.' + x) },
}
];
interface BlockFileModalProps {
isOpen: boolean;
smartPort: SmartPort;
number: DriveNumber;
onClose: (closeBox?: boolean) => void;
}
export const BlockFileModal = ({ smartPort, number, onClose, isOpen } : BlockFileModalProps) => {
const [handles, setHandles] = useState<FileSystemFileHandle[]>();
const [busy, setBusy] = useState<boolean>(false);
const [empty, setEmpty] = useState<boolean>(true);
const [error, setError] = useState<unknown>();
const hash = useHash();
const doCancel = useCallback(() => onClose(true), [onClose]);
const doOpen = useCallback(async () => {
const hashParts = getHashParts(hash);
if (handles?.length === 1) {
hashParts[number] = '';
setBusy(true);
try {
await loadLocalBlockFile(smartPort, number, await handles[0].getFile());
} catch (error) {
setError(error);
} finally {
setBusy(false);
onClose();
}
}
setHashParts(hashParts);
}, [handles, hash, smartPort, number, onClose]);
const onChange = useCallback((handles: FileSystemFileHandle[]) => {
setEmpty(handles.length === 0);
setHandles(handles);
}, []);
return (
<>
<Modal title="Open File" isOpen={isOpen} onClose={onClose}>
<ModalContent>
<div className={styles.modalContent}>
<FileChooser onChange={onChange} accept={DISK_TYPES} />
</div>
</ModalContent>
<ModalFooter>
<button onClick={doCancel}>Cancel</button>
<button onClick={noAwait(doOpen)} disabled={busy || empty}>Open</button>
</ModalFooter>
</Modal>
<ErrorModal error={error} setError={setError} />
</>
);
};

View File

@ -0,0 +1,74 @@
import { h } from 'preact';
import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
import { Apple2 as Apple2Impl } from '../apple2';
import type { Stats } from '../apple2';
import styles from './css/CPUMeter.module.css';
/**
* Interface for CPUMeter.
*/
export interface CPUMeterProps {
apple2: Apple2Impl | undefined;
}
/**
* A simple display that can cycle between emulator Khz
* performance, frames/second and rendered frames/second.
*
* @param apple2 Apple2 object
* @returns CPU Meter component
*/
export const CPUMeter = ({ apple2 }: CPUMeterProps) => {
const lastStats = useRef<Stats>({
frames: 0,
renderedFrames: 0,
cycles: 0,
});
const lastTime = useRef<number>(Date.now());
const [khz, setKhz] = useState<number>(0);
const [fps, setFps] = useState<number>(0);
const [rps, setRps] = useState<number>(0);
const [mode, setMode] = useState<number>(0);
useEffect(() => {
const interval = setInterval(() => {
const { cycles, frames, renderedFrames } = lastStats.current;
const stats = apple2?.getStats();
const time = Date.now();
const delta = time - lastTime.current;
if (stats) {
setKhz(
Math.floor(
(stats.cycles - cycles) / delta
)
);
setFps(
Math.floor(
(stats.frames - frames) / delta * 1000
)
);
setRps(
Math.floor(
(stats.renderedFrames - renderedFrames) / delta * 1000
)
);
lastStats.current = { ...stats };
lastTime.current = time;
}
}, 1000);
return () => clearInterval(interval);
}, [apple2]);
const onClick = useCallback(() => {
setMode((mode) => (mode + 1) % 3);
}, []);
return (
<div className={styles.khz} onClick={onClick}>
{mode === 0 && `${khz} Khz`}
{mode === 1 && `${fps} fps`}
{mode === 2 && `${rps} rps`}
</div>
);
};

View File

@ -0,0 +1,29 @@
import { h, JSX } from 'preact';
import cs from 'classnames';
import styles from './css/ControlButton.module.css';
/**
* Interface for ControlButton.
*/
export interface ControlButtonProps {
icon: string;
title: string;
disabled?: boolean;
active?: boolean;
onClick: JSX.MouseEventHandler<HTMLButtonElement>;
}
/**
* Simple button with an icon, tooltip text and a callback.
*
* @param icon FontAwesome icon name
* @param title Tooltip text
* @param onClick Click callback
* @returns Control Button component
*/
export const ControlButton = ({ active, icon, title, onClick, ...props }: ControlButtonProps) => (
<button onClick={onClick} title={title} {...props} >
<i className={cs('fas', `fa-${icon}`, { [styles.active]: active })}></i>
</button>
);

View File

@ -0,0 +1,104 @@
import { h } from 'preact';
import { useCallback, useContext, useEffect, useState } from 'preact/hooks';
import { CPUMeter } from './CPUMeter';
import { Inset } from './Inset';
import { useHotKey } from './hooks/useHotKey';
import { AudioControl } from './AudioControl';
import { OptionsModal} from './OptionsModal';
import { OptionsContext } from './OptionsContext';
import { Printer } from './Printer';
import { ControlButton } from './ControlButton';
import { Apple2 as Apple2Impl } from '../apple2';
import { JoyStick } from '../ui/joystick';
import { Screen, SCREEN_FULL_PAGE } from '../ui/screen';
import { System } from '../ui/system';
import styles from './css/ControlStrip.module.css';
import Apple2IO from 'js/apple2io';
const README = 'https://github.com/whscullin/apple2js#readme';
interface ControlStripProps {
apple2: Apple2Impl | undefined;
e: boolean;
toggleDebugger: () => void;
}
/**
* Strip containing containing controls for various system
* characteristics, like CPU speed, audio, and the system
* options panel.
*
* @param apple2 Apple2 object
* @param e Whether or not this is a //e
* @returns ControlStrip component
*/
export const ControlStrip = ({ apple2, e, toggleDebugger }: ControlStripProps) => {
const [showOptions, setShowOptions] = useState(false);
const [io, setIO] = useState<Apple2IO>();
const options = useContext(OptionsContext);
useEffect(() => {
if (apple2) {
const io = apple2.getIO();
const vm = apple2.getVideoModes();
setIO(io);
const system = new System(io, e);
options.addOptions(system);
const joystick = new JoyStick(io);
options.addOptions(joystick);
const screen = new Screen(vm);
options.addOptions(screen);
}
}, [apple2, e, options]);
const doReset = useCallback(() =>
apple2?.reset()
, [apple2]);
const doReadme = useCallback(() =>
window.open(README, '_blank')
, []);
const doShowOptions = useCallback(() =>
setShowOptions(true)
, []);
const doCloseOptions = useCallback(() =>
setShowOptions(false)
, []);
const doToggleFullPage = useCallback(() =>
options.setOption(
SCREEN_FULL_PAGE,
!options.getOption(SCREEN_FULL_PAGE)
)
, [options]);
useHotKey('F2', doToggleFullPage);
useHotKey('F4', doShowOptions);
useHotKey('F12', doReset);
return (
<div className={styles.resetRow}>
<OptionsModal isOpen={showOptions} onClose={doCloseOptions} />
<Inset>
<CPUMeter apple2={apple2} />
<ControlButton onClick={toggleDebugger} title="Toggle Debugger" icon="bug" />
<AudioControl apple2={apple2} />
<Printer io={io} slot={1} />
<div style={{flexGrow: 1}} />
<ControlButton onClick={doReadme} title="About" icon="info" />
<ControlButton onClick={doShowOptions} title="Options (F4)" icon="cog" />
</Inset>
{e && (
<div className={styles.reset} onClick={doReset}>
Reset
</div>
)}
</div>
);
};

View File

@ -0,0 +1,97 @@
import { BLOCK_FORMATS, DISK_FORMATS, DriveNumber, FLOPPY_FORMATS, MassStorage } from 'js/formats/types';
import { h, JSX, RefObject } from 'preact';
import { useEffect, useRef } from 'preact/hooks';
import { loadLocalFile } from './util/files';
import { spawn } from './util/promises';
export interface DiskDragTargetProps<T> extends JSX.HTMLAttributes<HTMLDivElement> {
storage: MassStorage<T> | undefined;
drive?: DriveNumber;
formats: typeof FLOPPY_FORMATS
| typeof BLOCK_FORMATS
| typeof DISK_FORMATS;
dropRef?: RefObject<HTMLElement>;
onError: (error: unknown) => void;
}
export const DiskDragTarget = ({
storage,
drive,
dropRef,
formats,
onError,
children,
...props
}: DiskDragTargetProps<unknown>) => {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const div = dropRef?.current || ref.current;
if (div) {
const onDragOver = (event: DragEvent) => {
event.preventDefault();
const dt = event.dataTransfer;
if (dt) {
if (Array.from(dt.items).every((item) => item.kind === 'file')) {
dt.dropEffect = 'copy';
} else {
dt.dropEffect = 'none';
}
}
};
const onDragEnd = (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();
}
};
const onDrop = (event: DragEvent) => {
event.preventDefault();
event.stopPropagation();
const targetDrive = drive ?? 1; //TODO(whscullin) Maybe pick available drive
const dt = event.dataTransfer;
if (dt?.files.length === 1 && storage) {
spawn(async () => {
try {
await loadLocalFile(storage, formats, targetDrive, dt.files[0]);
} catch (e) {
onError(e);
}
});
} else if (dt?.files.length === 2 && storage) {
spawn(async () => {
try {
await loadLocalFile(storage, formats, 1, dt.files[0]);
await loadLocalFile(storage, formats, 2, dt.files[1]);
} catch (e) {
onError(e);
}
});
}
};
div.addEventListener('dragover', onDragOver);
div.addEventListener('dragend', onDragEnd);
div.addEventListener('drop', onDrop);
return () => {
div.removeEventListener('dragover', onDragOver);
div.removeEventListener('dragend', onDragEnd);
div.removeEventListener('drop', onDrop);
};
}
}, [drive, dropRef, formats, onError, storage]);
return (
<div ref={ref} {...props}>
{children}
</div>
);
};

92
js/components/DiskII.tsx Normal file
View File

@ -0,0 +1,92 @@
import { h } from 'preact';
import { useCallback, useState } from 'preact/hooks';
import cs from 'classnames';
import Disk2 from '../cards/disk2';
import { ErrorModal } from './ErrorModal';
import { FileModal } from './FileModal';
import styles from './css/DiskII.module.css';
import { DiskDragTarget } from './DiskDragTarget';
import { FLOPPY_FORMATS } from 'js/formats/types';
import { DownloadModal } from './DownloadModal';
/**
* Storage structure for Disk II state returned via callbacks.
*/
export interface DiskIIData {
number: 1 | 2;
on: boolean;
name: string;
side?: string | undefined;
}
/**
* Interface for Disk II component.
*/
export interface DiskIIProps extends DiskIIData {
disk2: Disk2;
}
/**
* Disk II component
*
* Includes drive light, disk name and side, and UI for loading disks.
*
* @param disk2 Disk2 object
* @param number Drive 1 or 2
* @param on Active state
* @param name Disk name identifier
* @param side Disk side identifier
* @returns DiskII component
*/
export const DiskII = ({ disk2, number, on, name, side }: DiskIIProps) => {
const label = side ? `${name} - ${side}` : name;
const [modalOpen, setModalOpen] = useState(false);
const [downloadModalOpen, setDownloadModalOpen] = useState(false);
const [error, setError] = useState<unknown>();
const doClose = useCallback(() => {
setModalOpen(false);
}, []);
const onOpenModal = useCallback(() => {
setModalOpen(true);
}, []);
const doCloseDownload = useCallback(() => {
setDownloadModalOpen(false);
}, []);
const onOpenDownloadModal = useCallback(() => {
setDownloadModalOpen(true);
}, []);
return (
<DiskDragTarget
className={styles.disk}
storage={disk2}
drive={number}
formats={FLOPPY_FORMATS}
onError={setError}
>
<FileModal disk2={disk2} number={number} onClose={doClose} isOpen={modalOpen} />
<DownloadModal
number={number}
massStorage={disk2}
isOpen={downloadModalOpen}
onClose={doCloseDownload}
/>
<ErrorModal error={error} setError={setError} />
<div className={cs(styles.diskLight, { [styles.on]: on })} />
<button title="Load Disk" onClick={onOpenModal}>
<i className="fas fa-folder-open" />
</button>
<button title="Save Disk" onClick={onOpenDownloadModal}>
<i className="fas fa-save" />
</button>
<div className={styles.diskLabel}>
{label}
</div>
</DiskDragTarget>
);
};

View File

@ -0,0 +1,71 @@
import { h, Fragment } from 'preact';
import { useCallback, useEffect, useState } from 'preact/hooks';
import { DriveNumber, MassStorage } from '../formats/types';
import { Modal, ModalContent, ModalFooter } from './Modal';
import styles from './css/DownloadModal.module.css';
interface DownloadModalProps {
isOpen: boolean;
massStorage: MassStorage<unknown>;
number: DriveNumber;
onClose: (closeBox?: boolean) => void;
}
export const DownloadModal = ({ massStorage, number, onClose, isOpen } : DownloadModalProps) => {
const [href, setHref] = useState('');
const [downloadName, setDownloadName] = useState('');
const doCancel = useCallback(() => onClose(true), [onClose]);
useEffect(() => {
if (isOpen) {
const storageData = massStorage.getBinary(number);
if (storageData) {
const { ext, data } = storageData;
const { name } = storageData.metadata;
if (data.byteLength) {
const blob = new Blob(
[data],
{ type: 'application/octet-stream' }
);
const href = window.URL.createObjectURL(blob);
setHref(href);
setDownloadName(`${name}.${ext}`);
return;
}
}
setHref('');
setDownloadName('');
}
}, [isOpen, number, massStorage]);
return (
<>
<Modal title="Save File" isOpen={isOpen} onClose={onClose}>
<ModalContent>
<div className={styles.modalContent}>
{ href
? (
<>
<span>Disk Name: {downloadName}</span>
<a
role="button"
href={href}
download={downloadName}
>
Download
</a>
</>
) : (
<span>No Download Available</span>
)
}
</div>
</ModalContent>
<ModalFooter>
<button onClick={doCancel}>Close</button>
</ModalFooter>
</Modal>
</>
);
};

200
js/components/Drives.tsx Normal file
View File

@ -0,0 +1,200 @@
import { h } from 'preact';
import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
import Disk2, { Callbacks } from '../cards/disk2';
import Apple2IO from '../apple2io';
import { DiskII, DiskIIData } from './DiskII';
import SmartPort from 'js/cards/smartport';
import CPU6502 from 'js/cpu6502';
import { BlockDisk } from './BlockDisk';
import { ErrorModal } from './ErrorModal';
import { ProgressModal } from './ProgressModal';
import { loadHttpUnknownFile, getHashParts, loadJSON, SmartStorageBroker } from './util/files';
import { useHash } from './hooks/useHash';
import { DISK_FORMATS, DriveNumber, SupportedSectors } from 'js/formats/types';
import { spawn, Ready } from './util/promises';
import styles from './css/Drives.module.css';
import { DiskDragTarget } from './DiskDragTarget';
/**
* Storage device storage
*/
interface StorageDevices {
disk2: Disk2;
smartPort: SmartPort;
smartStorageBroker: SmartStorageBroker;
}
/**
* Interface for Drives component.
*/
export interface DrivesProps {
cpu: CPU6502 | undefined;
io: Apple2IO | undefined;
enhanced: boolean;
sectors: SupportedSectors;
ready: Ready;
}
/**
* Drive interface component. Presents the interface to load disks.
* Provides the callback to the Disk2 and SmartPort objects to update
* the DiskII and BlockDisk components.
* Handles initial loading of disks specified in the hash.
*
* @cpu CPU object
* @param io Apple I/O object
* @param sectors 13 or 16 sector rom mode
* @enhanced Whether to create a SmartPort ROM device
* @ready Signal disk availability
* @returns Drives component
*/
export const Drives = ({ cpu, io, sectors, enhanced, ready }: DrivesProps) => {
const [current, setCurrent] = useState(0);
const [error, setError] = useState<unknown>();
const [total, setTotal] = useState(0);
const bodyRef = useRef(document.body);
const onProgress = useCallback((current: number, total: number) => {
setCurrent(current);
setTotal(total);
}, []);
const [storageDevices, setStorageDevices] = useState<StorageDevices>();
const [data1, setData1] = useState<DiskIIData>({
on: false,
number: 1,
name: 'Disk 1',
});
const [data2, setData2] = useState<DiskIIData>({
on: false,
number: 2,
name: 'Disk 2',
});
const [smartData1, setSmartData1] = useState<DiskIIData>({
on: false,
number: 1,
name: 'HD 1'
});
const [smartData2, setSmartData2] = useState<DiskIIData>({
on: false,
number: 2,
name: 'HD 2'
});
const hash = useHash();
useEffect(() => {
if (storageDevices) {
const { smartStorageBroker, disk2 } = storageDevices;
const hashParts = getHashParts(hash);
const controllers: AbortController[] = [];
let loading = 0;
for (const drive of [1, 2] as DriveNumber[]) {
if (hashParts && hashParts[drive]) {
const hashPart = decodeURIComponent(hashParts[drive]);
const isHttp = hashPart.match(/^https?:/i);
const isJson = hashPart.match(/\.json$/i);
if (isHttp && !isJson) {
loading++;
controllers.push(spawn(async (signal) => {
try {
await loadHttpUnknownFile(
smartStorageBroker,
drive,
hashPart,
signal,
onProgress);
} catch (e) {
setError(e);
}
if (--loading === 0) {
ready.onReady();
}
setCurrent(0);
setTotal(0);
}));
} else {
const url = isHttp ? hashPart : `json/disks/${hashPart}.json`;
loadJSON(disk2, drive, url).catch((e) => setError(e));
}
}
}
if (!loading) {
ready.onReady();
}
return () => controllers.forEach((controller) => controller.abort());
}
}, [hash, onProgress, ready, storageDevices]);
useEffect(() => {
const setData = [setData1, setData2];
const setSmartData = [setSmartData1, setSmartData2];
const callbacks: Callbacks = {
driveLight: (drive, on) => {
setData[drive - 1]?.(data => ({ ...data, on }));
},
label: (drive, name, side) => {
setData[drive - 1]?.(data => ({
...data,
name: name ?? `Disk ${drive}`,
side,
}));
},
dirty: () => {
// do nothing
}
};
const smartPortCallbacks: Callbacks = {
driveLight: (drive, on) => {
setSmartData[drive - 1]?.(data => ({ ...data, on }));
},
label: (drive, name, side) => {
setSmartData[drive - 1]?.(data => ({
...data,
name: name ?? `HD ${drive}`,
side,
}));
},
dirty: () => {/* Unused */ }
};
if (cpu && io) {
const disk2 = new Disk2(io, callbacks, sectors);
io.setSlot(6, disk2);
const smartPort = new SmartPort(cpu, smartPortCallbacks, { block: !enhanced });
io.setSlot(7, smartPort);
const smartStorageBroker = new SmartStorageBroker(disk2, smartPort);
setStorageDevices({ disk2, smartPort, smartStorageBroker });
}
}, [cpu, enhanced, io, sectors]);
if (!storageDevices) {
return null;
}
const { disk2, smartPort, smartStorageBroker } = storageDevices;
return (
<DiskDragTarget
storage={smartStorageBroker}
dropRef={bodyRef}
className={styles.drives}
onError={setError}
formats={DISK_FORMATS}
>
<ProgressModal current={current} total={total} title="Loading..." />
<ErrorModal error={error} setError={setError} />
<div className={styles.driveBay}>
<DiskII disk2={disk2} {...data1} />
<DiskII disk2={disk2} {...data2} />
</div>
<div className={styles.driveBay}>
<BlockDisk smartPort={smartPort} {...smartData1} />
<BlockDisk smartPort={smartPort} {...smartData2} />
</div>
</DiskDragTarget>
);
};

View File

@ -0,0 +1,47 @@
import { h } from 'preact';
import { useCallback } from 'preact/hooks';
import { Modal, ModalContent, ModalFooter } from './Modal';
import styles from './css/ErrorModal.module.css';
export interface ErrorProps {
error: unknown | undefined;
setError: (error: string | undefined) => void;
}
export const ErrorModal = ({ error, setError }: ErrorProps) => {
const onClose = useCallback(() => setError(undefined), [setError]);
let errorStr = null;
if (error) {
if (error instanceof Error) {
errorStr = error.message;
} else if (typeof error === 'string') {
errorStr = error;
} else {
errorStr = 'Unknown Error';
console.error(error);
}
}
if (errorStr) {
return (
<Modal
title="Error"
icon="triangle-exclamation"
isOpen={true}
onClose={onClose}
>
<ModalContent>
<div className={styles.errorModal}>
{errorStr}
</div>
</ModalContent>
<ModalFooter>
<button onClick={onClose}>OK</button>
</ModalFooter>
</Modal>
);
} else {
return null;
}
};

View File

@ -0,0 +1,200 @@
import { h, Fragment } from 'preact';
import { useCallback, useEffect, useMemo, useRef, useState } from 'preact/hooks';
import { noAwait } from './util/promises';
export interface FilePickerAcceptType {
description?: string | undefined;
accept: Record<string, string | string[]>;
}
const ACCEPT_EVERYTHING_TYPE: FilePickerAcceptType = {
description: 'Any file',
accept: { '*/*': [] },
};
export interface FileChooserProps {
disabled?: boolean;
onChange: (handles: Array<FileSystemFileHandle>) => void;
accept?: FilePickerAcceptType[];
control?: typeof controlDefault;
}
const hasPicker = !!window.showOpenFilePicker;
const controlDefault = hasPicker ? 'picker' : 'input';
interface InputFileChooserProps {
disabled?: boolean;
onChange?: (files: FileList) => void;
accept?: FilePickerAcceptType[];
}
interface ExtraProps {
accept?: string;
}
const InputFileChooser = ({
disabled = false,
onChange = () => { /* do nothing */ },
accept = [],
}: InputFileChooserProps) => {
const inputRef = useRef<HTMLInputElement>(null);
const filesRef = useRef<FileList>();
const onChangeInternal = useCallback(() => {
if (inputRef.current?.files) {
const newFiles = inputRef.current?.files;
if (filesRef.current !== newFiles) {
filesRef.current = newFiles;
onChange(newFiles);
}
}
}, [onChange]);
const extraProps = useMemo<ExtraProps>(() => {
// Accept all of the given MIME types and extensions. An argument
// could be made to throw out all of the MIME types and just keep
// the extensions, but this seemed to follow the intent in the
// spec:
//
// https://html.spec.whatwg.org/multipage/input.html#file-upload-state-(type=file)
//
// which allows pretty generous interpretations.
//
// Update(whscullin) - Adding the MIME type seems to block loading anything
// from iCloud when using Safari, so reverting to simply using extensions for
// now.
const newAccept = [];
for (const type of accept) {
for (const [/* typeString */, suffixes] of Object.entries(type.accept)) {
// newAccept.push(typeString);
if (Array.isArray(suffixes)) {
newAccept.push(...suffixes);
} else {
newAccept.push(suffixes);
}
}
}
const extraProps: { accept?: string } = {};
if (newAccept.length > 0) {
extraProps['accept'] = newAccept.join(',');
}
return extraProps;
}, [accept]);
return (
<input type="file" role='button' aria-label='Open file'
ref={inputRef}
onChange={onChangeInternal}
disabled={disabled}
{...extraProps} />
);
};
interface FilePickerChooserProps {
disabled?: boolean;
onChange?: (files: FileSystemFileHandle[]) => void;
accept?: FilePickerAcceptType[];
}
const FilePickerChooser = ({
disabled = false,
onChange = () => { /* do nothing */ },
accept = [ACCEPT_EVERYTHING_TYPE]
}: FilePickerChooserProps) => {
const [busy, setBusy] = useState<boolean>(false);
const [selectedFilename, setSelectedFilename] = useState<string>();
const [fileHandles, setFileHandles] = useState<FileSystemFileHandle[]>();
const onClickInternal = useCallback(async () => {
if (busy) {
return;
}
setBusy(true);
try {
const pickedFiles = await window.showOpenFilePicker({
multiple: false,
excludeAcceptAllOption: true,
types: accept,
});
if (fileHandles !== pickedFiles) {
setFileHandles(pickedFiles);
onChange(pickedFiles);
}
} catch (e: unknown) {
console.error(e);
} finally {
setBusy(false);
}
}, [accept, busy, fileHandles, onChange]);
useEffect(() => {
setSelectedFilename(
fileHandles?.length
? fileHandles[0].name
: 'No file selected');
}, [fileHandles]);
return (
<>
<button onClick={noAwait(onClickInternal)} disabled={disabled || busy}>
Choose File
</button>
&nbsp;
<span role="label">{selectedFilename}</span>
</>
);
};
/**
* File chooser component displayed as a button followed by the name of the
* chosen file (if any). When clicked, the button opens a native file chooser.
* If the browser supports the `window.showOpenFilePicker` function, this
* component uses it to open the file chooser. Otherwise, the component uses
* a regular file input element.
*
* Using `window.showOpenFilePicker` has the advantage of allowing read/write
* access to the file, whereas the regular input element only gives read
* access.
*/
export const FileChooser = ({
onChange,
control = controlDefault,
...rest
}: FileChooserProps) => {
const onChangeForInput = useCallback((files: FileList) => {
const handles: FileSystemFileHandle[] = [];
for (let i = 0; i < files.length; i++) {
const file = files.item(i);
if (file === null) {
continue;
}
handles.push({
kind: 'file',
name: file.name,
getFile: () => Promise.resolve(file),
createWritable: (_options) => Promise.reject('File not writable.'),
queryPermission: (descriptor) => Promise.resolve(descriptor === 'read' ? 'granted' : 'denied'),
requestPermission: (descriptor) => Promise.resolve(descriptor === 'read' ? 'granted' : 'denied'),
isSameEntry: (_unused) => Promise.resolve(false),
isDirectory: false,
isFile: true,
});
}
onChange(handles);
}, [onChange]);
const onChangeForPicker = useCallback((fileHandles: FileSystemFileHandle[]) => {
onChange(fileHandles);
}, [onChange]);
return control === 'picker'
? (
<FilePickerChooser onChange={onChangeForPicker} {...rest} />
)
: (
<InputFileChooser onChange={onChangeForInput} {...rest} />
);
};

158
js/components/FileModal.tsx Normal file
View File

@ -0,0 +1,158 @@
import { h, Fragment, JSX } from 'preact';
import { useCallback, useEffect, useState } from 'preact/hooks';
import { DiskDescriptor, DriveNumber, FLOPPY_FORMATS, NibbleFormat } from '../formats/types';
import { Modal, ModalContent, ModalFooter } from './Modal';
import { loadLocalNibbleFile, loadJSON, getHashParts, setHashParts } from './util/files';
import DiskII from '../cards/disk2';
import { ErrorModal } from './ErrorModal';
import { noAwait, spawn } from './util/promises';
import { useHash } from './hooks/useHash';
import { FileChooser, FilePickerAcceptType } from './FileChooser';
import styles from './css/FileModal.module.css';
const DISK_TYPES: FilePickerAcceptType[] = [
{
description: 'Disk Images',
accept: { 'application/octet-stream': FLOPPY_FORMATS.map(x => '.' + x) },
}
];
export type NibbleFileCallback = (
name: string,
fmt: NibbleFormat,
rawData: ArrayBuffer
) => boolean;
interface FileModalProps {
isOpen: boolean;
disk2: DiskII;
number: DriveNumber;
onClose: (closeBox?: boolean) => void;
}
interface IndexEntry {
filename: string;
name: string;
category: string;
}
export const FileModal = ({ disk2, number, onClose, isOpen }: FileModalProps) => {
const [busy, setBusy] = useState<boolean>(false);
const [empty, setEmpty] = useState<boolean>(true);
const [category, setCategory] = useState<string>();
const [handles, setHandles] = useState<FileSystemFileHandle[]>();
const [filename, setFilename] = useState<string>();
const [error, setError] = useState<unknown>();
const [index, setIndex] = useState<IndexEntry[]>();
const hash = useHash();
useEffect(() => {
spawn(async () => {
try {
const indexResponse = await fetch('json/disks/index.json');
const index = await indexResponse.json() as IndexEntry[];
setIndex(index);
} catch (error) {
setIndex([]);
setError(error);
}
});
}, []);
const doCancel = useCallback(() => onClose(true), [onClose]);
const doOpen = useCallback(async () => {
const hashParts = getHashParts(hash);
setBusy(true);
try {
if (handles?.length === 1) {
hashParts[number] = '';
await loadLocalNibbleFile(disk2, number, await handles[0].getFile());
}
if (filename) {
const name = filename.match(/\/([^/]+).json$/) || ['', ''];
hashParts[number] = name[1];
await loadJSON(disk2, number, filename);
}
} catch (e) {
setError(e);
} finally {
setHashParts(hashParts);
setBusy(false);
onClose();
}
setHashParts(hashParts);
}, [disk2, filename, number, onClose, handles, hash]);
const onChange = useCallback((handles: FileSystemFileHandle[]) => {
setEmpty(handles.length === 0);
setHandles(handles);
}, []);
const doSelectCategory = useCallback(
(event: JSX.TargetedMouseEvent<HTMLSelectElement>) =>
setCategory(event.currentTarget.value)
, []
);
const doSelectFilename = useCallback(
(event: JSX.TargetedMouseEvent<HTMLSelectElement>) => {
setEmpty(!event.currentTarget.value);
setFilename(event.currentTarget.value);
}, []
);
if (!index) {
return null;
}
const categories = index.reduce<Record<string, DiskDescriptor[]>>(
(
acc: Record<string, DiskDescriptor[]>,
disk: DiskDescriptor
) => {
const category = disk.category || 'Misc';
acc[category] = [disk, ...(acc[category] || [])];
return acc;
},
{}
);
const categoryNames = Object.keys(categories).sort();
const disks = category ? categories[category].sort() : [];
return (
<>
<Modal title="Open File" isOpen={isOpen} onClose={onClose}>
<ModalContent>
<div className={styles.loadModal}>
<select multiple onChange={doSelectCategory}>
{categoryNames.map((name) => (
<option key={name}>{name}</option>
))}
</select>
<select multiple onChange={doSelectFilename}>
{disks.map((disk) => (
<option key={disk.filename} value={disk.filename}>
{disk.name}
{disk.disk ? ` - ${disk.disk}` : ''}
</option>
))}
</select>
</div>
<FileChooser onChange={onChange} accept={DISK_TYPES} />
</ModalContent>
<ModalFooter>
<button onClick={doCancel}>Cancel</button>
<button onClick={noAwait(doOpen)} disabled={busy || empty}>Open</button>
</ModalFooter>
</Modal>
<ErrorModal error={error} setError={setError} />
</>
);
};

30
js/components/Header.tsx Normal file
View File

@ -0,0 +1,30 @@
import { h } from 'preact';
import styles from './css/Header.module.css';
const README = 'https://github.com/whscullin/apple2js#readme';
/**
* Header component properties.
*/
export interface HeaderProps {
e: boolean;
}
/**
* Header component, which consists of a badge and title.
*
* @returns Header component
*/
export const Header = ({ e }: HeaderProps) => {
return (
<div className={styles.header}>
<a href={README} rel="noreferrer" target="_blank">
<img src="img/badge.png" className={styles.badge} />
</a>
<div className={styles.subtitle}>
An Apple {e ? '//e' : ']['} Emulator in JavaScript
</div>
</div>
);
};

19
js/components/Inset.tsx Normal file
View File

@ -0,0 +1,19 @@
import { h, ComponentChildren, JSX } from 'preact';
import cs from 'classnames';
import styles from './css/Inset.module.css';
interface InsetProps extends JSX.HTMLAttributes<HTMLDivElement> {
children: ComponentChildren;
}
/**
* Convenience component for a nice beveled border.
*
* @returns Inset component
*/
export const Inset = ({ children, className, ...props }: InsetProps) => (
<div className={cs(className, styles.inset)} {...props}>
{children}
</div>
);

266
js/components/Keyboard.tsx Normal file
View File

@ -0,0 +1,266 @@
import { h, Fragment, JSX } from 'preact';
import cs from 'classnames';
import { useCallback, useEffect, useMemo, useState } from 'preact/hooks';
import { Apple2 as Apple2Impl } from '../apple2';
import {
keys2,
keys2e,
mapMouseEvent,
keysAsTuples,
mapKeyboardEvent
} from './util/keyboard';
import styles from './css/Keyboard.module.css';
/**
* Convenience function for massaging key labels for upper
* and lower case
*
* @param key Raw key label
* @returns Span representing that label
*/
const buildLabel = (key: string) => {
const small = key.length > 1 && !key.startsWith('&');
return (
<span
className={cs({[styles.small]: small})}
dangerouslySetInnerHTML={{ __html: key }}
/>
);
};
/**
* Key properties
*/
interface KeyProps {
lower: string;
upper: string;
active: boolean;
pressed: boolean;
onMouseDown: (event: MouseEvent) => void;
onMouseUp: (event: MouseEvent) => void;
}
/**
* Individual Key components. Sets up DOM data attributes to be passed to mouse
* handlers
*
* @param lower Lower key symbol
* @param upper Upper key symbol
* @param active Active state for shift, control, lock
* @param pressed Pressed state
* @param onMouseDown mouse down callback
* @param onMouseUp mouse up callback
*/
export const Key = ({
lower,
upper,
active,
pressed,
onMouseDown,
onMouseUp
}: KeyProps) => {
const keyName = lower.replace(/[&#;]/g, '');
const center =
lower === 'LOCK'
? styles.vCenter2
: (upper === lower && upper.length > 1)
? styles.vCenter
: '';
return (
<div
className={cs(
styles.key,
styles[`key-${keyName}`],
center,
{
[styles.pressed]: pressed,
[styles.active]: active,
},
)}
data-key1={lower}
data-key2={upper}
onMouseDown={onMouseDown}
onMouseUp={onMouseUp}
>
<div>
{buildLabel(upper)}
{upper !== lower && <><br />{buildLabel(lower)}</>}
</div>
</div>
);
};
/**
* Keyboard properties
*/
export interface KeyboardProps {
apple2: Apple2Impl | undefined;
e: boolean;
}
/**
* Keyboard component that can render an Apple ][ or //e keyboard
* and accept keyboard and mouse input. Relies heavily on the
* ancient keyboard css to achieve its appearance.
*
* @param apple2 Apple2 object
* @returns Keyboard component
*/
export const Keyboard = ({ apple2, e }: KeyboardProps) => {
const [pressed, setPressed] = useState<string[]>([]);
const [active, setActive] = useState<string[]>(['LOCK']);
const keys = useMemo(() => keysAsTuples(e ? keys2e : keys2 ), [e]);
// Set global keystroke handler
useEffect(() => {
const keyDown = (event: KeyboardEvent) => {
if (!apple2) {
return;
}
if (document.activeElement && document.activeElement !== document.body) {
return;
}
event.preventDefault();
const {key, keyCode, keyLabel} = mapKeyboardEvent(event, active.includes('LOCK'), active.includes('CTRL'));
setPressed(pressed => pressed.concat([keyLabel]));
setActive(active => active.concat([keyLabel]));
if (key === 'RESET') {
apple2.reset();
return;
}
const io = apple2.getIO();
if (key === 'OPEN_APPLE') {
io.buttonDown(0, true);
return;
}
if (key === 'CLOSED_APPLE') {
io.buttonDown(1, true);
return;
}
if (keyCode !== 0xff) {
apple2.getIO().keyDown(keyCode);
}
};
const keyUp = (event: KeyboardEvent) => {
if (!apple2) {
return;
}
const {key, keyLabel} = mapKeyboardEvent(event);
setPressed(pressed => pressed.filter(k => k !== keyLabel));
setActive(active => active.filter(k => k !== keyLabel));
const io = apple2.getIO();
if (key === 'OPEN_APPLE') {
io.buttonDown(0, false);
}
if (key === 'CLOSED_APPLE') {
io.buttonDown(1, false);
}
apple2.getIO().keyUp();
};
document.addEventListener('keydown', keyDown);
document.addEventListener('keyup', keyUp);
return () => {
document.removeEventListener('keydown', keyDown);
document.removeEventListener('keyup', keyUp);
};
}, [apple2, active]);
const onMouseDown = useCallback(
(event: JSX.TargetedMouseEvent<HTMLElement>) => {
if (!apple2) {
return;
}
// Sometimes control-clicking will open a menu, so don't do that.
event.preventDefault();
const toggleActive = (key: string) => {
if (!active.includes(key)) {
setActive([...active, key]);
return true;
}
setActive(active.filter(x => x !== key));
return false;
};
const io = apple2.getIO();
const { keyCode, key, keyLabel } = mapMouseEvent(
event,
active.includes('SHIFT'),
active.includes('CTRL'),
active.includes('LOCK'),
true
);
if (keyCode !== 0xff) {
io.keyDown(keyCode);
} else if (key) {
switch (key) {
case 'SHIFT':
case 'CTRL':
case 'LOCK':
toggleActive(key);
break;
case 'RESET':
apple2.reset();
break;
case 'OPEN_APPLE':
io.buttonDown(0, toggleActive(key));
toggleActive(key);
break;
case 'CLOSED_APPLE':
io.buttonDown(1, toggleActive(key));
toggleActive(key);
break;
default:
break;
}
}
setPressed([...pressed, keyLabel]);
},
[apple2, active, pressed]
);
const onMouseUp = useCallback(
(event: JSX.TargetedMouseEvent<HTMLElement>) => {
const { keyLabel } = mapMouseEvent(
event,
active.includes('SHIFT'),
active.includes('CTRL'),
active.includes('LOCK'),
true
);
apple2?.getIO().keyUp();
setPressed(pressed.filter(x => x !== keyLabel));
},
[apple2, active, pressed]
);
const bindKey = ([lower, upper]: [string, string]) =>
<Key
lower={lower}
upper={upper}
active={active.includes(lower)}
pressed={pressed.includes(lower)}
onMouseDown={onMouseDown}
onMouseUp={onMouseUp}
/>;
const rows = keys.map((row, idx) =>
<div key={idx} className={cs(styles.row, styles[`row${idx}`])}>
{row.map(bindKey)}
</div>
);
return (
<div className={styles.keyboard}>
{rows}
</div>
);
};

View File

@ -0,0 +1,35 @@
import CPU6502 from 'js/cpu6502';
import { Memory } from 'js/types';
import { useEffect } from 'preact/hooks';
import Apple2IO, { slot } from '../apple2io';
import LanguageCardImpl from '../cards/langcard';
/**
* Language Card component properties
*/
export interface LanguageCardProps {
io: Apple2IO | undefined;
cpu: CPU6502 | undefined;
rom: Memory | undefined;
slot: slot;
}
/**
* Language card component. Adds 16KB of memory.
*
* @param cpu 6502 object
* @param io Apple2IO object
* @param slot Slot to register card in
* @returns LanguageCard component
*/
export const LanguageCard = ({ cpu, io, rom, slot }: LanguageCardProps) => {
useEffect(() => {
if (io && cpu && rom) {
const lc = new LanguageCardImpl(rom);
io.setSlot(slot, lc);
cpu.addPageHandler(lc);
}
}, [io, cpu, rom, slot]);
return null;
};

142
js/components/Modal.tsx Normal file
View File

@ -0,0 +1,142 @@
import { h, ComponentChildren } from 'preact';
import { createPortal } from 'preact/compat';
import { useCallback } from 'preact/hooks';
import { useHotKey } from './hooks/useHotKey';
import styles from './css/Modal.module.css';
/**
* ModalOverlay creates a semi-transparent overlay in which the
* modal is centered.
*
* @returns ModalOverlay component
*/
export const ModalOverlay = ({ children }: { children: ComponentChildren }) => {
return (
<div className={styles.modalOverlay}>
{children}
</div>
);
};
/**
* ModalContent provides a styled container for modal content
*
* @returns ModalContent component
*/
export const ModalContent = ({ children }: { children: ComponentChildren }) => {
return (
<div className={styles.modalContent}>
{children}
</div>
);
};
/**
* ModalFooter provides a right-aligned container for modal buttons.
*
* @returns ModalFooter component
*/
export const ModalFooter = ({ children }: { children: ComponentChildren }) => {
return (
<footer className={styles.modalFooter}>
{children}
</footer>
);
};
/**
* ModalCloseButton component properties
*/
interface ModalCloseButtonProp {
onClose: (closeBox?: boolean) => void;
}
/**
* Renders a close button and registers a global Escape key
* hook to trigger it.
*
* @param onClose Close callback
* @returns ModalClose component
*/
export const ModalCloseButton = ({ onClose }: ModalCloseButtonProp) => {
const doClose = useCallback(() => onClose(true), [onClose]);
useHotKey('Escape', doClose);
return (
<button onClick={doClose} title="Close">
{'\u2715'}
</button>
);
};
type OnCloseCallback = (closeBox?: boolean) => void;
/**
* ModalHeader component properties
*/
export interface ModalHeaderProps {
onClose?: OnCloseCallback;
title: string;
icon?: string;
}
/**
* Header used internally for Modal component
*
* @param onClose Close callback
* @param title Modal title
* @returns ModalHeader component
*/
export const ModalHeader = ({ onClose, title, icon }: ModalHeaderProps) => {
return (
<header className={styles.modalHeader}>
<span className={styles.modalTitle}>
{icon && <i className={`fa-solid fa-${icon}`} role="img" />}
{' '}
{title}
</span>
{onClose && <ModalCloseButton onClose={onClose} />}
</header>
);
};
/**
* Modal component properties
*/
export interface ModalProps {
onClose?: (closeBox?: boolean) => void;
isOpen: boolean;
title: string;
children: ComponentChildren;
icon?: string;
}
/**
* Very simple modal component, provides a transparent overlay, title bar
* with optional close box if onClose is provided. ModalContent and
* ModalFooter components are provided for convenience but not required.
*
* @param isOpen true to show modal
* @param title Modal title
* @param onClose Close callback
* @returns Modal component
*/
export const Modal = ({
isOpen,
children,
title,
...props
}: ModalProps) => {
return (
isOpen ? createPortal((
<ModalOverlay>
<div className={styles.modal} role="dialog">
{title && <ModalHeader title={title} {...props} />}
{children}
</div>
</ModalOverlay>
), document.body) : null
);
};

37
js/components/Mouse.tsx Normal file
View File

@ -0,0 +1,37 @@
import { RefObject } from 'preact';
import Apple2IO, { slot } from '../apple2io';
import { MouseUI } from '../ui/mouse';
import MouseCard from '../cards/mouse';
import CPU6502 from '../cpu6502';
import { useEffect } from 'preact/hooks';
/**
* Mouse component properties.
*/
export interface MouseProps {
cpu: CPU6502 | undefined;
io: Apple2IO | undefined;
screenRef: RefObject<HTMLCanvasElement>;
slot: slot;
}
/**
* Mouse card component that adds a simple mouse driver.
*
* @param cpu CPU6502 object
* @param screen Screen element reference
* @param io Apple2IO object
* @param slot Slot to register card in
* @returns Mouse component
*/
export const Mouse = ({ cpu, screenRef, io, slot }: MouseProps) => {
useEffect(() => {
if (cpu && io && screenRef.current) {
const mouseUI = new MouseUI(screenRef.current);
const mouse = new MouseCard(cpu, mouseUI);
io.setSlot(slot, mouse);
}
}, [cpu, io, screenRef, slot]);
return null;
};

View File

@ -0,0 +1,7 @@
import { createContext } from 'preact';
import { Options } from '../options';
/**
* Context for getting, setting and configuring options
*/
export const OptionsContext = createContext(new Options());

View File

@ -0,0 +1,168 @@
import { h, Fragment, JSX } from 'preact';
import { useCallback, useContext } from 'preact/hooks';
import { Modal, ModalContent, ModalFooter } from './Modal';
import { OptionsContext } from './OptionsContext';
import {
BOOLEAN_OPTION,
SELECT_OPTION,
BooleanOption,
Option,
OptionSection,
SelectOption,
} from '../options';
import styles from './css/OptionsModal.module.css';
/**
* Boolean property interface
*/
interface BooleanProps {
option: BooleanOption;
value: boolean;
setValue: (name: string, value: boolean) => void;
}
/**
*
* @param option Boolean option
* @param value Current value
* @param setValue Value setter
* @returns Boolean component
*/
const Boolean = ({ option, value, setValue } : BooleanProps) => {
const { label, name } = option;
const onChange = useCallback(
(event: JSX.TargetedMouseEvent<HTMLInputElement>) =>
setValue(name, event.currentTarget.checked)
, [name, setValue]
);
return (
<li>
<input
type="checkbox"
checked={value}
onChange={onChange}
/>
<label>{label}</label>
</li>
);
};
/**
* Select property interface
*/
interface SelectProps {
option: SelectOption;
value: string;
setValue: (name: string, value: string) => void;
}
/**
* Select component that provides a dropdown to choose between
* options.
*
* @param option Select option
* @param value Current value
* @param setValue Value setter
* @returns Select component
*/
const Select = ({ option, value, setValue } : SelectProps) => {
const { label, name } = option;
const onChange = useCallback(
(event: JSX.TargetedMouseEvent<HTMLSelectElement>) => {
setValue(name, event.currentTarget.value);
},
[name, setValue]
);
const makeOption = (option: { name: string; value: string }) => (
<option selected={option.value === value} value={option.value}>
{option.name}
</option>
);
return (
<li>
<select onChange={onChange}>
{option.values.map(makeOption)}
</select>
<label>{label}</label>
</li>
);
};
/**
* OptionsModal properties
*/
export interface OptionsModalProps {
isOpen: boolean;
onClose: (closeBox?: boolean) => void;
}
/**
* Modal to allow editing of various component provided
* options, like screen and cpu type
*
* @param Modal params
* @returns OptionsModal component
*/
export const OptionsModal = ({ isOpen, onClose }: OptionsModalProps) => {
const options = useContext(OptionsContext);
const sections = options.getSections();
const setValue = useCallback(( name: string, value: string | boolean ) => {
options.setOption(name, value);
}, [options]);
const makeOption = (option: Option) => {
const { name, type } = option;
const value = options.getOption(name);
switch (type) {
case BOOLEAN_OPTION:
return (
<Boolean
option={option as BooleanOption}
value={value as boolean}
setValue={setValue}
/>
);
case SELECT_OPTION:
return (
<Select
option={option as SelectOption}
value={value as string}
setValue={setValue}
/>
);
default:
break;
}
};
const makeSection = (section: OptionSection) => {
return (
<>
<h3>{section.name}</h3>
<ul>
{section.options.map(makeOption)}
</ul>
</>
);
};
const doClose = useCallback(() => onClose(), [onClose]);
return (
<Modal title="Options" isOpen={isOpen} onClose={onClose}>
<ModalContent>
<div className={styles.optionsModal}>
{sections.map(makeSection)}
</div>
<i>* Reload page to take effect</i>
</ModalContent>
<ModalFooter>
<button onClick={doClose}>Close</button>
</ModalFooter>
</Modal>
);
};

107
js/components/Printer.tsx Normal file
View File

@ -0,0 +1,107 @@
import { h, Fragment } from 'preact';
import { useCallback, useEffect, useMemo, useRef, useState } from 'preact/hooks';
import Apple2IO, { slot } from 'js/apple2io';
import Parallel, { ParallelOptions } from 'js/cards/parallel';
import { Modal, ModalContent, ModalFooter } from './Modal';
import styles from './css/Printer.module.css';
import { ControlButton } from './ControlButton';
import { byte } from 'js/types';
export interface PrinterProps {
io: Apple2IO | undefined;
slot: slot;
}
export const Printer = ({ io, slot }: PrinterProps) => {
const [isOpen, setIsOpen] = useState(false);
const [content, setContent] = useState('');
const raw = useRef(new Uint8Array(1024));
const rawLength = useRef(0);
const [href, setHref] = useState('');
const cbs = useMemo<ParallelOptions>(() => ({
putChar: (val: byte) => {
const ascii = val & 0x7f;
const visible = val >= 0x20;
const char = String.fromCharCode(ascii);
if (char === '\r') {
// Skip for once
} else if (char === '\t') {
// possibly not right due to tab stops
setContent((content) => content += ' ');
} else if (ascii === 0x04) {
setContent((content) => content = content.slice(0, -1));
return;
} else if (visible) {
setContent((content) => content += char);
}
raw.current[rawLength.current++] = val;
if (rawLength.current > raw.current.length) {
const newRaw = new Uint8Array(raw.current.length * 2);
newRaw.set(raw.current);
raw.current = newRaw;
}
}
}), [rawLength]);
useEffect(() => {
if (io) {
const parallel = new Parallel(cbs);
io.setSlot(slot, parallel);
}
}, [cbs, io, slot]);
useEffect(() => {
if (isOpen) {
const blob = new Blob(
[raw.current.slice(0, rawLength.current)],
{ type: 'application/octet-stream' }
);
const href = window.URL.createObjectURL(blob);
setHref(href);
}
}, [isOpen]);
const onClear = useCallback(() => {
setContent('');
rawLength.current = 0;
}, []);
const onClose = useCallback(() => {
setIsOpen(false);
}, []);
const onOpen = useCallback(() => {
setIsOpen(true);
}, []);
return (
<>
<Modal isOpen={isOpen} onClose={onClose} title="Printer">
<ModalContent>
<pre className={styles.printer} tabIndex={-1} >
{content}
</pre>
</ModalContent>
<ModalFooter>
<a
download="raw_printer_output.bin"
href={href}
role="button"
>
Download Raw
</a>
<button onClick={onClear}>Clear</button>
<button onClick={onClose}>OK</button>
</ModalFooter>
</Modal>
<ControlButton
icon="print"
title="Printer"
onClick={onOpen}
active={content.length > 0}
/>
</>
);
};

View File

@ -0,0 +1,29 @@
import { h } from 'preact';
import { Modal, ModalContent } from './Modal';
import styles from './css/ProgressModal.module.css';
export interface ErrorProps {
title: string;
current: number | undefined;
total: number | undefined;
}
export const ProgressModal = ({ title, current, total } : ErrorProps) => {
if (current && total) {
return (
<Modal title={title} isOpen={true}>
<ModalContent>
<div className={styles.progressContainer}>
<div
className={styles.progressBar}
style={{ width: Math.floor(320 * (current / total)) }}
/>
</div>
</ModalContent>
</Modal>
);
} else {
return null;
}
};

32
js/components/Screen.tsx Normal file
View File

@ -0,0 +1,32 @@
import { h, Ref } from 'preact';
import styles from './css/Screen.module.css';
/**
* Screen properties
*/
export interface ScreenProps {
screenRef: Ref<HTMLCanvasElement>;
}
/**
* Styled canvas element that the Apple II display is
* rendered to by VideoModes.
*
* @param screen Canvas element reference
* @returns
*/
export const Screen = ({ screenRef }: ScreenProps) => {
return (
<div className={styles.display}>
<div className={styles.overscan}>
<canvas
className={styles.screen}
width="592"
height="416"
ref={screenRef}
/>
</div>
</div>
);
};

30
js/components/Slinky.tsx Normal file
View File

@ -0,0 +1,30 @@
import { useEffect } from 'preact/hooks';
import Apple2IO, { slot } from '../apple2io';
import RAMFactor from '../cards/ramfactor';
/**
* Slinky component properties
*/
export interface SlinkyProps {
io: Apple2IO | undefined;
slot: slot;
}
/**
* RAMFactory (Slinky) memory card component. Adds
* 1MB of slinky compatible memory.
*
* @param io Apple2IO object
* @param slot Slot to register card in
* @returns Slinky component
*/
export const Slinky = ({ io, slot }: SlinkyProps) => {
useEffect(() => {
if (io) {
const slinky = new RAMFactor(1024 * 1024);
io.setSlot(slot, slinky);
}
}, [io, slot]);
return null;
};

63
js/components/Tabs.tsx Normal file
View File

@ -0,0 +1,63 @@
import { ComponentChild, ComponentChildren, h } from 'preact';
import { useCallback, useState } from 'preact/hooks';
import cs from 'classnames';
import styles from './css/Tabs.module.css';
export interface TabProps {
children: ComponentChildren;
}
export const Tab = ({ children }: TabProps) => {
return (
<div>
{children}
</div>
);
};
interface TabWrapperProps {
children: ComponentChild;
onClick: () => void;
selected: boolean;
}
const TabWrapper = ({ children, onClick, selected }: TabWrapperProps) => {
return (
<div onClick={onClick} className={cs(styles.tab, { [styles.selected]: selected })}>
{children}
</div>
);
};
export interface TabsProps {
children: ComponentChildren;
setSelected: (selected: number) => void;
}
export const Tabs = ({ children, setSelected }: TabsProps) => {
const [innerSelected, setInnerSelected] = useState(0);
const innerSetSelected = useCallback((idx: number) => {
setSelected(idx);
setInnerSelected(idx);
}, [setSelected]);
if (!Array.isArray(children)) {
return null;
}
return (
<div className={styles.tabs}>
{children.map((child, idx) =>
<TabWrapper
key={idx}
onClick={() => innerSetSelected(idx)}
selected={idx === innerSelected}
>
{child}
</TabWrapper>
)}
</div>
);
};

View File

@ -0,0 +1,28 @@
import { useEffect } from 'preact/hooks';
import Apple2IO, { slot } from '../apple2io';
import ThunderClockCard from '../cards/thunderclock';
/**
* ThunderClock component properties.
*/
export interface ThunderClockProps {
io: Apple2IO | undefined;
slot: slot;
}
/**
* ThunderClock card component.
*
* @param io Apple2IO object
* @param slot Slot to register card in
*/
export const ThunderClock = ({ io, slot }: ThunderClockProps) => {
useEffect(() => {
if (io) {
const clock = new ThunderClockCard();
io.setSlot(slot, clock);
}
}, [io, slot]);
return null;
};

View File

@ -0,0 +1,20 @@
import { useEffect } from 'preact/hooks';
import Apple2IO, { slot } from 'js/apple2io';
import VideotermImpl from 'js/cards/videoterm';
/**
* VideoTerm component properties
*/
export interface VideotermProps {
io: Apple2IO | undefined;
slot: slot;
}
export const Videoterm = ({ io, slot }: VideotermProps ) => {
useEffect(() => {
if (io) {
const videoterm = new VideotermImpl();
io.setSlot(slot, videoterm);
}
}, [io, slot]);
return null;
};

View File

@ -0,0 +1,77 @@
body {
margin: 16px 0;
font-size: 14px;
background-color: #c4c1a0; /* Pantone 453 */
font-family: sans-serif;
}
:global(.full-page) {
background: black;
}
a[role="button"] {
text-decoration: none;
}
button,
a[role="button"],
input[type="file"]::file-selector-button {
background: #44372c;
color: #fff;
padding: 2px 8px;
border: 1px outset #66594e;
border-radius: 3px;
font-size: 15px;
min-width: 75px;
}
button:hover,
a[role="button"]:hover,
input[type="file"]::file-selector-button {
background-color: #55473d;
border: 1px outset #66594e;
}
button:active,
a[role="button"]:active,
input[type="file"]::file-selector-button {
background-color: #22150a;
border: 1px outset #44372c;
}
button:focus,
a[role="button"]:focus,
input[type="file"]::file-selector-button {
outline: none;
}
.container {
display: flex;
flex-direction: column;
}
input[type="checkbox"] {
appearance: none;
background-color: #65594d;
border: 1px inset #65594d;
padding: 7px;
top: 7px;
border-radius: 3px;
display: inline-block;
position: relative;
}
input[type="checkbox"]:checked {
background-color: #65594d;
border: 1px inset #65594d;
color: #0d0;
}
input[type="checkbox"]:checked::after {
content: "\2716";
font-size: 12px;
position: absolute;
top: 0;
left: 2px;
color: #0d0;
}

View File

@ -0,0 +1,13 @@
.container {
display: flex;
margin: auto;
}
.outer {
width: 620px;
display: none;
}
.outer.ready {
display: block;
}

View File

@ -0,0 +1,40 @@
.disk {
align-items: center;
display: flex;
flex-grow: 1;
}
.diskLight {
margin: 5px;
background-image: url("../../../css/green-off-16.png");
background-size: 16px 16px;
flex-shrink: 0;
width: 16px;
height: 16px;
}
.diskLight.on {
background-image: url("../../../css/green-on-16.png");
}
.diskLabel {
color: #000;
font-family: sans-serif;
font-weight: bold;
margin-right: 0.5em;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
flex-grow: 1;
user-select: none;
}
@media only screen and (min-resolution: 1.25dppx) {
.diskLight {
background-image: url("../../../css/green-off-32.png");
}
.diskLight.on {
background-image: url("../../../css/green-on-32.png");
}
}

View File

@ -0,0 +1,3 @@
.modalContent {
width: 320px;
}

View File

@ -0,0 +1,15 @@
.khz {
margin-right: 4px;
overflow: hidden;
padding: 4px;
font-family: Courier, monospace;
font-size: 12px;
width: 60px;
height: 15px;
text-align: right;
background: #000;
color: #0f0;
border: 2px inset #888;
border-radius: 4px;
user-select: none;
}

View File

@ -0,0 +1,3 @@
.active {
color: #0f0;
}

View File

@ -0,0 +1,43 @@
.reset {
background: #44372c;
border-left: 3px solid #65594d;
border-top: 3px solid #65594d;
border-right: 3px solid #110e0d;
border-bottom: 3px solid #110e0d;
/* border: 5px outset #66594E; */
border-radius: 3px;
color: white;
font: 9px Helvetica, sans-serif;
height: 36px;
padding: 0;
margin-left: 10px;
width: 36px;
display: flex;
justify-content: center;
align-items: center;
user-select: none;
}
.reset:hover {
background: #44372c;
border: 3px outset #66594e;
}
.reset:active {
background-color: #22150a;
border-left: 3px solid #44372c;
border-top: 3px solid #44372c;
border-right: 3px solid #000;
border-bottom: 3px solid #000;
}
:global(.full-page) .reset {
display: none;
}
.resetRow {
align-items: center;
display: flex;
margin: 10px 0;
}

View File

@ -0,0 +1,40 @@
.disk {
align-items: center;
display: flex;
flex-grow: 1;
}
.diskLight {
margin: 5px;
background-image: url("../../../css/red-off-16.png");
background-size: 16px 16px;
flex-shrink: 0;
width: 16px;
height: 16px;
}
.diskLight.on {
background-image: url("../../../css/red-on-16.png");
}
.diskLabel {
color: #000;
font-family: sans-serif;
font-weight: bold;
margin-right: 0.5em;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
flex-grow: 1;
user-select: none;
}
@media only screen and (min-resolution: 1.25dppx) {
.diskLight {
background-image: url("../../../css/red-off-32.png");
}
.diskLight.on {
background-image: url("../../../css/red-on-32.png");
}
}

View File

@ -0,0 +1,7 @@
.modalContent {
display: flex;
font-size: 1.1em;
justify-content: space-between;
padding: 10px 0;
width: 320px;
}

View File

@ -0,0 +1,10 @@
.drives {
display: flex;
width: 100%;
}
.driveBay {
display: flex;
flex-direction: column;
flex: 1 1 50%;
}

View File

@ -0,0 +1,5 @@
.errorModal {
width: 320px;
font-size: 1.1em;
padding: 5px 11px;
}

View File

@ -0,0 +1,5 @@
.loadModal select {
width: 250px;
height: 300px;
font-size: 14px;
}

View File

@ -0,0 +1,33 @@
.header {
width: 580px;
margin: auto;
}
@media all and (display-mode: minimal-ui) {
.header {
display: none;
}
}
.header img {
border: none;
}
:global(.full-page) .header {
display: none;
}
.badge {
cursor: pointer;
user-select: none;
}
.subtitle {
margin: 0;
padding: 3px 0 0 10;
color: black;
font-family: "Adobe Garamond Pro", Garamond, Times, serif;
font-size: 13px;
font-weight: normal;
user-select: none;
}

View File

@ -0,0 +1,17 @@
.inset {
border-radius: 6px;
border: 3px inset #f0edd0;
padding: 6px;
margin: 0 auto;
display: flex;
width: 604px;
}
:global(.full-page) .inset {
display: none;
}
.inset button {
min-width: 36px;
margin: 0 2px;
}

View File

@ -0,0 +1,213 @@
.keyboard {
margin-left: 15px;
user-select: none;
}
:global(.apple2e) .keyboard {
margin-left: 0;
}
.row {
display: block;
margin: 0;
padding: 0;
height: 42px;
font-family: Helvetica, sans-serif;
width: 570px;
}
:global(.apple2e) .row {
width: 610px;
}
.row0 {
margin-left: 20px;
}
.row2 {
margin-left: 10px;
}
.row3 {
margin-left: 10px;
}
.row4 {
margin-left: 10px;
}
:global(.apple2e) .row0 {
margin-left: 0;
}
:global(.apple2e) .row2 {
margin-left: 0;
}
:global(.apple2e) .row3 {
margin-left: 0;
}
:global(.apple2e) .row4 {
margin-left: 0;
}
.key {
display: inline-block;
margin: 0;
padding: 0;
width: 32px;
height: 32px;
background-color: #44372c;
color: white;
text-align: center;
position: relative;
border-left: 5px solid #65594d;
border-top: 5px solid #65594d;
border-right: 5px solid #110e0d;
border-bottom: 5px solid #110e0d;
/* border: 5px outset #66594E; */
border-radius: 5px;
cursor: pointer;
}
.pressed {
background-color: #22150a;
border-left: 5px solid #44372c;
border-top: 5px solid #44372c;
border-right: 5px solid #000;
border-bottom: 5px solid #000;
/* border: 5px outset #44372C; */
}
.key div {
border-radius: 3px;
position: absolute;
bottom: 0;
width: 100%;
text-align: center;
}
.vCenter div {
bottom: 10px;
}
.vCenter2 div {
bottom: 5px;
line-height: 10px;
}
.key span {
font-size: 13px;
}
.key .small {
font-size: 7px;
}
.key-SHIFT {
width: 53px;
}
.active {
color: #4f4;
}
.key-RETURN {
width: 52px;
}
.key-DELETE {
width: 47px;
}
.key-TAB {
width: 47px;
}
.key-POWER {
width: 38px;
height: 38px;
border: 2px groove black;
background-color: #ffd;
color: black;
border-radius: 2px;
}
.key-POWER div {
bottom: 15px;
}
.key-nbsp {
margin-left: 62px;
width: 330px;
}
:global(.apple2e) .key-CTRL {
width: 60px;
}
:global(.apple2e) .key-RETURN {
width: 60px;
}
:global(.apple2e) .key-SHIFT {
width: 81px;
}
:global(.apple2e) .key-nbsp {
margin-left: 0;
width: 215px;
}
.key-POW {
width: 38px;
height: 38px;
border: 2px groove black;
background-color: #ffd;
color: black;
border-radius: 2px;
}
.key-POW div {
bottom: 15px;
}
.key-POW span {
background: #4f4;
color: #4f4;
}
.key-OPEN_APPLE div {
background-image: url("../../../img/open-apple24.png");
width: 24px;
height: 24px;
bottom: 1px;
left: 4px;
}
.key-CLOSED_APPLE div {
background-image: url("../../../img/closed-apple24.png");
width: 24px;
height: 24px;
bottom: 1px;
left: 4px;
}
.key-OPEN_APPLE span {
display: none;
}
.key-CLOSED_APPLE span {
display: none;
}
.key-OPEN_APPLE.active div {
background-image: url("../../../img/open-apple24-green.png");
}
.key-CLOSED_APPLE.active div {
background-image: url("../../../img/closed-apple24-green.png");
}

View File

@ -0,0 +1,71 @@
.modalOverlay {
position: fixed;
left: 0;
right: 0;
top: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgb(0 0 0 / 60%);
z-index: 1;
}
.modalOverlay button {
min-width: 36px;
margin: 0 2px;
}
.modal {
background-color: #c4c1a0;
padding: 10px;
max-height: 100vh;
border-radius: 4px;
overflow-y: auto;
box-sizing: border-box;
}
.modalHeader {
display: flex;
font-size: 14px;
justify-content: space-between;
align-items: center;
background: #44372c;
color: #fff;
padding: 5px 11px;
border: 1px outset #66594e;
border-radius: 3px;
user-select: none;
}
.modalHeader button {
min-width: 36px;
}
.modalTitle {
margin-top: 0;
margin-bottom: 0;
font-weight: 600;
font-size: 1.25rem;
line-height: 1.25;
color: #fff;
box-sizing: border-box;
}
.modalContent {
margin-top: 10px;
margin-bottom: 10px;
line-height: 1.5;
color: #000;
}
.modalFooter {
text-align: right;
user-select: none;
}
.modalFooter a[role="button"],
.modalFooter button {
margin: 0 0 0 5px;
min-width: 75px;
}

View File

@ -0,0 +1,11 @@
.optionsModal {
width: 300px;
}
.optionsModal h3 {
font-size: 13px;
}
.optionsModal li {
list-style-type: none;
}

View File

@ -0,0 +1,9 @@
.printer {
background: white;
font: monospace;
width: 60em;
padding: 5em;
height: 60vh;
overflow: auto;
border: 2px inset #f0edd0;
}

View File

@ -0,0 +1,10 @@
.progressContainer {
width: 320px;
height: 20px;
background: #000;
}
.progressBar {
height: 20px;
background: #0f0;
}

View File

@ -0,0 +1,94 @@
:global(.mono) {
filter: url("#green");
}
.display {
margin: 5px auto 10px;
}
:global(.full-page) .display {
width: 100vw;
height: 68.5714vw; /* 384px / 560px * 100% */
max-height: 100vh;
max-width: 145.83vh; /* 560px / 384px * 100% */
padding: 0;
border: 0;
position: fixed;
top: 0;
bottom: 0;
left: 0;
right: 0;
justify-content: center;
align-items: center;
margin: auto !important;
}
.overscan {
margin: auto;
position: relative;
background-color: black;
width: 592px;
height: 416px;
border: 6px inset #f0edd0;
border-radius: 10px;
}
:global(.full-page) .overscan {
margin: initial;
padding: 0;
width: initial;
border: 0;
border-radius: 0;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
}
:global(.scanlines)::after {
display: block;
pointer-events: none;
background-image:
repeating-linear-gradient(
to bottom,
transparent 0,
transparent 1px,
rgb(0 0 0 / 50%) 1px,
rgb(0 0 0 / 50%) 2px
);
content: "";
position: absolute;
top: 0;
left: 0;
bottom: 0;
right: 0;
}
:global(.full-page) :global(.scanlines)::after {
background-image:
repeating-linear-gradient(
to bottom,
transparent 0,
transparent 0.25vh,
rgb(0 0 0 / 50%) 0.25vh,
rgb(0 0 0 / 50%) 0.5vh
);
}
.screen {
cursor: crosshair;
image-rendering: crisp-edges;
width: 592px;
height: 416px;
touch-action: manipulation;
user-select: none;
}
.screen:global(.mouseMode) {
cursor: none;
}
:global(.full-page) .screen {
width: 100%;
height: 100%;
}

View File

@ -0,0 +1,24 @@
.tab {
border-top: 2px groove;
border-left: 2px groove;
border-right: 2px groove;
margin: 0 2px;
font-weight: bold;
padding: 4px;
border-radius: 4px 4px 0 0;
}
.tab.selected {
background-color: #c4c1a0;
border-bottom: none;
margin-bottom: -2px;
color: #080;
}
.tabs {
display: flex;
flex-direction: row;
border-bottom: 2px groove;
margin-bottom: 6px;
user-select: none;
}

View File

@ -0,0 +1,137 @@
import { h } from 'preact';
import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
import { toHex } from 'js/util';
import ApplesoftDecompiler from 'js/applesoft/decompiler';
import { ApplesoftHeap, ApplesoftVariable } from 'js/applesoft/heap';
import { Apple2 as Apple2Impl } from 'js/apple2';
import styles from './css/Applesoft.module.css';
import debuggerStyles from './css/Debugger.module.css';
export interface ApplesoftProps {
apple2: Apple2Impl | undefined;
}
interface ApplesoftData {
variables: ApplesoftVariable[];
internals: {
txttab?: number;
fac?: number;
arg?: number;
curline?: number;
};
listing: string;
}
const TYPE_SYMBOL = ['', '$', '()', '%'] as const;
const TYPE_NAME = ['Float', 'String', 'Function', 'Integer'] as const;
const formatArray = (value: unknown): string => {
if (Array.isArray(value)) {
if (Array.isArray(value[0])) {
return `[${value.map((x) => formatArray(x)).join(',\n ')}]`;
} else {
return `[${value.map((x) => formatArray(x)).join(', ')}]`;
}
} else {
return `${JSON.stringify(value)}`;
}
};
const Variable = ({ variable }: { variable: ApplesoftVariable }) => {
const { name, type, sizes, value } = variable;
const isArray = !!sizes;
const arrayStr = isArray ? `(${sizes.map((size) => size - 1).join(',')})` : '';
return (
<tr>
<td>{name}{TYPE_SYMBOL[type]}{arrayStr}</td>
<td>{TYPE_NAME[type]}{isArray ? ' Array' : ''}</td>
<td><pre tabIndex={-1}>{isArray ? formatArray(value) : value}</pre></td>
</tr>
);
};
export const Applesoft = ({ apple2 }: ApplesoftProps) => {
const animationRef = useRef<number>(0);
const [data, setData] = useState<ApplesoftData>({
listing: '',
variables: [],
internals: {}
});
const [heap, setHeap] = useState<ApplesoftHeap>();
const cpu = apple2?.getCPU();
useEffect(() => {
if (cpu) {
// setDecompiler();
setHeap(new ApplesoftHeap(cpu));
}
}, [cpu]);
const animate = useCallback(() => {
if (cpu && heap) {
try {
const decompiler = ApplesoftDecompiler.decompilerFromMemory(cpu);
setData({
variables: heap.dumpVariables(),
internals: heap.dumpInternals(),
listing: decompiler.decompile()
});
} catch (error) {
if (error instanceof Error) {
setData({
variables: [],
internals: {},
listing: error.message
});
} else {
throw error;
}
}
}
animationRef.current = requestAnimationFrame(animate);
}, [cpu, heap]);
useEffect(() => {
animationRef.current = requestAnimationFrame(animate);
return () => cancelAnimationFrame(animationRef.current);
}, [animate]);
const { listing, internals, variables } = data;
return (
<div className={styles.column}>
<span className={debuggerStyles.subHeading}>Listing</span>
<pre className={styles.listing} tabIndex={-1}>{listing}</pre>
<span className={debuggerStyles.subHeading}>Variables</span>
<div className={styles.variables}>
<table>
<tr>
<th>Name</th>
<th>Type</th>
<th>Value</th>
</tr>
{variables.map((variable, idx) => <Variable key={idx} variable={variable} />)}
</table>
</div>
<span className={debuggerStyles.subHeading}>Internals</span>
<div className={styles.internals}>
<table>
<tr>
<th>TXTTAB</th>
<td>{toHex(internals.txttab ?? 0)}</td>
<th>FAC</th>
<td>{internals.fac}</td>
</tr>
<tr>
<th>ARG</th>
<td>{internals.arg}</td>
<th>CURLINE</th>
<td>{internals.curline}</td>
</tr>
</table>
</div>
</div>
);
};

View File

@ -0,0 +1,211 @@
import { h, JSX } from 'preact';
import cs from 'classnames';
import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
import { Apple2 as Apple2Impl } from '../../apple2';
import { ControlButton } from '../ControlButton';
import { FileChooser } from '../FileChooser';
import { loadLocalBinaryFile } from '../util/files';
import { spawn } from '../util/promises';
import { toHex } from 'js/util';
import styles from './css/CPU.module.css';
import debuggerStyles from './css/Debugger.module.css';
export interface CPUProps {
apple2: Apple2Impl | undefined;
}
interface DebugData {
memory: string;
registers: string;
running: boolean;
stack: string;
trace: string;
zeroPage: string;
}
const CIDERPRESS_EXTENSION = /#([0-9a-f]{2})([0-9a-f]{4})$/i;
const VALID_PAGE = /^[0-9A-F]{1,2}$/i;
const VALID_ADDRESS = /^[0-9A-F]{1,4}$/i;
const ERROR_ICON = (
<div className={styles.invalid}>
<i
className="fa-solid fa-triangle-exclamation"
title="Invalid hex address"
/>
</div>
);
export const CPU = ({ apple2 }: CPUProps) => {
const debug = apple2?.getDebugger();
const [data, setData] = useState<DebugData>({
running: true,
registers: '',
stack: '',
trace: '',
zeroPage: '',
memory: '',
});
const [memoryPage, setMemoryPage] = useState('08');
const [loadAddress, setLoadAddress] = useState('0800');
const [run, setRun] = useState(true);
const animationRef = useRef<number>(0);
const animate = useCallback(() => {
if (debug) {
setData({
registers: debug.dumpRegisters(),
running: debug.isRunning(),
stack: debug.getStack(38),
trace: debug.getTrace(16),
zeroPage: debug.dumpPage(0),
memory: debug.dumpPage(parseInt(memoryPage, 16) || 0)
});
}
animationRef.current = requestAnimationFrame(animate);
}, [debug, memoryPage]);
useEffect(() => {
animationRef.current = requestAnimationFrame(animate);
return () => cancelAnimationFrame(animationRef.current);
}, [animate]);
const doPause = useCallback(() => {
apple2?.stop();
}, [apple2]);
const doRun = useCallback(() => {
apple2?.run();
}, [apple2]);
const doStep = useCallback(() => {
debug?.step();
}, [debug]);
const doLoadAddress = useCallback((event: JSX.TargetedEvent<HTMLInputElement>) => {
setLoadAddress(event.currentTarget.value);
}, []);
const doRunCheck = useCallback((event: JSX.TargetedEvent<HTMLInputElement>) => {
setRun(event.currentTarget.checked);
}, []);
const doMemoryPage = useCallback((event: JSX.TargetedEvent<HTMLInputElement>) => {
setMemoryPage(event.currentTarget.value);
}, []);
const doChooseFile = useCallback((handles: FileSystemFileHandle[]) => {
if (debug && handles.length === 1) {
spawn(async () => {
const file = await handles[0].getFile();
let atAddress = parseInt(loadAddress, 16) || 0x800;
const matches = file.name.match(CIDERPRESS_EXTENSION);
if (matches && matches.length === 3) {
const [, , aux] = matches;
atAddress = parseInt(aux, 16);
}
await loadLocalBinaryFile(file, atAddress, debug);
setLoadAddress(toHex(atAddress, 4));
if (run) {
debug?.runAt(atAddress);
}
});
}
}, [debug, loadAddress, run]);
const {
memory,
registers,
running,
stack,
trace,
zeroPage
} = data;
const memoryPageValid = VALID_PAGE.test(memoryPage);
const loadAddressValid = VALID_ADDRESS.test(loadAddress);
return (
<div className={debuggerStyles.column}>
<span className={debuggerStyles.subHeading}>Controls</span>
<div className={styles.controls}>
{running ? (
<ControlButton
onClick={doPause}
disabled={!apple2}
title="Pause"
icon="pause"
/>
) : (
<ControlButton
onClick={doRun}
disabled={!apple2}
title="Run"
icon="play"
/>
)}
<ControlButton
onClick={doStep}
disabled={!apple2 || running}
title="Step"
icon="forward-step"
/>
</div>
<div className={debuggerStyles.row}>
<div className={debuggerStyles.column}>
<span className={debuggerStyles.subHeading}>Registers</span>
<pre tabIndex={-1}>
{registers}
</pre>
<span className={debuggerStyles.subHeading}>Trace</span>
<pre className={styles.trace} tabIndex={-1}>
{trace}
</pre>
<span className={debuggerStyles.subHeading}>ZP</span>
<pre className={styles.zeroPage} tabIndex={-1}>
{zeroPage}
</pre>
</div>
<div className={debuggerStyles.column}>
<span className={debuggerStyles.subHeading}>Stack</span>
<pre className={styles.stack} tabIndex={-1}>
{stack}
</pre>
</div>
</div>
<div>
<hr />
<span className={debuggerStyles.subHeading}>Memory Page: $ </span>
<input
value={memoryPage}
onChange={doMemoryPage}
maxLength={2}
className={cs({ [styles.invalid]: !memoryPageValid })}
/>
{memoryPageValid ? null : ERROR_ICON}
<pre className={styles.zp} tabIndex={-1}>
{memory}
</pre>
</div>
<div>
<hr />
<span className={debuggerStyles.subHeading}>Load File: $ </span>
<input
type="text"
value={loadAddress}
maxLength={4}
onChange={doLoadAddress}
className={cs({ [styles.invalid]: !loadAddressValid })}
/>
{loadAddressValid ? null : ERROR_ICON}
{' '}
<input type="checkbox" checked={run} onChange={doRunCheck} />Run
<div className={styles.fileChooser}>
<FileChooser onChange={doChooseFile} />
</div>
</div>
</div>
);
};

View File

@ -0,0 +1,44 @@
import { h } from 'preact';
import { useState } from 'preact/hooks';
import { Inset } from '../Inset';
import { Tab, Tabs } from '../Tabs';
import { Apple2 } from 'js/apple2';
import { Applesoft } from './Applesoft';
import { CPU } from './CPU';
import { Disks } from './Disks';
import { Memory } from './Memory';
import { VideoModes } from './VideoModes';
import styles from './css/Debugger.module.css';
interface DebuggerProps {
apple2: Apple2 | undefined;
}
export const Debugger = ({ apple2 }: DebuggerProps) => {
const [selected, setSelected] = useState(0);
if (!apple2) {
return null;
}
return (
<Inset className={styles.inset}>
<Tabs setSelected={setSelected}>
<Tab>CPU</Tab>
<Tab>Video</Tab>
<Tab>Memory</Tab>
<Tab>Disks</Tab>
<Tab>Applesoft</Tab>
</Tabs>
<div className={styles.debugger}>
{selected === 0 ? <CPU apple2={apple2} /> : null}
{selected === 1 ? <VideoModes apple2={apple2} /> : null}
{selected === 2 ? <Memory apple2={apple2} /> : null}
{selected === 3 ? <Disks apple2={apple2} /> : null}
{selected === 4 ? <Applesoft apple2={apple2} /> : null}
</div>
</Inset>
);
};

View File

@ -0,0 +1,422 @@
import { h, Fragment } from 'preact';
import { useMemo } from 'preact/hooks';
import cs from 'classnames';
import { Apple2 as Apple2Impl } from 'js/apple2';
import { BlockDisk, DiskFormat, DriveNumber, FloppyDisk, isBlockDiskFormat, isNibbleDisk, MassStorage } from 'js/formats/types';
import { slot } from 'js/apple2io';
import DiskII from 'js/cards/disk2';
import SmartPort from 'js/cards/smartport';
import createDiskFrom2MG from 'js/formats/2mg';
import createBlockDisk from 'js/formats/block';
import { ProDOSVolume } from 'js/formats/prodos';
import { FILE_TYPES, STORAGE_TYPES } from 'js/formats/prodos/constants';
import { Directory } from 'js/formats/prodos/directory';
import { FileEntry } from 'js/formats/prodos/file_entry';
import { VDH } from 'js/formats/prodos/vdh';
import { toHex } from 'js/util';
import styles from './css/Disks.module.css';
import debuggerStyles from './css/Debugger.module.css';
import { useCallback, useState } from 'preact/hooks';
import { DOS33, FileEntry as DOSEntry, isMaybeDOS33 } from 'js/formats/dos/dos33';
import createDiskFromDOS from 'js/formats/do';
import { FileData, FileViewer } from './FileViewer';
/**
* Formats a short date string
*
* @param date Data object
* @returns Short string date
*/
const formatDate = (date: Date) => {
return date.toLocaleString(undefined, { dateStyle: 'short', timeStyle: 'short' });
};
/**
* Guard for determining whether a disk is a nibble or block based disk
*
* @param disk NibbleDisk or BlockDisk
* @returns true if is BlockDisk
*/
function isBlockDisk(disk: FloppyDisk | BlockDisk): disk is BlockDisk {
return !!((disk as BlockDisk).blocks);
}
/**
* Props for FileListing component
*/
interface FileListingProps {
volume: ProDOSVolume;
fileEntry: FileEntry;
depth: number;
setFileData: (fileData: FileData) => void;
}
/**
* Renders a ProDOS file entry.
*
* @param depth Depth of listing from root
* @param fileEntry ProDOS file entry to display
* @returns FileListing component
*/
const FileListing = ({ depth, fileEntry, setFileData }: FileListingProps) => {
const deleted = fileEntry.storageType === STORAGE_TYPES.DELETED;
const doSetFileData = useCallback(() => {
const binary = fileEntry.getFileData();
const text = fileEntry.getFileText();
if (binary && text) {
setFileData({
binary,
text,
fileName: fileEntry.name,
});
}
}, [fileEntry, setFileData]);
return (
<tr>
<td
className={cs(styles.filename, { [styles.deleted]: deleted })}
title={fileEntry.name}
onClick={doSetFileData}
>
{'| '.repeat(depth)}
{deleted ?
<i className="fas fa-file-circle-xmark" /> :
<i className="fas fa-file" />
}
{' '}
{fileEntry.name}
</td>
<td>{FILE_TYPES[fileEntry.fileType] ?? `$${toHex(fileEntry.fileType)}`}</td>
<td>{`$${toHex(fileEntry.auxType, 4)}`}</td>
<td>{fileEntry.blocksUsed}</td>
<td>{formatDate(fileEntry.creation)}</td>
<td>{formatDate(fileEntry.lastMod)}</td>
</tr>
);
};
/**
* Props for DirectoryListing Component.
*/
interface DirectoryListingProps {
volume: ProDOSVolume;
dirEntry: VDH | Directory;
depth: number;
setFileData: (fileData: FileData) => void;
}
/**
* Displays information about a ProDOS directory, recursing through child
* directories.
*
* @param volume ProDOS volume
* @param depth Current directory depth
* @param dirEntry Current directory entry to display
* @returns DirectoryListing component
*/
const DirectoryListing = ({ volume, depth, dirEntry, setFileData }: DirectoryListingProps) => {
const [open, setOpen] = useState(depth === 0);
return (
<>
<tr>
<td
className={styles.filename}
onClick={() => setOpen((open) => !open)}
title={dirEntry.name}
>
{'| '.repeat(depth)}
<i className={cs('fas', { 'fa-folder-open': open, 'fa-folder-closed': !open })} />
{' '}
{dirEntry.name}
</td>
<td></td>
<td></td>
<td></td>
<td>{formatDate(dirEntry.creation)}</td>
<td></td>
</tr>
{open && dirEntry.entries.map((fileEntry, idx) => {
if (fileEntry.storageType === STORAGE_TYPES.DIRECTORY) {
const dirEntry = new Directory(volume, fileEntry);
return <DirectoryListing
key={idx}
depth={depth + 1}
volume={volume}
dirEntry={dirEntry}
setFileData={setFileData}
/>;
} else {
return <FileListing
key={idx}
depth={depth + 1}
volume={volume}
fileEntry={fileEntry}
setFileData={setFileData}
/>;
}
})}
</>
);
};
/**
* Props for CatalogEntry component
*/
interface CatalogEntryProps {
dos: DOS33;
fileEntry: DOSEntry;
setFileData: (fileData: FileData) => void;
}
/**
* Component for a single DOS 3.x catalog entry
*
* @param entry Catalog entry to display
* @returns CatalogEntry component
*/
const CatalogEntry = ({ dos, fileEntry, setFileData }: CatalogEntryProps) => {
const doSetFileData = useCallback(() => {
const { data } = dos.readFile(fileEntry);
setFileData({
binary: data,
text: dos.dumpFile(fileEntry),
fileName: fileEntry.name,
});
}, [dos, fileEntry, setFileData]);
return (
<tr onClick={doSetFileData}>
<td className={cs(styles.filename, { [styles.deleted]: fileEntry.deleted })}>
{fileEntry.locked && <i className="fas fa-lock" />}
{' '}
{fileEntry.name}
</td>
<td>{fileEntry.type}</td>
<td>{fileEntry.size}</td>
<td></td>
</tr>
);
};
/**
* Catalog component props
*/
interface CatalogProps {
dos: DOS33;
setFileData: (fileData: FileData) => void;
}
/**
* DOS 3.3 disk catalog component
*
* @param dos DOS 3.3 disk object
* @returns Catalog component
*/
const Catalog = ({ dos, setFileData }: CatalogProps) => {
const catalog = useMemo(() => dos.readCatalog(), [dos]);
return (
<>
{catalog.map((fileEntry, idx) => (
<CatalogEntry
key={idx}
dos={dos}
fileEntry={fileEntry}
setFileData={setFileData}
/>
))}
</>
);
};
/**
* Props for DiskInfo component
*/
interface DiskInfoProps {
massStorage: MassStorage<DiskFormat>;
drive: DriveNumber;
setFileData: (fileData: FileData) => void;
}
/**
* Top level disk info component, handles determining what sort of disk
* is present and using the appropriate sub-component depending on whether
* it's a ProDOS block disk or a DOS 3.3 disk.
*
* TODO(whscullin): Does not handle woz or 13 sector.
*
* @param massStorage The storage device
* @param drive The drive number
* @returns DiskInfo component
*/
const DiskInfo = ({ massStorage, drive, setFileData }: DiskInfoProps) => {
const disk = useMemo(() => {
const massStorageData = massStorage.getBinary(drive, 'po');
if (massStorageData) {
const { data, readOnly, ext } = massStorageData;
const { name } = massStorageData.metadata;
let disk: BlockDisk | FloppyDisk | null = null;
if (ext === '2mg') {
disk = createDiskFrom2MG({
name,
rawData: data,
readOnly,
volume: 254,
});
} else if (data.byteLength < 800 * 1024) {
const doData = massStorage.getBinary(drive, 'do');
if (doData) {
if (isMaybeDOS33(doData)) {
disk = createDiskFromDOS({
name,
rawData: doData.data,
readOnly,
volume: 254,
});
}
}
}
if (!disk && isBlockDiskFormat(ext)) {
disk = createBlockDisk(ext, {
name,
rawData: data,
readOnly,
volume: 254,
});
}
return disk;
}
return null;
}, [massStorage, drive]);
if (disk) {
try {
if (isBlockDisk(disk)) {
if (disk.blocks.length) {
const prodos = new ProDOSVolume(disk);
const { totalBlocks } = prodos.vdh();
const freeCount = prodos.bitMap().freeBlocks().length;
const usedCount = totalBlocks - freeCount;
return (
<div className={styles.volume}>
<table>
<thead>
<tr>
<th className={styles.filename}>Filename</th>
<th className={styles.type}>Type</th>
<th className={styles.aux}>Aux</th>
<th className={styles.blocks}>Blocks</th>
<th className={styles.created}>Created</th>
<th className={styles.modified}>Modified</th>
</tr>
</thead>
<tbody>
<DirectoryListing
depth={0}
volume={prodos}
dirEntry={prodos.vdh()}
setFileData={setFileData}
/>
</tbody>
<tfoot>
<tr>
<td colSpan={1}>Blocks Free: {freeCount}</td>
<td colSpan={3}>Used: {usedCount}</td>
<td colSpan={2}>Total: {totalBlocks}</td>
</tr>
</tfoot>
</table>
</div>
);
}
} else if (isNibbleDisk(disk)) {
const dos = new DOS33(disk);
return (
<div className={styles.volume}>
<table>
<thead>
<tr>
<th className={styles.filename}>Filename</th>
<th className={styles.type}>Type</th>
<th className={styles.sectors}>Sectors</th>
<th></th>
</tr>
</thead>
<tbody>
<Catalog dos={dos} setFileData={setFileData} />
</tbody>
<tfoot>
<tr>
<td>Volume Number:</td>
<td colSpan={3}>{dos.getVolumeNumber()}</td>
</tr>
<tr>
<td>Used Sectors:</td>
<td colSpan={3}>{dos.usedSectorCount()}</td>
</tr>
<tr>
<td>Free Sectors:</td>
<td colSpan={3}>{dos.freeSectorCount()}</td>
</tr>
</tfoot>
</table>
</div>
);
}
} catch (error) {
console.error(error);
return <pre>Unknown volume</pre>;
}
}
return <pre>No disk</pre>;
};
/**
* Disks component props
*/
export interface DisksProps {
apple2: Apple2Impl;
}
/**
* A debugger panel that displays information about currently mounted
* disks.
*
* @param apple2 The apple2 object
* @returns Disks component
*/
export const Disks = ({ apple2 }: DisksProps) => {
const [fileData, setFileData] = useState<FileData | null>(null);
const io = apple2.getIO();
const cards: MassStorage<DiskFormat>[] = [];
const onClose = useCallback(() => {
setFileData(null);
}, []);
for (let idx = 0; idx <= 7; idx++) {
const card = io.getSlot(idx as slot);
if (card instanceof DiskII || card instanceof SmartPort) {
cards.push(card);
}
}
return (
<div>
{cards.map((card, idx) => (
<div key={idx}>
<div className={debuggerStyles.subHeading}>
{card.constructor.name} - 1
</div>
<DiskInfo massStorage={card} drive={1} setFileData={setFileData} />
<div className={debuggerStyles.subHeading}>
{card.constructor.name} - 2
</div>
<DiskInfo massStorage={card} drive={2} setFileData={setFileData} />
</div>
))}
<FileViewer fileData={fileData} onClose={onClose} />
</div>
);
};

View File

@ -0,0 +1,170 @@
import { HiresPage2D, LoresPage2D, VideoModes2D } from 'js/canvas';
import { h, Fragment } from 'preact';
import { useEffect, useRef, useState } from 'preact/hooks';
import { Modal, ModalContent, ModalFooter } from '../Modal';
import styles from './css/FileViewer.module.css';
/**
* Binary and text representation of file to be previewed
*/
export interface FileData {
fileName: string;
binary: Uint8Array;
text: string;
}
/**
* FileViewer props
*/
export interface FileViewerProps {
fileData: FileData | null;
onClose: () => void;
}
/**
* Preview a file as a hires image if a file is roughly 8192 bytes.
* Leverages HiresPage2D.
*
* @param binary Potential file to preview
* @returns HiresPreview component
*/
const HiresPreview = ({ binary }: { binary: Uint8Array }) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
// Hires pictures are often a few bytes short of 8192 bytes
// because that saves a sector on DOS 3.3 disks.
if (binary.byteLength < 8184 || binary.byteLength > 8192) {
return null;
}
if (canvasRef.current) {
const vm = new VideoModes2D(canvasRef.current, true);
const lores = new LoresPage2D(vm, 1, new Uint8Array(), false);
const hires = new HiresPage2D(vm, 1);
vm.setLoresPage(1, lores);
vm.setHiresPage(1, hires);
vm.text(false);
vm.hires(true);
vm.doubleHires(false);
vm.page(1);
for (let idx = 0; idx < 0x20; idx++) {
for (let jdx = 0; jdx < 0x100; jdx++) {
hires.write(idx + 0x20, jdx, binary[idx * 0x100 + jdx]);
}
}
vm.blit();
}
return <canvas ref={canvasRef} width={560} height={384} className={styles.hiresPreview} />;
};
/**
* Preview a file as a double hires if a file is roughly 16384 bytes.
*
* @param binary Potential file to preview
* @returns DoubleHiresPreview component
*/
const DoubleHiresPreview = ({ binary }: { binary: Uint8Array }) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
if (binary.byteLength < 16376 || binary.byteLength > 16384) {
return null;
}
if (canvasRef.current) {
const vm = new VideoModes2D(canvasRef.current, true);
const lores = new LoresPage2D(vm, 1, new Uint8Array(), false);
const hires = new HiresPage2D(vm, 1);
vm.setLoresPage(1, lores);
vm.setHiresPage(1, hires);
vm.text(false);
vm.hires(true);
vm._80col(true);
vm.doubleHires(true);
vm.page(1);
for (let idx = 0; idx < 0x20; idx++) {
for (let jdx = 0; jdx < 0x100; jdx++) {
hires.bank1().write(idx + 0x20, jdx, binary[idx * 0x100 + jdx]);
}
}
for (let idx = 0x20; idx < 0x40; idx++) {
for (let jdx = 0; jdx < 0x100; jdx++) {
hires.bank0().write(idx, jdx, binary[idx * 0x100 + jdx]);
}
}
vm.blit();
}
return <canvas ref={canvasRef} width={560} height={384} className={styles.hiresPreview} />;
};
/**
* Apple file preview component. Supports a binary dump and hires and
* double hires images.
*
* @param fileData
* @param onClose Close button callback
* @returns
*/
export const FileViewer = ({ fileData, onClose }: FileViewerProps) => {
const [binaryHref, setBinaryHref] = useState('');
const [textHref, setTextHref] = useState('');
useEffect(() => {
if (fileData) {
const { binary, text } = fileData;
const binaryBlob = new Blob(
[binary],
{ type: 'application/octet-stream' }
);
const binaryHref = window.URL.createObjectURL(binaryBlob);
setBinaryHref(binaryHref);
const textBlob = new Blob(
[text],
{ type: 'application/octet-stream' }
);
const textHref = window.URL.createObjectURL(textBlob);
setTextHref(textHref);
}
}, [fileData]);
if (!fileData) {
return null;
}
const { fileName, text, binary } = fileData;
return (
<>
<Modal isOpen={true} onClose={onClose} title={fileName}>
<ModalContent>
<div className={styles.fileViewer}>
<HiresPreview binary={binary} />
<DoubleHiresPreview binary={binary} />
<pre className={styles.textViewer} tabIndex={-1} >
{text}
</pre>
</div>
</ModalContent>
<ModalFooter>
<a
download={`${fileName}.bin`}
href={binaryHref}
role="button"
>
Download Raw
</a>
<a
download={`${fileName}.txt`}
href={textHref}
role="button"
>
Download Text
</a>
<button onClick={onClose}>Close</button>
</ModalFooter>
</Modal>
</>
);
};

View File

@ -0,0 +1,363 @@
import { ComponentChildren, h } from 'preact';
import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
import cs from 'classnames';
import { Apple2 as Apple2Impl } from 'js/apple2';
import MMU from 'js/mmu';
import LanguageCard from 'js/cards/langcard';
import styles from './css/Memory.module.css';
import debuggerStyles from './css/Debugger.module.css';
/**
* Encapsulates the read/write status of a bank
*/
interface ReadWrite {
read: boolean;
write: boolean;
}
/**
* Encapsulates the read/write status of a language card
*/
interface LC extends ReadWrite {
bank0: ReadWrite;
bank1: ReadWrite;
rom: ReadWrite;
}
/**
* Encapsulates the read/write status of an aux/main memory bank.
*/
interface Bank extends ReadWrite {
lc: LC;
hires: ReadWrite;
text: ReadWrite;
zp: ReadWrite;
}
/**
* Encapsulates the read/write status of aux main memory and rom banks.
*/
interface Banks {
main: Bank;
aux: Bank;
io: ReadWrite;
intcxrom: ReadWrite;
}
/**
* Computes a language card state for an MMU aux or main bank.
*
* @param mmu MMU object
* @param altzp Compute for main or aux bank
* @returns LC read/write state
*/
const calcLC = (mmu: MMU, altzp: boolean) => {
const read = mmu.readbsr && (mmu.altzp === altzp);
const write = mmu.writebsr && (mmu.altzp === altzp);
return {
read,
write,
bank0: {
read: read && !mmu.bank1,
write: write && !mmu.bank1,
},
bank1: {
read: read && mmu.bank1,
write: write && mmu.bank1,
},
rom: {
read: !mmu.readbsr,
write: !mmu.writebsr,
},
};
};
/**
* Computes the hires aux or main read/write status.
*
* @param mmu MMU object
* @param aux Compute for main or aux bank
* @returns Hires pags read/write state
*/
const calcHires = (mmu: MMU, aux: boolean) => {
const page2sel = mmu.hires && mmu._80store;
return {
read: page2sel ? mmu.page2 === aux : mmu.auxread === aux,
write: page2sel ? mmu.page2 === aux : mmu.auxwrite === aux,
};
};
/**
* Computes the text aux or main read/write status.
*
* @param mmu MMU object
* @param aux Compute for main or aux bank
* @returns Text page read/write state
*/
const calcText = (mmu: MMU, aux: boolean) => {
const page2sel = mmu._80store;
return {
read: page2sel ? mmu.page2 === aux : mmu.auxread === aux,
write: page2sel ? mmu.page2 === aux : mmu.auxwrite === aux,
};
};
/**
* Creates read/write state from a flag
*
* @param flag Read/write flag
* @returns A read/write state
*/
const readAndWrite = (flag: boolean) => {
return {
read: flag,
write: flag,
};
};
/**
* Computes the aux or main bank read/write status.
*
* @param mmu MMU object
* @param aux Compute for main or aux bank
* @returns read/write state
*/
const calcBanks = (mmu: MMU): Banks => {
return {
main: {
read: !mmu.auxread,
write: !mmu.auxwrite,
lc: calcLC(mmu, false),
hires: calcHires(mmu, false),
text: calcText(mmu, false),
zp: readAndWrite(!mmu.altzp),
},
aux: {
read: mmu.auxread,
write: mmu.auxwrite,
lc: calcLC(mmu, true),
hires: calcHires(mmu, true),
text: calcText(mmu, true),
zp: readAndWrite(mmu.altzp),
},
io: readAndWrite(!mmu.intcxrom),
intcxrom: readAndWrite(mmu.intcxrom),
};
};
/**
* Computes the read/write state of a language card.
*
* @param card The language card
* @returns read/write state
*/
const calcLanguageCard = (card: LanguageCard): LC => {
const read = card.readbsr;
const write = card.writebsr;
return {
read,
write,
bank0: {
read: read && !card.bsr2,
write: write && !card.bsr2,
},
bank1: {
read: read && card.bsr2,
write: write && card.bsr2,
},
rom: {
read: !card.readbsr,
write: !card.writebsr,
}
};
};
/**
* Computes the classes for a bank from read/write state.
*
* @param rw Read/write state
* @returns Classes
*/
const rw = (rw: ReadWrite) => {
return {
[styles.read]: rw.read,
[styles.write]: rw.write,
[styles.inactive]: !rw.write && !rw.read,
};
};
/**
* Properties for LanguageCard component
*/
interface LanguageCardMapProps {
lc: LC;
children?: ComponentChildren;
}
/**
* Language card state component use by both the MMU and LanguageCard
* visualizations.
*
* @param lc LC state
* @param children label component
* @returns LanguageCard component
*/
const LanguageCardMap = ({lc, children}: LanguageCardMapProps) => {
return (
<div className={cs(styles.bank)}>
<div className={cs(styles.lc, rw(lc))}>
{children} LC
</div>
<div className={styles.lcbanks}>
<div className={cs(styles.lcbank, styles.lcbank0, rw(lc.bank0))}>
Bank 0
</div>
<div className={cs(styles.lcbank, rw(lc.bank1))}>
Bank 1
</div>
</div>
</div>
);
};
/**
* Legend of state colors. Green for read, red for write, blue for both, grey for
* inactive.
*
* @returns Legend component
*/
const Legend = () => {
return (
<div>
<div>
<div className={cs(styles.read, styles.legend)}> </div> Read
</div>
<div>
<div className={cs(styles.write, styles.legend)}> </div> Write
</div>
<div>
<div className={cs(styles.write, styles.read, styles.legend)}> </div> Read/Write
</div>
<div>
<div className={cs(styles.inactive, styles.legend)}> </div> Inactive
</div>
</div>
);
};
/**
* Properties for the Memory component.
*/
export interface MemoryProps {
apple2: Apple2Impl | undefined;
}
/**
* Memory debugger component. Displays the active state of banks of
* memory - aux, 80 column and language card depending up the machine.
*
* @param apple2 Apple2 object
* @returns Memory component
*/
export const Memory = ({ apple2 }: MemoryProps) => {
const animationRef = useRef<number>(0);
const [banks, setBanks] = useState<Banks>();
const [lc, setLC] = useState<LC>();
const animate = useCallback(() => {
if (apple2) {
const mmu = apple2.getMMU();
if (mmu) {
setBanks(calcBanks(mmu));
} else {
const card = apple2.getIO().getSlot(0);
if (card instanceof LanguageCard) {
setLC(calcLanguageCard(card));
}
}
}
animationRef.current = requestAnimationFrame(animate);
}, [apple2]);
useEffect(() => {
animationRef.current = requestAnimationFrame(animate);
return () => cancelAnimationFrame(animationRef.current);
}, [animate]);
if (banks) {
return (
<div className={styles.memory}>
<div className={debuggerStyles.heading}>MMU</div>
<div className={cs(styles.upperMemory, debuggerStyles.row)}>
<LanguageCardMap lc={banks.aux.lc}>
Aux
</LanguageCardMap>
<LanguageCardMap lc={banks.main.lc}>
Main
</LanguageCardMap>
<div className={cs(styles.bank)}>
<div className={cs(styles.rom, rw(banks.main.lc.rom))}>
ROM
</div>
</div>
</div>
<div className={cs(debuggerStyles.row)}>
<div className={cs(styles.io, rw(banks.io))}>
IO
</div>
<div className={cs(styles.intcxrom, rw(banks.intcxrom))}>
CXROM
</div>
</div>
<div className={cs(styles.lowerMemory, debuggerStyles.row)}>
<div className={cs(styles.bank, rw(banks.aux))}>
Aux Mem
<div className={cs(styles.hires, rw(banks.aux.hires))}>
Hires
</div>
<div className={cs(styles.text, rw(banks.aux.text))}>
Text/Lores
</div>
<div className={cs(styles.zp, rw(banks.aux.zp))}>
Stack/ZP
</div>
</div>
<div className={cs(styles.bank, rw(banks.main))}>
Main Mem
<div className={cs(styles.hires, rw(banks.main.hires))}>
Hires
</div>
<div className={cs(styles.text, rw(banks.main.text))}>
Text/Lores
</div>
<div className={cs(styles.zp, rw(banks.main.zp))}>
<span>Stack/ZP</span>
</div>
</div>
</div>
<hr />
<Legend />
</div>
);
} else if (lc) {
return (
<div className={styles.memory}>
<div className={debuggerStyles.heading}>Language Card</div>
<div className={cs(debuggerStyles.row, styles.languageCard)}>
<LanguageCardMap lc={lc} />
<div className={cs(styles.bank)}>
<div className={cs(styles.rom, rw(lc.rom))}>
ROM
</div>
</div>
</div>
<hr />
<Legend />
</div>
);
} else {
return null;
}
};

View File

@ -0,0 +1,94 @@
import { h } from 'preact';
import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
import cs from 'classnames';
import { Apple2 as Apple2Impl } from 'js/apple2';
import { VideoPage } from 'js/videomodes';
import styles from './css/VideoModes.module.css';
import debuggerStyles from './css/Debugger.module.css';
export interface VideoModesProps {
apple2: Apple2Impl | undefined;
}
const blit = (page: VideoPage, canvas: HTMLCanvasElement | null) => {
if (canvas) {
const context = canvas.getContext('2d');
if (context) {
context.putImageData(page.imageData, 0, 0);
}
}
};
export const VideoModes = ({ apple2 }: VideoModesProps) => {
const [text, setText] = useState(false);
const [hires, setHires] = useState(false);
const [page2, setPage2] = useState(false);
const canvas1 = useRef<HTMLCanvasElement>(null);
const canvas2 = useRef<HTMLCanvasElement>(null);
const canvas3 = useRef<HTMLCanvasElement>(null);
const canvas4 = useRef<HTMLCanvasElement>(null);
const animationRef = useRef<number>(0);
const animate = useCallback(() => {
if (apple2) {
const vm = apple2.getVideoModes();
const text = vm.isText();
const hires = vm.isHires();
const page2 = vm.isPage2();
vm.getLoresPage(1).refresh();
vm.getLoresPage(2).refresh();
vm.getHiresPage(1).refresh();
vm.getHiresPage(2).refresh();
blit(vm.getLoresPage(1), canvas1.current);
blit(vm.getLoresPage(2), canvas2.current);
blit(vm.getHiresPage(1), canvas3.current);
blit(vm.getHiresPage(2), canvas4.current);
setText(text);
setHires(hires);
setPage2(page2);
}
animationRef.current = requestAnimationFrame(animate);
}, [apple2]);
useEffect(() => {
animationRef.current = requestAnimationFrame(animate);
return () => cancelAnimationFrame(animationRef.current);
}, [animate]);
return (
<div className={styles.pages}>
<div className={debuggerStyles.row}>
<div className={cs(styles.page, {[styles.active]: (text || !hires) && !page2})}>
<div className={debuggerStyles.heading}>
Text/Lores Page 1
</div>
<canvas width="560" height="192" ref={canvas1} />
</div>
<div className={cs(styles.page, {[styles.active]: (text || !hires) && page2})}>
<div className={debuggerStyles.heading}>
Text/Lores Page 2
</div>
<canvas width="560" height="192" ref={canvas2} />
</div>
</div>
<div className={debuggerStyles.row}>
<div className={cs(styles.page, {[styles.active]: (!text && hires) && !page2})}>
<div className={debuggerStyles.heading}>
Hires Page 1
</div>
<canvas width="560" height="192" ref={canvas3} />
</div>
<div className={cs(styles.page, {[styles.active]: (!text && hires) && page2})}>
<div className={debuggerStyles.heading}>
Hires Page 2
</div>
<canvas width="560" height="192" ref={canvas4} />
</div>
</div>
</div>
);
};

Some files were not shown because too many files have changed in this diff Show More