luanette 0.0.0-stage → 0.4.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/LICENSE +21 -0
- package/README.md +19 -2
- package/api.json +1 -0
- package/engine/luanette-node.js +0 -0
- package/lib/gif.js +103 -0
- package/lib/tables.js +67 -0
- package/luanette.mjs +456 -0
- package/mcp.mjs +175 -0
- package/package.json +11 -4
- package/pyrandom.mjs +50 -0
- package/templates/blank.lua +45 -0
- package/templates/platformer.lua +95 -0
- package/templates/puzzle.lua +96 -0
- package/templates/shooter.lua +108 -0
package/luanette.mjs
ADDED
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Luanette's command-line tool: the console's own engine (WebAssembly, the playground's) in Node,
|
|
3
|
+
// with nothing to build or install. For people and their coding agents:
|
|
4
|
+
//
|
|
5
|
+
// luanette check CART.lua [--frames N] [--input FILE] what check.py says, as JSON
|
|
6
|
+
// luanette run CART.lua [--frames N] [--input FILE] [--shots 1,30,90] [--out DIR] [--gif]
|
|
7
|
+
// luanette sound "C4 pulse 5, E4 slide, -, G4 saw 7 fadeout" [--speed N] [--loop A-B]
|
|
8
|
+
// luanette sound CART.lua N --wav OUT.wav a cart's sound, as a WAV
|
|
9
|
+
// luanette song CART.lua [--from N] --wav OUT.wav its song
|
|
10
|
+
// luanette link CART.lua [--name NAME] a playground share link
|
|
11
|
+
// luanette new NAME [--template blank|platformer|shooter|puzzle]
|
|
12
|
+
// luanette mcp an MCP server over stdio (for agents)
|
|
13
|
+
//
|
|
14
|
+
// docs/cli.md has the details and the input script's format.
|
|
15
|
+
import fs from "node:fs";
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
import zlib from "node:zlib";
|
|
18
|
+
import { createRequire } from "node:module";
|
|
19
|
+
import { fileURLToPath } from "node:url";
|
|
20
|
+
import { pyRandom } from "./pyrandom.mjs";
|
|
21
|
+
|
|
22
|
+
const require = createRequire(import.meta.url);
|
|
23
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
24
|
+
// The package carries its parts beside this file; in the repository they're where the build leaves them.
|
|
25
|
+
const find = (...tries) => tries.map((t) => path.join(HERE, t)).find((p) => fs.existsSync(p));
|
|
26
|
+
const ENGINE = find("engine/luanette-node.js", "../web/build/luanette-node.js");
|
|
27
|
+
const LIB = find("lib", "../web");
|
|
28
|
+
const TEMPLATES = find("templates", "../templates");
|
|
29
|
+
export const PLAYGROUND = "https://geofresh.github.io/luanette/";
|
|
30
|
+
const Gif = require(path.join(LIB, "gif.js"));
|
|
31
|
+
const Tables = require(path.join(LIB, "tables.js"));
|
|
32
|
+
|
|
33
|
+
export const BUDGET = 30000, MAX_SOURCE = 32 * 1024, API_VERSION = 4;
|
|
34
|
+
const W = 410, H = 502; // the glass
|
|
35
|
+
const BUTTONS = { left: 1, right: 2, up: 4, down: 8, a: 16, b: 32 };
|
|
36
|
+
|
|
37
|
+
// ---- the engine ------------------------------------------------------------------------------------
|
|
38
|
+
let engine = null;
|
|
39
|
+
export async function loadEngine() {
|
|
40
|
+
if (engine) return engine;
|
|
41
|
+
if (!ENGINE) throw new Error("the engine isn't built: run web/build.sh (in the repository), or install the npm package");
|
|
42
|
+
const m = await require(ENGINE)();
|
|
43
|
+
const c = (n, r, a) => m.cwrap(n, r, a);
|
|
44
|
+
engine = {
|
|
45
|
+
m,
|
|
46
|
+
start: c("lw_start", "number", ["string", "string"]),
|
|
47
|
+
frame: c("lw_frame", "number", ["number", "number", "number", "number", "number", "number"]),
|
|
48
|
+
error: c("lw_error", "string", []),
|
|
49
|
+
ops: c("lw_ops", "number", []),
|
|
50
|
+
fps: c("lw_fps", "number", []),
|
|
51
|
+
lazy: c("lw_lazy", "number", []),
|
|
52
|
+
pixels: c("lw_pixels", "number", []),
|
|
53
|
+
logCount: c("lw_log_count", "number", []),
|
|
54
|
+
log: c("lw_log", "string", []),
|
|
55
|
+
sound: c("lw_sound", "number", []),
|
|
56
|
+
shipped: c("lw_shipped", "string", ["string"]),
|
|
57
|
+
clock: c("lw_clock", null, ["number"]),
|
|
58
|
+
compile: c("lw_compile", "string", ["string"]),
|
|
59
|
+
renderAudio: c("lw_render_audio", "string", ["string", "number", "number", "number", "number"]),
|
|
60
|
+
pcmData: c("lw_pcm_data", "number", []),
|
|
61
|
+
pcmLen: c("lw_pcm_len", "number", []),
|
|
62
|
+
colors: () => m.ccall("lw_colors", "string", [], []).split(" ").map((h) => "#" + h),
|
|
63
|
+
glass: () => new Uint8Array(m.HEAPU8.buffer, engine.pixels(), W * H * 4),
|
|
64
|
+
};
|
|
65
|
+
return engine;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ---- input: check.py's script, or yours ------------------------------------------------------------
|
|
69
|
+
// check.py's (tools/check.py script_input), the same each run: buttons on a 120-frame cycle, A now and
|
|
70
|
+
// then at random (Python's own generator, seeded 7), a tap and a drag, a slow tilt.
|
|
71
|
+
function checkInput() {
|
|
72
|
+
const rng = pyRandom(7);
|
|
73
|
+
return (f) => {
|
|
74
|
+
const phase = f % 120;
|
|
75
|
+
let held = 0;
|
|
76
|
+
[[40, 52], [8, 20], [56, 68], [24, 36]].forEach(([a, z], b) => { if (a <= phase && phase < z) held |= 1 << b; });
|
|
77
|
+
if ([5, 6, 30, 31, 61, 62, 90, 91].includes(phase) || rng() < 0.03) held |= BUTTONS.a; // (as Python: rng only when needed)
|
|
78
|
+
if ([75, 76, 105, 106].includes(phase)) held |= BUTTONS.b;
|
|
79
|
+
let touch = null;
|
|
80
|
+
if (phase >= 70 && phase < 73) touch = [64, 64];
|
|
81
|
+
else if (phase >= 80 && phase < 100) touch = [64, 110 - (phase - 80) * 4];
|
|
82
|
+
const r = (v) => Math.round(v * 1000) / 1000;
|
|
83
|
+
return { held, touch, tilt: [r(Math.sin(f / 20) * 0.6), r(Math.cos(f / 25) * 0.6)] };
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// An input script (docs/cli.md): a list of steps, each for `frame` N or `frames` "A-B":
|
|
88
|
+
// {"hold": ["right", "a"]} buttons down {"touch": [x, y]} a finger there (picture pixels)
|
|
89
|
+
// {"tap": [x, y]} touch, then lift: A on the frame it lifts (as the board does)
|
|
90
|
+
// {"drag": [[x0, y0], [x1, y1]]} a finger moving: the d-pad from its direction, as the board does
|
|
91
|
+
// {"tilt": [ax, ay]} each -1 to 1
|
|
92
|
+
export function scriptInput(steps) {
|
|
93
|
+
if (!Array.isArray(steps)) throw new Error("an input script is a JSON list of steps");
|
|
94
|
+
const span = (s) => {
|
|
95
|
+
if (s.frame !== undefined) return [Number(s.frame), Number(s.frame)];
|
|
96
|
+
const m = /^(\d+)\s*-\s*(\d+)$/.exec(String(s.frames || ""));
|
|
97
|
+
if (!m) throw new Error(`a step needs "frame": N or "frames": "A-B": ${JSON.stringify(s)}`);
|
|
98
|
+
return [Number(m[1]), Number(m[2])];
|
|
99
|
+
};
|
|
100
|
+
const bits = (names) => (names || []).reduce((b, n) => {
|
|
101
|
+
if (!(n in BUTTONS)) throw new Error(`a button is left, right, up, down, a or b, not ${n}`);
|
|
102
|
+
return b | BUTTONS[n];
|
|
103
|
+
}, 0);
|
|
104
|
+
return (f) => {
|
|
105
|
+
let held = 0, touch = null, tilt = [0, 0];
|
|
106
|
+
for (const s of steps) {
|
|
107
|
+
const [a, z] = span(s);
|
|
108
|
+
if (s.tap && f >= a && f <= a + 2) { // down for two frames, then lifted: A for that frame
|
|
109
|
+
if (f < a + 2) touch = s.tap; else held |= BUTTONS.a;
|
|
110
|
+
}
|
|
111
|
+
if (f < a || f > z) continue;
|
|
112
|
+
held |= bits(s.hold || s.press);
|
|
113
|
+
if (s.touch) touch = s.touch;
|
|
114
|
+
if (s.tilt) tilt = s.tilt;
|
|
115
|
+
if (s.drag) {
|
|
116
|
+
const t = z > a ? (f - a) / (z - a) : 1, [[x0, y0], [x1, y1]] = s.drag;
|
|
117
|
+
touch = [x0 + (x1 - x0) * t, y0 + (y1 - y0) * t];
|
|
118
|
+
const dx = touch[0] - x0, dy = touch[1] - y0;
|
|
119
|
+
if (dx * dx + dy * dy > 36) { // past 6 picture pixels (the board's 18 on the glass): the d-pad
|
|
120
|
+
const ax = Math.abs(dx), ay = Math.abs(dy);
|
|
121
|
+
if (ax * 5 > ay * 2) held |= dx < 0 ? BUTTONS.left : BUTTONS.right;
|
|
122
|
+
if (ay * 5 > ax * 2) held |= dy < 0 ? BUTTONS.up : BUTTONS.down;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return { held, touch, tilt };
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ---- running a cart --------------------------------------------------------------------------------
|
|
131
|
+
const strip = (e) => e.split("\nstack traceback")[0].trim();
|
|
132
|
+
// The runtime's words, as check.py puts them.
|
|
133
|
+
function startError(e) {
|
|
134
|
+
e = strip(e);
|
|
135
|
+
const m = /^in (the top of the cart|_init): ([^]*)$/.exec(e);
|
|
136
|
+
return m ? `error in ${m[1]}: ${m[2]}` : e;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Runs `frames` frames of the cart, its clock a frame's time each (as check.py's and cartrun's), and
|
|
140
|
+
// calls onFrame(f, input) after each. {error?, frame?, log: [{frame, line}], ops: [...], fps}
|
|
141
|
+
export async function runCart(src, name, frames, input, onFrame) {
|
|
142
|
+
const e = await loadEngine();
|
|
143
|
+
const log = [], ops = [];
|
|
144
|
+
const drain = (f) => { for (let n = e.logCount(); n > 0; n--) log.push({ frame: f, line: e.log() }); };
|
|
145
|
+
e.clock(0);
|
|
146
|
+
if (!e.start(name, src)) {
|
|
147
|
+
drain(0);
|
|
148
|
+
return { error: startError(e.error()), log, ops, fps: e.fps() };
|
|
149
|
+
}
|
|
150
|
+
drain(0);
|
|
151
|
+
let baseUs = 0, baseN = 0, rate = e.fps(), cur = 0;
|
|
152
|
+
const timeUs = () => baseUs + Math.floor((cur - baseN) * 1e6 / rate);
|
|
153
|
+
for (let f = 1; f <= frames; f++) {
|
|
154
|
+
if (e.fps() !== rate) { baseUs = timeUs(); baseN = f - 1; rate = e.fps(); }
|
|
155
|
+
cur = f;
|
|
156
|
+
e.clock(timeUs());
|
|
157
|
+
const i = input(f);
|
|
158
|
+
const alive = e.frame(i.held, i.touch ? 1 : 0, i.touch ? i.touch[0] : 0, i.touch ? i.touch[1] : 0, i.tilt[0], i.tilt[1]);
|
|
159
|
+
drain(f);
|
|
160
|
+
while (e.sound() >= 0); // (sounds aren't heard here)
|
|
161
|
+
ops.push(e.ops());
|
|
162
|
+
if (!alive) {
|
|
163
|
+
return { error: `error in frame ${f}: ` + strip(e.error()).replace(/^in (_update60|_update|_draw): /, ""), frame: f, log, ops, fps: rate };
|
|
164
|
+
}
|
|
165
|
+
if (onFrame) onFrame(f, i);
|
|
166
|
+
}
|
|
167
|
+
return { log, ops, fps: rate };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ---- check -----------------------------------------------------------------------------------------
|
|
171
|
+
// check.py's checks and words (tools/check.py check()), through the console's own engine. Its warnings
|
|
172
|
+
// are a subset of check.py's (docs/cli.md lists them).
|
|
173
|
+
export async function check(src, name, opts = {}) {
|
|
174
|
+
const e = await loadEngine();
|
|
175
|
+
const frames = opts.frames || 150;
|
|
176
|
+
const shipped = e.shipped(src);
|
|
177
|
+
const res = { ok: false, cart: opts.file || name, bytes: Buffer.byteLength(shipped), source_bytes: Buffer.byteLength(src) };
|
|
178
|
+
if (res.bytes > MAX_SOURCE) return { ...res, error: `cart is ${res.bytes} bytes without its comments; the limit is ${MAX_SOURCE}` };
|
|
179
|
+
const head = shipped.split("\n").slice(0, 12).join("\n");
|
|
180
|
+
const missing = ["title", "by", "about"].filter((k) => !new RegExp(`^--\\s*${k}\\s*:\\s*\\S`, "m").test(head));
|
|
181
|
+
if (missing.length) return { ...res, error: "missing header comment(s) at the top: " + missing.map((k) => `-- ${k}: ...`).join(", ") };
|
|
182
|
+
res.title = /^--\s*title\s*:\s*(.+)$/m.exec(head)[1].trim();
|
|
183
|
+
let api = 1, m = /^--\s*api\s*:\s*(.*?)\s*$/m.exec(head);
|
|
184
|
+
if (m) {
|
|
185
|
+
if (!/^\d+$/.test(m[1]) || Number(m[1]) < 1) return { ...res, error: `its header says '-- api: ${m[1]}'; that should be a whole number (1 to ${API_VERSION})` };
|
|
186
|
+
api = Number(m[1]);
|
|
187
|
+
if (api > API_VERSION) return { ...res, error: `it needs API ${api}; the board's runtime has ${API_VERSION}` };
|
|
188
|
+
}
|
|
189
|
+
res.api = api;
|
|
190
|
+
let full = false;
|
|
191
|
+
m = /^--\s*canvas\s*:\s*(.*?)\s*$/m.exec(head);
|
|
192
|
+
if (m) {
|
|
193
|
+
if (m[1] !== "full" && m[1] !== "square") return { ...res, error: `its header says '-- canvas: ${m[1]}'; that should be full or square` };
|
|
194
|
+
full = m[1] === "full";
|
|
195
|
+
}
|
|
196
|
+
if (full) res.canvas = "full";
|
|
197
|
+
const compiled = e.compile(shipped);
|
|
198
|
+
if (compiled) return { ...res, error: `doesn't load: ${compiled}` };
|
|
199
|
+
const code = shipped.replace(/--[^\n]*/g, "");
|
|
200
|
+
if (!/\b_(update60|update|draw)\b/.test(code)) return { ...res, error: "the cart defines none of _update, _update60, _draw" };
|
|
201
|
+
|
|
202
|
+
let quiet = 0, idle = 0, prev = null, last = null;
|
|
203
|
+
const hash = (px) => { let h = 2166136261; const u = new Uint32Array(px.buffer, px.byteOffset, px.length / 4); for (let i = 0; i < u.length; i++) h = Math.imul(h ^ u[i], 16777619); return h >>> 0; };
|
|
204
|
+
const input = opts.input || checkInput();
|
|
205
|
+
const run = await runCart(shipped, name, frames, input, (f, i) => {
|
|
206
|
+
const px = e.glass(), h = hash(px);
|
|
207
|
+
if (e.fps() >= 20 && !e.lazy() && !i.touch && !i.held) { // (as check.py: a lazy cart has an idle rate)
|
|
208
|
+
quiet++;
|
|
209
|
+
if (h === prev) idle++;
|
|
210
|
+
}
|
|
211
|
+
prev = h;
|
|
212
|
+
last = px;
|
|
213
|
+
});
|
|
214
|
+
if (run.log.length) res.log = run.log.map((l) => l.line);
|
|
215
|
+
if (run.error) return { ...res, error: run.error, ...(run.frame ? { frame: run.frame } : {}) };
|
|
216
|
+
const ops = run.ops;
|
|
217
|
+
Object.assign(res, { ok: true, frames, fps: run.fps, max_ops: Math.max(0, ...ops), avg_ops: Math.round(ops.reduce((a, b) => a + b, 0) / frames) });
|
|
218
|
+
const warn = (w) => (res.warnings = res.warnings || []).push(w);
|
|
219
|
+
if (api < 2 && /\bui\s*\./.test(code)) warn("it uses the runtime's ui helpers (API 2): add '-- api: 2' to its header, so a board with older firmware says it needs an update instead of failing at its first ui call");
|
|
220
|
+
else if (api < 2 && /\bhd\s*\./.test(code)) warn("it uses the full-color layer (API 2): add '-- api: 2' to its header, so a board with older firmware says it needs an update instead of failing at its first hd call");
|
|
221
|
+
if (api < 4 && /^\s*\[?\d*\]?\s*=?\s*"[0-9a-fA-F]+\s+[0-9a-fA-F]/m.test(code.split(/^SPRITES\s*=/m)[1] || "")) warn("its sprites have spaces between rows (API 4): add '-- api: 4' to its header, so a board with older firmware says it needs an update instead of failing to draw them");
|
|
222
|
+
if (api < 4 && run.log.length) warn("it calls log() (API 4): add '-- api: 4' to its header, so a board with older firmware says it needs an update instead of failing at its first log call");
|
|
223
|
+
if (api < 3 && /\bmusic\s*\(|^\s*(SOUNDS|MUSIC)\s*=/m.test(code)) warn("it has its own sounds or music (API 3): add '-- api: 3' to its header, so a board with older firmware says it needs an update instead of failing at its first sfx or music call");
|
|
224
|
+
if (!full) warn("it shows in the 384 x 384 window, about 30% of the glass left dark: add '-- canvas: full' (and '-- api: 2') and use the whole glass, laid out from caps(): the picture keeps its window, and the layer has the bands above and below it for a title, the score, what to do (docs/DESIGN.md, 1)");
|
|
225
|
+
if (quiet >= 20 && idle >= quiet * 0.6 && !/\bhd\s*\./.test(code)) warn(`${idle} of its ${quiet} frames without input (at ${run.fps} fps) changed nothing on the screen: give it a lazy rate, fps(60, idle) in _init (60 while touched, a few frames a second otherwise), and move things by time() (docs/DESIGN.md, 2 and 3)`);
|
|
226
|
+
if (last && new Uint32Array(last.buffer, last.byteOffset, last.length / 4).every((v, i, a) => v === a[0])) warn("the last frame is a single color: does _draw draw anything?");
|
|
227
|
+
return res;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// ---- images out: PNG and GIF -----------------------------------------------------------------------
|
|
231
|
+
const CRC = new Uint32Array(256).map((_, n) => { let c = n; for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; return c; });
|
|
232
|
+
const crc32 = (buf) => { let c = 0xffffffff; for (const b of buf) c = CRC[(c ^ b) & 255] ^ (c >>> 8); return (c ^ 0xffffffff) >>> 0; };
|
|
233
|
+
export function png(w, h, rgba) {
|
|
234
|
+
const raw = Buffer.alloc((w * 4 + 1) * h);
|
|
235
|
+
for (let y = 0; y < h; y++) Buffer.from(rgba.buffer, rgba.byteOffset + y * w * 4, w * 4).copy(raw, y * (w * 4 + 1) + 1);
|
|
236
|
+
const chunk = (type, data) => {
|
|
237
|
+
const len = Buffer.alloc(4), crc = Buffer.alloc(4), body = Buffer.concat([Buffer.from(type), data]);
|
|
238
|
+
len.writeUInt32BE(data.length);
|
|
239
|
+
crc.writeUInt32BE(crc32(body));
|
|
240
|
+
return Buffer.concat([len, body, crc]);
|
|
241
|
+
};
|
|
242
|
+
const ihdr = Buffer.alloc(13);
|
|
243
|
+
ihdr.writeUInt32BE(w, 0); ihdr.writeUInt32BE(h, 4); ihdr[8] = 8; ihdr[9] = 6;
|
|
244
|
+
return Buffer.concat([Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]), chunk("IHDR", ihdr), chunk("IDAT", zlib.deflateSync(raw)), chunk("IEND", Buffer.alloc(0))]);
|
|
245
|
+
}
|
|
246
|
+
const half = (rgba, w, h) => { // a 2x2 average
|
|
247
|
+
const w2 = w >> 1, h2 = h >> 1, out = new Uint8Array(w2 * h2 * 4);
|
|
248
|
+
for (let y = 0; y < h2; y++) for (let x = 0; x < w2; x++) for (let c = 0; c < 4; c++) {
|
|
249
|
+
const i = ((2 * y) * w + 2 * x) * 4 + c;
|
|
250
|
+
out[(y * w2 + x) * 4 + c] = (rgba[i] + rgba[i + 4] + rgba[i + w * 4] + rgba[i + w * 4 + 4] + 2) >> 2;
|
|
251
|
+
}
|
|
252
|
+
return out;
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
// ---- run: frames to look at ------------------------------------------------------------------------
|
|
256
|
+
export async function run(src, name, opts = {}) {
|
|
257
|
+
const e = await loadEngine();
|
|
258
|
+
const frames = opts.frames || 90;
|
|
259
|
+
const shots = new Set(opts.shots || [1, Math.max(1, frames >> 1), frames]);
|
|
260
|
+
const out = opts.out || "luanette-run";
|
|
261
|
+
fs.mkdirSync(out, { recursive: true });
|
|
262
|
+
const saved = [], images = [], gif = [];
|
|
263
|
+
const res = await runCart(src, name, frames, opts.input || scriptInput([]), (f) => {
|
|
264
|
+
const px = e.glass();
|
|
265
|
+
if (shots.has(f)) {
|
|
266
|
+
const file = path.join(out, `frame-${f}.png`), copy = px.slice();
|
|
267
|
+
fs.writeFileSync(file, png(W, H, copy));
|
|
268
|
+
saved.push(file);
|
|
269
|
+
images.push({ frame: f, rgba: copy });
|
|
270
|
+
}
|
|
271
|
+
if (opts.gif && f % 2 === 0) gif.push({ rgba: half(px, W, H) });
|
|
272
|
+
});
|
|
273
|
+
const result = { ok: !res.error, frames, fps: res.fps, shots: saved, log: res.log, max_ops: Math.max(0, ...res.ops), ops: res.ops };
|
|
274
|
+
if (res.error) Object.assign(result, { error: res.error, frame: res.frame });
|
|
275
|
+
if (images.length > 1) { // the shots side by side at half size
|
|
276
|
+
const w2 = W >> 1, h2 = H >> 1, gap = 8, sw = images.length * (w2 + gap) + gap, sh = h2 + 2 * gap;
|
|
277
|
+
const sheet = new Uint8Array(sw * sh * 4).fill(24);
|
|
278
|
+
for (let i = 3; i < sheet.length; i += 4) sheet[i] = 255;
|
|
279
|
+
images.forEach((im, k) => {
|
|
280
|
+
const small = half(im.rgba, W, H);
|
|
281
|
+
for (let y = 0; y < h2; y++) sheet.set(small.subarray(y * w2 * 4, (y + 1) * w2 * 4), ((y + gap) * sw + gap + k * (w2 + gap)) * 4);
|
|
282
|
+
});
|
|
283
|
+
result.sheet = path.join(out, "shots.png");
|
|
284
|
+
fs.writeFileSync(result.sheet, png(sw, sh, sheet));
|
|
285
|
+
}
|
|
286
|
+
if (gif.length) {
|
|
287
|
+
result.gif = path.join(out, "run.gif");
|
|
288
|
+
fs.writeFileSync(result.gif, Gif.encode(gif, W >> 1, H >> 1, Math.round(200 / res.fps), e.colors()));
|
|
289
|
+
}
|
|
290
|
+
return result;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// ---- sounds: readable notes to SOUNDS text, and WAVs -----------------------------------------------
|
|
294
|
+
const NOTE = { c: 0, d: 2, e: 4, f: 5, g: 7, a: 9, b: 11 };
|
|
295
|
+
const INSTRUMENTS = { square: 0, sq: 0, pulse: 1, thin: 2, "thin-pulse": 2, triangle: 3, tri: 3, saw: 4, sine: 5, organ: 6, noise: 7 };
|
|
296
|
+
const EFFECTS = { none: 0, slide: 1, vibrato: 2, vib: 2, drop: 3, fadein: 4, "fade-in": 4, fadeout: 5, "fade-out": 5, arp: 6, arpfast: 6, "arp-fast": 6, arpslow: 7, "arp-slow": 7 };
|
|
297
|
+
const hex2 = (n) => n.toString(16).padStart(2, "0");
|
|
298
|
+
// "C4 pulse 5, E4 slide, -, G4 saw 7 fadeout": a note is a pitch (C2 to D#7, # or b), then any of an
|
|
299
|
+
// instrument, a volume (0-7) and an effect; one left out is the note before's (to start: square, 5,
|
|
300
|
+
// none). "-" is a rest. The result is SOUNDS text (engine/synth.hpp).
|
|
301
|
+
export function notesToSound(text, speed = 8, loop = null) {
|
|
302
|
+
let inst = 0, vol = 5;
|
|
303
|
+
const notes = text.split(",").map((s) => s.trim()).filter(Boolean).map((s) => {
|
|
304
|
+
const t = s.toLowerCase().split(/\s+/);
|
|
305
|
+
if (t[0] === "-" || t[0] === "rest") return [0, inst, 0, 0];
|
|
306
|
+
const m = /^([a-g])(#|b)?(\d)$/.exec(t[0]);
|
|
307
|
+
if (!m) throw new Error(`a note starts with its pitch, like C4 or F#5: ${s}`);
|
|
308
|
+
const pitch = NOTE[m[1]] + (m[2] === "#" ? 1 : m[2] === "b" ? -1 : 0) + (Number(m[3]) - 2) * 12;
|
|
309
|
+
if (pitch < 0 || pitch > 63) throw new Error(`${t[0]} is outside C2 to D#7`);
|
|
310
|
+
let fx = 0;
|
|
311
|
+
for (const w of t.slice(1)) {
|
|
312
|
+
if (w in INSTRUMENTS) inst = INSTRUMENTS[w];
|
|
313
|
+
else if (/^[0-7]$/.test(w)) vol = Number(w);
|
|
314
|
+
else if (w in EFFECTS) fx = EFFECTS[w];
|
|
315
|
+
else throw new Error(`"${w}" isn't an instrument (${Object.keys(INSTRUMENTS).join(", ")}), a volume (0-7) or an effect (${Object.keys(EFFECTS).join(", ")})`);
|
|
316
|
+
}
|
|
317
|
+
return [pitch, inst, vol, fx];
|
|
318
|
+
});
|
|
319
|
+
if (!notes.length || notes.length > 32) throw new Error("a sound is 1 to 32 notes");
|
|
320
|
+
if (!(speed >= 1 && speed <= 255)) throw new Error("the speed is 1 to 255 (ticks of 1/120 s a note)");
|
|
321
|
+
const [ls, le] = loop || [0, 0];
|
|
322
|
+
return `${hex2(speed)} ${hex2(ls)}${hex2(le)} ` + notes.map(([p, i, v, e]) => hex2(p) + i.toString(16) + v.toString(16) + e.toString(16)).join(" ");
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function decodeSound(text) {
|
|
326
|
+
const d = text.replace(/\s+/g, "");
|
|
327
|
+
return { speed: parseInt(d.slice(0, 2), 16), ls: parseInt(d.slice(2, 4), 16), le: parseInt(d.slice(4, 6), 16), notes: (d.length - 6) / 5 };
|
|
328
|
+
}
|
|
329
|
+
const soundSeconds = (s) => ((s.le > s.ls ? s.le + (s.le - s.ls) : s.notes) * s.speed) / 120;
|
|
330
|
+
function songSeconds(music, sounds, from) { // to its end, or round its loop once more (the editor's rule)
|
|
331
|
+
let n = from, loopTo = -1, loops = 0, t = 0;
|
|
332
|
+
for (let guard = 0; guard < 10000 && t < 180; guard++) {
|
|
333
|
+
if (!music.has(n)) break;
|
|
334
|
+
const d = music.get(n).text.replace(/\s+/g, ""), flags = parseInt(d.slice(0, 2), 16);
|
|
335
|
+
let ticks = 0;
|
|
336
|
+
for (let c = 0; c < 4; c++) {
|
|
337
|
+
const k = d.slice(2 + c * 2, 4 + c * 2);
|
|
338
|
+
if (k !== "--" && sounds.has(parseInt(k, 16))) { const s = decodeSound(sounds.get(parseInt(k, 16)).text); ticks = Math.max(ticks, s.notes * s.speed); }
|
|
339
|
+
}
|
|
340
|
+
if (flags & 1) loopTo = n;
|
|
341
|
+
t += ticks ? ticks / 120 : 0.25;
|
|
342
|
+
if (flags & 4) break;
|
|
343
|
+
if ((flags & 2) && loopTo >= 0) { if (++loops > 1) break; n = loopTo; } else n++;
|
|
344
|
+
}
|
|
345
|
+
return Math.min(t, 180);
|
|
346
|
+
}
|
|
347
|
+
export function wav(pcm, rate) {
|
|
348
|
+
const b = Buffer.alloc(44 + pcm.length * 2);
|
|
349
|
+
b.write("RIFF", 0); b.writeUInt32LE(36 + pcm.length * 2, 4); b.write("WAVE", 8); b.write("fmt ", 12);
|
|
350
|
+
b.writeUInt32LE(16, 16); b.writeUInt16LE(1, 20); b.writeUInt16LE(1, 22); b.writeUInt32LE(rate, 24);
|
|
351
|
+
b.writeUInt32LE(rate * 2, 28); b.writeUInt16LE(2, 32); b.writeUInt16LE(16, 34); b.write("data", 36); b.writeUInt32LE(pcm.length * 2, 40);
|
|
352
|
+
for (let i = 0; i < pcm.length; i++) b.writeInt16LE(pcm[i], 44 + i * 2);
|
|
353
|
+
return b;
|
|
354
|
+
}
|
|
355
|
+
// A cart's sound n, or its song (n null, from pattern `from`), as 16-bit samples at `rate`.
|
|
356
|
+
export async function renderAudio(src, n, from = null, rate = 44100) {
|
|
357
|
+
const e = await loadEngine();
|
|
358
|
+
const sounds = Tables.readTable(src, "SOUNDS"), music = Tables.readTable(src, "MUSIC");
|
|
359
|
+
if (sounds.problem || music.problem) throw new Error(sounds.problem || music.problem);
|
|
360
|
+
let why, secs;
|
|
361
|
+
if (n !== null) {
|
|
362
|
+
if (!sounds.map.has(n)) throw new Error(`the cart has no SOUNDS[${n}]`);
|
|
363
|
+
secs = soundSeconds(decodeSound(sounds.map.get(n).text));
|
|
364
|
+
why = e.renderAudio(sounds.map.get(n).text, 0, 0, rate, secs);
|
|
365
|
+
} else {
|
|
366
|
+
if (!music.map.size) throw new Error("the cart has no MUSIC");
|
|
367
|
+
const start = from ?? Math.min(...music.map.keys()), json = { music: {}, sounds: {} };
|
|
368
|
+
for (const [k, v] of music.map) json.music[k] = v.text;
|
|
369
|
+
for (const [k, v] of sounds.map) json.sounds[k] = v.text;
|
|
370
|
+
secs = songSeconds(music.map, sounds.map, start);
|
|
371
|
+
why = e.renderAudio(JSON.stringify(json), 1, start, rate, secs);
|
|
372
|
+
}
|
|
373
|
+
if (why) throw new Error(why);
|
|
374
|
+
return { pcm: new Int16Array(e.m.HEAP16.buffer, e.pcmData(), e.pcmLen()).slice(), rate, seconds: e.pcmLen() / rate };
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// ---- links and new carts ---------------------------------------------------------------------------
|
|
378
|
+
export function shareLink(src, name, base = PLAYGROUND) {
|
|
379
|
+
const data = zlib.deflateRawSync(Buffer.from(JSON.stringify({ name, src })), { level: 9 });
|
|
380
|
+
return base + "#cart=d" + data.toString("base64url");
|
|
381
|
+
}
|
|
382
|
+
export function newCart(name, template = "blank") {
|
|
383
|
+
if (!/^[a-z0-9_-]{1,32}$/.test(name)) throw new Error("a cart's name is 1 to 32 of a-z, 0-9, _ and -");
|
|
384
|
+
const file = path.join(TEMPLATES, template + ".lua");
|
|
385
|
+
if (!fs.existsSync(file)) throw new Error(`no template ${template}: ${fs.readdirSync(TEMPLATES).map((f) => f.replace(".lua", "")).join(", ")}`);
|
|
386
|
+
return fs.readFileSync(file, "utf8").replace(/^-- title: .*$/m, "-- title: " + name);
|
|
387
|
+
}
|
|
388
|
+
export const templates = () => fs.readdirSync(TEMPLATES).filter((f) => f.endsWith(".lua")).map((f) => f.replace(".lua", ""));
|
|
389
|
+
|
|
390
|
+
// ---- the command line ------------------------------------------------------------------------------
|
|
391
|
+
function args(list) {
|
|
392
|
+
const pos = [], opt = {};
|
|
393
|
+
for (let i = 0; i < list.length; i++) {
|
|
394
|
+
const a = list[i];
|
|
395
|
+
if (a.startsWith("--")) {
|
|
396
|
+
const k = a.slice(2);
|
|
397
|
+
opt[k] = i + 1 < list.length && !list[i + 1].startsWith("--") ? list[++i] : true;
|
|
398
|
+
} else pos.push(a);
|
|
399
|
+
}
|
|
400
|
+
return { pos, opt };
|
|
401
|
+
}
|
|
402
|
+
const USAGE = fs.readFileSync(fileURLToPath(import.meta.url), "utf8").split("\n").slice(1, 15).map((l) => l.replace(/^\/\/ ?/, "")).join("\n");
|
|
403
|
+
const readCart = (file) => {
|
|
404
|
+
if (!file || !fs.existsSync(file)) throw new Error(`no cart ${file || "given"}`);
|
|
405
|
+
return { src: fs.readFileSync(file, "utf8"), name: path.basename(file, ".lua") };
|
|
406
|
+
};
|
|
407
|
+
const readInput = (file) => (file ? scriptInput(JSON.parse(fs.readFileSync(file, "utf8"))) : null);
|
|
408
|
+
const print = (o) => process.stdout.write(JSON.stringify(o, null, 1) + "\n");
|
|
409
|
+
|
|
410
|
+
async function main() {
|
|
411
|
+
const [cmd, ...rest] = process.argv.slice(2);
|
|
412
|
+
const { pos, opt } = args(rest);
|
|
413
|
+
if (cmd === "check") {
|
|
414
|
+
const { src, name } = readCart(pos[0]);
|
|
415
|
+
const res = await check(src, name, { file: pos[0], frames: Number(opt.frames) || 150, input: readInput(opt.input) });
|
|
416
|
+
print(res);
|
|
417
|
+
process.exitCode = res.ok ? 0 : 1;
|
|
418
|
+
} else if (cmd === "run") {
|
|
419
|
+
const { src, name } = readCart(pos[0]);
|
|
420
|
+
const shots = opt.shots ? String(opt.shots).split(",").map(Number) : null;
|
|
421
|
+
const res = await run(src, name, { frames: Number(opt.frames) || 90, input: readInput(opt.input), shots, out: opt.out, gif: !!opt.gif });
|
|
422
|
+
print(res.ops.length > 20 ? { ...res, ops: undefined } : res);
|
|
423
|
+
process.exitCode = res.ok ? 0 : 1;
|
|
424
|
+
} else if (cmd === "sound" && pos[0] && pos[0].endsWith(".lua")) {
|
|
425
|
+
const { src } = readCart(pos[0]);
|
|
426
|
+
const r = await renderAudio(src, Number(pos[1]), null);
|
|
427
|
+
fs.writeFileSync(opt.wav || `sound-${pos[1]}.wav`, wav(r.pcm, r.rate));
|
|
428
|
+
print({ ok: true, wav: opt.wav || `sound-${pos[1]}.wav`, seconds: Number(r.seconds.toFixed(2)) });
|
|
429
|
+
} else if (cmd === "sound") {
|
|
430
|
+
const loop = opt.loop ? String(opt.loop).split("-").map(Number) : null;
|
|
431
|
+
process.stdout.write(notesToSound(pos.join(" "), Number(opt.speed) || 8, loop) + "\n");
|
|
432
|
+
} else if (cmd === "song") {
|
|
433
|
+
const { src } = readCart(pos[0]);
|
|
434
|
+
const r = await renderAudio(src, null, opt.from !== undefined ? Number(opt.from) : null);
|
|
435
|
+
fs.writeFileSync(opt.wav || "song.wav", wav(r.pcm, r.rate));
|
|
436
|
+
print({ ok: true, wav: opt.wav || "song.wav", seconds: Number(r.seconds.toFixed(2)) });
|
|
437
|
+
} else if (cmd === "link") {
|
|
438
|
+
const { src, name } = readCart(pos[0]);
|
|
439
|
+
process.stdout.write(shareLink(src, opt.name || name, opt.base || PLAYGROUND) + "\n");
|
|
440
|
+
} else if (cmd === "new") {
|
|
441
|
+
const name = pos[0], file = opt.out || `${name}.lua`;
|
|
442
|
+
if (fs.existsSync(file)) throw new Error(`${file} is there already`);
|
|
443
|
+
fs.writeFileSync(file, newCart(name, opt.template || "blank"));
|
|
444
|
+
process.stdout.write(`wrote ${file} (from the ${opt.template || "blank"} template)\n`);
|
|
445
|
+
} else if (cmd === "mcp") {
|
|
446
|
+
await (await import("./mcp.mjs")).serve();
|
|
447
|
+
} else {
|
|
448
|
+
process.stdout.write(USAGE + "\n");
|
|
449
|
+
process.exitCode = cmd && cmd !== "help" && cmd !== "--help" ? 1 : 0;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
if (process.argv[1] && fs.realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
454
|
+
process.stdout.on("error", (err) => { if (err.code === "EPIPE") process.exit(0); throw err; }); // `| head` closed it: fine
|
|
455
|
+
main().catch((err) => { process.stderr.write(`luanette: ${err.message}\n`); process.exitCode = 1; });
|
|
456
|
+
}
|
package/mcp.mjs
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// `luanette mcp`: the command-line tool as an MCP server over stdio, for coding agents. JSON-RPC 2.0, a
|
|
2
|
+
// message a line (the MCP stdio transport), no dependencies. Its tools check a cart, run it with input
|
|
3
|
+
// and hand back what it drew as images, describe and render its sounds, make a share link, and look
|
|
4
|
+
// up the API. Requests are handled one at a time (the engine is one).
|
|
5
|
+
//
|
|
6
|
+
// claude mcp add luanette -- npx -y luanette mcp
|
|
7
|
+
import fs from "node:fs";
|
|
8
|
+
import os from "node:os";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import readline from "node:readline";
|
|
11
|
+
import { createRequire } from "node:module";
|
|
12
|
+
import { fileURLToPath } from "node:url";
|
|
13
|
+
import * as L from "./luanette.mjs";
|
|
14
|
+
|
|
15
|
+
const require = createRequire(import.meta.url);
|
|
16
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
17
|
+
const VERSION = JSON.parse(fs.readFileSync(path.join(HERE, "package.json"), "utf8")).version;
|
|
18
|
+
const API = (() => {
|
|
19
|
+
const file = [path.join(HERE, "api.json"), path.join(HERE, "../web/build/site/api.json")].find((p) => fs.existsSync(p));
|
|
20
|
+
return file ? JSON.parse(fs.readFileSync(file, "utf8")) : [];
|
|
21
|
+
})();
|
|
22
|
+
|
|
23
|
+
const CART = {
|
|
24
|
+
source: { type: "string", description: "The cart's Lua source (or give path)" },
|
|
25
|
+
path: { type: "string", description: "A .lua file to read instead of source" },
|
|
26
|
+
};
|
|
27
|
+
const INPUT = {
|
|
28
|
+
type: "array",
|
|
29
|
+
description: 'Input by frame, as docs/cli.md: [{"frames":"1-30","hold":["right"]},{"frame":40,"tap":[64,100]},' +
|
|
30
|
+
'{"frames":"50-80","drag":[[64,110],[64,40]]},{"frames":"1-200","tilt":[0.4,0]}]. Buttons: left, right, up, down, a, b. ' +
|
|
31
|
+
"Positions are picture pixels, 0-127. A tap is A on the frame it lifts; a drag is the d-pad.",
|
|
32
|
+
items: { type: "object" },
|
|
33
|
+
};
|
|
34
|
+
const TOOLS = [
|
|
35
|
+
{ name: "check_cart", description: "Checks a Luanette cart as tools/check.py does: size, header, that it loads and runs 150 frames with scripted input inside the 30,000-instruction frame budget; errors with their line, warnings, the cart's log(). Run this after every change.",
|
|
36
|
+
inputSchema: { type: "object", properties: { ...CART, frames: { type: "integer", description: "Frames to run (150)" }, input: INPUT } } },
|
|
37
|
+
{ name: "run_cart", description: "Runs a cart with input you choose and returns what the screen showed at chosen frames (images), the log() lines by frame, and each frame's cost. Use it to see your cart and to play it.",
|
|
38
|
+
inputSchema: { type: "object", properties: { ...CART, frames: { type: "integer", description: "Frames to run (90)" }, input: INPUT,
|
|
39
|
+
shots: { type: "array", items: { type: "integer" }, description: "Frames to show (the first, middle and last)" },
|
|
40
|
+
full: { type: "boolean", description: "Each shot at full size (410x502) instead of one strip at half size" } } } },
|
|
41
|
+
{ name: "render_sound", description: "Describes one of a cart's sounds (SOUNDS[n]) or its song (MUSIC) note by note, and how long it plays; can write it to a WAV file to listen to.",
|
|
42
|
+
inputSchema: { type: "object", properties: { ...CART, sound: { type: "integer", description: "The sound, 8-63; leave out for the song" },
|
|
43
|
+
from: { type: "integer", description: "The song's first pattern" }, wav: { type: "string", description: "A file to write the WAV to" } } } },
|
|
44
|
+
{ name: "make_sound", description: 'Turns readable notes into the SOUNDS text a cart keeps: "C4 pulse 5, E4 slide, -, G4 saw 7 fadeout". A note is a pitch (C2-D#7), then any of: instrument (square, pulse, thin, triangle, saw, sine, organ, noise), volume 0-7, effect (slide, vibrato, drop, fadein, fadeout, arp, arpslow); left out, they carry on from the note before. "-" is a rest.',
|
|
45
|
+
inputSchema: { type: "object", required: ["notes"], properties: { notes: { type: "string" },
|
|
46
|
+
speed: { type: "integer", description: "Ticks of 1/120 s a note, 1-255 (8)" },
|
|
47
|
+
loop: { type: "array", items: { type: "integer" }, description: "[first, end]: loop those notes for good" } } } },
|
|
48
|
+
{ name: "share_link", description: "A link that opens the cart in the playground (https://geofresh.github.io/luanette/), to play it, edit it or send it to a board.",
|
|
49
|
+
inputSchema: { type: "object", properties: { ...CART, name: { type: "string" } } } },
|
|
50
|
+
{ name: "api_help", description: "The cart API from docs/api.md: a call's signatures and what it does, or every call whose name or description has a word in it, or (with nothing) the list of names.",
|
|
51
|
+
inputSchema: { type: "object", properties: { name: { type: "string" }, search: { type: "string" } } } },
|
|
52
|
+
{ name: "new_cart", description: "A new cart's source from a template (blank, platformer, shooter, puzzle): full-screen, with sprites, sounds and log() to build on.",
|
|
53
|
+
inputSchema: { type: "object", required: ["name"], properties: { name: { type: "string", description: "a-z, 0-9, _ and -" },
|
|
54
|
+
template: { type: "string", enum: ["blank", "platformer", "shooter", "puzzle"] } } } },
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
function cartOf(a) {
|
|
58
|
+
if (typeof a.source === "string") return { src: a.source, name: a.name || "cart" };
|
|
59
|
+
if (typeof a.path === "string") {
|
|
60
|
+
const file = path.resolve(a.path);
|
|
61
|
+
if (!fs.existsSync(file)) throw new Error(`no file ${a.path}`);
|
|
62
|
+
return { src: fs.readFileSync(file, "utf8"), name: path.basename(file, ".lua") };
|
|
63
|
+
}
|
|
64
|
+
throw new Error("give the cart as source or path");
|
|
65
|
+
}
|
|
66
|
+
const text = (t) => ({ type: "text", text: typeof t === "string" ? t : JSON.stringify(t, null, 1) });
|
|
67
|
+
|
|
68
|
+
const NOTE = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"];
|
|
69
|
+
const INST = ["square", "pulse", "thin", "triangle", "saw", "sine", "organ", "noise"];
|
|
70
|
+
const FX = ["", "slide", "vibrato", "drop", "fadein", "fadeout", "arp", "arpslow"];
|
|
71
|
+
function describe(textHex) { // SOUNDS text, note by note, in make_sound's words
|
|
72
|
+
const d = textHex.replace(/\s+/g, ""), notes = [];
|
|
73
|
+
for (let i = 6; i + 5 <= d.length; i += 5) {
|
|
74
|
+
const p = parseInt(d.slice(i, i + 2), 16), inst = parseInt(d[i + 2], 16), v = parseInt(d[i + 3], 16), e = parseInt(d[i + 4], 16);
|
|
75
|
+
notes.push(v ? `${NOTE[p % 12]}${2 + Math.floor(p / 12)} ${INST[inst]} ${v}${FX[e] ? " " + FX[e] : ""}` : "-");
|
|
76
|
+
}
|
|
77
|
+
const ls = parseInt(d.slice(2, 4), 16), le = parseInt(d.slice(4, 6), 16);
|
|
78
|
+
return { speed: parseInt(d.slice(0, 2), 16), loop: le > ls ? [ls, le] : null, notes: notes.join(", ") };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const handlers = {
|
|
82
|
+
async check_cart(a) {
|
|
83
|
+
const { src, name } = cartOf(a);
|
|
84
|
+
const res = await L.check(src, name, { file: a.path || name, frames: a.frames || 150, input: a.input ? L.scriptInput(a.input) : null });
|
|
85
|
+
return { content: [text(res)], isError: !res.ok };
|
|
86
|
+
},
|
|
87
|
+
async run_cart(a) {
|
|
88
|
+
const { src, name } = cartOf(a);
|
|
89
|
+
const out = fs.mkdtempSync(path.join(os.tmpdir(), "luanette-"));
|
|
90
|
+
try {
|
|
91
|
+
const res = await L.run(src, name, { frames: a.frames || 90, input: L.scriptInput(a.input || []), shots: a.shots, out });
|
|
92
|
+
const summary = { ok: res.ok, error: res.error, frame: res.frame, fps: res.fps, max_ops: res.max_ops, log: res.log.slice(0, 100),
|
|
93
|
+
shots: res.shots.map((f) => Number(/frame-(\d+)/.exec(f)[1])) };
|
|
94
|
+
const images = (a.full || !res.sheet ? res.shots : [res.sheet]).map((f) => ({ type: "image", mimeType: "image/png", data: fs.readFileSync(f).toString("base64") }));
|
|
95
|
+
return { content: [text(summary), ...images], isError: !res.ok };
|
|
96
|
+
} finally {
|
|
97
|
+
fs.rmSync(out, { recursive: true, force: true });
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
async render_sound(a) {
|
|
101
|
+
const { src } = cartOf(a);
|
|
102
|
+
const r = await L.renderAudio(src, a.sound ?? null, a.from ?? null);
|
|
103
|
+
if (a.wav) fs.writeFileSync(path.resolve(a.wav), L.wav(r.pcm, r.rate));
|
|
104
|
+
let peak = 0;
|
|
105
|
+
for (const s of r.pcm) peak = Math.max(peak, Math.abs(s));
|
|
106
|
+
const sounds = require(path.join(HERE, fs.existsSync(path.join(HERE, "lib")) ? "lib" : "../web", "tables.js")).readTable(src, "SOUNDS").map;
|
|
107
|
+
const about = a.sound != null ? describe(sounds.get(a.sound).text) : { patterns: "the song from MUSIC", sounds: Object.fromEntries([...sounds].map(([k, v]) => [k, describe(v.text).notes])) };
|
|
108
|
+
return { content: [text({ seconds: Number(r.seconds.toFixed(2)), loudest: Number((peak / 32768).toFixed(2)), silent: peak === 0, ...about, wav: a.wav || undefined })] };
|
|
109
|
+
},
|
|
110
|
+
async make_sound(a) {
|
|
111
|
+
return { content: [text(L.notesToSound(a.notes, a.speed || 8, a.loop || null))] };
|
|
112
|
+
},
|
|
113
|
+
async share_link(a) {
|
|
114
|
+
const { src, name } = cartOf(a);
|
|
115
|
+
return { content: [text(L.shareLink(src, a.name || name))] };
|
|
116
|
+
},
|
|
117
|
+
async api_help(a) {
|
|
118
|
+
if (a.name) {
|
|
119
|
+
const e = API.find((x) => x.name === a.name);
|
|
120
|
+
return { content: [text(e || `no call named ${a.name}; api_help with nothing lists them`)], isError: !e };
|
|
121
|
+
}
|
|
122
|
+
if (a.search) {
|
|
123
|
+
const w = a.search.toLowerCase();
|
|
124
|
+
return { content: [text(API.filter((x) => x.name.toLowerCase().includes(w) || x.help.some((h) => h.does.toLowerCase().includes(w))))] };
|
|
125
|
+
}
|
|
126
|
+
return { content: [text(API.map((x) => x.name).join(" "))] };
|
|
127
|
+
},
|
|
128
|
+
async new_cart(a) {
|
|
129
|
+
return { content: [text(L.newCart(a.name, a.template || "blank"))] };
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
async function handle(msg) {
|
|
134
|
+
const { id, method, params } = msg;
|
|
135
|
+
if (method === "initialize") {
|
|
136
|
+
return { protocolVersion: params?.protocolVersion || "2025-06-18", capabilities: { tools: {} },
|
|
137
|
+
serverInfo: { name: "luanette", version: VERSION },
|
|
138
|
+
instructions: "Tools for making Luanette carts (Lua games for a small console). Read AGENTS.md in the Luanette repository for the cart format. Check after every change; run with input and look at the shots." };
|
|
139
|
+
}
|
|
140
|
+
if (method === "ping") return {};
|
|
141
|
+
if (method === "tools/list") return { tools: TOOLS };
|
|
142
|
+
if (method === "tools/call") {
|
|
143
|
+
const h = handlers[params?.name];
|
|
144
|
+
if (!h) throw Object.assign(new Error(`no tool ${params?.name}`), { code: -32602 });
|
|
145
|
+
try {
|
|
146
|
+
return await h(params.arguments || {});
|
|
147
|
+
} catch (e) {
|
|
148
|
+
return { content: [text(e.message)], isError: true };
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
if (id === undefined) return undefined; // a notification (notifications/initialized and the like)
|
|
152
|
+
throw Object.assign(new Error(`no method ${method}`), { code: -32601 });
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export async function serve() {
|
|
156
|
+
console.log = console.info = (...a) => console.error(...a); // stdout carries the protocol and nothing else
|
|
157
|
+
const rl = readline.createInterface({ input: process.stdin });
|
|
158
|
+
let queue = Promise.resolve();
|
|
159
|
+
const send = (o) => process.stdout.write(JSON.stringify(o) + "\n");
|
|
160
|
+
rl.on("line", (line) => {
|
|
161
|
+
if (!line.trim()) return;
|
|
162
|
+
queue = queue.then(async () => {
|
|
163
|
+
let msg;
|
|
164
|
+
try { msg = JSON.parse(line); } catch { send({ jsonrpc: "2.0", id: null, error: { code: -32700, message: "not JSON" } }); return; }
|
|
165
|
+
try {
|
|
166
|
+
const result = await handle(msg);
|
|
167
|
+
if (msg.id !== undefined && result !== undefined) send({ jsonrpc: "2.0", id: msg.id, result });
|
|
168
|
+
} catch (e) {
|
|
169
|
+
if (msg.id !== undefined) send({ jsonrpc: "2.0", id: msg.id, error: { code: e.code || -32603, message: e.message } });
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
await new Promise((r) => rl.on("close", r));
|
|
174
|
+
await queue;
|
|
175
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "luanette",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.4.0",
|
|
4
|
+
"description": "Check, run, hear and share carts for Luanette, a Lua game console for ESP32-S3 boards: the console's own engine in Node, nothing to build",
|
|
5
|
+
"bin": { "luanette": "luanette.mjs" },
|
|
6
|
+
"files": ["luanette.mjs", "pyrandom.mjs", "mcp.mjs", "engine/", "lib/", "templates/", "api.json", "README.md", "LICENSE"],
|
|
7
|
+
"engines": { "node": ">=18" },
|
|
8
|
+
"license": "MIT",
|
|
9
|
+
"homepage": "https://geofresh.github.io/luanette/",
|
|
10
|
+
"repository": { "type": "git", "url": "git+https://github.com/Geofresh/luanette.git", "directory": "cli" },
|
|
11
|
+
"bugs": "https://github.com/Geofresh/luanette/issues",
|
|
12
|
+
"keywords": ["luanette", "fantasy-console", "lua", "esp32", "game", "mcp", "pico-8"]
|
|
13
|
+
}
|