@henols/c64-re-tools 2.0.1 → 2.1.0-rc.2
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/README.md +8 -4
- package/package.json +1 -1
- package/skills/c64-assembler/SKILL.md +24 -9
- package/skills/c64-assembler/scripts/assembler.ts +95 -0
- package/skills/c64-basic/SKILL.md +17 -9
- package/skills/c64-basic/scripts/basic.ts +26 -36
- package/skills/c64-disk/SKILL.md +17 -7
- package/skills/c64-disk/scripts/disk.ts +130 -104
- package/skills/c64-disk/scripts/lib/containers.ts +10 -12
- package/skills/c64-docs/SKILL.md +47 -28
- package/skills/c64-docs/references/style.md +6 -6
- package/skills/c64-docs/scripts/docs.ts +323 -0
- package/skills/c64-docs/scripts/lib/source-map.ts +1 -1
- package/skills/c64-emulator/SKILL.md +11 -7
- package/skills/c64-knowledge/SKILL.md +34 -21
- package/skills/c64-knowledge/scripts/knowledge.ts +597 -718
- package/skills/c64-knowledge/scripts/lib/listing.ts +5 -7
- package/skills/c64-memory-map/SKILL.md +18 -10
- package/skills/c64-memory-map/scripts/memory-map.ts +81 -0
- package/skills/c64-provenance/SKILL.md +82 -26
- package/skills/c64-provenance/scripts/lib/compare.ts +8 -12
- package/skills/c64-provenance/scripts/lib/leftovers.ts +442 -0
- package/skills/c64-provenance/scripts/lib/library.ts +15 -25
- package/skills/c64-provenance/scripts/lib/runtime.ts +12 -37
- package/skills/c64-provenance/scripts/lib/separate.ts +9 -10
- package/skills/c64-provenance/scripts/provenance.ts +222 -252
- package/skills/c64-reverse-engineering/SKILL.md +18 -15
- package/skills/c64-source/SKILL.md +28 -16
- package/skills/c64-source/scripts/lib/loader.ts +52 -77
- package/skills/c64-source/scripts/lib/source.ts +33 -42
- package/skills/c64-source/scripts/{generate.ts → source.ts} +68 -109
- package/skills/c64-static-analysis/SKILL.md +24 -13
- package/skills/c64-static-analysis/scripts/lib/coverage.ts +3 -4
- package/skills/c64-static-analysis/scripts/lib/findings.ts +2 -2
- package/skills/c64-static-analysis/scripts/lib/instructions.ts +1 -1
- package/skills/c64-static-analysis/scripts/lib/macros.ts +6 -8
- package/skills/c64-static-analysis/scripts/{analyze.ts → static-analysis.ts} +58 -118
- package/skills/c64-testing/SKILL.md +94 -19
- package/skills/c64-testing/references/recording-format.md +125 -0
- package/skills/c64-testing/references/scenario-format.md +24 -19
- package/skills/c64-testing/scripts/{diff.ts → lib/diff-files.ts} +18 -69
- package/skills/c64-testing/scripts/lib/diff.ts +5 -3
- package/skills/c64-testing/scripts/lib/play.ts +334 -0
- package/skills/c64-testing/scripts/lib/recording.ts +662 -0
- package/skills/c64-testing/scripts/lib/replay.ts +105 -0
- package/skills/c64-testing/scripts/{run.ts → lib/runs.ts} +36 -59
- package/skills/c64-testing/scripts/lib/scenario.ts +73 -81
- package/skills/c64-testing/scripts/testing.ts +189 -0
- package/skills/c64-unpacker/SKILL.md +42 -25
- package/skills/c64-unpacker/scripts/lib/evidence.ts +3 -4
- package/skills/c64-unpacker/scripts/lib/loads.ts +12 -10
- package/skills/c64-unpacker/scripts/lib/machine.ts +0 -2
- package/skills/c64-unpacker/scripts/lib/stages.ts +14 -19
- package/skills/c64-unpacker/scripts/{unpack.ts → unpacker.ts} +109 -211
- package/src/c64.ts +35 -20
- package/src/cli/main.ts +2 -2
- package/src/errors.ts +38 -0
- package/src/graphics.ts +3 -1
- package/src/host/server.ts +3 -2
- package/src/host/vice/session.ts +4 -4
- package/src/host-client/entry-state.ts +106 -0
- package/src/host-client/image.ts +21 -10
- package/src/host-client/steps.ts +285 -108
- package/src/host-client/transfer.ts +21 -15
- package/src/host-client/vice-session.ts +1 -1
- package/src/knowledge/database.ts +3 -23
- package/src/knowledge/expression.ts +8 -11
- package/src/knowledge/history.ts +2 -2
- package/src/knowledge/import.ts +13 -14
- package/src/knowledge/names.ts +50 -0
- package/src/knowledge/program.ts +11 -10
- package/src/knowledge/read.ts +16 -21
- package/src/knowledge/schema.ts +9 -3
- package/src/knowledge/text.ts +5 -5
- package/src/knowledge/write.ts +93 -137
- package/src/mcp/inputs.ts +105 -0
- package/src/mcp/output.ts +2 -13
- package/src/mcp/server.ts +12 -60
- package/src/mcp/tools/debug.ts +17 -12
- package/src/mcp/tools/execution.ts +5 -32
- package/src/mcp/tools/input.ts +7 -32
- package/src/mcp/tools/media.ts +6 -5
- package/src/mcp/tools/memory.ts +11 -38
- package/src/mcp/tools/video.ts +6 -5
- package/src/native/c64run.ts +162 -0
- package/src/native/discover.ts +8 -0
- package/src/native/dxa.ts +7 -6
- package/src/native/ghidra/analyze.ts +2 -1
- package/src/native/ghidra/index.ts +2 -1
- package/src/native/processes.ts +4 -3
- package/src/native/status.ts +25 -4
- package/src/native/types.ts +3 -7
- package/src/opcodes.ts +13 -13
- package/src/platform.ts +9 -9
- package/src/project.ts +16 -26
- package/src/protocol/messages.ts +3 -21
- package/src/protocol/vice.ts +7 -7
- package/src/script.ts +491 -49
- package/skills/c64-assembler/scripts/assemble.ts +0 -138
- package/skills/c64-docs/scripts/generate.ts +0 -109
- package/skills/c64-docs/scripts/link.ts +0 -239
- package/skills/c64-memory-map/scripts/memmap.ts +0 -92
- package/skills/c64-testing/scripts/checklist.ts +0 -51
- package/skills/c64-testing/scripts/test.ts +0 -65
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
// A person plays the program in the replay core: a page on localhost shows
|
|
2
|
+
// the screen and sends the keyboard and gamepad. The machine is frozen at the
|
|
3
|
+
// start screen until Start on the page; then the core runs one frame each
|
|
4
|
+
// 20 ms. End freezes it again and the recording is saved; Cancel ends the
|
|
5
|
+
// session without one. The reads of the program keep the input, so the
|
|
6
|
+
// recording needs nothing from the page. RESTORE is no read: it is an event
|
|
7
|
+
// of the recording (see record in recording.ts).
|
|
8
|
+
|
|
9
|
+
import { createServer, type ServerResponse } from "node:http";
|
|
10
|
+
|
|
11
|
+
import { PAL_FRAME_CYCLES, type C64RunSession } from "#src/native/c64run.ts";
|
|
12
|
+
|
|
13
|
+
const FRAME_MS = 20;
|
|
14
|
+
|
|
15
|
+
/** The reason that play gives when the user clicks Cancel. */
|
|
16
|
+
export const CANCELLED = "the user cancelled the session on the page";
|
|
17
|
+
|
|
18
|
+
export interface PlayOptions {
|
|
19
|
+
/** The TCP port of the page; 0 takes a free one. */
|
|
20
|
+
httpPort: number;
|
|
21
|
+
/** The control port that the joystick keys and the gamepad move first; F9 on the page changes it. */
|
|
22
|
+
joystick: 1 | 2;
|
|
23
|
+
/** Called with the address of the page when it listens. */
|
|
24
|
+
ready: (url: string) => void;
|
|
25
|
+
/** Presses RESTORE as an event of the recording. A reason stops the session. */
|
|
26
|
+
restore: () => Promise<string | undefined>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The key codes of the replay core: chips c64.h, and 11, 12 and 5e of c64run. */
|
|
30
|
+
const C64 = {
|
|
31
|
+
del: 0x01, clr: 0x02, stop: 0x03, leftArrow: 0x04, run: 0x07, csrLeft: 0x08, csrRight: 0x09, csrDown: 0x0a, csrUp: 0x0b,
|
|
32
|
+
home: 0x0c, return: 0x0d, ctrl: 0x0e, cbm: 0x0f, inst: 0x10, leftShift: 0x11, rightShift: 0x12, space: 0x20, upArrow: 0x5e, pound: 0x7e,
|
|
33
|
+
} as const;
|
|
34
|
+
const F_KEYS = [0xf1, 0xf2, 0xf3, 0xf4, 0xf5, 0xf6, 0xf7, 0xf8];
|
|
35
|
+
const LETTERS = [..."ABCDEFGHIJKLMNOPQRSTUVWXYZ"];
|
|
36
|
+
const DIGITS = [..."0123456789"];
|
|
37
|
+
const code = (character: string) => character.charCodeAt(0);
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The key maps of the page. Each one maps a KeyboardEvent property to a key code.
|
|
41
|
+
* The symbolic map (event.key) gives the character on the PC key; the positional
|
|
42
|
+
* map (event.code) gives the C64 key at the place of the PC key, with each SHIFT alone.
|
|
43
|
+
*/
|
|
44
|
+
export const KEY_MAPS = {
|
|
45
|
+
/** event.code, in both maps. */
|
|
46
|
+
both: { Tab: C64.ctrl, AltLeft: C64.cbm },
|
|
47
|
+
/** event.code, when the arrow keys and space are keyboard keys. */
|
|
48
|
+
arrows: { ArrowUp: C64.csrUp, ArrowDown: C64.csrDown, ArrowLeft: C64.csrLeft, ArrowRight: C64.csrRight, Space: C64.space },
|
|
49
|
+
/** event.code */
|
|
50
|
+
positional: {
|
|
51
|
+
...Object.fromEntries(LETTERS.map((letter) => ["Key" + letter, code(letter)])),
|
|
52
|
+
...Object.fromEntries(DIGITS.map((digit) => ["Digit" + digit, code(digit)])),
|
|
53
|
+
...Object.fromEntries(F_KEYS.map((key, index) => ["F" + (index + 1), key])),
|
|
54
|
+
Backquote: C64.leftArrow, Minus: code("+"), Equal: code("-"), Insert: C64.pound, Home: C64.home, Backspace: C64.del,
|
|
55
|
+
BracketLeft: code("@"), BracketRight: code("*"), Delete: C64.upArrow,
|
|
56
|
+
Escape: C64.stop, Semicolon: code(":"), Quote: code(";"), Backslash: code("="), Enter: C64.return,
|
|
57
|
+
ShiftLeft: C64.leftShift, ShiftRight: C64.rightShift, Comma: code(","), Period: code("."), Slash: code("/"),
|
|
58
|
+
} as Record<string, number>,
|
|
59
|
+
/** event.key; a-z are the C64 letters, A-Z the letters with SHIFT. */
|
|
60
|
+
symbolic: {
|
|
61
|
+
...Object.fromEntries(LETTERS.flatMap((letter) => [[letter.toLowerCase(), code(letter)], [letter, code(letter.toLowerCase())]])),
|
|
62
|
+
...Object.fromEntries([...DIGITS, ..."+-.:@,*;=/!\"#$%&'()[]<>?"].map((character) => [character, code(character)])),
|
|
63
|
+
...Object.fromEntries(F_KEYS.map((key, index) => ["F" + (index + 1), key])),
|
|
64
|
+
"£": C64.pound, "^": C64.upArrow, "`": C64.leftArrow,
|
|
65
|
+
Enter: C64.return, Backspace: C64.del, Delete: C64.del, Insert: C64.inst, Home: C64.home, Escape: C64.stop,
|
|
66
|
+
} as Record<string, number>,
|
|
67
|
+
/** event.key with SHIFT held, in the symbolic map. */
|
|
68
|
+
symbolicShift: { Home: C64.clr, Backspace: C64.inst, Escape: C64.run, F1: 0xf2, F3: 0xf4, F5: 0xf6, F7: 0xf8 } as Record<string, number>,
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/** The key codes that the page can send: every code of the key maps. */
|
|
72
|
+
const PAGE_KEYS = new Set(Object.values(KEY_MAPS).flatMap((map) => Object.values(map)));
|
|
73
|
+
|
|
74
|
+
const PAGE = String.raw`<!doctype html>
|
|
75
|
+
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
|
76
|
+
<title>c64-testing record</title>
|
|
77
|
+
<style>
|
|
78
|
+
body{margin:0;background:#111;color:#ddd;font:14px system-ui,sans-serif;display:flex;flex-direction:column;align-items:center;gap:12px;padding:16px}
|
|
79
|
+
canvas{image-rendering:pixelated;width:min(784px,100%);aspect-ratio:392/272;background:#000}
|
|
80
|
+
button{font:inherit;font-size:12px;padding:3px 8px}
|
|
81
|
+
.row{display:flex;flex-wrap:wrap;gap:6px;align-items:center;justify-content:center}
|
|
82
|
+
.gap{width:12px}
|
|
83
|
+
table{border-collapse:collapse;font-size:13px}td{padding:2px 10px;border-top:1px solid #333}
|
|
84
|
+
</style></head><body>
|
|
85
|
+
<div class="row"><button id="start" disabled>Start (Enter)</button><button id="end" disabled>End and save (F12)</button><button id="cancel" disabled>Cancel (F11)</button><span class="gap"></span><button id="port">Port 2 (F9)</button><button id="arrows">Arrows: joystick (F10)</button><button id="map">Keys: symbolic</button></div>
|
|
86
|
+
<canvas id="screen" width="392" height="272"></canvas>
|
|
87
|
+
<div id="state">connecting</div>
|
|
88
|
+
<table>
|
|
89
|
+
<tr><td>Start, End, Cancel</td><td>Enter (on the start screen), F12, F11</td></tr>
|
|
90
|
+
<tr><td>Joystick</td><td>arrow keys and space (when F10 sets joystick), or a gamepad</td></tr>
|
|
91
|
+
<tr><td>RUN/STOP, RESTORE</td><td>Esc, Page Up</td></tr>
|
|
92
|
+
<tr><td>CTRL, C=</td><td>Tab, left Alt</td></tr>
|
|
93
|
+
<tr><td>CLR/HOME, INST/DEL</td><td>Home, Backspace</td></tr>
|
|
94
|
+
<tr><td>Symbolic map</td><td>the characters on the PC keys; £, ^ (up arrow) and the backquote (left arrow)</td></tr>
|
|
95
|
+
<tr><td>Positional map</td><td>the C64 keys at the places of the PC keys; each SHIFT alone; Insert £, Delete up arrow, the backquote left arrow</td></tr>
|
|
96
|
+
</table>
|
|
97
|
+
<script>
|
|
98
|
+
const KEYS = __KEYS__;
|
|
99
|
+
const BITS = { ArrowUp: 1, ArrowDown: 2, ArrowLeft: 4, ArrowRight: 8, Space: 16 };
|
|
100
|
+
const $ = (id) => document.getElementById(id);
|
|
101
|
+
const canvas = $("screen"), context = canvas.getContext("2d"), state = $("state");
|
|
102
|
+
let palette = [], image = context.createImageData(392, 272), frames = 0, phase = "connecting", port = 2, arrows = "joystick", keyMap = "symbolic", sent = -1;
|
|
103
|
+
// The C64 key of each PC key that is down (by event.code), and the joystick keys that are down.
|
|
104
|
+
const pressed = new Map(), stick = new Set();
|
|
105
|
+
// One request at a time, so that the core gets each key down before its key up.
|
|
106
|
+
let queue = Promise.resolve();
|
|
107
|
+
function send(path) { queue = queue.then(() => fetch(path, { method: "POST" })).catch(() => {}); }
|
|
108
|
+
function show() {
|
|
109
|
+
$("start").disabled = phase !== "waiting";
|
|
110
|
+
$("end").disabled = phase !== "playing";
|
|
111
|
+
$("cancel").disabled = phase !== "waiting" && phase !== "playing";
|
|
112
|
+
$("port").textContent = "Port " + port + " (F9)";
|
|
113
|
+
$("arrows").textContent = "Arrows: " + arrows + " (F10)";
|
|
114
|
+
$("map").textContent = "Keys: " + keyMap;
|
|
115
|
+
if (phase === "waiting") state.textContent = "Frozen at the start: read the screen, then press Start.";
|
|
116
|
+
else if (phase === "playing") state.textContent = "Frame " + frames;
|
|
117
|
+
else if (phase === "ended") state.textContent = "Frozen at frame " + frames + ". The script saves the recording.";
|
|
118
|
+
else if (phase === "cancelled") state.textContent = "Cancelled. No recording is saved.";
|
|
119
|
+
else if (phase === "failed") state.textContent = "The session stopped. The script output gives the reason. No recording is saved.";
|
|
120
|
+
else state.textContent = phase;
|
|
121
|
+
}
|
|
122
|
+
function releaseAll() {
|
|
123
|
+
for (const code of pressed.values()) send("key?code=" + code.toString(16) + "&down=0");
|
|
124
|
+
pressed.clear();
|
|
125
|
+
stick.clear();
|
|
126
|
+
}
|
|
127
|
+
const source = new EventSource("frames");
|
|
128
|
+
source.addEventListener("palette", (event) => { palette = event.data.split(" ").map((hex) => [0, 2, 4].map((at) => parseInt(hex.slice(at, at + 2), 16))); });
|
|
129
|
+
source.addEventListener("state", (event) => { phase = event.data; if (phase === "playing") sent = -1; show(); });
|
|
130
|
+
source.addEventListener("port", (event) => { port = +event.data; show(); });
|
|
131
|
+
source.onmessage = (event) => {
|
|
132
|
+
const [width, height, data] = event.data.split(" ");
|
|
133
|
+
if (image.width != width) { canvas.width = width; canvas.height = height; image = context.createImageData(+width, +height); }
|
|
134
|
+
const bytes = atob(data);
|
|
135
|
+
for (let at = 0; at < bytes.length; at++) { const rgb = palette[bytes.charCodeAt(at)] || [0, 0, 0]; image.data.set(rgb, at * 4); image.data[at * 4 + 3] = 255; }
|
|
136
|
+
context.putImageData(image, 0, 0);
|
|
137
|
+
if (phase === "playing") { frames++; show(); }
|
|
138
|
+
};
|
|
139
|
+
// The canvas keeps the last frame when the stream closes.
|
|
140
|
+
source.onerror = () => {
|
|
141
|
+
source.close();
|
|
142
|
+
if (phase !== "ended" && phase !== "cancelled" && phase !== "failed") { phase = "the session ended"; show(); }
|
|
143
|
+
};
|
|
144
|
+
function joystick() {
|
|
145
|
+
let mask = 0;
|
|
146
|
+
for (const key of stick) mask |= BITS[key];
|
|
147
|
+
for (const pad of navigator.getGamepads ? navigator.getGamepads() : []) {
|
|
148
|
+
if (!pad) continue;
|
|
149
|
+
const [x, y] = pad.axes;
|
|
150
|
+
if (y < -0.5 || pad.buttons[12]?.pressed) mask |= 1;
|
|
151
|
+
if (y > 0.5 || pad.buttons[13]?.pressed) mask |= 2;
|
|
152
|
+
if (x < -0.5 || pad.buttons[14]?.pressed) mask |= 4;
|
|
153
|
+
if (x > 0.5 || pad.buttons[15]?.pressed) mask |= 8;
|
|
154
|
+
if (pad.buttons[0]?.pressed || pad.buttons[1]?.pressed) mask |= 16;
|
|
155
|
+
}
|
|
156
|
+
if (phase === "playing" && mask !== sent) { sent = mask; send("joystick?mask=" + mask); }
|
|
157
|
+
requestAnimationFrame(joystick);
|
|
158
|
+
}
|
|
159
|
+
requestAnimationFrame(joystick);
|
|
160
|
+
function c64Key(event) {
|
|
161
|
+
if (event.code in KEYS.both) return KEYS.both[event.code];
|
|
162
|
+
if (arrows === "keyboard" && event.code in KEYS.arrows) return KEYS.arrows[event.code];
|
|
163
|
+
if (keyMap === "positional") return KEYS.positional[event.code];
|
|
164
|
+
if (event.shiftKey && event.key in KEYS.symbolicShift) return KEYS.symbolicShift[event.key];
|
|
165
|
+
return KEYS.symbolic[event.key];
|
|
166
|
+
}
|
|
167
|
+
function toggleArrows() { releaseAll(); arrows = arrows === "joystick" ? "keyboard" : "joystick"; show(); }
|
|
168
|
+
function toggleMap() { releaseAll(); keyMap = keyMap === "symbolic" ? "positional" : "symbolic"; show(); }
|
|
169
|
+
function key(event, down) {
|
|
170
|
+
// Ctrl and Cmd keep their browser use: they are not C64 keys.
|
|
171
|
+
if (event.ctrlKey || event.metaKey) return;
|
|
172
|
+
// The keys of the page: they are not C64 keys. Enter is Start only on the start screen.
|
|
173
|
+
const command = { F9: "port", F10: "arrows", F11: "cancel", F12: "end", PageUp: "restore" }[event.code] ?? (phase === "waiting" && event.code === "Enter" ? "start" : undefined);
|
|
174
|
+
if (command !== undefined) {
|
|
175
|
+
event.preventDefault();
|
|
176
|
+
if (!down || event.repeat) return;
|
|
177
|
+
if (command === "arrows") toggleArrows();
|
|
178
|
+
else if (command !== "restore" || phase === "playing") send(command);
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
if (arrows === "joystick" && event.code in BITS) { event.preventDefault(); down ? stick.add(event.code) : stick.delete(event.code); return; }
|
|
182
|
+
if (!down) {
|
|
183
|
+
const code = pressed.get(event.code);
|
|
184
|
+
if (code === undefined) return;
|
|
185
|
+
event.preventDefault();
|
|
186
|
+
pressed.delete(event.code);
|
|
187
|
+
send("key?code=" + code.toString(16) + "&down=0");
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
const code = c64Key(event);
|
|
191
|
+
if (code === undefined) return;
|
|
192
|
+
event.preventDefault();
|
|
193
|
+
if (event.repeat || phase !== "playing" || pressed.has(event.code)) return;
|
|
194
|
+
pressed.set(event.code, code);
|
|
195
|
+
send("key?code=" + code.toString(16) + "&down=1");
|
|
196
|
+
}
|
|
197
|
+
addEventListener("keydown", (event) => key(event, true));
|
|
198
|
+
addEventListener("keyup", (event) => key(event, false));
|
|
199
|
+
addEventListener("blur", releaseAll);
|
|
200
|
+
$("start").onclick = () => send("start");
|
|
201
|
+
$("end").onclick = () => send("end");
|
|
202
|
+
$("cancel").onclick = () => send("cancel");
|
|
203
|
+
$("port").onclick = () => send("port");
|
|
204
|
+
$("arrows").onclick = toggleArrows;
|
|
205
|
+
$("map").onclick = toggleMap;
|
|
206
|
+
show();
|
|
207
|
+
</script></body></html>`.replace("__KEYS__", JSON.stringify(KEY_MAPS));
|
|
208
|
+
|
|
209
|
+
/** The phases of a session: the start screen, the play, then End, Cancel or a failed RESTORE. */
|
|
210
|
+
type Phase = "waiting" | "playing" | "ended" | "cancelled" | "failed";
|
|
211
|
+
|
|
212
|
+
/** A RESTORE press in the queue of the page input. */
|
|
213
|
+
const RESTORE = Symbol("restore");
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Shows the start screen frozen until Start, then runs the core in real time
|
|
217
|
+
* with the page as its joystick and keyboard. End freezes the machine and gives
|
|
218
|
+
* undefined: the recording is complete. Cancel gives CANCELLED, and a RESTORE
|
|
219
|
+
* that fails gives its reason. No frame runs after End or Cancel.
|
|
220
|
+
*/
|
|
221
|
+
export async function play(core: Pick<C64RunSession, "command" | "status">, options: PlayOptions): Promise<string | undefined> {
|
|
222
|
+
const palette = (await core.command("palette")).split(" ").slice(1).join(" ");
|
|
223
|
+
const viewers = new Set<ServerResponse>();
|
|
224
|
+
const pending: Array<string | typeof RESTORE> = [];
|
|
225
|
+
let mask = 0;
|
|
226
|
+
let port = options.joystick;
|
|
227
|
+
let reason: string | undefined;
|
|
228
|
+
// Not narrowed: the request handlers change it.
|
|
229
|
+
let phase = "waiting" as Phase;
|
|
230
|
+
let leaveWaiting: () => void = () => {};
|
|
231
|
+
const leftWaiting = new Promise<void>((resolve) => (leaveWaiting = resolve));
|
|
232
|
+
const tell = (viewer: ServerResponse) => viewer.write("event: state\ndata: " + phase + "\n\nevent: port\ndata: " + port + "\n\n");
|
|
233
|
+
// The phase changes in the request handler, so the loop sees it before its next frame.
|
|
234
|
+
const enter = (next: Phase) => {
|
|
235
|
+
phase = next;
|
|
236
|
+
for (const viewer of viewers) tell(viewer);
|
|
237
|
+
leaveWaiting();
|
|
238
|
+
};
|
|
239
|
+
const server = createServer((request, response) => {
|
|
240
|
+
const url = new URL(request.url ?? "/", "http://localhost");
|
|
241
|
+
const answer = (allowed: boolean, next?: Phase) => {
|
|
242
|
+
if (!allowed) return void response.writeHead(409).end();
|
|
243
|
+
if (next !== undefined) enter(next);
|
|
244
|
+
response.writeHead(204).end();
|
|
245
|
+
};
|
|
246
|
+
if (request.method === "GET" && url.pathname === "/") {
|
|
247
|
+
response.writeHead(200, { "content-type": "text/html; charset=utf-8" }).end(PAGE);
|
|
248
|
+
} else if (request.method === "GET" && url.pathname === "/frames") {
|
|
249
|
+
response.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-store" });
|
|
250
|
+
response.write("event: palette\ndata: " + palette + "\n\n");
|
|
251
|
+
tell(response);
|
|
252
|
+
viewers.add(response);
|
|
253
|
+
request.on("close", () => viewers.delete(response));
|
|
254
|
+
// The frozen start screen; in play, the loop sends each frame.
|
|
255
|
+
if (phase === "waiting") {
|
|
256
|
+
core.command("frame").then(
|
|
257
|
+
(frame) => void response.write("data: " + frame.slice(3) + "\n\n"),
|
|
258
|
+
() => {},
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
} else if (request.method === "POST" && url.pathname === "/joystick") {
|
|
262
|
+
if (phase === "playing") mask = Number(url.searchParams.get("mask")) & 0x1f;
|
|
263
|
+
answer(phase === "playing");
|
|
264
|
+
} else if (request.method === "POST" && url.pathname === "/key") {
|
|
265
|
+
const key = Number.parseInt(url.searchParams.get("code") ?? "", 16);
|
|
266
|
+
if (!PAGE_KEYS.has(key)) return void response.writeHead(400).end();
|
|
267
|
+
if (phase === "playing") pending.push("key " + key.toString(16) + " " + (url.searchParams.get("down") === "1" ? "down" : "up"));
|
|
268
|
+
answer(phase === "playing");
|
|
269
|
+
} else if (request.method === "POST" && url.pathname === "/restore") {
|
|
270
|
+
if (phase === "playing") pending.push(RESTORE);
|
|
271
|
+
answer(phase === "playing");
|
|
272
|
+
} else if (request.method === "POST" && url.pathname === "/port") {
|
|
273
|
+
// The other port; the joystick mask moves with it, the old port is released.
|
|
274
|
+
if (phase === "waiting" || phase === "playing") {
|
|
275
|
+
port = port === 1 ? 2 : 1;
|
|
276
|
+
for (const viewer of viewers) tell(viewer);
|
|
277
|
+
}
|
|
278
|
+
answer(phase === "waiting" || phase === "playing");
|
|
279
|
+
} else if (request.method === "POST" && url.pathname === "/start") {
|
|
280
|
+
answer(phase === "waiting", "playing");
|
|
281
|
+
} else if (request.method === "POST" && url.pathname === "/end") {
|
|
282
|
+
answer(phase === "playing", "ended");
|
|
283
|
+
} else if (request.method === "POST" && url.pathname === "/cancel") {
|
|
284
|
+
if (phase === "waiting" || phase === "playing") reason = CANCELLED;
|
|
285
|
+
answer(phase === "waiting" || phase === "playing", "cancelled");
|
|
286
|
+
} else {
|
|
287
|
+
response.writeHead(404).end();
|
|
288
|
+
}
|
|
289
|
+
});
|
|
290
|
+
await new Promise<void>((resolve) => server.listen(options.httpPort, "127.0.0.1", resolve));
|
|
291
|
+
const address = server.address();
|
|
292
|
+
options.ready("http://localhost:" + (typeof address === "object" && address !== null ? address.port : options.httpPort) + "/");
|
|
293
|
+
let tick = (await core.status("status")).tick;
|
|
294
|
+
let sent = "";
|
|
295
|
+
try {
|
|
296
|
+
await leftWaiting;
|
|
297
|
+
while (phase === "playing") {
|
|
298
|
+
const started = Date.now();
|
|
299
|
+
const joy = port === 1 ? "joy " + mask.toString(16) + " 0" : "joy 0 " + mask.toString(16);
|
|
300
|
+
if (joy !== sent) {
|
|
301
|
+
await core.command(joy);
|
|
302
|
+
sent = joy;
|
|
303
|
+
}
|
|
304
|
+
while (pending.length > 0 && phase === "playing") {
|
|
305
|
+
const next = pending.shift()!;
|
|
306
|
+
if (next !== RESTORE) {
|
|
307
|
+
await core.command(next);
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
const failed = await options.restore();
|
|
311
|
+
if (failed !== undefined) {
|
|
312
|
+
reason = failed;
|
|
313
|
+
enter("failed");
|
|
314
|
+
}
|
|
315
|
+
// The event ran the machine to its point and held RESTORE: the frames go on from there.
|
|
316
|
+
tick = (await core.status("status")).tick;
|
|
317
|
+
}
|
|
318
|
+
// End or Cancel while the input went in: the machine stays at the frame on the page.
|
|
319
|
+
if (phase !== "playing") break;
|
|
320
|
+
tick += PAL_FRAME_CYCLES;
|
|
321
|
+
await core.status("run " + tick);
|
|
322
|
+
if (viewers.size > 0) {
|
|
323
|
+
const frame = (await core.command("frame")).slice(3);
|
|
324
|
+
for (const viewer of viewers) viewer.write("data: " + frame + "\n\n");
|
|
325
|
+
}
|
|
326
|
+
await new Promise((resolve) => setTimeout(resolve, Math.max(0, FRAME_MS - (Date.now() - started))));
|
|
327
|
+
}
|
|
328
|
+
} finally {
|
|
329
|
+
await core.command("joy 0 0");
|
|
330
|
+
for (const viewer of viewers) viewer.end();
|
|
331
|
+
await new Promise((resolve) => server.close(resolve));
|
|
332
|
+
}
|
|
333
|
+
return reason;
|
|
334
|
+
}
|