@moonbase-machines/c64 0.0.0-stage → 0.5.0

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/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
+ }