picopilot 0.1.3 → 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.
Files changed (69) hide show
  1. package/dist/commands/cart-includes.d.ts +11 -0
  2. package/dist/commands/cart-includes.d.ts.map +1 -0
  3. package/dist/commands/cart-includes.js +26 -0
  4. package/dist/commands/cart-includes.js.map +1 -0
  5. package/dist/commands/export.d.ts.map +1 -1
  6. package/dist/commands/export.js +62 -77
  7. package/dist/commands/export.js.map +1 -1
  8. package/dist/commands/label-cart.d.ts +72 -0
  9. package/dist/commands/label-cart.d.ts.map +1 -0
  10. package/dist/commands/label-cart.js +228 -0
  11. package/dist/commands/label-cart.js.map +1 -0
  12. package/dist/commands/playtest.d.ts.map +1 -1
  13. package/dist/commands/playtest.js +2 -23
  14. package/dist/commands/playtest.js.map +1 -1
  15. package/dist/commands/serve.d.ts.map +1 -1
  16. package/dist/commands/serve.js +61 -10
  17. package/dist/commands/serve.js.map +1 -1
  18. package/dist/engine/gfx/label.d.ts +15 -9
  19. package/dist/engine/gfx/label.d.ts.map +1 -1
  20. package/dist/engine/gfx/label.js +130 -38
  21. package/dist/engine/gfx/label.js.map +1 -1
  22. package/dist/engine/pico8/session-daemon-entry.d.ts +1 -1
  23. package/dist/engine/pico8/session-daemon-entry.js +1 -1
  24. package/dist/engine/pico8/session-daemon-main.d.ts +1 -1
  25. package/dist/engine/pico8/session-daemon-main.js +1 -1
  26. package/dist/engine/pico8/session-daemon.d.ts +1 -1
  27. package/dist/engine/pico8/session-daemon.d.ts.map +1 -1
  28. package/dist/engine/pico8/session-daemon.js +19 -19
  29. package/dist/engine/pico8/session-daemon.js.map +1 -1
  30. package/dist/engine/pico8/session.d.ts +1 -1
  31. package/dist/engine/pico8/session.js +1 -1
  32. package/dist/engine/pico8/supervisor.d.ts +1 -1
  33. package/dist/engine/pico8/supervisor.js +1 -1
  34. package/dist/engine/scaffold/scaffold.d.ts.map +1 -1
  35. package/dist/engine/scaffold/scaffold.js +15 -0
  36. package/dist/engine/scaffold/scaffold.js.map +1 -1
  37. package/dist/engine/shrinko/adapter.d.ts +1 -1
  38. package/package.json +2 -1
  39. package/src/commands/audio.test.ts +12 -0
  40. package/src/commands/cart-includes.ts +27 -0
  41. package/src/commands/export.test.ts +327 -3
  42. package/src/commands/export.ts +72 -86
  43. package/src/commands/init.test.ts +10 -0
  44. package/src/commands/label-cart.ts +304 -0
  45. package/src/commands/lint.test.ts +6 -0
  46. package/src/commands/minify.test.ts +6 -0
  47. package/src/commands/playtest.test.ts +3 -0
  48. package/src/commands/playtest.ts +2 -23
  49. package/src/commands/run.test.ts +6 -0
  50. package/src/commands/serve.test.ts +138 -2
  51. package/src/commands/serve.ts +72 -10
  52. package/src/commands/tokens.test.ts +19 -0
  53. package/src/commands/verify.test.ts +12 -0
  54. package/src/engine/gfx/fixtures/pico8-screenshot-384.png +0 -0
  55. package/src/engine/gfx/label.test.ts +467 -1
  56. package/src/engine/gfx/label.ts +147 -41
  57. package/src/engine/pico8/session-daemon-entry.ts +1 -1
  58. package/src/engine/pico8/session-daemon-main.ts +1 -1
  59. package/src/engine/pico8/session-daemon.test.ts +32 -1
  60. package/src/engine/pico8/session-daemon.ts +25 -17
  61. package/src/engine/pico8/session.test.ts +1 -1
  62. package/src/engine/pico8/session.ts +1 -1
  63. package/src/engine/pico8/shell.test.ts +1 -1
  64. package/src/engine/pico8/supervisor.ts +1 -1
  65. package/src/engine/scaffold/scaffold.ts +15 -0
  66. package/src/engine/shrinko/adapter.ts +1 -1
  67. package/src/engine/skills/skills.test.ts +16 -0
  68. package/src/skills/game-design-reference/SKILL.md +2 -0
  69. package/src/skills/picopilot-debug/SKILL.md +18 -1
