picopilot 0.1.4 → 0.1.5
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/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/export.d.ts.map +1 -1
- package/dist/commands/export.js +62 -77
- package/dist/commands/export.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/playtest.d.ts.map +1 -1
- package/dist/commands/playtest.js +2 -23
- package/dist/commands/playtest.js.map +1 -1
- package/dist/commands/serve.d.ts.map +1 -1
- package/dist/commands/serve.js +61 -10
- package/dist/commands/serve.js.map +1 -1
- 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/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/supervisor.d.ts +1 -1
- package/dist/engine/pico8/supervisor.js +1 -1
- package/dist/engine/shrinko/adapter.d.ts +1 -1
- package/package.json +2 -1
- package/src/commands/audio.test.ts +12 -0
- package/src/commands/cart-includes.ts +27 -0
- package/src/commands/export.test.ts +327 -3
- package/src/commands/export.ts +72 -86
- package/src/commands/label-cart.ts +304 -0
- package/src/commands/lint.test.ts +6 -0
- package/src/commands/minify.test.ts +6 -0
- package/src/commands/playtest.test.ts +3 -0
- package/src/commands/playtest.ts +2 -23
- package/src/commands/run.test.ts +6 -0
- package/src/commands/serve.test.ts +138 -2
- package/src/commands/serve.ts +72 -10
- package/src/commands/tokens.test.ts +19 -0
- package/src/commands/verify.test.ts +12 -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/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 +1 -1
- package/src/engine/pico8/supervisor.ts +1 -1
- package/src/engine/shrinko/adapter.ts +1 -1
- package/src/engine/skills/skills.test.ts +10 -0
- package/src/skills/picopilot-debug/SKILL.md +18 -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);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The detached daemon process ENTRY (ADR-0011,
|
|
2
|
+
* The detached daemon process ENTRY (ADR-0011, spec US #6). `playtest start`
|
|
3
3
|
* spawns this file (via `process.execPath [+ execArgv] <this> <configJson>`, so
|
|
4
4
|
* it re-uses the exact loader the parent ran under, dev `tsx` or built `dist`) as
|
|
5
5
|
* a DETACHED background process that owns the live driven PICO-8 for the session's
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The playtest session DAEMON ENTRYPOINT (ADR-0011,
|
|
2
|
+
* The playtest session DAEMON ENTRYPOINT (ADR-0011, spec US #6): the small
|
|
3
3
|
* detached process `playtest start` spawns to OWN a live driven PICO-8 for a
|
|
4
4
|
* session's lifetime. It is invoked via the hidden `playtest __daemon` verb (so it
|
|
5
5
|
* re-uses the exact same executable/loader the parent ran under, dev `tsx` or
|
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
existsSync,
|
|
3
|
+
mkdtempSync,
|
|
4
|
+
readFileSync,
|
|
5
|
+
rmSync,
|
|
6
|
+
writeFileSync,
|
|
7
|
+
} from 'node:fs';
|
|
2
8
|
import {tmpdir} from 'node:os';
|
|
3
9
|
import {join} from 'node:path';
|
|
4
10
|
import {afterEach, beforeEach, describe, expect, it} from 'vitest';
|
|
@@ -90,6 +96,31 @@ async function waitSocket(): Promise<void> {
|
|
|
90
96
|
}
|
|
91
97
|
|
|
92
98
|
describe('session daemon: verb round-trip over the socket (fake process)', () => {
|
|
99
|
+
it('shot waits for the PNG PICO-8 writes AFTER the ACK, and never returns an older shot', async () => {
|
|
100
|
+
// Real PICO-8 acks the SHOT (printh) before extcmd("screen") lands on disk.
|
|
101
|
+
const proc = new FakeDriven();
|
|
102
|
+
let n = 0;
|
|
103
|
+
const realWrite = proc.writeStdin.bind(proc);
|
|
104
|
+
proc.writeStdin = (bytes: Uint8Array) => {
|
|
105
|
+
realWrite(bytes);
|
|
106
|
+
if (bytes[0] === OPCODE.shot) {
|
|
107
|
+
const file = join(paths.shotDir, `play${n++}.png`);
|
|
108
|
+
setTimeout(() => writeFileSync(file, 'png'), 150);
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
startFakeDaemon(proc);
|
|
112
|
+
await waitSocket();
|
|
113
|
+
const client = new SessionClient(paths.socket);
|
|
114
|
+
const first = await client.send({verb: 'shot'});
|
|
115
|
+
expect(first.ok && first.value.screenshot).toBe(
|
|
116
|
+
join(paths.shotDir, 'play0.png'),
|
|
117
|
+
);
|
|
118
|
+
const second = await client.send({verb: 'shot'});
|
|
119
|
+
expect(second.ok && second.value.screenshot).toBe(
|
|
120
|
+
join(paths.shotDir, 'play1.png'),
|
|
121
|
+
);
|
|
122
|
+
});
|
|
123
|
+
|
|
93
124
|
it('step advances frames and returns the structured result', async () => {
|
|
94
125
|
startFakeDaemon(new FakeDriven());
|
|
95
126
|
await waitSocket();
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The playtest session DAEMON runtime + CLIENT (ADR-0011,
|
|
2
|
+
* The playtest session DAEMON runtime + CLIENT (ADR-0011, spec US #6). This is the
|
|
3
3
|
* IO half of the supervisor: the daemon LISTENS on a session's Unix domain socket
|
|
4
4
|
* and drives a live {@link DriveSession} on behalf of each verb sub-invocation;
|
|
5
5
|
* the {@link SessionClient} CONNECTS to that socket to send a verb and read back
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
|
|
15
15
|
import {spawn} from 'node:child_process';
|
|
16
16
|
import {createConnection, createServer, type Socket} from 'node:net';
|
|
17
|
-
import {existsSync,
|
|
17
|
+
import {existsSync, rmSync, writeFileSync} from 'node:fs';
|
|
18
18
|
import {dirname, join} from 'node:path';
|
|
19
19
|
import {fileURLToPath} from 'node:url';
|
|
20
20
|
import {
|
|
@@ -206,7 +206,10 @@ export function serveSession(options: ServeSessionOptions): {
|
|
|
206
206
|
}
|
|
207
207
|
case 'shot': {
|
|
208
208
|
const r: ShotResult = await session.shot();
|
|
209
|
-
const screenshot = collectShot(
|
|
209
|
+
const screenshot = await collectShot(
|
|
210
|
+
options.paths.shotDir,
|
|
211
|
+
r.shotName,
|
|
212
|
+
);
|
|
210
213
|
return ok(options.id, {
|
|
211
214
|
frame: r.frame,
|
|
212
215
|
ack: r.ack,
|
|
@@ -285,22 +288,27 @@ function ok(
|
|
|
285
288
|
return {ok: true, value: {id, ...partial}};
|
|
286
289
|
}
|
|
287
290
|
|
|
291
|
+
/** How long `shot` waits for PICO-8 to write the PNG after acking the SHOT. */
|
|
292
|
+
const SHOT_FILE_TIMEOUT_MS = 3000;
|
|
293
|
+
const SHOT_FILE_POLL_MS = 20;
|
|
294
|
+
|
|
288
295
|
/**
|
|
289
|
-
* Resolves the
|
|
290
|
-
*
|
|
291
|
-
*
|
|
292
|
-
* to the most
|
|
296
|
+
* Resolves the screenshot the cart just took: `<shotDir>/<shotName>.png`. The
|
|
297
|
+
* cart's SHOT ACK is printed BEFORE `extcmd("screen")` lands on disk (verified
|
|
298
|
+
* on PICO-8 0.2.7), so poll briefly for that exact file. It deliberately never
|
|
299
|
+
* falls back to "the most recent PNG": that returned the PREVIOUS shot (or
|
|
300
|
+
* nothing) whenever the new file was still being written. `undefined` only if
|
|
301
|
+
* the file never appears within {@link SHOT_FILE_TIMEOUT_MS}.
|
|
293
302
|
*/
|
|
294
|
-
function collectShot(
|
|
303
|
+
async function collectShot(
|
|
304
|
+
shotDir: string,
|
|
305
|
+
shotName: string,
|
|
306
|
+
): Promise<string | undefined> {
|
|
295
307
|
const exact = join(shotDir, `${shotName}.png`);
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
.sort();
|
|
301
|
-
const last = pngs.at(-1);
|
|
302
|
-
return last !== undefined ? join(shotDir, last) : undefined;
|
|
303
|
-
} catch {
|
|
304
|
-
return undefined;
|
|
308
|
+
const deadline = Date.now() + SHOT_FILE_TIMEOUT_MS;
|
|
309
|
+
while (!existsSync(exact)) {
|
|
310
|
+
if (Date.now() >= deadline) return undefined;
|
|
311
|
+
await new Promise((r) => setTimeout(r, SHOT_FILE_POLL_MS));
|
|
305
312
|
}
|
|
313
|
+
return exact;
|
|
306
314
|
}
|
|
@@ -5,7 +5,7 @@ import type {Pico8Process} from './shell.js';
|
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* The RESUMABLE session HANDSHAKE core is tested WITHOUT the paid PICO-8 binary
|
|
8
|
-
* (ADR-0011,
|
|
8
|
+
* (ADR-0011, spec US #6): a FAKE driven process lets us assert the load-bearing
|
|
9
9
|
* transport contract, each verb WRITES its fixed-size command block and WAITS for
|
|
10
10
|
* the cart's ACK before returning (send -> wait-ack -> return), and the failure
|
|
11
11
|
* paths (a lost/late ack, a process that dies mid-command) surface as STRUCTURED
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The RESUMABLE playtest SESSION handshake core (ADR-0011,
|
|
2
|
+
* The RESUMABLE playtest SESSION handshake core (ADR-0011, spec US #6): the pure,
|
|
3
3
|
* CI-testable half of the "A" live model. It keeps a single driven PICO-8 process
|
|
4
4
|
* ALIVE and PAUSED between the agent's turns and drives it one command at a time
|
|
5
5
|
* over the verified transport: write a FIXED-SIZE command block, WAIT for the
|
|
@@ -144,7 +144,7 @@ describe('ShellPico8Adapter: sentinel-driven termination', () => {
|
|
|
144
144
|
proc.emit(`${DONE_SENTINEL}\n`);
|
|
145
145
|
await resultP;
|
|
146
146
|
expect(spawnArgs).toContain('-home');
|
|
147
|
-
const home = spawnArgs[spawnArgs.indexOf('-home') + 1]
|
|
147
|
+
const home = spawnArgs[spawnArgs.indexOf('-home') + 1]!;
|
|
148
148
|
// Points at an isolated temp dir, NOT the process CWD.
|
|
149
149
|
expect(home.startsWith(tmpdir())).toBe(true);
|
|
150
150
|
expect(home).not.toBe(process.cwd());
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The playtest SESSION SUPERVISOR (ADR-0011,
|
|
2
|
+
* The playtest SESSION SUPERVISOR (ADR-0011, spec US #6): the daemon that keeps a
|
|
3
3
|
* driven PICO-8 process ALIVE + PAUSED across the agent's separate CLI turns, and
|
|
4
4
|
* the client the per-verb sub-invocations use to address it BY ID.
|
|
5
5
|
*
|
|
@@ -26,7 +26,7 @@ export const SHRINKO_NEEDS = ['python>=3.8'] as const;
|
|
|
26
26
|
|
|
27
27
|
/**
|
|
28
28
|
* The structured "shrinko is not installed" failure (ADR-0002). Its shape is
|
|
29
|
-
* fixed by the
|
|
29
|
+
* fixed by the spec's two-tier contract (US #17): `ok:false`, a machine-readable
|
|
30
30
|
* `reason`, the EXACT `remedy` string, and the `needs` list. A command turns
|
|
31
31
|
* this into an incur `error({ code: reason, ... })` with a nonzero exit.
|
|
32
32
|
*/
|
|
@@ -185,6 +185,16 @@ describe('picopilot skills: generation output (the authored discipline skills)',
|
|
|
185
185
|
expect(md.toLowerCase()).toContain('offline export');
|
|
186
186
|
});
|
|
187
187
|
|
|
188
|
+
it('picopilot-debug documents a run/playtest screenshot as the export label, as-is', () => {
|
|
189
|
+
const md = readSkill('picopilot-debug');
|
|
190
|
+
expect(md).toContain('--label');
|
|
191
|
+
expect(md).toContain('384x384');
|
|
192
|
+
expect(md.toLowerCase()).toContain('no image tool');
|
|
193
|
+
// The zero-image path: capture a frame of the running cart as the label.
|
|
194
|
+
expect(md).toContain('--label-frame 0');
|
|
195
|
+
expect(md).toContain('label-frame-not-captured');
|
|
196
|
+
});
|
|
197
|
+
|
|
188
198
|
it('picopilot-debug carries the static-gate + run loop and the dependency boundaries', () => {
|
|
189
199
|
const md = readSkill('picopilot-debug');
|
|
190
200
|
expect(md).toContain('picopilot verify');
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: picopilot-debug
|
|
3
|
-
description: Close the run-test loop for a PICO-8 cart with picopilot. Run the cart headless and read printh output, use the static verify gate honestly (well-formed is not proven-to-run),
|
|
3
|
+
description: Close the run-test loop for a PICO-8 cart with picopilot. Run the cart headless and read printh output, use the static verify gate honestly (well-formed is not proven-to-run), understand the structured pico8-not-found / shrinko-not-found boundaries, and export with a screenshot as the label splash. Use when a cart misbehaves, you need to confirm it boots, or you are exporting it.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# picopilot debug
|
|
@@ -181,6 +181,23 @@ NON-deterministic source (wall-clock/entropy) mid-run and so cannot be replayed
|
|
|
181
181
|
from frame 0. Both drive the SAME cart-side transform; only the host lifecycle
|
|
182
182
|
differs.
|
|
183
183
|
|
|
184
|
+
## Ship it: `picopilot export` and the label splash
|
|
185
|
+
|
|
186
|
+
`picopilot export <cart> [dest]` writes a playable `index.html` + `index.js` bundle (requires PICO-8). PICO-8 needs a `__label__` (the loading splash) to export well; a labelless cart gives a blank splash or no bundle at all. Give it one from the game itself, with no image tool. The simplest way captures a frame for you:
|
|
187
|
+
|
|
188
|
+
```sh
|
|
189
|
+
picopilot export main.p8 out --label-frame 0 # run the cart with no input, use frame 0 (the title screen)
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
`--label-frame <n>` runs any cart headlessly through the playtest harness with NO input and bakes frame `n` in (0-based: 0 is the first drawn frame); the cart does not need to screenshot itself. Use 0 for the title screen, or a later frame if the title fades in or animates. Check the result: the output's `labelSource` is `frame`, and a cart that never reaches frame `n` fails as `label-frame-not-captured`. To look at a frame BEFORE baking it, a session reproduces it exactly: `playtest start main.p8`, `playtest step <id> --frames <n+1>`, `playtest shot <id>` (not `playtest run`, which presses buttons). With no input it cannot get past the title, so for a GAMEPLAY splash, drive the cart and pass the screenshot you want:
|
|
193
|
+
|
|
194
|
+
```sh
|
|
195
|
+
picopilot playtest run main.p8 --input "..." # or: picopilot run main.p8 --shot-dir shots
|
|
196
|
+
picopilot export main.p8 out --label shots/<name>.png # <name>: a path listed under `screenshots`
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
Pass the screenshot unchanged. `run` and `playtest` write PICO-8's pixel-exact upscale (384x384 at the default 3x `screenshot_scale`), and `--label` accepts any square multiple of 128 (128x128, 256x256, 384x384, ...) and samples it back down to 128x128. Any 128x128 PNG works too, including palette-indexed and greyscale ones. Do NOT resize or convert it with ImageMagick first: that is never needed, and a resize that is not an exact multiple is rejected (`label-wrong-size`). An existing `__label__` in the cart always wins; `--label` only fills a missing one. `--label` and `--label-frame` never write into your cart (they export a labeled copy), so pass one on every export; passing both is an error. `picopilot serve` (play the build in a browser) takes the same two flags for the same reason, e.g. `picopilot serve main.p8 --label-frame 0`.
|
|
200
|
+
|
|
184
201
|
## The two structured boundaries
|
|
185
202
|
|
|
186
203
|
Both dependency boundaries are soft and well-signposted, so read the result, do
|