Merge branch 'main' into master
|
@ -1,3 +1,4 @@
|
|||
dist
|
||||
json/disks/index.js
|
||||
node_modules
|
||||
tmp
|
||||
|
|
157
.eslintrc.json
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -9,7 +9,7 @@ jobs:
|
|||
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [12.x]
|
||||
node-version: [14.x]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
|
|
|
@ -0,0 +1,2 @@
|
|||
css/apple2.css
|
||||
coverage
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"extends": [
|
||||
"stylelint-config-standard",
|
||||
"stylelint-config-css-modules"
|
||||
],
|
||||
"rules": {
|
||||
"indentation": 4,
|
||||
"selector-class-pattern": "^[a-z][a-zA-Z0-9_-]+$"
|
||||
}
|
||||
}
|
2
LICENSE
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
|
|
@ -9,5 +9,19 @@ module.exports = {
|
|||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
'@babel/typescript',
|
||||
{
|
||||
jsxPragma: 'h'
|
||||
}
|
||||
],
|
||||
],
|
||||
plugins: [
|
||||
[
|
||||
'@babel/plugin-transform-react-jsx', {
|
||||
pragma: 'h',
|
||||
pragmaFrag: 'Fragment',
|
||||
}
|
||||
]
|
||||
]
|
||||
};
|
||||
|
|
|
@ -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)
|
||||
);
|
||||
|
|
|
@ -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 {
|
||||
|
|
After Width: | Height: | Size: 784 B |
After Width: | Height: | Size: 1.9 KiB |
After Width: | Height: | Size: 919 B |
After Width: | Height: | Size: 2.5 KiB |
Before Width: | Height: | Size: 764 B After Width: | Height: | Size: 764 B |
After Width: | Height: | Size: 1.9 KiB |
Before Width: | Height: | Size: 869 B After Width: | Height: | Size: 869 B |
After Width: | Height: | Size: 2.3 KiB |
|
@ -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>
|
|
@ -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/',
|
||||
|
|
57
js/apple2.ts
|
@ -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(),
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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
|
||||
};
|
|
@ -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;
|
12
js/base64.ts
|
@ -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);
|
||||
}
|
||||
|
|
81
js/canvas.ts
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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
|
||||
};
|
||||
}
|
||||
}
|
126
js/cards/nsc.js
|
@ -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(_) {
|
||||
}
|
||||
};
|
||||
}
|
|
@ -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.
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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'}
|
||||
/>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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} />
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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;
|
||||
}
|
||||
};
|
|
@ -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>
|
||||
|
||||
<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} />
|
||||
);
|
||||
};
|
|
@ -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} />
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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;
|
||||
};
|
|
@ -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
|
||||
);
|
||||
};
|
|
@ -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;
|
||||
};
|
|
@ -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());
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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;
|
||||
}
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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;
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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;
|
||||
};
|
|
@ -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;
|
||||
};
|
|
@ -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;
|
||||
}
|
|
@ -0,0 +1,13 @@
|
|||
.container {
|
||||
display: flex;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.outer {
|
||||
width: 620px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.outer.ready {
|
||||
display: block;
|
||||
}
|
|
@ -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");
|
||||
}
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
.modalContent {
|
||||
width: 320px;
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
.active {
|
||||
color: #0f0;
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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");
|
||||
}
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
.modalContent {
|
||||
display: flex;
|
||||
font-size: 1.1em;
|
||||
justify-content: space-between;
|
||||
padding: 10px 0;
|
||||
width: 320px;
|
||||
}
|
|
@ -0,0 +1,10 @@
|
|||
.drives {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.driveBay {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1 1 50%;
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
.errorModal {
|
||||
width: 320px;
|
||||
font-size: 1.1em;
|
||||
padding: 5px 11px;
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
.loadModal select {
|
||||
width: 250px;
|
||||
height: 300px;
|
||||
font-size: 14px;
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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");
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -0,0 +1,11 @@
|
|||
.optionsModal {
|
||||
width: 300px;
|
||||
}
|
||||
|
||||
.optionsModal h3 {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.optionsModal li {
|
||||
list-style-type: none;
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
.printer {
|
||||
background: white;
|
||||
font: monospace;
|
||||
width: 60em;
|
||||
padding: 5em;
|
||||
height: 60vh;
|
||||
overflow: auto;
|
||||
border: 2px inset #f0edd0;
|
||||
}
|
|
@ -0,0 +1,10 @@
|
|||
.progressContainer {
|
||||
width: 320px;
|
||||
height: 20px;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.progressBar {
|
||||
height: 20px;
|
||||
background: #0f0;
|
||||
}
|
|
@ -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%;
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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;
|
||||
}
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|