@@ -1,5 +1,6 @@
1
1
  /**
2
- * The `__label__` builder: turn a 128x128 image into the hex a `.p8` cart's
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 intentionally supports only the PNG subset a caller realistically produces
13
- * for a 128x128 label: 8-bit, non-interlaced, colour types 2 (RGB) and 6 (RGBA),
14
- * and (via the palette map) 0/3 are rejected with a clear error rather than
15
- * guessed at. Anything else fails loud with a structured {@link LabelError}.
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 an 8-bit, non-interlaced PNG (colour type 2 RGB or 6 RGBA) to RGBA.
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. Throws {@link LabelError} for anything outside this subset.
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(bytes: Uint8Array): DecodedPng {
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
- bitDepth !== 8 ||
101
- interlace !== 0 ||
102
- (colourType !== 2 && colourType !== 6)
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 8-bit non-interlaced RGB or RGBA (got bitDepth=${bitDepth}, colourType=${colourType}, interlace=${interlace})`,
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 stride = width * channels;
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
- const prev = new Uint8Array(stride);
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 >= channels ? line[x - channels]! : 0; // left
196
+ const a = x >= bpp ? line[x - bpp]! : 0; // left
132
197
  const b = prev[x]!; // up
133
- const c = x >= channels ? prev[x - channels]! : 0; // up-left
134
- let val = rawByte;
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 = (rawByte + a) & 0xff;
205
+ val = rawByte + a;
140
206
  break; // Sub
141
207
  case 2:
142
- val = (rawByte + b) & 0xff;
208
+ val = rawByte + b;
143
209
  break; // Up
144
210
  case 3:
145
- val = (rawByte + ((a + b) >> 1)) & 0xff;
211
+ val = rawByte + ((a + b) >> 1);
146
212
  break; // Average
147
213
  case 4:
148
- val = (rawByte + paeth(a, b, c)) & 0xff;
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
- rgba[d] = line[s]!;
163
- rgba[d + 1] = line[s + 1]!;
164
- rgba[d + 2] = line[s + 2]!;
165
- rgba[d + 3] = channels === 4 ? line[s + 3]! : 255;
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.set(line);
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 128x128 PNG. Each pixel is mapped to its nearest
207
- * PICO-8 palette colour. The PNG MUST be exactly 128x128 ({@link LABEL_SIZE});
208
- * anything else is a {@link LabelError} (`wrong-size`) so a mis-sized image fails
209
- * loud instead of being silently cropped or stretched.
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
- if (width !== LABEL_SIZE || height !== LABEL_SIZE) {
214
- throw new LabelError(
215
- 'wrong-size',
216
- `label image must be ${LABEL_SIZE}x${LABEL_SIZE}, got ${width}x${height}`,
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 = (y * LABEL_SIZE + x) * 4;
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, prd US #6). `playtest start`
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, prd US #6): the small
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 {existsSync, mkdtempSync, readFileSync, rmSync} from 'node:fs';
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, prd US #6). This is the
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, readdirSync, rmSync, writeFileSync} from 'node:fs';
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(options.paths.shotDir, r.shotName);
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 absolute path of the screenshot a SHOT just produced in the
290
- * `-desktop` dir. The cart named it `<shotName>` (basename) and PICO-8 appends
291
- * `.png`; if the exact file is not yet visible (a filesystem race) it falls back
292
- * to the most-recent PNG, so the caller still gets a path to read.
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(shotDir: string, shotName: string): string | undefined {
303
+ async function collectShot(
304
+ shotDir: string,
305
+ shotName: string,
306
+ ): Promise<string | undefined> {
295
307
  const exact = join(shotDir, `${shotName}.png`);
296
- if (existsSync(exact)) return exact;
297
- try {
298
- const pngs = readdirSync(shotDir)
299
- .filter((f) => f.toLowerCase().endsWith('.png'))
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, prd US #6): a FAKE driven process lets us assert the load-bearing
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, prd US #6): the pure,
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, prd US #6): the daemon that keeps a
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
  *
@@ -150,6 +150,21 @@ Two checks that catch the bugs models ship most:
150
150
  losing, meaning it is too easy)? does it escalate? is it fair (no unavoidable
151
151
  loss)? If you drove a full round and nothing threatened you, tune it before
152
152
  calling it done. (The \`game-design-reference\` skill carries these self-checks.)
153
+ - **JUDGE the frame, do not just confirm it drew.** When you look at a gameplay
154
+ screenshot, actively hunt for what is WRONG, do not narrate that it rendered.
155
+ "It renders" is NOT "it is good". Ask concretely:
156
+ - **Readability / contrast:** can you tell EVERY entity apart, from the
157
+ background AND from each other, at a glance? If a hazard or pickup blends
158
+ into the scenery (same or near colour), that is a bug, recolour it. Every
159
+ thing the player must react to has to be instantly distinguishable.
160
+ - **Layout fairness:** are hazards spaced and placed so a HUMAN can actually
161
+ clear them (react in ~250ms, not frame-perfect)? Gaps too tight to pass, or
162
+ an obstacle you cannot see coming, are fairness defects, not difficulty.
163
+ - **Honest quality:** does it look intentional, or just non-broken? If a
164
+ player saw this, would the visuals read as deliberate?
165
+ These are the \`game-design-reference\` readability + fairness lenses applied to
166
+ the IMAGE you are looking at, not only to the logic. Apply them to the actual
167
+ frame; fix what fails before calling it done.
153
168
 
154
169
  A green \`verify\`, a bare \`timeout\`, or a nice-looking title is NOT done.
155
170
 
@@ -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 prd's two-tier contract (US #17): `ok:false`, a machine-readable
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
  */
