picopilot 0.1.4 → 0.2.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/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +111 -16
- package/dist/cli.js.map +1 -1
- package/dist/commands/audio.d.ts +3 -3
- package/dist/commands/audio.d.ts.map +1 -1
- package/dist/commands/audio.js +61 -44
- package/dist/commands/audio.js.map +1 -1
- package/dist/commands/cart-includes.d.ts +11 -0
- package/dist/commands/cart-includes.d.ts.map +1 -0
- package/dist/commands/cart-includes.js +26 -0
- package/dist/commands/cart-includes.js.map +1 -0
- package/dist/commands/doctor.d.ts +22 -0
- package/dist/commands/doctor.d.ts.map +1 -0
- package/dist/commands/doctor.js +222 -0
- package/dist/commands/doctor.js.map +1 -0
- package/dist/commands/export.d.ts +3 -4
- package/dist/commands/export.d.ts.map +1 -1
- package/dist/commands/export.js +66 -86
- package/dist/commands/export.js.map +1 -1
- package/dist/commands/gfx.d.ts +2 -2
- package/dist/commands/gfx.d.ts.map +1 -1
- package/dist/commands/gfx.js +3 -2
- package/dist/commands/gfx.js.map +1 -1
- package/dist/commands/label-cart.d.ts +72 -0
- package/dist/commands/label-cart.d.ts.map +1 -0
- package/dist/commands/label-cart.js +228 -0
- package/dist/commands/label-cart.js.map +1 -0
- package/dist/commands/lint.d.ts +2 -2
- package/dist/commands/lint.d.ts.map +1 -1
- package/dist/commands/lint.js +3 -2
- package/dist/commands/lint.js.map +1 -1
- package/dist/commands/minify.d.ts +2 -2
- package/dist/commands/minify.d.ts.map +1 -1
- package/dist/commands/minify.js +3 -2
- package/dist/commands/minify.js.map +1 -1
- package/dist/commands/pico8-env.d.ts +76 -0
- package/dist/commands/pico8-env.d.ts.map +1 -0
- package/dist/commands/pico8-env.js +71 -0
- package/dist/commands/pico8-env.js.map +1 -0
- package/dist/commands/playtest.d.ts +2 -2
- package/dist/commands/playtest.d.ts.map +1 -1
- package/dist/commands/playtest.js +6 -32
- package/dist/commands/playtest.js.map +1 -1
- package/dist/commands/run.d.ts +3 -4
- package/dist/commands/run.d.ts.map +1 -1
- package/dist/commands/run.js +46 -19
- package/dist/commands/run.js.map +1 -1
- package/dist/commands/serve.d.ts +2 -2
- package/dist/commands/serve.d.ts.map +1 -1
- package/dist/commands/serve.js +65 -19
- package/dist/commands/serve.js.map +1 -1
- package/dist/commands/shrinko-env.d.ts +22 -0
- package/dist/commands/shrinko-env.d.ts.map +1 -0
- package/dist/commands/shrinko-env.js +15 -0
- package/dist/commands/shrinko-env.js.map +1 -0
- package/dist/commands/tokens.d.ts +4 -4
- package/dist/commands/tokens.d.ts.map +1 -1
- package/dist/commands/tokens.js +3 -2
- package/dist/commands/tokens.js.map +1 -1
- package/dist/commands/verify.d.ts +2 -2
- package/dist/commands/verify.d.ts.map +1 -1
- package/dist/commands/verify.js +3 -2
- package/dist/commands/verify.js.map +1 -1
- package/dist/engine/child-env.d.ts +14 -0
- package/dist/engine/child-env.d.ts.map +1 -0
- package/dist/engine/child-env.js +21 -0
- package/dist/engine/child-env.js.map +1 -0
- package/dist/engine/gfx/label.d.ts +15 -9
- package/dist/engine/gfx/label.d.ts.map +1 -1
- package/dist/engine/gfx/label.js +130 -38
- package/dist/engine/gfx/label.js.map +1 -1
- package/dist/engine/pico8/adapter.d.ts +23 -3
- package/dist/engine/pico8/adapter.d.ts.map +1 -1
- package/dist/engine/pico8/adapter.js.map +1 -1
- package/dist/engine/pico8/cleanup.d.ts +16 -0
- package/dist/engine/pico8/cleanup.d.ts.map +1 -0
- package/dist/engine/pico8/cleanup.js +74 -0
- package/dist/engine/pico8/cleanup.js.map +1 -0
- package/dist/engine/pico8/display.d.ts +149 -0
- package/dist/engine/pico8/display.d.ts.map +1 -0
- package/dist/engine/pico8/display.js +311 -0
- package/dist/engine/pico8/display.js.map +1 -0
- package/dist/engine/pico8/drive.d.ts.map +1 -1
- package/dist/engine/pico8/drive.js +28 -2
- package/dist/engine/pico8/drive.js.map +1 -1
- package/dist/engine/pico8/harness.d.ts +8 -0
- package/dist/engine/pico8/harness.d.ts.map +1 -1
- package/dist/engine/pico8/harness.js +9 -1
- package/dist/engine/pico8/harness.js.map +1 -1
- package/dist/engine/pico8/index.d.ts +3 -0
- package/dist/engine/pico8/index.d.ts.map +1 -1
- package/dist/engine/pico8/index.js +3 -0
- package/dist/engine/pico8/index.js.map +1 -1
- package/dist/engine/pico8/session-daemon-entry.d.ts +1 -1
- package/dist/engine/pico8/session-daemon-entry.js +1 -1
- package/dist/engine/pico8/session-daemon-main.d.ts +1 -1
- package/dist/engine/pico8/session-daemon-main.js +1 -1
- package/dist/engine/pico8/session-daemon.d.ts +1 -1
- package/dist/engine/pico8/session-daemon.d.ts.map +1 -1
- package/dist/engine/pico8/session-daemon.js +19 -19
- package/dist/engine/pico8/session-daemon.js.map +1 -1
- package/dist/engine/pico8/session.d.ts +1 -1
- package/dist/engine/pico8/session.js +1 -1
- package/dist/engine/pico8/shell.d.ts +56 -3
- package/dist/engine/pico8/shell.d.ts.map +1 -1
- package/dist/engine/pico8/shell.js +188 -24
- package/dist/engine/pico8/shell.js.map +1 -1
- package/dist/engine/pico8/supervisor.d.ts +1 -1
- package/dist/engine/pico8/supervisor.js +1 -1
- package/dist/engine/shrinko/adapter.d.ts +1 -1
- package/dist/engine/shrinko/shell.d.ts +3 -3
- package/dist/engine/shrinko/shell.js +3 -3
- package/package.json +2 -1
- package/src/cli.test.ts +188 -0
- package/src/cli.ts +122 -16
- package/src/commands/audio.test.ts +76 -1
- package/src/commands/audio.ts +79 -57
- package/src/commands/cart-includes.ts +27 -0
- package/src/commands/doctor.test.ts +149 -0
- package/src/commands/doctor.ts +280 -0
- package/src/commands/export.test.ts +327 -3
- package/src/commands/export.ts +81 -99
- package/src/commands/gfx.ts +7 -6
- package/src/commands/label-cart.ts +304 -0
- package/src/commands/lint.test.ts +6 -0
- package/src/commands/lint.ts +7 -6
- package/src/commands/minify.test.ts +6 -0
- package/src/commands/minify.ts +7 -6
- package/src/commands/pico8-env.ts +124 -0
- package/src/commands/playtest.test.ts +3 -0
- package/src/commands/playtest.ts +10 -34
- package/src/commands/run.test.ts +116 -2
- package/src/commands/run.ts +62 -24
- package/src/commands/serve.test.ts +138 -2
- package/src/commands/serve.ts +80 -21
- package/src/commands/shrinko-env.test.ts +68 -0
- package/src/commands/shrinko-env.ts +40 -0
- package/src/commands/tokens.test.ts +19 -0
- package/src/commands/tokens.ts +9 -6
- package/src/commands/verify.test.ts +12 -0
- package/src/commands/verify.ts +7 -4
- package/src/engine/child-env.ts +22 -0
- package/src/engine/gfx/fixtures/pico8-screenshot-384.png +0 -0
- package/src/engine/gfx/label.test.ts +467 -1
- package/src/engine/gfx/label.ts +147 -41
- package/src/engine/pico8/adapter.ts +25 -3
- package/src/engine/pico8/cleanup.test.ts +28 -0
- package/src/engine/pico8/cleanup.ts +76 -0
- package/src/engine/pico8/display.test.ts +210 -0
- package/src/engine/pico8/display.ts +429 -0
- package/src/engine/pico8/drive.test.ts +20 -0
- package/src/engine/pico8/drive.ts +25 -2
- package/src/engine/pico8/fake-xvfb.test-support.ts +55 -0
- package/src/engine/pico8/harness.test.ts +26 -0
- package/src/engine/pico8/harness.ts +18 -2
- package/src/engine/pico8/index.ts +3 -0
- package/src/engine/pico8/session-daemon-entry.ts +1 -1
- package/src/engine/pico8/session-daemon-main.ts +1 -1
- package/src/engine/pico8/session-daemon.test.ts +32 -1
- package/src/engine/pico8/session-daemon.ts +25 -17
- package/src/engine/pico8/session.test.ts +1 -1
- package/src/engine/pico8/session.ts +1 -1
- package/src/engine/pico8/shell.test.ts +449 -10
- package/src/engine/pico8/shell.ts +255 -26
- package/src/engine/pico8/supervisor.ts +1 -1
- package/src/engine/pico8/virtual-display.process.test.ts +206 -0
- package/src/engine/shrinko/adapter.ts +1 -1
- package/src/engine/shrinko/shell.ts +3 -3
- package/src/engine/skills/skills.test.ts +10 -0
- package/src/skills/picopilot-audio/SKILL.md +4 -8
- package/src/skills/picopilot-debug/SKILL.md +18 -1
- package/src/skills/picopilot-overview/SKILL.md +9 -1
package/src/engine/gfx/label.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The `__label__` builder: turn a 128x128 image
|
|
2
|
+
* The `__label__` builder: turn a 128x128 image (or a PICO-8 screenshot, an
|
|
3
|
+
* integer upscale of one) into the hex a `.p8` cart's
|
|
3
4
|
* label section holds, so `picopilot export --label <png>` can give a labelless
|
|
4
5
|
* cart a loading splash WITHOUT an interactive PICO-8 (F7) capture.
|
|
5
6
|
*
|
|
@@ -9,10 +10,13 @@
|
|
|
9
10
|
* palette colour, and emits that hex. It is the inverse direction of the
|
|
10
11
|
* palette-accurate PNG RENDER in `render.ts`/`png.ts` (which goes indices -> RGB).
|
|
11
12
|
*
|
|
12
|
-
* It
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
13
|
+
* It accepts what a caller realistically hands it: a PICO-8 screenshot exactly as
|
|
14
|
+
* `picopilot run`/`playtest` captured it (384x384 8-bit RGB by default; any
|
|
15
|
+
* square integer multiple of 128 is sampled back down), or a 128x128 image from
|
|
16
|
+
* any tool, including palette-indexed (1/2/4/8-bit, the form image tools write
|
|
17
|
+
* when shrinking a screenshot) and greyscale PNGs. Interlaced PNGs, sizes that
|
|
18
|
+
* are not a square multiple of 128, and malformed files are rejected, loud, with
|
|
19
|
+
* a structured {@link LabelError}.
|
|
16
20
|
*/
|
|
17
21
|
|
|
18
22
|
import {inflateSync} from 'node:zlib';
|
|
@@ -42,12 +46,37 @@ interface DecodedPng {
|
|
|
42
46
|
readonly rgba: Uint8Array;
|
|
43
47
|
}
|
|
44
48
|
|
|
49
|
+
/** Samples per pixel for each PNG colour type we decode. */
|
|
50
|
+
const CHANNELS: Readonly<Record<number, number>> = {
|
|
51
|
+
0: 1, // greyscale
|
|
52
|
+
2: 3, // RGB
|
|
53
|
+
3: 1, // palette index
|
|
54
|
+
4: 2, // greyscale + alpha
|
|
55
|
+
6: 4, // RGBA
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/** The bit depths the PNG spec allows per colour type. */
|
|
59
|
+
const ALLOWED_DEPTHS: Readonly<Record<number, readonly number[]>> = {
|
|
60
|
+
0: [1, 2, 4, 8, 16],
|
|
61
|
+
2: [8, 16],
|
|
62
|
+
3: [1, 2, 4, 8],
|
|
63
|
+
4: [8, 16],
|
|
64
|
+
6: [8, 16],
|
|
65
|
+
};
|
|
66
|
+
|
|
45
67
|
/**
|
|
46
|
-
* Decodes
|
|
68
|
+
* Decodes a non-interlaced PNG of any standard colour type and bit depth to
|
|
69
|
+
* RGBA: truecolour (2) and truecolour+alpha (6) at 8/16 bits, palette-indexed (3)
|
|
70
|
+
* at 1/2/4/8 bits via its PLTE chunk, and greyscale (0) / greyscale+alpha (4).
|
|
47
71
|
* Uses `node:zlib` inflate for the IDAT stream and applies the five PNG scanline
|
|
48
|
-
* filters.
|
|
72
|
+
* filters. `checkSize` runs on the IHDR dimensions before any pixel work. 16-bit samples keep their high byte (a label is quantised to 16
|
|
73
|
+
* colours anyway). Interlaced (Adam7) PNGs are rejected: PICO-8 never writes them.
|
|
74
|
+
* Throws {@link LabelError} for anything outside this set.
|
|
49
75
|
*/
|
|
50
|
-
function decodePng(
|
|
76
|
+
function decodePng(
|
|
77
|
+
bytes: Uint8Array,
|
|
78
|
+
checkSize: (width: number, height: number) => void = () => {},
|
|
79
|
+
): DecodedPng {
|
|
51
80
|
for (let i = 0; i < PNG_SIGNATURE.length; i++) {
|
|
52
81
|
if (bytes[i] !== PNG_SIGNATURE[i]) {
|
|
53
82
|
throw new LabelError('not-a-png', 'not a PNG file (bad signature)');
|
|
@@ -60,6 +89,7 @@ function decodePng(bytes: Uint8Array): DecodedPng {
|
|
|
60
89
|
let bitDepth = 0;
|
|
61
90
|
let colourType = 0;
|
|
62
91
|
let interlace = 0;
|
|
92
|
+
let palette: Uint8Array | undefined;
|
|
63
93
|
const idat: Uint8Array[] = [];
|
|
64
94
|
|
|
65
95
|
const readU32 = (p: number): number =>
|
|
@@ -88,6 +118,8 @@ function decodePng(bytes: Uint8Array): DecodedPng {
|
|
|
88
118
|
bitDepth = bytes[dataStart + 8]!;
|
|
89
119
|
colourType = bytes[dataStart + 9]!;
|
|
90
120
|
interlace = bytes[dataStart + 12]!;
|
|
121
|
+
} else if (type === 'PLTE') {
|
|
122
|
+
palette = bytes.subarray(dataStart, dataEnd);
|
|
91
123
|
} else if (type === 'IDAT') {
|
|
92
124
|
idat.push(bytes.subarray(dataStart, dataEnd));
|
|
93
125
|
} else if (type === 'IEND') {
|
|
@@ -96,18 +128,28 @@ function decodePng(bytes: Uint8Array): DecodedPng {
|
|
|
96
128
|
off = dataEnd + 4; // skip the 4-byte CRC
|
|
97
129
|
}
|
|
98
130
|
|
|
131
|
+
const channels = CHANNELS[colourType];
|
|
99
132
|
if (
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
133
|
+
channels === undefined ||
|
|
134
|
+
!ALLOWED_DEPTHS[colourType]!.includes(bitDepth) ||
|
|
135
|
+
interlace !== 0
|
|
103
136
|
) {
|
|
104
137
|
throw new LabelError(
|
|
105
138
|
'unsupported-png',
|
|
106
|
-
`unsupported PNG: need
|
|
139
|
+
`unsupported PNG: need a non-interlaced greyscale, RGB, RGBA or palette-indexed image (got bitDepth=${bitDepth}, colourType=${colourType}, interlace=${interlace})`,
|
|
107
140
|
);
|
|
108
141
|
}
|
|
142
|
+
if (colourType === 3 && (palette === undefined || palette.length < 3)) {
|
|
143
|
+
throw new LabelError(
|
|
144
|
+
'unsupported-png',
|
|
145
|
+
'palette-indexed PNG has no PLTE chunk',
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Reject a wrong-sized image from its header alone, before inflating and
|
|
150
|
+
// decoding pixels we would throw away.
|
|
151
|
+
checkSize(width, height);
|
|
109
152
|
|
|
110
|
-
const channels = colourType === 6 ? 4 : 3;
|
|
111
153
|
const compressed = Buffer.concat(idat.map((c) => Buffer.from(c)));
|
|
112
154
|
let raw: Buffer;
|
|
113
155
|
try {
|
|
@@ -119,33 +161,57 @@ function decodePng(bytes: Uint8Array): DecodedPng {
|
|
|
119
161
|
);
|
|
120
162
|
}
|
|
121
163
|
|
|
122
|
-
const
|
|
164
|
+
const bitsPerPixel = channels * bitDepth;
|
|
165
|
+
const stride = Math.ceil((width * bitsPerPixel) / 8);
|
|
166
|
+
// Filter byte distance: bytes per complete pixel, at least 1 (sub-byte depths).
|
|
167
|
+
const bpp = Math.max(1, bitsPerPixel >> 3);
|
|
168
|
+
if (raw.length < height * (stride + 1)) {
|
|
169
|
+
throw new LabelError(
|
|
170
|
+
'truncated-png',
|
|
171
|
+
`PNG image data too short for ${width}x${height}`,
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Reads sample `i` (0-based within the scanline) scaled to 0..255.
|
|
176
|
+
const maxSample = (1 << Math.min(bitDepth, 8)) - 1;
|
|
177
|
+
const sampleAt = (line: Uint8Array, i: number): number => {
|
|
178
|
+
if (bitDepth === 16) return line[i * 2]!;
|
|
179
|
+
if (bitDepth === 8) return line[i]!;
|
|
180
|
+
const bit = i * bitDepth;
|
|
181
|
+
const v = (line[bit >> 3]! >> (8 - bitDepth - (bit & 7))) & maxSample;
|
|
182
|
+
return v;
|
|
183
|
+
};
|
|
184
|
+
// Palette indices must stay raw; greyscale samples are scaled to 0..255.
|
|
185
|
+
const scaleGrey = (v: number): number =>
|
|
186
|
+
bitDepth >= 8 ? v : Math.round((v * 255) / maxSample);
|
|
187
|
+
|
|
123
188
|
const rgba = new Uint8Array(width * height * 4);
|
|
124
|
-
|
|
189
|
+
let prev = new Uint8Array(stride);
|
|
190
|
+
let line = new Uint8Array(stride);
|
|
125
191
|
let src = 0;
|
|
126
192
|
for (let y = 0; y < height; y++) {
|
|
127
193
|
const filter = raw[src++]!;
|
|
128
|
-
const line = new Uint8Array(stride);
|
|
129
194
|
for (let x = 0; x < stride; x++) {
|
|
130
195
|
const rawByte = raw[src++]!;
|
|
131
|
-
const a = x >=
|
|
196
|
+
const a = x >= bpp ? line[x - bpp]! : 0; // left
|
|
132
197
|
const b = prev[x]!; // up
|
|
133
|
-
const c = x >=
|
|
134
|
-
let val
|
|
198
|
+
const c = x >= bpp ? prev[x - bpp]! : 0; // up-left
|
|
199
|
+
let val: number;
|
|
135
200
|
switch (filter) {
|
|
136
201
|
case 0:
|
|
202
|
+
val = rawByte;
|
|
137
203
|
break; // None
|
|
138
204
|
case 1:
|
|
139
|
-
val =
|
|
205
|
+
val = rawByte + a;
|
|
140
206
|
break; // Sub
|
|
141
207
|
case 2:
|
|
142
|
-
val =
|
|
208
|
+
val = rawByte + b;
|
|
143
209
|
break; // Up
|
|
144
210
|
case 3:
|
|
145
|
-
val =
|
|
211
|
+
val = rawByte + ((a + b) >> 1);
|
|
146
212
|
break; // Average
|
|
147
213
|
case 4:
|
|
148
|
-
val =
|
|
214
|
+
val = rawByte + paeth(a, b, c);
|
|
149
215
|
break; // Paeth
|
|
150
216
|
default:
|
|
151
217
|
throw new LabelError(
|
|
@@ -153,18 +219,38 @@ function decodePng(bytes: Uint8Array): DecodedPng {
|
|
|
153
219
|
`unsupported PNG scanline filter ${filter}`,
|
|
154
220
|
);
|
|
155
221
|
}
|
|
156
|
-
line[x] = val;
|
|
222
|
+
line[x] = val & 0xff;
|
|
157
223
|
}
|
|
158
224
|
// Expand this decoded scanline into RGBA.
|
|
159
225
|
for (let x = 0; x < width; x++) {
|
|
160
226
|
const s = x * channels;
|
|
161
227
|
const d = (y * width + x) * 4;
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
228
|
+
if (colourType === 3) {
|
|
229
|
+
const p = sampleAt(line, s) * 3;
|
|
230
|
+
if (p + 2 >= palette!.length) {
|
|
231
|
+
throw new LabelError(
|
|
232
|
+
'unsupported-png',
|
|
233
|
+
`palette index ${p / 3} is outside the PLTE chunk`,
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
rgba[d] = palette![p]!;
|
|
237
|
+
rgba[d + 1] = palette![p + 1]!;
|
|
238
|
+
rgba[d + 2] = palette![p + 2]!;
|
|
239
|
+
rgba[d + 3] = 255;
|
|
240
|
+
} else if (colourType === 0 || colourType === 4) {
|
|
241
|
+
const g = scaleGrey(sampleAt(line, s));
|
|
242
|
+
rgba[d] = g;
|
|
243
|
+
rgba[d + 1] = g;
|
|
244
|
+
rgba[d + 2] = g;
|
|
245
|
+
rgba[d + 3] = colourType === 4 ? sampleAt(line, s + 1) : 255;
|
|
246
|
+
} else {
|
|
247
|
+
rgba[d] = sampleAt(line, s);
|
|
248
|
+
rgba[d + 1] = sampleAt(line, s + 1);
|
|
249
|
+
rgba[d + 2] = sampleAt(line, s + 2);
|
|
250
|
+
rgba[d + 3] = colourType === 6 ? sampleAt(line, s + 3) : 255;
|
|
251
|
+
}
|
|
166
252
|
}
|
|
167
|
-
prev
|
|
253
|
+
[prev, line] = [line, prev];
|
|
168
254
|
}
|
|
169
255
|
|
|
170
256
|
return {width, height, rgba};
|
|
@@ -201,26 +287,46 @@ export function nearestPaletteIndex(r: number, g: number, b: number): number {
|
|
|
201
287
|
|
|
202
288
|
const HEX = '0123456789abcdef';
|
|
203
289
|
|
|
290
|
+
/**
|
|
291
|
+
* The integer scale of a PICO-8-shaped screenshot: `k` when the image is exactly
|
|
292
|
+
* `(128*k)x(128*k)` (128x128 is `k = 1`), else `undefined`. PICO-8's
|
|
293
|
+
* `extcmd("screen")` writes a pixel-exact upscale (3x by default, 384x384; the
|
|
294
|
+
* factor follows its `screenshot_scale` config), so any square integer multiple
|
|
295
|
+
* is a screenshot we can sample straight back down.
|
|
296
|
+
*/
|
|
297
|
+
function labelScale(width: number, height: number): number | undefined {
|
|
298
|
+
if (width !== height || width < LABEL_SIZE || width % LABEL_SIZE !== 0) {
|
|
299
|
+
return undefined;
|
|
300
|
+
}
|
|
301
|
+
return width / LABEL_SIZE;
|
|
302
|
+
}
|
|
303
|
+
|
|
204
304
|
/**
|
|
205
305
|
* Builds the `__label__` hex body (128 rows of 128 nibbles, newline-joined, with
|
|
206
|
-
* a trailing newline) from a
|
|
207
|
-
*
|
|
208
|
-
*
|
|
209
|
-
*
|
|
306
|
+
* a trailing newline) from a PNG. Each pixel is mapped to its nearest PICO-8
|
|
307
|
+
* palette colour. The PNG must be 128x128 ({@link LABEL_SIZE}) or an exact square
|
|
308
|
+
* integer multiple of it (256x256, 384x384, ...: a PICO-8 screenshot), which is
|
|
309
|
+
* downscaled by nearest-neighbour sampling (the centre of each `k x k` block).
|
|
310
|
+
* Any other size is a {@link LabelError} (`wrong-size`) so a mis-sized image
|
|
311
|
+
* fails loud instead of being silently cropped or stretched.
|
|
210
312
|
*/
|
|
211
313
|
export function labelHexFromPng(pngBytes: Uint8Array): string {
|
|
212
|
-
const {width, height, rgba} = decodePng(pngBytes)
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
314
|
+
const {width, height, rgba} = decodePng(pngBytes, (w, h) => {
|
|
315
|
+
if (labelScale(w, h) === undefined) {
|
|
316
|
+
throw new LabelError(
|
|
317
|
+
'wrong-size',
|
|
318
|
+
`label image must be ${LABEL_SIZE}x${LABEL_SIZE} or an exact multiple of it (256x256, 384x384, 512x512, ...), got ${w}x${h}`,
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
});
|
|
322
|
+
const scale = labelScale(width, height)!;
|
|
323
|
+
const centre = scale >> 1;
|
|
219
324
|
const rows: string[] = [];
|
|
220
325
|
for (let y = 0; y < LABEL_SIZE; y++) {
|
|
221
326
|
let row = '';
|
|
327
|
+
const sy = y * scale + centre;
|
|
222
328
|
for (let x = 0; x < LABEL_SIZE; x++) {
|
|
223
|
-
const d = (
|
|
329
|
+
const d = (sy * width + x * scale + centre) * 4;
|
|
224
330
|
row += HEX[nearestPaletteIndex(rgba[d]!, rgba[d + 1]!, rgba[d + 2]!)];
|
|
225
331
|
}
|
|
226
332
|
rows.push(row);
|
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
* a normal, well-signposted boundary (US #14, #19), mirroring shrinko-not-found.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
+
import type {DisplayUnavailable, VirtualDisplayBackend} from './display.js';
|
|
16
|
+
|
|
15
17
|
/** The remedy string in a pico8-not-found result: how the user makes `run` work. */
|
|
16
18
|
export const PICO8_REMEDY = 'set PICO8_PATH or install PICO-8' as const;
|
|
17
19
|
|
|
@@ -75,6 +77,23 @@ export interface RecordReport {
|
|
|
75
77
|
readonly wavPath: string | undefined;
|
|
76
78
|
readonly printh: string;
|
|
77
79
|
readonly exitReason: ExitReason;
|
|
80
|
+
/**
|
|
81
|
+
* Which display the `-run` session used (ADR-0017): `native` (the caller's
|
|
82
|
+
* own DISPLAY/WAYLAND_DISPLAY, or none) or a private virtual display (`Xvfb`
|
|
83
|
+
* spawned directly, or the `xvfb-run` fallback). Omitted by fakes.
|
|
84
|
+
*/
|
|
85
|
+
readonly display?: 'native' | VirtualDisplayBackend;
|
|
86
|
+
/**
|
|
87
|
+
* Set only when `wavPath` is undefined: WHY no WAV was captured (the backstop
|
|
88
|
+
* fired before the done-sentinel, PICO-8 exited early, or the sentinel was
|
|
89
|
+
* reached but the WAV was missing/empty), so a silent failure always says why.
|
|
90
|
+
*/
|
|
91
|
+
readonly noWavReason?: string;
|
|
92
|
+
/**
|
|
93
|
+
* Set only when `wavPath` is undefined: the tail of PICO-8's own `log.txt`
|
|
94
|
+
* from the isolated `-home` (load errors, SDL/audio init failures).
|
|
95
|
+
*/
|
|
96
|
+
readonly pico8LogTail?: string;
|
|
78
97
|
}
|
|
79
98
|
|
|
80
99
|
/** A successful audio-record call carrying the {@link RecordReport}. */
|
|
@@ -132,7 +151,8 @@ export interface DriveOptions {
|
|
|
132
151
|
* The result {@link Pico8Adapter.record} returns: a {@link RecordReport} or the
|
|
133
152
|
* structured {@link Pico8NotFound} (PICO-8 absent), mirroring {@link Pico8Result}.
|
|
134
153
|
*/
|
|
135
|
-
export type Pico8RecordResult =
|
|
154
|
+
export type Pico8RecordResult =
|
|
155
|
+
Pico8RecordOk | Pico8NotFound | DisplayUnavailable;
|
|
136
156
|
|
|
137
157
|
/**
|
|
138
158
|
* Options for a single {@link Pico8Adapter.record} (ADR-0009). The recorded cart
|
|
@@ -256,8 +276,10 @@ export interface Pico8Adapter {
|
|
|
256
276
|
* `-x`, which mixes no audio and yields an empty WAV, ADR-0009), streams
|
|
257
277
|
* stdout, ends on the sentinel (backstop as the net), and collects the WAV the
|
|
258
278
|
* cooperating cart wrote via `audio_rec`/`audio_end(1)`. Returns
|
|
259
|
-
* {@link Pico8NotFound} when PICO-8 is not installed
|
|
260
|
-
*
|
|
279
|
+
* {@link Pico8NotFound} when PICO-8 is not installed, and
|
|
280
|
+
* {@link DisplayUnavailable} when the session needs a display, there is none,
|
|
281
|
+
* and no Xvfb is installed to provide a virtual one (ADR-0017). CI drives a
|
|
282
|
+
* fake runner + fake Xvfb; a live capture needs the paid binary.
|
|
261
283
|
*/
|
|
262
284
|
record(options: RecordOptions): Promise<Pico8RecordResult>;
|
|
263
285
|
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import {afterEach, describe, expect, it} from 'vitest';
|
|
2
|
+
import {killOnAbort} from './cleanup.js';
|
|
3
|
+
|
|
4
|
+
/** The kill-on-abort registry: one listener per event, however many runs. */
|
|
5
|
+
describe('killOnAbort', () => {
|
|
6
|
+
const disposers: (() => void)[] = [];
|
|
7
|
+
afterEach(() => {
|
|
8
|
+
for (const d of disposers.splice(0)) d();
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it('many concurrent runs add ONE listener per signal, removed when all end', () => {
|
|
12
|
+
const before = process.listenerCount('SIGTERM');
|
|
13
|
+
for (let i = 0; i < 20; i++) disposers.push(killOnAbort(() => {}));
|
|
14
|
+
expect(process.listenerCount('SIGTERM')).toBe(before + 1);
|
|
15
|
+
expect(process.listenerCount('exit')).toBeGreaterThan(0);
|
|
16
|
+
for (const d of disposers.splice(0)) d();
|
|
17
|
+
expect(process.listenerCount('SIGTERM')).toBe(before);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('kills every pending run on exit, and a disposed run is not killed', () => {
|
|
21
|
+
const killed: number[] = [];
|
|
22
|
+
const d1 = killOnAbort(() => killed.push(1));
|
|
23
|
+
disposers.push(killOnAbort(() => killed.push(2)));
|
|
24
|
+
d1();
|
|
25
|
+
process.emit('exit', 0);
|
|
26
|
+
expect(killed).toEqual([2]);
|
|
27
|
+
});
|
|
28
|
+
});
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Kill-on-abort for child processes picopilot owns while a run is in flight
|
|
3
|
+
* (the PICO-8 process group, a private Xvfb). Normal endings (sentinel,
|
|
4
|
+
* backstop, crash) are handled by the run itself; this covers picopilot being
|
|
5
|
+
* stopped mid-run: `process.exit`, an uncaught throw, or SIGINT / SIGTERM /
|
|
6
|
+
* SIGHUP sent to picopilot alone (an agent harness or `timeout` killing it).
|
|
7
|
+
* Without it, a detached PICO-8 or an Xvfb would outlive picopilot.
|
|
8
|
+
*
|
|
9
|
+
* `kill` must be synchronous (it runs inside an `exit` handler). On a signal,
|
|
10
|
+
* the hook kills, then re-raises the signal with picopilot's handler removed so
|
|
11
|
+
* the default behaviour (terminate) still happens, unless some other listener
|
|
12
|
+
* owns that signal (an MCP server, a test runner), which then decides.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const SIGNALS: readonly NodeJS.Signals[] = ['SIGINT', 'SIGTERM', 'SIGHUP'];
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The kill functions of every run in flight. ONE listener per event serves them
|
|
19
|
+
* all (installed while the set is non-empty), so many concurrent runs (an MCP
|
|
20
|
+
* server) never trip Node's MaxListeners warning.
|
|
21
|
+
*/
|
|
22
|
+
const pending = new Set<() => void>();
|
|
23
|
+
const signalHandlers = new Map<NodeJS.Signals, () => void>();
|
|
24
|
+
|
|
25
|
+
function killAll(): void {
|
|
26
|
+
for (const kill of [...pending]) {
|
|
27
|
+
pending.delete(kill);
|
|
28
|
+
try {
|
|
29
|
+
kill();
|
|
30
|
+
} catch {
|
|
31
|
+
// best effort: the process may already be gone
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function onExit(): void {
|
|
37
|
+
killAll();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function install(): void {
|
|
41
|
+
process.on('exit', onExit);
|
|
42
|
+
for (const signal of SIGNALS) {
|
|
43
|
+
const handler = () => {
|
|
44
|
+
killAll();
|
|
45
|
+
uninstall();
|
|
46
|
+
// Re-raise for the default behaviour (terminate) unless another listener
|
|
47
|
+
// (an MCP server, a test runner) owns this signal.
|
|
48
|
+
if (process.listenerCount(signal) === 0) {
|
|
49
|
+
process.kill(process.pid, signal);
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
signalHandlers.set(signal, handler);
|
|
53
|
+
process.on(signal, handler);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function uninstall(): void {
|
|
58
|
+
process.removeListener('exit', onExit);
|
|
59
|
+
for (const [signal, handler] of signalHandlers) {
|
|
60
|
+
process.removeListener(signal, handler);
|
|
61
|
+
}
|
|
62
|
+
signalHandlers.clear();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Registers `kill` to run if picopilot exits or is signalled; returns the disposer. */
|
|
66
|
+
export function killOnAbort(kill: () => void): () => void {
|
|
67
|
+
let entry: (() => void) | undefined = () => kill();
|
|
68
|
+
if (pending.size === 0) install();
|
|
69
|
+
pending.add(entry);
|
|
70
|
+
return () => {
|
|
71
|
+
if (entry === undefined) return;
|
|
72
|
+
pending.delete(entry);
|
|
73
|
+
entry = undefined;
|
|
74
|
+
if (pending.size === 0) uninstall();
|
|
75
|
+
};
|
|
76
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import {describe, expect, it} from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
detectVirtualDisplayBackend,
|
|
4
|
+
needsVirtualDisplay,
|
|
5
|
+
resolveDisplayMode,
|
|
6
|
+
startVirtualDisplay,
|
|
7
|
+
type XvfbSpawner,
|
|
8
|
+
} from './display.js';
|
|
9
|
+
import {FakeXvfb} from './fake-xvfb.test-support.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The display-mode seam (ADR-0017) is tested WITHOUT a real X server: the
|
|
13
|
+
* resolution + decision tables are pure, and Xvfb is a fake process that the
|
|
14
|
+
* test makes report a display number, exit early, or hang.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
describe('resolveDisplayMode: flag > env > config > default', () => {
|
|
18
|
+
const cases: {
|
|
19
|
+
name: string;
|
|
20
|
+
flag?: string;
|
|
21
|
+
env?: string;
|
|
22
|
+
config?: unknown;
|
|
23
|
+
mode: string;
|
|
24
|
+
source: string;
|
|
25
|
+
}[] = [
|
|
26
|
+
{name: 'nothing set', mode: 'auto', source: 'default'},
|
|
27
|
+
{name: 'config only', config: 'native', mode: 'native', source: 'config'},
|
|
28
|
+
{name: 'env only', env: 'xvfb', mode: 'xvfb', source: 'env'},
|
|
29
|
+
{name: 'flag only', flag: 'native', mode: 'native', source: 'flag'},
|
|
30
|
+
{
|
|
31
|
+
name: 'env beats config',
|
|
32
|
+
env: 'xvfb',
|
|
33
|
+
config: 'native',
|
|
34
|
+
mode: 'xvfb',
|
|
35
|
+
source: 'env',
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
name: 'flag beats env and config',
|
|
39
|
+
flag: 'auto',
|
|
40
|
+
env: 'xvfb',
|
|
41
|
+
config: 'native',
|
|
42
|
+
mode: 'auto',
|
|
43
|
+
source: 'flag',
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
name: 'an empty env value is unset',
|
|
47
|
+
env: ' ',
|
|
48
|
+
config: 'xvfb',
|
|
49
|
+
mode: 'xvfb',
|
|
50
|
+
source: 'config',
|
|
51
|
+
},
|
|
52
|
+
];
|
|
53
|
+
for (const c of cases) {
|
|
54
|
+
it(c.name, () => {
|
|
55
|
+
expect(
|
|
56
|
+
resolveDisplayMode({flag: c.flag, env: c.env, config: c.config}),
|
|
57
|
+
).toEqual({ok: true, mode: c.mode, source: c.source});
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
it('reports an unknown value instead of silently falling back', () => {
|
|
62
|
+
expect(resolveDisplayMode({env: 'xfvb', config: 'native'})).toEqual({
|
|
63
|
+
ok: false,
|
|
64
|
+
source: 'env',
|
|
65
|
+
value: 'xfvb',
|
|
66
|
+
});
|
|
67
|
+
expect(resolveDisplayMode({config: 42})).toEqual({
|
|
68
|
+
ok: false,
|
|
69
|
+
source: 'config',
|
|
70
|
+
value: '42',
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe('needsVirtualDisplay: the auto decision', () => {
|
|
76
|
+
const table: [string, NodeJS.ProcessEnv, boolean, boolean, boolean][] = [
|
|
77
|
+
// [name, env, auto, xvfb, native]
|
|
78
|
+
['no display at all', {}, true, true, false],
|
|
79
|
+
['X11 DISPLAY set', {DISPLAY: ':0'}, false, true, false],
|
|
80
|
+
['Wayland only', {WAYLAND_DISPLAY: 'wayland-0'}, false, true, false],
|
|
81
|
+
['both set', {DISPLAY: ':0', WAYLAND_DISPLAY: 'w'}, false, true, false],
|
|
82
|
+
['empty DISPLAY counts as none', {DISPLAY: ''}, true, true, false],
|
|
83
|
+
];
|
|
84
|
+
for (const [name, env, auto, xvfb, native] of table) {
|
|
85
|
+
it(name, () => {
|
|
86
|
+
expect(needsVirtualDisplay('auto', env)).toBe(auto);
|
|
87
|
+
expect(needsVirtualDisplay('xvfb', env)).toBe(xvfb);
|
|
88
|
+
expect(needsVirtualDisplay('native', env)).toBe(native);
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe('detectVirtualDisplayBackend', () => {
|
|
94
|
+
it('prefers Xvfb over xvfb-run, and reports none when neither exists', () => {
|
|
95
|
+
const has =
|
|
96
|
+
(...names: string[]) =>
|
|
97
|
+
(n: string) =>
|
|
98
|
+
names.includes(n) ? `/bin/${n}` : undefined;
|
|
99
|
+
expect(detectVirtualDisplayBackend({}, has('Xvfb', 'xvfb-run'))).toBe(
|
|
100
|
+
'Xvfb',
|
|
101
|
+
);
|
|
102
|
+
expect(detectVirtualDisplayBackend({}, has('xvfb-run'))).toBe('xvfb-run');
|
|
103
|
+
expect(detectVirtualDisplayBackend({}, has())).toBeUndefined();
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
const onlyXvfb = (n: string) => (n === 'Xvfb' ? '/fake/Xvfb' : undefined);
|
|
108
|
+
|
|
109
|
+
describe('startVirtualDisplay: direct Xvfb with -displayfd', () => {
|
|
110
|
+
it('spawns Xvfb -displayfd and sets DISPLAY to the number it reports', async () => {
|
|
111
|
+
const xvfb = new FakeXvfb();
|
|
112
|
+
let spawned: {file: string; args: string[]} | undefined;
|
|
113
|
+
const spawnXvfb: XvfbSpawner = (file, args) => {
|
|
114
|
+
spawned = {file, args};
|
|
115
|
+
setImmediate(() => xvfb.reportDisplay(37));
|
|
116
|
+
return xvfb;
|
|
117
|
+
};
|
|
118
|
+
const started = await startVirtualDisplay({}, {find: onlyXvfb, spawnXvfb});
|
|
119
|
+
expect(started.ok).toBe(true);
|
|
120
|
+
if (!started.ok) return;
|
|
121
|
+
expect(spawned?.file).toBe('/fake/Xvfb');
|
|
122
|
+
expect(spawned?.args).toContain('-displayfd');
|
|
123
|
+
// No fixed display number: the server picks one (no races between runs).
|
|
124
|
+
expect(spawned?.args.some((a) => /^:\d+$/.test(a))).toBe(false);
|
|
125
|
+
expect(started.display.backend).toBe('Xvfb');
|
|
126
|
+
expect(started.display.env).toEqual({DISPLAY: ':37'});
|
|
127
|
+
expect(started.display.wrap('pico8', ['-run', 'c.p8'])).toEqual({
|
|
128
|
+
file: 'pico8',
|
|
129
|
+
args: ['-run', 'c.p8'],
|
|
130
|
+
});
|
|
131
|
+
await started.display.stop();
|
|
132
|
+
expect(xvfb.signals).toContain('SIGTERM');
|
|
133
|
+
expect(xvfb.exited).toBe(true);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('escalates to SIGKILL when Xvfb ignores SIGTERM', async () => {
|
|
137
|
+
const xvfb = new FakeXvfb();
|
|
138
|
+
xvfb.exitOnTerm = false;
|
|
139
|
+
const started = await startVirtualDisplay(
|
|
140
|
+
{},
|
|
141
|
+
{
|
|
142
|
+
find: onlyXvfb,
|
|
143
|
+
stopGraceMs: 10,
|
|
144
|
+
spawnXvfb: () => {
|
|
145
|
+
setImmediate(() => xvfb.reportDisplay(1));
|
|
146
|
+
return xvfb;
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
);
|
|
150
|
+
if (!started.ok) throw new Error('expected a display');
|
|
151
|
+
await started.display.stop();
|
|
152
|
+
expect(xvfb.signals).toEqual(['SIGTERM', 'SIGKILL']);
|
|
153
|
+
expect(xvfb.exited).toBe(true);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('is display-unavailable (with stderr) when Xvfb exits before reporting', async () => {
|
|
157
|
+
const xvfb = new FakeXvfb();
|
|
158
|
+
const started = await startVirtualDisplay(
|
|
159
|
+
{},
|
|
160
|
+
{
|
|
161
|
+
find: onlyXvfb,
|
|
162
|
+
spawnXvfb: () => {
|
|
163
|
+
setImmediate(() => {
|
|
164
|
+
xvfb.writeStderr('Fatal server error: no screens\n');
|
|
165
|
+
xvfb.exit(1);
|
|
166
|
+
});
|
|
167
|
+
return xvfb;
|
|
168
|
+
},
|
|
169
|
+
},
|
|
170
|
+
);
|
|
171
|
+
expect(started.ok).toBe(false);
|
|
172
|
+
if (started.ok) return;
|
|
173
|
+
expect(started.reason).toBe('display-unavailable');
|
|
174
|
+
expect(started.detail).toContain('no screens');
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('kills Xvfb and is display-unavailable when it never reports a display', async () => {
|
|
178
|
+
const xvfb = new FakeXvfb();
|
|
179
|
+
const started = await startVirtualDisplay(
|
|
180
|
+
{},
|
|
181
|
+
{find: onlyXvfb, startTimeoutMs: 20, spawnXvfb: () => xvfb},
|
|
182
|
+
);
|
|
183
|
+
expect(started.ok).toBe(false);
|
|
184
|
+
expect(xvfb.exited).toBe(true); // never left behind
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
describe('startVirtualDisplay: xvfb-run fallback and absence', () => {
|
|
189
|
+
it('wraps the command in xvfb-run -a when only xvfb-run exists', async () => {
|
|
190
|
+
const started = await startVirtualDisplay(
|
|
191
|
+
{},
|
|
192
|
+
{find: (n) => (n === 'xvfb-run' ? '/fake/xvfb-run' : undefined)},
|
|
193
|
+
);
|
|
194
|
+
if (!started.ok) throw new Error('expected xvfb-run');
|
|
195
|
+
expect(started.display.backend).toBe('xvfb-run');
|
|
196
|
+
const w = started.display.wrap('pico8', ['-run', 'c.p8']);
|
|
197
|
+
expect(w.file).toBe('/fake/xvfb-run');
|
|
198
|
+
expect(w.args[0]).toBe('-a');
|
|
199
|
+
expect(w.args.slice(-3)).toEqual(['pico8', '-run', 'c.p8']);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('is display-unavailable with a remedy naming Xvfb when nothing is installed', async () => {
|
|
203
|
+
const started = await startVirtualDisplay({}, {find: () => undefined});
|
|
204
|
+
expect(started.ok).toBe(false);
|
|
205
|
+
if (started.ok) return;
|
|
206
|
+
expect(started.reason).toBe('display-unavailable');
|
|
207
|
+
expect(started.remedy).toMatch(/Xvfb/);
|
|
208
|
+
expect(started.remedy).toMatch(/desktop/);
|
|
209
|
+
});
|
|
210
|
+
});
|