@moonbase-machines/player 0.0.0-stage → 0.9.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.
@@ -0,0 +1,72 @@
1
+ // The original's screen, dot for dot, for the scenes that read it back. PROB6 steers Helen's car by
2
+ // the colors of the dots ahead of it, so the remake must know what the original's screen shows
3
+ // there, whatever the view draws. The raster is 192 rows of 140 colors, painted by each step as the
4
+ // original's routines paint the double hi-res screen, and read by read-dot. The view never sees it.
5
+ import { screen, draw, line as lineDots } from '@moonbase-machines/vector/draw.js';
6
+ import { put } from '@moonbase-machines/vector/objects.js';
7
+ import { width, dots } from '@moonbase-machines/vector/font.js';
8
+ import { LAYOUT } from './layout.js';
9
+
10
+ export class Raster {
11
+ constructor() { this.dots = screen(15); this.kept = null; } // $5BE1 clears the screen white
12
+ // Nature Park's menus over a game: the screen kept and cleared to black ($4C5D), and put back
13
+ // as it was ($4CFB), with what an object kept under it
14
+ keepScreen() { this.screenKept = { dots: this.dots, kept: this.kept }; this.dots = screen(0); }
15
+ putScreen() { if (this.screenKept) ({ dots: this.dots, kept: this.kept } = this.screenKept); this.screenKept = null; }
16
+ // Animal Rescue's PART3 at $E000: rows 19 to 115 moved two bytes to the left in each half of the
17
+ // screen. A row is 560 bits, aux and main bytes in turn, seven bits a byte and four a dot; bytes
18
+ // 2 to 73 of the row, bits 14 to 517, take the bits 28 on, so the dots move seven, the ends half
19
+ // way through a dot, and bytes 74 to 79 stay
20
+ scroll() {
21
+ for (let y = 19; y <= 115; y++) {
22
+ const row = this.dots[y], bits = [];
23
+ for (const c of row) bits.push(c >> 3 & 1, c >> 2 & 1, c >> 1 & 1, c & 1);
24
+ for (let b = 14; b < 518; b++) bits[b] = bits[b + 28];
25
+ for (let x = 0; x < 140; x++) row[x] = bits[4 * x] << 3 | bits[4 * x + 1] << 2 | bits[4 * x + 2] << 1 | bits[4 * x + 3];
26
+ }
27
+ }
28
+ set(x, y, c) { if (x >= 0 && x < 140 && y >= 0 && y < 192) this.dots[y][x] = c & 15; }
29
+ get(x, y) { return x >= 0 && x < 140 && y >= 0 && y < 192 ? this.dots[y][x] : 0; }
30
+ // a run of a row, both ends drawn ($7947)
31
+ span(c, left, right, y) { for (let x = left; x <= right; x++) this.set(x, y, c); }
32
+ // rows filled across the screen, with white edge columns when `edges` ($5F4B)
33
+ rows(c, count, first, edges) {
34
+ for (let y = first; y < first + count; y++) { this.span(c, 0, 139, y); if (edges) { this.set(0, y, 15); this.set(139, y, 15); } }
35
+ }
36
+ whiteLines(y) { this.span(15, 0, 139, y); this.span(15, 0, 139, y + 1); }
37
+ // $65D2: the window outlined in black and filled, and the two boxes, each two white rows and its
38
+ // rows in its color with white edges; $80 leaves a part as it is
39
+ box(layout, window, one, two) {
40
+ const [bottom, top1, top2] = LAYOUT[layout] ?? LAYOUT[0x32];
41
+ if (window !== 0x80) {
42
+ this.span(0, 1, 138, 1); this.span(0, 1, 138, bottom - 1);
43
+ for (let y = 2; y < bottom - 1; y++) { this.set(1, y, 0); this.set(138, y, 0); this.span(window, 2, 137, y); }
44
+ }
45
+ if (one !== 0x80) { this.whiteLines(top1); this.rows(one, top2 - top1 - 2, top1 + 2, true); }
46
+ if (two !== 0x80) { this.whiteLines(top2); this.rows(two, 190 - top2 - 2, top2 + 2, true); }
47
+ }
48
+ // a panel: its box's two white rows and its rows in its color, with white edges
49
+ panel(top, bottom, c) { this.whiteLines(top); this.rows(c, bottom - top - 1, top + 2, true); }
50
+ picture(shapes) { draw(shapes, this.dots); }
51
+ rectangle(c, x, y, w, h) { for (let r = y; r < y + h; r++) this.span(c, x, x + w - 1, r); }
52
+ outline(c, x, y, w, h) { this.span(c, x, x + w - 1, y); this.span(c, x, x + w - 1, y + h - 1); for (let r = y; r < y + h; r++) { this.set(x, r, c); this.set(x + w - 1, r, c); } }
53
+ line(c, x0, y0, x1, y1) { for (const [x, y] of lineDots(x0, y0, x1, y1)) this.set(x, y, c); }
54
+ // An object: 'keep' keeps the ground under it, so that object 0 puts it back, and its clear
55
+ // color shows the ground ($686F); 'clear' shows the ground and keeps none ($6863); 'whole' puts
56
+ // it down whole, its clear color and all ($686D)
57
+ object(o, x, y, how) {
58
+ if (how === 'keep') this.kept = { x, y, rows: o.rows.map((row, dy) => row.map((_, dx) => this.get(x + dx, y + dy))) };
59
+ put(this.dots, o, x, y, how === 'whole');
60
+ }
61
+ restore() { const k = this.kept; if (!k) return; k.rows.forEach((row, dy) => row.forEach((c, dx) => this.set(k.x + dx, k.y + dy, c))); }
62
+ // a glyph's cell painted, ink on its dots and paper on the rest, `h` rows a glyph row, as many
63
+ // glyph rows as the line is high; the cell takes in the spacing after the glyph, `space`
64
+ glyph(ch, x, y, ink, paper, h = 1, height = 9, space = 0) {
65
+ const w = width(ch);
66
+ // a spacer, a code below $20, is that many dots of the paper
67
+ if (ch.charCodeAt(0) < 0x20) { for (let gy = 0; gy < Math.min(9, height); gy++) for (let gx = 0; gx < w; gx++) for (let k = 0; k < h; k++) this.set(x + gx, y + gy * h + k, paper); return w; }
68
+ const on = new Set(dots(ch).map(([gx, gy]) => gx + ',' + gy));
69
+ for (let gy = 0; gy < Math.min(9, height); gy++) for (let gx = 0; gx < w + space; gx++) for (let k = 0; k < h; k++) this.set(x + gx, y + gy * h + k, on.has(gx + ',' + gy) ? ink : paper);
70
+ return w;
71
+ }
72
+ }
@@ -0,0 +1,243 @@
1
+ // A title of scenes played, the remake's own engine (main.js hands it the game): its scripts on the engine, its events
2
+ // drawn by the view, on the stage; its controls on the options line, the sound, the old style text, a C64 title's
3
+ // printer, the bugs fixed where the title has a version so, the mini view where the page gives one. A run's events
4
+ // are drawn one after another, with a moment for each sound and flash, so that what the original took time over takes
5
+ // time here too.
6
+ import { Printer, paper } from '@moonbase-machines/c64/printer.js';
7
+ import { makeEngine } from './load.js';
8
+ import { Speaker } from '../audio.js';
9
+ import { SID } from '@moonbase-machines/c64/sid.js';
10
+ import { matrixOf } from '@moonbase-machines/c64/kernal.js';
11
+ import { standIn } from '@moonbase-machines/c64/rom.js';
12
+ import { withSaves, keepSaves } from '@moonbase-machines/apple2e/saves.js';
13
+ import { Drive } from '@moonbase-machines/c64/drive.js';
14
+ import { keepDrive } from '@moonbase-machines/c64/saves.js';
15
+ import { paperBox, phoneKeys, KEYS_MARKUP } from '../parts.js';
16
+ import { View } from './view.js';
17
+ import { CardView } from './card-view.js';
18
+ import { C64View } from './c64-view.js';
19
+
20
+ export async function play(game) {
21
+ const { title, scripts, disk, root, stage, status, fail, kept, words, fixed } = game;
22
+ root.classList.add('game'); // (the screen of a machine: the stage the game's own shape, player.css)
23
+ stage.innerHTML = `<svg id="screen" preserveAspectRatio="xMidYMid meet" tabindex="0" aria-label="The game's screen"></svg>`;
24
+ status.insertAdjacentHTML('afterend', `${KEYS_MARKUP}<label><input type="checkbox" id="sound"> Sound</label><label><input type="checkbox" id="old-style"> Old style text</label><label hidden><input type="checkbox" id="printer" checked> Printer</label><label hidden title="The game with its bugs fixed: it starts again"><input type="checkbox" id="fixed"> Bugs fixed</label><label hidden>Mini view <select id="mini"><option value="">Off</option><option value="live">Live</option><option value="pictures">Pictures</option></select></label>`);
25
+ root.append(paperBox(words.printer));
26
+ const $ = id => root.querySelector('#' + id);
27
+ const svg = $('screen'), soundBox = $('sound');
28
+ phoneKeys(root, stage);
29
+
30
+ try {
31
+ // the choice of the fixed version, where there is one: the page again, with ?fixed or without
32
+ const fixedBox = $('fixed');
33
+ if (fixedBox && title.fixed) {
34
+ fixedBox.checked = fixed; fixedBox.parentElement.hidden = false;
35
+ fixedBox.addEventListener('change', () => { const u = new URL(location.href); if (fixedBox.checked) u.searchParams.set('fixed', ''); else u.searchParams.delete('fixed'); location.href = u.href.replace('fixed=&', 'fixed&').replace(/fixed=$/, 'fixed'); });
36
+ }
37
+ // its view's own drawing, for a title that has one (its title.js `view`, load.js)
38
+ const extra = title.viewSteps ?? {};
39
+ // a Commodore 64's character ROM, for its view: Commodore's where the page gives them (`roms`), else moonbase's own
40
+ const chargen = disk.kind === 'c64' ? game.roms?.chargen ?? standIn().chargen : null;
41
+
42
+ // A printer on device 4, for a Commodore 64 title (@moonbase-machines/c64/printer.js): on unless its box is off,
43
+ // what it prints drawn below the screen as a sheet of paper, until it is torn off
44
+ if (disk.kind === 'c64') {
45
+ const box = $('printer'), sheet = $('paper'), shown = $('paper-box');
46
+ try { box.checked = localStorage.getItem('remake-printer') !== '0'; } catch {}
47
+ box.parentElement.hidden = false;
48
+ const printer = title.printer = new Printer({ on: box.checked });
49
+ box.addEventListener('change', () => { printer.on = box.checked; try { localStorage.setItem('remake-printer', box.checked ? '1' : '0'); } catch {} });
50
+ printer.changed = () => { shown.hidden = !printer.lines.length; if (printer.lines.length) paper(sheet, printer, chargen); };
51
+ $('tear').addEventListener('click', () => printer.tear());
52
+ $('print-paper').addEventListener('click', () => window.print()); // (the sheet alone: its print style)
53
+ }
54
+ // what the game saves, on the player's disk: an Apple title's data disk (@moonbase-machines/apple2e/saves.js), a
55
+ // Commodore 64 title's drive (@moonbase-machines/c64/saves.js, the drive card.js puts on the bus)
56
+ title.disk = withSaves(disk, kept);
57
+ if (disk.kind === 'c64') keepDrive(title.drive = new Drive(Uint8Array.from(atob(disk.image), ch => ch.charCodeAt(0))), kept);
58
+ const engine = makeEngine(title, scripts);
59
+ if (title.files) keepSaves(disk, title.files, kept);
60
+ const queue = [];
61
+ // The view, and every event it has drawn, so that it can be drawn again from the start when
62
+ // the old style text is turned on or off. The choice is kept in this browser.
63
+ const oldStyleBox = $('old-style'), history = [];
64
+ const remember = { get() { try { return localStorage.getItem('remake-old-style') === '1'; } catch { return false; } }, set(v) { try { localStorage.setItem('remake-old-style', v ? '1' : '0'); } catch {} } };
65
+ oldStyleBox.checked = remember.get() || new URLSearchParams(location.search).has('old-style'); // ?old-style, for a screenshot
66
+ let onView = null; // (the mini view's, given each view made)
67
+ const makeView = () => { svg.replaceChildren(); const v = disk.kind === 'c64' ? new C64View(svg, { engine, chargen }) : title.engine === 'card' ? new CardView(svg, { engine, inset: game.mini ? title.inset : null }) : new View(svg, { disk, volume: Object.keys(disk.volumes)[0], engine, extra }); v.oldStyle = oldStyleBox.checked; onView?.(v); return v; };
68
+ let view = makeView();
69
+ engine.on(e => queue.push(view.snapshot({ ...e })));
70
+ // The mini view, where the page gives one (`mini`) and the title has a place for it (its title.js `inset`): Off,
71
+ // Live or Pictures, the choice kept in this browser; shown in the band at the top of the screen while the band is
72
+ // there. A picture only: the game's screen otherwise as it is
73
+ const miniBox = $('mini');
74
+ if (title.inset && game.mini) {
75
+ miniBox.parentElement.hidden = false;
76
+ try { miniBox.value = localStorage.getItem('remake-mini') ?? ''; } catch {}
77
+ const { top, bottom, view: [left, right] } = title.inset, at = document.createElement('div');
78
+ at.className = 'mini'; at.hidden = true;
79
+ Object.assign(at.style, { left: `${left / 5.6}%`, top: `${top / 3.84}%`, width: `${(right - left) / 5.6}%`, height: `${(bottom - top + 1) / 3.84}%` });
80
+ svg.parentElement.append(at);
81
+ const mini = await game.mini(at, { memory: () => engine.mem });
82
+ // (each view's painting followed by the mini view shown or not, as the band is there or not)
83
+ onView = v => { v.insetOn = !!miniBox.value; const prompts = v.prompts.bind(v); v.prompts = () => { prompts(); at.hidden = !(miniBox.value && v.banded); }; };
84
+ onView(view);
85
+ const show = () => { view.insetOn = !!miniBox.value; mini.mode(miniBox.value); view.prompts(); };
86
+ miniBox.addEventListener('change', () => { try { localStorage.setItem('remake-mini', miniBox.value); } catch {} show(); });
87
+ show();
88
+ }
89
+ oldStyleBox.addEventListener('change', () => {
90
+ remember.set(oldStyleBox.checked);
91
+ view = makeView();
92
+ for (const e of history) view.apply(e);
93
+ view.prompts();
94
+ });
95
+
96
+ // the sound: the original's are off until the teacher turns them on, at the engine's sound flag.
97
+ // A title that keeps the original's time (a card title's, Engine.time) clicks the speaker as
98
+ // the original did, and the clicks are played as the emulator plays them (audio.js), the
99
+ // box the sound's on and off.
100
+ const speaker = engine.timed ? new Speaker() : null;
101
+ if (speaker) soundBox.checked = true;
102
+ const hear = () => { if (disk.kind !== 'c64') speaker?.feed(soundBox.checked ? engine.clicks : [], engine.cycles); engine.clicks.length = 0; };
103
+ const V = title.map?.vars ?? { sound: 0, soundOn: 0 };
104
+ let audio = null;
105
+ const tone = (from, to, ms) => {
106
+ if (!(engine.mem[V.sound] & V.soundOn)) return;
107
+ audio ??= new AudioContext();
108
+ const o = audio.createOscillator(), g = audio.createGain(), t = audio.currentTime;
109
+ o.type = 'square'; o.frequency.setValueAtTime(from, t); o.frequency.linearRampToValueAtTime(to, t + ms / 1000);
110
+ g.gain.setValueAtTime(0.06, t); g.gain.linearRampToValueAtTime(0, t + ms / 1000);
111
+ o.connect(g).connect(audio.destination); o.start(t); o.stop(t + ms / 1000);
112
+ };
113
+ const pitch = p => 16000 / (p + 8);
114
+ soundBox.addEventListener('change', () => { if (!speaker) engine.mem[V.sound] = soundBox.checked ? V.soundOn : 0; });
115
+
116
+ // how long each step takes to be seen or heard, in milliseconds
117
+ const PACE = { sound: 220, beep: 60, 'flash-box': 60, flash: 250, object: 30, 'object-keep': 30 };
118
+ const pace = () => game.pace ?? 1; // (the original's waits, a part of each: 1 all, 0 none; read at each wait)
119
+ let drawing = false;
120
+ const sleep = ms => new Promise(r => setTimeout(r, ms));
121
+ async function runs() {
122
+ if (drawing) return;
123
+ drawing = true;
124
+ try {
125
+ for (;;) {
126
+ const r = engine.run();
127
+ hear();
128
+ // the original took time here, a disk's read or a wait it counted out: the screen as it
129
+ // is, for that long (card.js)
130
+ if (r === 'pace') {
131
+ for (const e of queue.splice(0)) { history.push(e); view.apply(e); }
132
+ view.prompts(); if (!quick && pace()) await sleep(engine.delay * pace()); engine.delay = 0;
133
+ continue;
134
+ }
135
+ engine.delay = 0;
136
+ while (queue.length) {
137
+ const e = queue.shift();
138
+ history.push(e);
139
+ view.apply(e);
140
+ if (quick) continue; // (at full speed: no sound, no wait)
141
+ if (e.step === 'sound') { const [end, start] = e.words.split(' ').map(h => parseInt(h, 16)); tone(pitch(start), pitch(end), 180); }
142
+ if (e.step === 'beep') tone(pitch(e.a & 31) * 2, pitch(e.a & 31) * 2, 50);
143
+ if (!pace()) continue; // (none of the original's waits: its sounds still)
144
+ if (PACE[e.step]) await sleep(PACE[e.step] * pace());
145
+ if (e.step === 'pause') await sleep(e.ms * pace()); // a delay the original counted out
146
+ }
147
+ view.prompts();
148
+ status.textContent = r === 'end' ? 'The end.' : 'Waiting for a key.';
149
+ if (!engine.keys.length || r === 'end') break;
150
+ }
151
+ } catch (err) { fail(err); }
152
+ drawing = false;
153
+ }
154
+
155
+ // keys as the Apple's keyboard gave them
156
+ // (a Commodore 64's in PETSCII: its cursor keys, DEL and RUN/STOP, and its letters capitals)
157
+ const KEYS = disk.kind === 'c64'
158
+ ? { ArrowLeft: 157, ArrowRight: 29, ArrowDown: 17, ArrowUp: 145, Enter: 13, Backspace: 20, Delete: 20, Escape: 3, ' ': 32 }
159
+ : { ArrowLeft: 8, ArrowRight: 21, ArrowDown: 10, ArrowUp: 11, Enter: 13, Backspace: 127, Delete: 127, Escape: 27, ' ': 32 };
160
+ let quick = false;
161
+ const press = k => { engine.press(k); audio?.resume(); speaker?.start(); if (disk.kind !== 'c64') runs(); };
162
+ // (a page that follows the game, the island's, given it: the engine, its memory, a key pressed as the
163
+ // player presses one, and `quick(on)`, the game at full speed, none of its waits, to jump ahead in it)
164
+ game.watch?.({ engine, press, quick: on => { quick = on; } });
165
+ // A game that goes on without waiting for keys, as Nature Park's truck: each turn of its loop is
166
+ // a step `tick`, which takes a key or none. A turn passes every 100 milliseconds, as the
167
+ // original's took, with the key pressed since the last or still held, as the Apple's keyboard
168
+ // told the game a key was down.
169
+ const ticking = () => engine.waitingOn?.words?.[0] === 'tick';
170
+ let held = null, tapped = null;
171
+ setInterval(() => {
172
+ if (!ticking() || drawing || engine.keys.length) return;
173
+ const k = tapped ?? held ?? -1; tapped = null;
174
+ press(k);
175
+ }, 100);
176
+ window.addEventListener('keydown', e => {
177
+ if (e.metaKey || e.altKey) return;
178
+ const k = e.ctrlKey ? (/^[a-z]$/i.test(e.key) ? e.key.toUpperCase().charCodeAt(0) & 0x1f : undefined) : KEYS[e.key] ?? (e.key.length === 1 ? (disk.kind === 'c64' ? e.key.toUpperCase() : e.key).charCodeAt(0) : undefined);
179
+ if (k === undefined) return;
180
+ e.preventDefault();
181
+ if (ticking()) { tapped = held = k; return; }
182
+ // a Commodore 64's key held down in its keyboard's matrix, which its KERNAL scans and repeats
183
+ // as long as it is held, as the original's did (@moonbase-machines/c64/kernal.js); a character typed only
184
+ // with shift into the buffer
185
+ const m = disk.kind === 'c64' && !e.ctrlKey && matrixOf(k);
186
+ if (m) { if (!e.repeat) { engine.clock.held = { ...m, key: e.key }; audio?.resume(); speaker?.start(); } return; }
187
+ if (!e.repeat && !game.claim?.(k)) press(k); // (the player's key offered to the page first: `claim`, main.js)
188
+ });
189
+ window.addEventListener('keyup', e => {
190
+ held = null;
191
+ const h = engine.clock?.held;
192
+ if (h?.key === e.key) { if (h.seen) engine.clock.held = null; else h.up = true; }
193
+ });
194
+ svg.addEventListener('click', e => { const key = e.target.closest('.key')?.dataset.key; if (key) press(+key); });
195
+ svg.focus();
196
+ // A Commodore 64 title runs on the clock, as the original did, waiting for a key or not: a
197
+ // sixtieth of a second of the original's cycles at a time (the engine's run stops at each 16 ms of
198
+ // them), its raster and its interrupt at the time they come (card.js), and the screen
199
+ // painted after each. And the SID, its writes played a sixtieth of a second at a time, as the
200
+ // emulator plays a frame's (original.js).
201
+ if (disk.kind === 'c64') {
202
+ // a route the page gives (`route`, a workshop's pilots' route) played first as the pilots play it, each key when
203
+ // the game waits for one, off the clock, so that it goes where it went on both sides; then the game goes on as
204
+ // usual
205
+ if (game.route) {
206
+ const { route, name: routeName, keys: { expand, give, petscii, pollsAt } } = game.route;
207
+ title.seed = route.seed; // (RND's numbers from the route's seed, as the pilots have them)
208
+ if (route.printer && title.printer) { title.printer.on = true; $('printer').checked = true; }
209
+ status.textContent = 'Playing the route ' + routeName + '.';
210
+ // (a key behind the one pressed goes into the KERNAL's buffer, as the pilots hold a key)
211
+ const side = { press: k => engine.press(k), also: k => { const m = engine.mem, put = v => { if (m[0xc6] < 10) { m[0x277 + m[0xc6]] = v; m[0xc6]++; } }; if (engine.keys.length) put(engine.keys.shift()); put(k); } };
212
+ const go = () => { let r; do { engine.delay = 0; r = engine.run(); } while (r === 'pace'); queue.length = 0; };
213
+ go();
214
+ for (const [i, [k]] of expand(route.keys).entries()) { give(side, k, petscii); engine.polls = pollsAt(route, i + 1) ?? undefined; go(); }
215
+ engine.polls = undefined; view.prompts();
216
+ }
217
+ engine.sid = []; engine.clock = { irq: 0, ahead: [], since: 0 };
218
+ let sid = null, from = null, sent = 0, stopped = false;
219
+ status.textContent = 'Playing.';
220
+ setInterval(() => {
221
+ engine.delay -= 1000 / 60; // a load's time, or the last run's
222
+ if (!stopped && !engine.ended && engine.delay < 16) {
223
+ engine.delay = Math.max(0, engine.delay);
224
+ // a key of ?keys when the game is waiting for one, two seconds at least after the last, as
225
+ // a player would press them (a wait the game counts out goes on by itself on the clock)
226
+ if (engine.clock.ahead.length && engine.idle > (engine.routines.polls ?? 200) && !engine.keys.length && ++engine.clock.since > 120) { engine.press(engine.clock.ahead.shift()); engine.clock.since = 0; }
227
+ try { engine.run(); queue.length = 0; view.prompts(); } catch (err) { stopped = true; fail(err); }
228
+ if (engine.ended) status.textContent = 'The end.';
229
+ }
230
+ if (speaker?.node && soundBox.checked) {
231
+ sid ??= new SID(speaker.rate); from ??= performance.now();
232
+ const n = Math.floor((performance.now() - from) / 1000 * speaker.rate) - sent;
233
+ if (n > 0) { const wave = sid.render(n, engine.sid.map(([, r, v]) => [0, r, v])); sent += n; speaker.node.port.postMessage(wave, [wave.buffer]); }
234
+ }
235
+ engine.sid.length = 0;
236
+ }, 1000 / 60);
237
+ }
238
+ // ?keys=space,space,right,return presses keys at the start, to go straight to a scene
239
+ const start = new URLSearchParams(location.search).get('keys');
240
+ if (start) for (const k of start.split(',')) { const a = { space: 32, return: 13, escape: 27, delete: 127, left: 8, right: 21, down: 10, up: 11, 'control-e': 5, 'control-t': 20, none: -1 }[k] ?? k.charCodeAt(0); if (disk.kind === 'c64') engine.clock.ahead.push({ 8: 157, 21: 29, 10: 17, 11: 145, 127: 20, 27: 3 }[a] ?? a); else engine.press(a); }
241
+ if (disk.kind !== 'c64') runs();
242
+ } catch (err) { fail(err); }
243
+ }
@@ -0,0 +1,60 @@
1
+ // A scene script: the steps of a part or a problem, a step to a line, as a lifter's --script
2
+ // writes them and as they are edited after. A line may start with the address in the original
3
+ // it came from, which is kept so that the pilots can say where two runs part.
4
+ //
5
+ // L8157: a label
6
+ // 8157 object 3 4 44 a step: the engine's work, with its inputs
7
+ // 814F a = $8235 a statement on the registers a, x, y and memory
8
+ // 8152 if zero goto L8157 a branch on a flag, or on a compare: if a == 1 goto L8157
9
+ // data $86E4 01 02 03 04 bytes of the original's tables, at their addresses
10
+ // ; a comment
11
+ //
12
+ // parse() gives { steps, labels, data }: each step is { at, op, args, text }.
13
+
14
+ const WORD = /"(?:[^"\\]|\\.)*"|\S+/g;
15
+
16
+ export function parse(source, name = 'script') {
17
+ const steps = [], labels = {}, data = [];
18
+ source.split('\n').forEach((raw, n) => {
19
+ const line = raw.replace(/^\s+|\s+$/g, '');
20
+ if (!line || line.startsWith(';')) return;
21
+ const where = name + ':' + (n + 1);
22
+ let m;
23
+ if ((m = /^(L[0-9A-F]{4}|[A-Za-z_][\w-]*):$/.exec(line))) { labels[m[1]] = steps.length; return; }
24
+ if ((m = /^data \$([0-9A-F]+)((?: [0-9A-F]{2})+)$/i.exec(line))) { data.push({ at: parseInt(m[1], 16), bytes: m[2].trim().split(' ').map(h => parseInt(h, 16)) }); return; }
25
+ m = /^([0-9A-F]{4})\s+(.*)$/.exec(line);
26
+ const at = m ? parseInt(m[1], 16) : null, body = m ? m[2] : line;
27
+ steps.push({ at, words: words(body), text: body, where, index: steps.length });
28
+ });
29
+ return { steps, labels, data };
30
+ }
31
+
32
+ // a line's words, a quoted one as { string }
33
+ export const words = body => body.match(WORD).map(w => w.startsWith('"') ? { string: JSON.parse(w) } : w);
34
+
35
+ // an operand: a number, a register, a flag, or a place in memory; [$0D19] is the address the
36
+ // code keeps at $0D19 and $0D1A, written there as it runs, as an instruction's own operand is
37
+ // 12 a x y carry $866A $86E4[y] $12[x] ($12)[y] ($12[x]) [$0D19][y]
38
+ export function operand(word) {
39
+ if (typeof word !== 'string') return { string: word.string };
40
+ if (/^-?\d+$/.test(word)) return { number: +word };
41
+ if (/^[axy]$/.test(word) || word === 'carry' || word === 'borrow') return { reg: word };
42
+ let m;
43
+ if ((m = /^\$([0-9A-F]+)(?:\[([xy])\])?$/i.exec(word))) return { at: parseInt(m[1], 16), index: m[2], zp: m[1].length <= 2 };
44
+ if ((m = /^\(\$([0-9A-F]{2})\)\[y\]$/i.exec(word))) return { through: parseInt(m[1], 16) };
45
+ // ($27[x]): the address kept at the zero page's $27 plus X, as LDA ($27,X)
46
+ if ((m = /^\(\$([0-9A-F]{2})\[x\]\)$/i.exec(word))) return { pair: parseInt(m[1], 16) };
47
+ if ((m = /^\[\$([0-9A-F]{4})\](?:\[([xy])\])?$/i.exec(word))) return { via: parseInt(m[1], 16), index: m[2] };
48
+ throw new Error('not an operand: ' + word);
49
+ }
50
+
51
+ // The cycles a branch takes more when it goes: one, and one more when it goes to another page
52
+ // than the instruction after it. A line that compares and branches (if a != 13 goto L) is two
53
+ // instructions, the compare's size before the branch's.
54
+ const SIZE = w => /^\d+$/.test(w) || /^\$[0-9A-F]{2}(\[[xy]\])?$/i.test(w) || /^\(\$/.test(w) ? 2 : 3;
55
+ export function branchTaken(step) {
56
+ if (step.at === null) return 1;
57
+ const w = step.words, to = parseInt(w.at(-1).slice(1), 16);
58
+ const next = (step.at + (w.length === 6 ? SIZE(w[3]) : 0) + 2) & 0xffff;
59
+ return /^L[0-9A-F]{4}$/.test(w.at(-1)) && (to & 0xff00) !== (next & 0xff00) ? 2 : 1;
60
+ }