@@ -68,6 +68,12 @@ describe('picopilot skills: generation output (the authored discipline skills)',
68
68
  expect(md).toContain('frame-perfect');
69
69
  // Visibility (draw what you spr()).
70
70
  expect(md).toContain('spr(');
71
+ // Readability judged on the RENDERED FRAME, not just the code: the
72
+ // contrast/spacing lens a large model skipped (it confirmed "renders" but
73
+ // never asked if entities were distinguishable or gaps clearable).
74
+ expect(md.toLowerCase()).toContain('rendered frame');
75
+ expect(md.toLowerCase()).toContain('contrast');
76
+ expect(md.toLowerCase()).toMatch(/distinguish|tell every entity apart/);
71
77
  // Originality is a METHOD with a self-check, and lists NO concrete
72
78
  // mechanics (the answer-menu trap). Assert the caveat that guards it.
73
79
  expect(md.toLowerCase()).toContain('self-check');
@@ -179,6 +185,16 @@ describe('picopilot skills: generation output (the authored discipline skills)',
179
185
  expect(md.toLowerCase()).toContain('offline export');
180
186
  });
181
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
+
182
198
  it('picopilot-debug carries the static-gate + run loop and the dependency boundaries', () => {
183
199
  const md = readSkill('picopilot-debug');
184
200
  expect(md).toContain('picopilot verify');
@@ -41,6 +41,8 @@ If you `spr(n)` an entity, sprite n must actually be DRAWN (a non-empty sprite),
41
41
 
42
42
  The player must be able to READ the game state at a glance and get FEEDBACK for every action: what am I, what is a hazard, what is a goal, did my input register, did I score or get hit. Motion trails, colour contrast, a HUD, particles/shake/sfx on events, these are not polish for its own sake, they are how the player perceives the state they must act on.
43
43
 
44
+ **The self-check (apply it to the RENDERED FRAME, not just the code).** When you look at a gameplay screenshot, judge it, do not just confirm it drew. Looking for what is WRONG: can you tell EVERY entity apart from the background and from each other at a glance (a hazard the same colour as the scenery is a readability bug, recolour it)? is every hazard telegraphed and spaced so a human can react in ~250ms rather than by frame-perfect timing (a gap too tight to pass, or an off-screen hazard, is a fairness defect, not difficulty)? does it look intentional, or merely non-broken? "It renders" is not "it is readable and fair". Apply these lenses to the actual image and fix what fails before calling the game done.
45
+
44
46
  ## Difficulty curve and progression
45
47
 
46
48
  A good game GOES SOMEWHERE: it escalates. Ramp difficulty by introducing or combining MECHANICS (a new hazard type, a new interaction) over the playthrough, not merely by piling more of the same obstacle. More-of-the-same reads as samey and does not deepen; a new wrinkle does. Aim for a difficulty ARC with a satisfying peak, not a flat loop. Keep the first contact with each new mechanic gentle enough to learn it before it is tested under pressure.
@@ -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), and understand the structured pico8-not-found / shrinko-not-found boundaries. Use when a cart misbehaves or you need to confirm it boots.
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