@moonbase-machines/c64 0.0.0-stage → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +11 -2
- package/basic-compile.js +171 -0
- package/basic-lang.js +394 -0
- package/basic-list.js +21 -0
- package/basic-rom.js +572 -0
- package/basic.js +275 -0
- package/browser.js +116 -0
- package/d64.js +191 -0
- package/dist/moonbase-c64.js +9 -0
- package/dist/moonbase-c64.js.map +7 -0
- package/drive.js +209 -0
- package/editor.js +267 -0
- package/float.js +584 -0
- package/glyph-hash.js +8 -0
- package/glyph-hashes.js +70 -0
- package/index.js +27 -0
- package/kernal.js +319 -0
- package/keys.js +21 -0
- package/machine.js +209 -0
- package/numbers.js +64 -0
- package/package.json +14 -4
- package/paint.js +23 -0
- package/petscii.js +54 -0
- package/printer.js +95 -0
- package/rom.js +537 -0
- package/saves.js +10 -0
- package/sid.js +49 -0
- package/start.js +33 -0
- package/test/disks.test.js +35 -0
- package/vic.js +122 -0
package/basic.js
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
// What Commodore BASIC does for a program, for a remake whose BASIC (or Blitz!'s code) is
|
|
2
|
+
// compiled to JavaScript (basic-compile.js): its variables and arrays, its numbers printed as it
|
|
3
|
+
// prints them, its strings as their bytes, its FOR and GOSUB on one stack, its DATA, and its
|
|
4
|
+
// statements' calls into the KERNAL, made as the original makes them, through the engine, so
|
|
5
|
+
// that they are the same events.
|
|
6
|
+
//
|
|
7
|
+
// const b = new Basic(engine, { data, quiet })
|
|
8
|
+
// data where the program's DATA is in memory (Blitz!'s: each item its length and its text,
|
|
9
|
+
// $FF at the end)
|
|
10
|
+
// quiet BASIC's own interpreter, in the ROM, ran the program: its calls into the KERNAL are
|
|
11
|
+
// the ROM's, and no events (GOLDFIELDS, the boot)
|
|
12
|
+
//
|
|
13
|
+
// Numbers are JavaScript's; a whole-number variable (%) floors what is stored, as BASIC does, and
|
|
14
|
+
// BASIC's errors stop the remake with BASIC's own words. The rules for numbers (FOUT, VAL, RND,
|
|
15
|
+
// the errors) are BASIC's, kept once for every layer in numbers.js.
|
|
16
|
+
|
|
17
|
+
import { fail, int16, fout, val, scanNumber, seedRandom, nextRandom, BasicError } from './numbers.js';
|
|
18
|
+
|
|
19
|
+
export class Basic {
|
|
20
|
+
constructor(e, { data = 0, quiet = false } = {}) {
|
|
21
|
+
this.e = e; this.data = this.dataStart = data; this.quiet = quiet;
|
|
22
|
+
this.v = Object.create(null); this.arrays = Object.create(null); this.stack = [];
|
|
23
|
+
this.channel = 0;
|
|
24
|
+
}
|
|
25
|
+
// where the operation is that may fail next, as Blitz!'s program counter has it then: BASIC's
|
|
26
|
+
// error gives it as its line (the engine's `error`)
|
|
27
|
+
here(at) { this.at = at; }
|
|
28
|
+
// a variable's value, 0 or "" until it is set
|
|
29
|
+
get(n) { return this.v[n] ?? (n.endsWith('$') ? '' : 0); }
|
|
30
|
+
set(n, x) {
|
|
31
|
+
if (n.endsWith('$')) { if (typeof x !== 'string') fail('TYPE MISMATCH'); if (x.length > 255) fail('STRING TOO LONG'); this.v[n] = x; return; }
|
|
32
|
+
if (typeof x !== 'number') fail('TYPE MISMATCH');
|
|
33
|
+
this.v[n] = n.endsWith('%') ? int16(x) : x;
|
|
34
|
+
}
|
|
35
|
+
clr() { this.v = Object.create(null); this.arrays = Object.create(null); this.stack = []; this.fns = null; }
|
|
36
|
+
// RUN: every file closed (the KERNAL's CLALL), the variables cleared and DATA from its start,
|
|
37
|
+
// the program run again to its DIMs and on from `back`
|
|
38
|
+
run(back) { const m = this.e.mem; m[0x98] = 0; m[0x99] = 0; m[0x9a] = 3; this.clr(); this.data = this.dataStart; this.datum = 0; this.resume = back; }
|
|
39
|
+
|
|
40
|
+
// arrays: DIM's sizes are the last index of each dimension
|
|
41
|
+
dim(n, sizes) { if (this.arrays[n]) fail('REDIM\'D ARRAY'); const dims = sizes.map(s => int16(s) + 1), size = dims.reduce((a, b) => a * b, 1); this.arrays[n] = { dims, data: new Array(size).fill(n.endsWith('$') ? '' : 0) }; }
|
|
42
|
+
place(n, i) {
|
|
43
|
+
const a = this.arrays[n] ?? (this.dim(n, i.map(() => 10)), this.arrays[n]);
|
|
44
|
+
if (i.length !== a.dims.length) fail('BAD SUBSCRIPT');
|
|
45
|
+
let k = 0; for (let d = 0; d < i.length; d++) { const x = int16(i[d]); if (x < 0 || x >= a.dims[d]) fail('BAD SUBSCRIPT'); k = k * a.dims[d] + x; }
|
|
46
|
+
return [a, k];
|
|
47
|
+
}
|
|
48
|
+
elem(n, i) { const [a, k] = this.place(n, i); return a.data[k]; }
|
|
49
|
+
setElem(n, i, x) {
|
|
50
|
+
const [a, k] = this.place(n, i);
|
|
51
|
+
if (n.endsWith('$') !== (typeof x === 'string')) fail('TYPE MISMATCH');
|
|
52
|
+
a.data[k] = n.endsWith('%') ? int16(x) : x;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// operators: comparisons give -1 or 0, AND, OR and NOT work on whole numbers of 16 bits
|
|
56
|
+
add(a, b) { if (typeof a !== typeof b) fail('TYPE MISMATCH'); if (typeof a === 'string' && a.length + b.length > 255) fail('STRING TOO LONG'); return a + b; }
|
|
57
|
+
num(a) { if (typeof a !== 'number') fail('TYPE MISMATCH'); return a; }
|
|
58
|
+
cmp(a, b, o) {
|
|
59
|
+
if (typeof a !== typeof b) fail('TYPE MISMATCH');
|
|
60
|
+
const c = a < b ? -1 : a > b ? 1 : 0;
|
|
61
|
+
return ({ '=': c === 0, '<>': c !== 0, '<': c < 0, '>': c > 0, '<=': c <= 0, '>=': c >= 0 })[o] ? -1 : 0;
|
|
62
|
+
}
|
|
63
|
+
and(a, b) { return int16(this.num(a)) & int16(this.num(b)); }
|
|
64
|
+
or(a, b) { return int16(this.num(a)) | int16(this.num(b)); }
|
|
65
|
+
not(a) { return ~int16(this.num(a)); }
|
|
66
|
+
div(a, b) { if (this.num(b) === 0) fail('DIVISION BY ZERO'); return this.num(a) / b; }
|
|
67
|
+
|
|
68
|
+
pow(a, b) { a = this.num(a); b = this.num(b); if (a < 0 && b !== Math.floor(b)) fail('ILLEGAL QUANTITY'); return a ** b; }
|
|
69
|
+
|
|
70
|
+
// functions
|
|
71
|
+
int(x) { return Math.floor(this.num(x)); }
|
|
72
|
+
abs(x) { return Math.abs(this.num(x)); }
|
|
73
|
+
sqr(x) { if (this.num(x) < 0) fail('ILLEGAL QUANTITY'); return Math.sqrt(x); }
|
|
74
|
+
log(x) { if (this.num(x) <= 0) fail('ILLEGAL QUANTITY'); return Math.log(x); }
|
|
75
|
+
exp(x) { return Math.exp(this.num(x)); }
|
|
76
|
+
sin(x) { return Math.sin(this.num(x)); }
|
|
77
|
+
cos(x) { return Math.cos(this.num(x)); }
|
|
78
|
+
tan(x) { return Math.tan(this.num(x)); }
|
|
79
|
+
atn(x) { return Math.atan(this.num(x)); }
|
|
80
|
+
len(s) { if (typeof s !== 'string') fail('TYPE MISMATCH'); return s.length; }
|
|
81
|
+
val(s) { if (typeof s !== 'string') fail('TYPE MISMATCH'); return val(s); }
|
|
82
|
+
rnd() { return this.random(); }
|
|
83
|
+
fre() { return 0; }
|
|
84
|
+
usr() { fail('UNDEF\'D FUNCTION'); }
|
|
85
|
+
sgn(x) { return Math.sign(this.num(x)); }
|
|
86
|
+
chr(x) { const n = int16(x); if (n < 0 || n > 255) fail('ILLEGAL QUANTITY'); return String.fromCharCode(n); }
|
|
87
|
+
asc(s) { if (!s.length) fail('ILLEGAL QUANTITY'); return s.charCodeAt(0); }
|
|
88
|
+
str(x) { return fout(this.num(x)); }
|
|
89
|
+
left(s, n) { n = int16(n); if (n < 0 || n > 255) fail('ILLEGAL QUANTITY'); return s.slice(0, n); }
|
|
90
|
+
right(s, n) { n = int16(n); if (n < 0 || n > 255) fail('ILLEGAL QUANTITY'); return n ? s.slice(-n) : ''; }
|
|
91
|
+
mid(s, from, n = 255) { from = int16(from); n = int16(n); if (from < 1 || from > 255 || n < 0 || n > 255) fail('ILLEGAL QUANTITY'); return s.substr(from - 1, n); }
|
|
92
|
+
peek(a) { a = Math.floor(this.num(a)); if (a < 0 || a > 0xffff) fail('ILLEGAL QUANTITY'); return this.e.read(a); }
|
|
93
|
+
// RND: the seed's numbers, the same on the original (basic-rom.js's RND draws them too)
|
|
94
|
+
// (kept on the engine, one run of them for the boot and the game, as the ROM's is one)
|
|
95
|
+
random() { const e = this.e; e.randomState = nextRandom(e.randomState ?? seedRandom(e.title.seed ?? 0x11)); return e.randomState / 2 ** 32; }
|
|
96
|
+
// TI, the clock at $A0 to $A2; TI$ as its hours, minutes and seconds
|
|
97
|
+
// (a program's read of the clock takes the interrupts first, as a poll of GETIN does, so that a
|
|
98
|
+
// wait it counts out moves on, the same on the original)
|
|
99
|
+
// (but only every so many reads, the title's `reads`, and not after a poll of GETIN has taken
|
|
100
|
+
// them since the last read)
|
|
101
|
+
ti() {
|
|
102
|
+
const e = this.e, m = e.mem;
|
|
103
|
+
if (!e.clock && !this.quiet) {
|
|
104
|
+
if (e.polledSince) { e.polledSince = false; e.reads = 0; }
|
|
105
|
+
else if ((e.reads = (e.reads ?? 0) + 1) >= (e.routines.reads ?? 1)) { e.reads = 0; e.routines.poll(e); }
|
|
106
|
+
}
|
|
107
|
+
return m[0xa0] * 65536 + m[0xa1] * 256 + m[0xa2];
|
|
108
|
+
}
|
|
109
|
+
tis() { const t = Math.floor(this.ti() / 60); return [Math.floor(t / 3600) % 24, Math.floor(t / 60) % 60, t % 60].map(n => String(n).padStart(2, '0')).join(''); }
|
|
110
|
+
// TI$ set: through the KERNAL's SETTIM, as the original sets it (A the clock's low byte, Y its high)
|
|
111
|
+
setTis(s) { if (!/^\d{6}$/.test(s)) fail('ILLEGAL QUANTITY'); const t = ((+s.slice(0, 2) * 60 + +s.slice(2, 4)) * 60 + +s.slice(4)) * 60, e = this.e; e.a = t & 0xff; e.x = t >> 8 & 0xff; e.y = t >> 16 & 0xff; this.call('settim'); }
|
|
112
|
+
st() { return this.e.mem[0x90]; }
|
|
113
|
+
poke(a, v) { a = Math.floor(this.num(a)); v = Math.floor(this.num(v)); if (a < 0 || a > 0xffff || v < 0 || v > 255) fail('ILLEGAL QUANTITY'); this.e.write(a, v); }
|
|
114
|
+
|
|
115
|
+
// the time a statement took on the original, in its cycles, for the page's clock
|
|
116
|
+
cost(n) { const e = this.e; e.cycles += n; e.delay += n / 1022.727; }
|
|
117
|
+
// SYS, and the statements that go through the KERNAL's files: the engine's
|
|
118
|
+
sys(at, text, step) { return this.e.routines.sys(this.e, Math.floor(this.num(at)), text, step); }
|
|
119
|
+
undef() { fail('UNDEF\'D STATEMENT'); }
|
|
120
|
+
// READ from DATA in BASIC's own text (`items`, basic-lang.js `dataItems`): the next item, a
|
|
121
|
+
// string as it is, a number as FIN reads it, anything else in it a SYNTAX ERROR IN the DATA's
|
|
122
|
+
// line, as BASIC gives it; RESTORE, from the first again
|
|
123
|
+
readText(string) {
|
|
124
|
+
const item = this.items[this.datum ?? 0];
|
|
125
|
+
if (!item) fail('OUT OF DATA');
|
|
126
|
+
this.datum = (this.datum ?? 0) + 1;
|
|
127
|
+
if (string) return item.text;
|
|
128
|
+
const t = item.text.replace(/ /g, '');
|
|
129
|
+
if (item.quoted || !/^[+-]?(\d+\.?\d*|\.\d*)?(E[+-]?\d*)?$/.test(t)) { const err = new BasicError('?SYNTAX ERROR'); err.basicLine = item.line; throw err; }
|
|
130
|
+
return val(t);
|
|
131
|
+
}
|
|
132
|
+
restore() { this.datum = 0; this.data = this.dataStart; }
|
|
133
|
+
// DEF FN and FN: a function of one variable, which while it is worked out holds the argument,
|
|
134
|
+
// and then its own value again, as BASIC's FN does; one not defined, UNDEF'D FUNCTION; DEF only
|
|
135
|
+
// in a program, not typed at once (ILLEGAL DIRECT)
|
|
136
|
+
def(n, p, f) { if (this.e.mem[0x3a] === 0xff) fail('ILLEGAL DIRECT'); (this.fns ??= Object.create(null))[n] = { p, f }; }
|
|
137
|
+
fn(n, x) {
|
|
138
|
+
const d = this.fns?.[n];
|
|
139
|
+
if (!d) fail('UNDEF\'D FUNCTION');
|
|
140
|
+
const was = this.v[d.p], arg = this.num(x);
|
|
141
|
+
this.set(d.p, arg);
|
|
142
|
+
try { return d.f(this); } finally { if (was === undefined) delete this.v[d.p]; else this.v[d.p] = was; }
|
|
143
|
+
}
|
|
144
|
+
failWith(what) { fail(what); }
|
|
145
|
+
statement(k, args, into, step) {
|
|
146
|
+
const f = this.e.routines['basic-' + k];
|
|
147
|
+
if (!f) throw new Error(k.toUpperCase() + ' is not handled');
|
|
148
|
+
return f(this.e, args, into, step);
|
|
149
|
+
}
|
|
150
|
+
pos() { return this.e.mem[0xd3] % 40; }
|
|
151
|
+
|
|
152
|
+
// FOR and GOSUB on one stack, as BASIC keeps them: a FOR of a variable already looping drops
|
|
153
|
+
// that loop and what is above it; NEXT drops the loops above its own; RETURN, everything above
|
|
154
|
+
// its GOSUB
|
|
155
|
+
for(n, to, step, next) {
|
|
156
|
+
const k = this.stack.findLastIndex(f => f.for === n);
|
|
157
|
+
if (k >= 0) this.stack.length = k;
|
|
158
|
+
this.stack.push({ for: n, to: this.num(to), step: this.num(step), next });
|
|
159
|
+
}
|
|
160
|
+
next(n) {
|
|
161
|
+
const k = n ? this.stack.findLastIndex(f => f.for === n) : this.stack.findLastIndex(f => f.for !== undefined);
|
|
162
|
+
if (k < 0 || (n === null && this.stack.slice(k + 1).some(f => f.gosub !== undefined))) fail('NEXT WITHOUT FOR');
|
|
163
|
+
this.stack.length = k + 1;
|
|
164
|
+
const f = this.stack[k], x = this.get(f.for) + f.step;
|
|
165
|
+
this.set(f.for, x);
|
|
166
|
+
if (f.step >= 0 ? x <= f.to : x >= f.to) return f.next;
|
|
167
|
+
this.stack.length = k;
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
gosub(back) { this.stack.push({ gosub: back }); }
|
|
171
|
+
ret() { const k = this.stack.findLastIndex(f => f.gosub !== undefined); if (k < 0) fail('RETURN WITHOUT GOSUB'); const back = this.stack[k].gosub; this.stack.length = k; return back; }
|
|
172
|
+
on(x) { const k = Math.floor(this.num(x)); if (k < 0 || k > 255) fail('ILLEGAL QUANTITY'); return k; }
|
|
173
|
+
|
|
174
|
+
// READ, from DATA as Blitz! keeps it
|
|
175
|
+
read(string) {
|
|
176
|
+
if (this.items) return this.readText(string);
|
|
177
|
+
const m = this.e.mem, n = m[this.data];
|
|
178
|
+
if (n & 0x80) fail('OUT OF DATA');
|
|
179
|
+
const s = String.fromCharCode(...m.subarray(this.data + 1, this.data + 1 + n));
|
|
180
|
+
this.data += 1 + n;
|
|
181
|
+
return string ? s : val(s);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// the KERNAL's calls, through the engine: CHROUT a byte at a time, GETIN
|
|
185
|
+
out(ch) { const e = this.e; e.a = ch; this.call('chrout'); }
|
|
186
|
+
call(name) { const e = this.e; if (this.quiet) e.inside++; try { e.routines[name](e, [], null); } finally { if (this.quiet) e.inside--; } }
|
|
187
|
+
// PRINT: a number written as FOUT writes it, then a step right on the screen or a space to a
|
|
188
|
+
// file; a string as it is
|
|
189
|
+
print(x) {
|
|
190
|
+
if (typeof x === 'number') { for (const c of fout(x)) this.out(c.charCodeAt(0)); this.out(this.e.mem[0x13] ? 0x20 : 0x1d); }
|
|
191
|
+
else for (let k = 0; k < x.length; k++) this.out(x.charCodeAt(k));
|
|
192
|
+
}
|
|
193
|
+
// a new line: a carriage return, and a line feed after it to a file numbered from 128
|
|
194
|
+
newline() { this.out(0x0d); if (this.e.mem[0x13] & 0x80) this.out(0x0a); }
|
|
195
|
+
|
|
196
|
+
// files, through the KERNAL: OPEN's parameters (the file, the device, 1 if not given, the
|
|
197
|
+
// secondary address, 0, or 255 below device 3, and the name) as BASIC sets them, then OPEN;
|
|
198
|
+
// CLOSE; PRINT# and INPUT#, the output's or the input's file, which $13 notes for PRINT
|
|
199
|
+
open([la, fa = 1, sa, name = '']) {
|
|
200
|
+
const m = this.e.mem;
|
|
201
|
+
m[0xb8] = int16(la) & 0xff; m[0xba] = int16(fa) & 0xff; m[0xb9] = sa === undefined ? (m[0xba] < 3 ? 0xff : 0) : int16(sa) & 0xff;
|
|
202
|
+
// (a name through SETNAM, as Blitz!'s OPEN gives it, from a place of the remake's own)
|
|
203
|
+
for (let k = 0; k < name.length; k++) m[0x200 + k] = name.charCodeAt(k);
|
|
204
|
+
if (name.length) { Object.assign(this.e, { a: name.length, x: 0x00, y: 0x02 }); this.call('setnam'); }
|
|
205
|
+
else m[0xb7] = 0;
|
|
206
|
+
this.call('open');
|
|
207
|
+
if (this.e.c) fail(['', 'TOO MANY FILES', 'FILE OPEN', 'FILE NOT OPEN'][this.e.a] ?? 'DEVICE NOT PRESENT');
|
|
208
|
+
}
|
|
209
|
+
close(la) { this.e.a = int16(la) & 0xff; this.call('close'); }
|
|
210
|
+
output(n) { this.e.mem[0x13] = int16(n) & 0xff; this.e.x = this.e.mem[0x13]; this.call('chkout'); }
|
|
211
|
+
input(n) { this.e.mem[0x13] = int16(n) & 0xff; this.e.x = this.e.mem[0x13]; this.call('chkin'); this.line = null; }
|
|
212
|
+
clrchn() { this.call('clrchn'); this.e.mem[0x13] = 0; }
|
|
213
|
+
// WAIT: whether the place's bits, flipped by `xor`, meet the mask yet, as the ROM's loop reads
|
|
214
|
+
// them ($B840); a key pressed on the page goes into the KERNAL's buffer meanwhile, as the
|
|
215
|
+
// keyboard puts it there
|
|
216
|
+
wait(at, mask, xor = 0) {
|
|
217
|
+
const e = this.e, m = e.mem;
|
|
218
|
+
if (!m[0xc6] && e.keys.length) { const k = e.keys.shift(); if (k >= 0) { m[0x277] = k; m[0xc6] = 1; } }
|
|
219
|
+
return ((this.peek(at) ^ int16(xor)) & int16(mask)) !== 0;
|
|
220
|
+
}
|
|
221
|
+
// INPUT from a file (Microsoft's GETAGN and INLOOP): a line read with CHRIN to its carriage
|
|
222
|
+
// return, the first an INPUT# reads read again while it is empty, a later one empty its items
|
|
223
|
+
// empty; a read timed out (ST bit 1), the INPUT given up, its variables as they were
|
|
224
|
+
// (`given up`); then its items, each to a comma: a string quoted to its quote, or to a comma
|
|
225
|
+
// or a colon; a number as FIN reads it, a comma, a colon or the line's end after it, or
|
|
226
|
+
// ?FILE DATA ERROR
|
|
227
|
+
readLine(first) {
|
|
228
|
+
for (;;) {
|
|
229
|
+
let s = '';
|
|
230
|
+
for (;;) { this.call('chrin'); if (this.e.a === 0x0d) break; s += String.fromCharCode(this.e.a); if (s.length > 88) fail('STRING TOO LONG'); }
|
|
231
|
+
if (this.e.mem[0x90] & 2) { this.givenUp = true; return ''; }
|
|
232
|
+
if (s.length || !first) return s;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
item(string, first) {
|
|
236
|
+
if (first) this.givenUp = false;
|
|
237
|
+
if (first || this.line === null || this.linePos >= this.line.length) { this.line = this.readLine(first); this.linePos = 0; }
|
|
238
|
+
if (this.givenUp) return undefined;
|
|
239
|
+
const s = this.line; let k = this.linePos;
|
|
240
|
+
while (s[k] === ' ') k++;
|
|
241
|
+
let v;
|
|
242
|
+
if (string && s[k] === '"') { const end = s.indexOf('"', k + 1); v = s.slice(k + 1, end < 0 ? s.length : end); k = end < 0 ? s.length : end + 1; while (s[k] === ' ') k++; }
|
|
243
|
+
else if (string) { let end = k; while (end < s.length && s[end] !== ',' && s[end] !== ':') end++; v = s.slice(k, end); k = end; }
|
|
244
|
+
else { const n = scanNumber(s, k); v = n.text; k = n.end; }
|
|
245
|
+
if (k < s.length && s[k] !== ',' && s[k] !== ':') fail('FILE DATA');
|
|
246
|
+
if (s[k] === ',') k++;
|
|
247
|
+
this.linePos = k;
|
|
248
|
+
return string ? v : val(v);
|
|
249
|
+
}
|
|
250
|
+
// the end of an INPUT: from a file, the input back to the keyboard (Blitz!'s $1AAA)
|
|
251
|
+
inputDone() {
|
|
252
|
+
if (this.e.mem[0x13]) { this.call('clrchn'); this.e.mem[0x13] = 0; return; }
|
|
253
|
+
throw new Error('INPUT from the keyboard is not handled');
|
|
254
|
+
}
|
|
255
|
+
// GET#'s character: GETIN from the file CHKIN chose, "" for none
|
|
256
|
+
getFrom() { this.call('getin'); return this.e.a ? String.fromCharCode(this.e.a) : ''; }
|
|
257
|
+
// `,`: on to the next column of ten
|
|
258
|
+
comma() { const c = this.e.mem[0xd3] % 40; let n = 10 - c % 10; while (n--) this.out(this.e.mem[0x13] ? 0x20 : 0x1d); }
|
|
259
|
+
spc(n) { n = int16(n); if (n < 0 || n > 255) fail('ILLEGAL QUANTITY'); while (n-- > 0) this.out(this.e.mem[0x13] ? 0x20 : 0x1d); }
|
|
260
|
+
tab(n) { n = int16(n); if (n < 0 || n > 255) fail('ILLEGAL QUANTITY'); let k = n - this.e.mem[0xd3] % 40; while (k-- > 0) this.out(0x1d); }
|
|
261
|
+
// GET: the key from GETIN, or "" with none; Blitz!'s calls CLRCHN after each, as its handler
|
|
262
|
+
// does ($16CD), and BASIC's, in the ROM, does not
|
|
263
|
+
// (the run stops for a key as GETIN returns, as the original does, its CLRCHN still to come:
|
|
264
|
+
// null, and the step runs again for the rest)
|
|
265
|
+
key() {
|
|
266
|
+
if (this.got === undefined) {
|
|
267
|
+
this.e.mem[0x13] = 0; this.call('getin'); this.got = this.e.a;
|
|
268
|
+
if (this.e.polled && !this.quiet) return null;
|
|
269
|
+
}
|
|
270
|
+
const k = this.got; this.got = undefined;
|
|
271
|
+
if (!this.quiet) this.call('clrchn');
|
|
272
|
+
return k ? String.fromCharCode(k) : '';
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
package/browser.js
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// A Commodore 64 (machine.js) in a browser page: the keys as the C64's matrix, the arrows as the
|
|
2
|
+
// joystick and the cursor keys, Escape as RUN/STOP, Control the Commodore key (and fire), Tab
|
|
3
|
+
// CTRL; a gamepad (an 8BitDo, or any in the browser's standard mapping) as the joystick too; the
|
|
4
|
+
// frame painted on a canvas doubled; and the machine run 50 frames a second, keeping up with the
|
|
5
|
+
// clock, two frames at most at a time.
|
|
6
|
+
//
|
|
7
|
+
// const keys = keyboard(m, { ignore, onKey, log }) ignore(e): a key the page keeps (a pane's)
|
|
8
|
+
// keys.press(column, row, frames) a key held for so many frames (RUN/STOP)
|
|
9
|
+
// const draw = painter(canvas) draw(m.vic.frame)
|
|
10
|
+
// pace(m, after) the frames run; after() each animation frame
|
|
11
|
+
import { MATRIX, SHIFTED, JOYSTICK, CURSOR, MODIFIERS } from './keys.js';
|
|
12
|
+
import { PALETTE, WIDTH, HEIGHT } from './vic.js';
|
|
13
|
+
|
|
14
|
+
const PAD_DEAD = 0.4; // a stick pushed less far than this is at rest (the dead zone)
|
|
15
|
+
|
|
16
|
+
export function keyboard(m, { ignore = () => false, onKey = () => {}, log = false } = {}) {
|
|
17
|
+
// (a letter by the key it is, not the character the browser makes of it: in the C64's first
|
|
18
|
+
// character set, capitals and graphics, a letter is a letter, Shift or Caps Lock or not, and
|
|
19
|
+
// Option with it is its graphic, the C64's shifted letter; in the second, small letters and
|
|
20
|
+
// capitals, Shift is a capital, as on the C64)
|
|
21
|
+
const upper = () => !((m.vic?.r[0x18] ?? 0x15) & 2);
|
|
22
|
+
// (a key let go within two frames of going down is held to the end of them: the KERNAL scans
|
|
23
|
+
// the keyboard once a frame, and a tap shorter than that, which a real key never is, would
|
|
24
|
+
// never be seen)
|
|
25
|
+
const down = new Map(), held = [], pressed = new Map(), withCbm = new Set();
|
|
26
|
+
let frame = 0;
|
|
27
|
+
const release = (key, shift) => {
|
|
28
|
+
const since = frame - (pressed.get(key) ?? -9);
|
|
29
|
+
if (since >= 2) { m.key(key[0], key[1], false); if (shift) m.key(1, 7, false); return; }
|
|
30
|
+
held.push([key[0], key[1], 2 - since]); if (shift) held.push([1, 7, 2 - since]);
|
|
31
|
+
};
|
|
32
|
+
window.addEventListener('keydown', e => {
|
|
33
|
+
if (ignore(e)) return;
|
|
34
|
+
onKey(e);
|
|
35
|
+
if (e.metaKey || e.repeat) return;
|
|
36
|
+
if (MODIFIERS[e.key]) { m.key(...MODIFIERS[e.key], true); e.preventDefault(); }
|
|
37
|
+
if (JOYSTICK[e.key]) { m.joystick |= JOYSTICK[e.key]; const cur = CURSOR[e.key]; if (cur) { if (cur[2]) m.key(1, 7, true); m.key(cur[0], cur[1], true); } e.preventDefault(); return; }
|
|
38
|
+
const letter = /^Key([A-Z])$/.exec(e.code)?.[1]?.toLowerCase();
|
|
39
|
+
const shifted = letter ? null : SHIFTED[e.key], key = letter ? MATRIX[letter] : shifted ? MATRIX[shifted] : MATRIX[e.key] || MATRIX[e.key.toLowerCase()];
|
|
40
|
+
if (!key) { if (MODIFIERS[e.key]) e.preventDefault(); return; }
|
|
41
|
+
// (the left shift: a letter's graphic, a shifted sign, and shift with a function key, F2, F4, F6
|
|
42
|
+
// and F8 being shift with F1, F3, F5 and F7 on the 64)
|
|
43
|
+
if (letter ? (upper() ? e.altKey : e.shiftKey) : shifted || (e.key.length === 1 && e.key !== e.key.toLowerCase() && !/[0-9]/.test(e.key)) || (e.shiftKey && /^F[1357]$/.test(e.key))) m.key(1, 7, true);
|
|
44
|
+
// (Control held, as the event says, the Commodore key down with the key, if Control's own
|
|
45
|
+
// keydown has not put it there, and let go with it)
|
|
46
|
+
const [cc, cr] = MODIFIERS.Control, cbm = e.ctrlKey && !(m.keys[cc] & 1 << cr) ? MODIFIERS.Control : null;
|
|
47
|
+
if (cbm) { m.key(...cbm, true); pressed.set(cbm, frame); }
|
|
48
|
+
m.key(key[0], key[1], true); down.set(e.code, key); pressed.set(key, frame); if (cbm) withCbm.add(e.code);
|
|
49
|
+
if (log) console.log('key', JSON.stringify(e.key), '-> column', key[0], 'row', key[1]);
|
|
50
|
+
e.preventDefault();
|
|
51
|
+
});
|
|
52
|
+
window.addEventListener('keyup', e => {
|
|
53
|
+
if (MODIFIERS[e.key]) m.key(...MODIFIERS[e.key], false);
|
|
54
|
+
if (JOYSTICK[e.key]) { m.joystick &= ~JOYSTICK[e.key]; const cur = CURSOR[e.key]; if (cur) { m.key(cur[0], cur[1], false); if (cur[2]) m.key(1, 7, false); } return; }
|
|
55
|
+
const key = down.get(e.code);
|
|
56
|
+
if (key) { release(key, true); if (withCbm.delete(e.code)) release(MODIFIERS.Control, false); down.delete(e.code); } else m.key(1, 7, false);
|
|
57
|
+
});
|
|
58
|
+
// the page left (another window, another tab, Cmd-Tab): the browser sends no keyup for what was
|
|
59
|
+
// held, so everything is let go, the keys, the joystick and fire, or a key (CTRL by Tab, Shift's
|
|
60
|
+
// fire) would stay down and every key after it be read wrongly
|
|
61
|
+
const letGo = () => { m.keys.fill(0); m.joystick = 0; down.clear(); held.length = 0; pressed.clear(); withCbm.clear(); padBits = 0; padStop = false; };
|
|
62
|
+
// a gamepad, read each frame in the browser's standard mapping: the D-pad or the left stick
|
|
63
|
+
// (pushed past PAD_DEAD) the joystick in port 2, A or B its fire, Start RUN/STOP; added to what
|
|
64
|
+
// the keys hold, and let go with the pad
|
|
65
|
+
let padBits = 0, padStop = false;
|
|
66
|
+
const readPads = () => {
|
|
67
|
+
if (typeof navigator === 'undefined' || typeof navigator.getGamepads !== 'function') return;
|
|
68
|
+
let bits = 0, stop = false;
|
|
69
|
+
for (const p of navigator.getGamepads()) {
|
|
70
|
+
if (!p || !p.connected) continue;
|
|
71
|
+
const b = i => !!p.buttons[i]?.pressed, x = p.axes[0] ?? 0, y = p.axes[1] ?? 0;
|
|
72
|
+
if (b(12) || y < -PAD_DEAD) bits |= 1;
|
|
73
|
+
if (b(13) || y > PAD_DEAD) bits |= 2;
|
|
74
|
+
if (b(14) || x < -PAD_DEAD) bits |= 4;
|
|
75
|
+
if (b(15) || x > PAD_DEAD) bits |= 8;
|
|
76
|
+
if (b(0) || b(1)) bits |= 16;
|
|
77
|
+
if (b(9)) stop = true;
|
|
78
|
+
}
|
|
79
|
+
m.joystick = (m.joystick & ~padBits) | bits; padBits = bits;
|
|
80
|
+
if (stop !== padStop) { m.key(7, 7, stop); padStop = stop; }
|
|
81
|
+
};
|
|
82
|
+
window.addEventListener('blur', letGo);
|
|
83
|
+
document.addEventListener('visibilitychange', () => { if (document.hidden) letGo(); });
|
|
84
|
+
return {
|
|
85
|
+
// a key held for so many frames, then let go (frame() lets them go)
|
|
86
|
+
press(column, row, frames = 3) { m.key(column, row, true); held.push([column, row, frames]); },
|
|
87
|
+
frame() { frame++; readPads(); for (let i = held.length - 1; i >= 0; i--) if (--held[i][2] <= 0) { m.key(held[i][0], held[i][1], false); held.splice(i, 1); } },
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function painter(canvas) {
|
|
92
|
+
const ctx = canvas.getContext('2d');
|
|
93
|
+
canvas.width = WIDTH * 2; canvas.height = HEIGHT * 2;
|
|
94
|
+
const rgba = new Uint8ClampedArray(WIDTH * 2 * HEIGHT * 2 * 4), image = new ImageData(rgba, WIDTH * 2, HEIGHT * 2);
|
|
95
|
+
return f => {
|
|
96
|
+
for (let y = 0; y < HEIGHT; y++) for (let x = 0; x < WIDTH; x++) {
|
|
97
|
+
const [r, g, b] = PALETTE[f[y * WIDTH + x]];
|
|
98
|
+
for (let dy = 0; dy < 2; dy++) { let o = (((y * 2 + dy) * WIDTH * 2) + x * 2) * 4; for (let dx = 0; dx < 2; dx++) { rgba[o++] = r; rgba[o++] = g; rgba[o++] = b; rgba[o++] = 255; } }
|
|
99
|
+
}
|
|
100
|
+
ctx.putImageData(image, 0, 0);
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// the C64's 50 frames a second, kept up with the clock (two at most at a time, so a tab left in
|
|
105
|
+
// the background does not run on and on when it comes back); each(frame) after each one run
|
|
106
|
+
export function pace(m, after, each = () => {}) {
|
|
107
|
+
let frames = 0;
|
|
108
|
+
const started = performance.now();
|
|
109
|
+
function tick() {
|
|
110
|
+
const due = Math.min(2, Math.floor((performance.now() - started) / 20) - frames);
|
|
111
|
+
for (let i = 0; i < due; i++) { m.runFrame(); frames++; each(frames); }
|
|
112
|
+
after(frames);
|
|
113
|
+
requestAnimationFrame(tick);
|
|
114
|
+
}
|
|
115
|
+
tick();
|
|
116
|
+
}
|
package/d64.js
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
// Writing a Commodore 1541 disk image, a .d64, as the 1541 itself would leave it, so that it loads
|
|
2
|
+
// on Moonbase's C64, in VICE and on a real 64 (by an SD2IEC or the like). 35 tracks of 256-byte
|
|
3
|
+
// sectors, 21 a track on the outer tracks and fewer further in, 174,848 bytes. Track 18 holds the
|
|
4
|
+
// BAM and the disk's name in sector 0 and the directory from sector 1; each file is a chain of
|
|
5
|
+
// sectors, each starting with the track and sector of the next, or 0 and the index of its last
|
|
6
|
+
// byte. Files are laid down from track 17 outward and from 19 inward, as the 1541 does, ten
|
|
7
|
+
// sectors apart on a track, the directory three apart.
|
|
8
|
+
//
|
|
9
|
+
// make({ name: 'CRAYON', id: 'MB', files: [{ name: 'CRAYON', data, type: 'PRG' }] })
|
|
10
|
+
// data the file's bytes; for a program, its load address first, as a .prg has it
|
|
11
|
+
// a Uint8Array of 174,848 bytes
|
|
12
|
+
// add(image, files) files added to a disk that has some, theirs left where they are (below)
|
|
13
|
+
// read(image) a disk read: { name, id, files: [{ name, type, locked, closed, blocks, body, address and raw
|
|
14
|
+
// (a program: its load address, and its bytes with it) }] }; each body a Uint8Array
|
|
15
|
+
// petsciiName(bytes) a name as the 1541 keeps it, as text: shifted letters $C1 to $DA as capitals, the $A0
|
|
16
|
+
// padding gone
|
|
17
|
+
export const SECTORS = [...Array(17).fill(21), ...Array(7).fill(19), ...Array(6).fill(18), ...Array(5).fill(17)];
|
|
18
|
+
const TYPES = { DEL: 0, SEQ: 1, PRG: 2, USR: 3, REL: 4 };
|
|
19
|
+
const offset = (t, s) => (SECTORS.slice(0, t - 1).reduce((a, b) => a + b, 0) + s) * 256;
|
|
20
|
+
|
|
21
|
+
// a name as the 1541 keeps it: 16 bytes of PETSCII, the letters as capitals, padded with $A0
|
|
22
|
+
const name16 = text => {
|
|
23
|
+
const out = new Uint8Array(16).fill(0xa0);
|
|
24
|
+
[...String(text).toUpperCase()].slice(0, 16).forEach((c, i) => { out[i] = c.charCodeAt(0) & 0xff; });
|
|
25
|
+
return out;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export function make({ name = 'MOONBASE', id = 'MB', files = [] } = {}) {
|
|
29
|
+
const image = new Uint8Array(174848), used = SECTORS.map(n => new Array(n).fill(false));
|
|
30
|
+
const take = (t, s) => { used[t - 1][s] = true; return offset(t, s); };
|
|
31
|
+
take(18, 0);
|
|
32
|
+
|
|
33
|
+
// the sectors for a file: the tracks nearest the directory first, ten sectors apart on each
|
|
34
|
+
const ORDER = [];
|
|
35
|
+
for (let d = 1; d <= 17; d++) { if (18 - d >= 1) ORDER.push(18 - d); if (18 + d <= 35) ORDER.push(18 + d); }
|
|
36
|
+
const free = () => {
|
|
37
|
+
for (const t of ORDER) {
|
|
38
|
+
const n = SECTORS[t - 1], start = used[t - 1].lastTaken ?? -10;
|
|
39
|
+
for (let k = 0; k < n; k++) { const s = (start + 10 + k) % n; if (!used[t - 1][s]) { used[t - 1][s] = true; used[t - 1].lastTaken = s; return [t, s]; } }
|
|
40
|
+
}
|
|
41
|
+
throw new Error('the disk is full');
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
// the files' bodies, sector by sector
|
|
45
|
+
const entries = files.map(f => {
|
|
46
|
+
const data = f.data instanceof Uint8Array ? f.data : Uint8Array.from(f.data);
|
|
47
|
+
const chunks = Math.max(1, Math.ceil(data.length / 254)), places = Array.from({ length: chunks }, free);
|
|
48
|
+
places.forEach(([t, s], i) => {
|
|
49
|
+
const at = take(t, s), part = data.subarray(i * 254, (i + 1) * 254);
|
|
50
|
+
if (i + 1 < chunks) { image[at] = places[i + 1][0]; image[at + 1] = places[i + 1][1]; }
|
|
51
|
+
else { image[at] = 0; image[at + 1] = part.length + 1; }
|
|
52
|
+
image.set(part, at + 2);
|
|
53
|
+
});
|
|
54
|
+
return { name: name16(f.name), type: 0x80 | (TYPES[f.type ?? 'PRG'] ?? 2), first: places[0], blocks: chunks };
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// the directory: eight entries a sector, on track 18 from sector 1, three apart
|
|
58
|
+
const dirSectors = [];
|
|
59
|
+
for (let i = 0, s = 1; i < Math.max(1, Math.ceil(entries.length / 8)); i++, s = (s + 3) % 19) {
|
|
60
|
+
while (used[17][s]) s = (s + 1) % 19;
|
|
61
|
+
if (dirSectors.length >= 18) throw new Error('the directory is full: 144 files');
|
|
62
|
+
take(18, s); dirSectors.push(s);
|
|
63
|
+
}
|
|
64
|
+
dirSectors.forEach((s, i) => {
|
|
65
|
+
const at = offset(18, s);
|
|
66
|
+
if (i + 1 < dirSectors.length) { image[at] = 18; image[at + 1] = dirSectors[i + 1]; } else { image[at] = 0; image[at + 1] = 0xff; }
|
|
67
|
+
entries.slice(i * 8, i * 8 + 8).forEach((e, k) => {
|
|
68
|
+
const p = at + k * 32;
|
|
69
|
+
image[p + 2] = e.type; image[p + 3] = e.first[0]; image[p + 4] = e.first[1];
|
|
70
|
+
image.set(e.name, p + 5);
|
|
71
|
+
image[p + 30] = e.blocks & 0xff; image[p + 31] = e.blocks >> 8;
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// the BAM: the directory's first sector, the format's mark, each track's free count and map
|
|
76
|
+
const bam = offset(18, 0);
|
|
77
|
+
image[bam] = 18; image[bam + 1] = dirSectors[0]; image[bam + 2] = 0x41;
|
|
78
|
+
for (let t = 1; t <= 35; t++) {
|
|
79
|
+
const map = used[t - 1].map(u => !u), at = bam + 4 * t;
|
|
80
|
+
image[at] = map.filter(Boolean).length;
|
|
81
|
+
for (let s = 0; s < map.length; s++) if (map[s]) image[at + 1 + (s >> 3)] |= 1 << (s & 7);
|
|
82
|
+
}
|
|
83
|
+
image.set(name16(name), bam + 0x90);
|
|
84
|
+
image[bam + 0xa0] = image[bam + 0xa1] = 0xa0;
|
|
85
|
+
image[bam + 0xa2] = String(id).charCodeAt(0) || 0x30; image[bam + 0xa3] = String(id).charCodeAt(1) || 0x30;
|
|
86
|
+
image[bam + 0xa4] = 0xa0; image[bam + 0xa5] = 0x32; image[bam + 0xa6] = 0x41;
|
|
87
|
+
image.fill(0xa0, bam + 0xa7, bam + 0xab);
|
|
88
|
+
return image;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Files added to a disk that already has some, as a 1541 SAVEs them: the disk's own files left in
|
|
92
|
+
// their sectors (a game may read them by track and sector), a file of the same name replaced and
|
|
93
|
+
// its sectors freed, the new ones laid in free sectors as the BAM has them and entered in the
|
|
94
|
+
// directory's first empty entry (a new directory sector where none is empty).
|
|
95
|
+
//
|
|
96
|
+
// add(image, [{ name, data }]) a new image; the one given is not changed
|
|
97
|
+
export function add(image, files) {
|
|
98
|
+
const out = Uint8Array.from(image), bam = offset(18, 0);
|
|
99
|
+
const isFree = (t, s) => !!(out[bam + 4 * t + 1 + (s >> 3)] & 1 << (s & 7));
|
|
100
|
+
const mark = (t, s, free) => {
|
|
101
|
+
const at = bam + 4 * t + 1 + (s >> 3), was = isFree(t, s);
|
|
102
|
+
if (free) out[at] |= 1 << (s & 7); else out[at] &= ~(1 << (s & 7));
|
|
103
|
+
out[bam + 4 * t] += free && !was ? 1 : !free && was ? -1 : 0;
|
|
104
|
+
};
|
|
105
|
+
const ORDER = [];
|
|
106
|
+
for (let d = 1; d <= 17; d++) { if (18 - d >= 1) ORDER.push(18 - d); if (18 + d <= 35) ORDER.push(18 + d); }
|
|
107
|
+
const last = {};
|
|
108
|
+
const take = () => {
|
|
109
|
+
for (const t of ORDER) {
|
|
110
|
+
const n = SECTORS[t - 1];
|
|
111
|
+
for (let k = 0; k < n; k++) { const s = ((last[t] ?? -10) + 10 + k) % n; if (isFree(t, s)) { mark(t, s, false); last[t] = s; return [t, s]; } }
|
|
112
|
+
}
|
|
113
|
+
throw new Error('the disk is full');
|
|
114
|
+
};
|
|
115
|
+
// the directory's entries, by where each is
|
|
116
|
+
const entries = () => {
|
|
117
|
+
const list = [];
|
|
118
|
+
for (let [t, s] = [out[bam], out[bam + 1]], n = 0; t && n < 18; n++) {
|
|
119
|
+
const at = offset(t, s);
|
|
120
|
+
for (let k = 0; k < 8; k++) list.push(at + k * 32);
|
|
121
|
+
[t, s] = [out[at], out[at + 1]];
|
|
122
|
+
}
|
|
123
|
+
return list;
|
|
124
|
+
};
|
|
125
|
+
for (const f of files) {
|
|
126
|
+
const name = name16(f.name), data = f.data instanceof Uint8Array ? f.data : Uint8Array.from(f.data);
|
|
127
|
+
// (one of the same name: its sectors freed, its entry emptied)
|
|
128
|
+
for (const e of entries()) {
|
|
129
|
+
if (!(out[e + 2] & 7) || !name.every((b, i) => out[e + 5 + i] === b)) continue;
|
|
130
|
+
for (let [t, s] = [out[e + 3], out[e + 4]], n = 0; t && n < 683; n++) { const at = offset(t, s); mark(t, s, true); [t, s] = [out[at], out[at + 1]]; }
|
|
131
|
+
out[e + 2] = 0;
|
|
132
|
+
}
|
|
133
|
+
const chunks = Math.max(1, Math.ceil(data.length / 254)), places = Array.from({ length: chunks }, take);
|
|
134
|
+
places.forEach(([t, s], i) => {
|
|
135
|
+
const at = offset(t, s), part = data.subarray(i * 254, (i + 1) * 254);
|
|
136
|
+
out.fill(0, at, at + 256);
|
|
137
|
+
if (i + 1 < chunks) { out[at] = places[i + 1][0]; out[at + 1] = places[i + 1][1]; } else { out[at] = 0; out[at + 1] = part.length + 1; }
|
|
138
|
+
out.set(part, at + 2);
|
|
139
|
+
});
|
|
140
|
+
let e = entries().find(e => !(out[e + 2] & 7) && !out[e + 2]);
|
|
141
|
+
if (e === undefined) {
|
|
142
|
+
// (a new directory sector, on track 18, linked after the last)
|
|
143
|
+
const list = entries(), lastSector = list[list.length - 1] - 7 * 32;
|
|
144
|
+
let s = 1; while (s < 19 && !isFree(18, s)) s++;
|
|
145
|
+
if (s >= 19) throw new Error('the directory is full');
|
|
146
|
+
mark(18, s, false);
|
|
147
|
+
const at = offset(18, s); out.fill(0, at, at + 256); out[at + 1] = 0xff;
|
|
148
|
+
out[lastSector] = 18; out[lastSector + 1] = s;
|
|
149
|
+
e = at;
|
|
150
|
+
}
|
|
151
|
+
out.fill(0, e + 2, e + 32);
|
|
152
|
+
out[e + 2] = 0x80 | (TYPES[f.type ?? 'PRG'] ?? 2); out[e + 3] = places[0][0]; out[e + 4] = places[0][1];
|
|
153
|
+
out.set(name, e + 5);
|
|
154
|
+
out[e + 30] = chunks & 0xff; out[e + 31] = chunks >> 8;
|
|
155
|
+
}
|
|
156
|
+
return out;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Reading a disk: the BAM's sector names the disk, and the directory's chain from track 18, sector 1, eight
|
|
160
|
+
// 32-byte entries a sector, each a file's type, its first sector and its name; a file's sectors follow each
|
|
161
|
+
// other by their first two bytes, the last one's second byte the index of its last byte.
|
|
162
|
+
const NAMES = ['DEL', 'SEQ', 'PRG', 'USR', 'REL'];
|
|
163
|
+
export const petsciiName = bytes => String.fromCharCode(...[...bytes].filter(b => b !== 0xa0).map(b => (b >= 0xc1 && b <= 0xda ? b - 0x80 : b))).trim();
|
|
164
|
+
|
|
165
|
+
export function read(image) {
|
|
166
|
+
if (image.length !== 174848 && image.length !== 175531) throw new Error('a .d64 is 174,848 bytes, and this one is ' + image.length);
|
|
167
|
+
const sector = (t, s) => image.subarray(offset(t, s), offset(t, s) + 256);
|
|
168
|
+
const chain = (t, s) => {
|
|
169
|
+
const parts = [];
|
|
170
|
+
while (t) { const b = sector(t, s); [t, s] = [b[0], b[1]]; parts.push(b.subarray(2, t ? 256 : s + 1)); }
|
|
171
|
+
const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
|
|
172
|
+
parts.reduce((at, p) => (out.set(p, at), at + p.length), 0);
|
|
173
|
+
return out;
|
|
174
|
+
};
|
|
175
|
+
const bam = sector(18, 0);
|
|
176
|
+
const name = petsciiName(bam.subarray(0x90, 0xa0)), id = String.fromCharCode(bam[0xa2], bam[0xa3]);
|
|
177
|
+
const files = [];
|
|
178
|
+
for (let [t, s] = [bam[0], bam[1]]; t; ) {
|
|
179
|
+
const b = sector(t, s);
|
|
180
|
+
for (let i = 0; i < 8; i++) {
|
|
181
|
+
const e = b.subarray(i * 32, (i + 1) * 32);
|
|
182
|
+
if (!e[2]) continue;
|
|
183
|
+
const type = NAMES[e[2] & 7] || '?', body = chain(e[3], e[4]);
|
|
184
|
+
const file = { name: petsciiName(e.subarray(5, 21)), type, locked: !!(e[2] & 0x40), closed: !!(e[2] & 0x80), blocks: e[30] | e[31] << 8, body };
|
|
185
|
+
if (type === 'PRG' && body.length >= 2) { file.address = body[0] | body[1] << 8; file.body = body.subarray(2); file.raw = body; }
|
|
186
|
+
files.push(file);
|
|
187
|
+
}
|
|
188
|
+
[t, s] = [b[0], b[1]];
|
|
189
|
+
}
|
|
190
|
+
return { name, id, files };
|
|
191
|
+
}
|