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.
Files changed (172) hide show
  1. package/dist/cli.d.ts.map +1 -1
  2. package/dist/cli.js +111 -16
  3. package/dist/cli.js.map +1 -1
  4. package/dist/commands/audio.d.ts +3 -3
  5. package/dist/commands/audio.d.ts.map +1 -1
  6. package/dist/commands/audio.js +61 -44
  7. package/dist/commands/audio.js.map +1 -1
  8. package/dist/commands/cart-includes.d.ts +11 -0
  9. package/dist/commands/cart-includes.d.ts.map +1 -0
  10. package/dist/commands/cart-includes.js +26 -0
  11. package/dist/commands/cart-includes.js.map +1 -0
  12. package/dist/commands/doctor.d.ts +22 -0
  13. package/dist/commands/doctor.d.ts.map +1 -0
  14. package/dist/commands/doctor.js +222 -0
  15. package/dist/commands/doctor.js.map +1 -0
  16. package/dist/commands/export.d.ts +3 -4
  17. package/dist/commands/export.d.ts.map +1 -1
  18. package/dist/commands/export.js +66 -86
  19. package/dist/commands/export.js.map +1 -1
  20. package/dist/commands/gfx.d.ts +2 -2
  21. package/dist/commands/gfx.d.ts.map +1 -1
  22. package/dist/commands/gfx.js +3 -2
  23. package/dist/commands/gfx.js.map +1 -1
  24. package/dist/commands/label-cart.d.ts +72 -0
  25. package/dist/commands/label-cart.d.ts.map +1 -0
  26. package/dist/commands/label-cart.js +228 -0
  27. package/dist/commands/label-cart.js.map +1 -0
  28. package/dist/commands/lint.d.ts +2 -2
  29. package/dist/commands/lint.d.ts.map +1 -1
  30. package/dist/commands/lint.js +3 -2
  31. package/dist/commands/lint.js.map +1 -1
  32. package/dist/commands/minify.d.ts +2 -2
  33. package/dist/commands/minify.d.ts.map +1 -1
  34. package/dist/commands/minify.js +3 -2
  35. package/dist/commands/minify.js.map +1 -1
  36. package/dist/commands/pico8-env.d.ts +76 -0
  37. package/dist/commands/pico8-env.d.ts.map +1 -0
  38. package/dist/commands/pico8-env.js +71 -0
  39. package/dist/commands/pico8-env.js.map +1 -0
  40. package/dist/commands/playtest.d.ts +2 -2
  41. package/dist/commands/playtest.d.ts.map +1 -1
  42. package/dist/commands/playtest.js +6 -32
  43. package/dist/commands/playtest.js.map +1 -1
  44. package/dist/commands/run.d.ts +3 -4
  45. package/dist/commands/run.d.ts.map +1 -1
  46. package/dist/commands/run.js +46 -19
  47. package/dist/commands/run.js.map +1 -1
  48. package/dist/commands/serve.d.ts +2 -2
  49. package/dist/commands/serve.d.ts.map +1 -1
  50. package/dist/commands/serve.js +65 -19
  51. package/dist/commands/serve.js.map +1 -1
  52. package/dist/commands/shrinko-env.d.ts +22 -0
  53. package/dist/commands/shrinko-env.d.ts.map +1 -0
  54. package/dist/commands/shrinko-env.js +15 -0
  55. package/dist/commands/shrinko-env.js.map +1 -0
  56. package/dist/commands/tokens.d.ts +4 -4
  57. package/dist/commands/tokens.d.ts.map +1 -1
  58. package/dist/commands/tokens.js +3 -2
  59. package/dist/commands/tokens.js.map +1 -1
  60. package/dist/commands/verify.d.ts +2 -2
  61. package/dist/commands/verify.d.ts.map +1 -1
  62. package/dist/commands/verify.js +3 -2
  63. package/dist/commands/verify.js.map +1 -1
  64. package/dist/engine/child-env.d.ts +14 -0
  65. package/dist/engine/child-env.d.ts.map +1 -0
  66. package/dist/engine/child-env.js +21 -0
  67. package/dist/engine/child-env.js.map +1 -0
  68. package/dist/engine/gfx/label.d.ts +15 -9
  69. package/dist/engine/gfx/label.d.ts.map +1 -1
  70. package/dist/engine/gfx/label.js +130 -38
  71. package/dist/engine/gfx/label.js.map +1 -1
  72. package/dist/engine/pico8/adapter.d.ts +23 -3
  73. package/dist/engine/pico8/adapter.d.ts.map +1 -1
  74. package/dist/engine/pico8/adapter.js.map +1 -1
  75. package/dist/engine/pico8/cleanup.d.ts +16 -0
  76. package/dist/engine/pico8/cleanup.d.ts.map +1 -0
  77. package/dist/engine/pico8/cleanup.js +74 -0
  78. package/dist/engine/pico8/cleanup.js.map +1 -0
  79. package/dist/engine/pico8/display.d.ts +149 -0
  80. package/dist/engine/pico8/display.d.ts.map +1 -0
  81. package/dist/engine/pico8/display.js +311 -0
  82. package/dist/engine/pico8/display.js.map +1 -0
  83. package/dist/engine/pico8/drive.d.ts.map +1 -1
  84. package/dist/engine/pico8/drive.js +28 -2
  85. package/dist/engine/pico8/drive.js.map +1 -1
  86. package/dist/engine/pico8/harness.d.ts +8 -0
  87. package/dist/engine/pico8/harness.d.ts.map +1 -1
  88. package/dist/engine/pico8/harness.js +9 -1
  89. package/dist/engine/pico8/harness.js.map +1 -1
  90. package/dist/engine/pico8/index.d.ts +3 -0
  91. package/dist/engine/pico8/index.d.ts.map +1 -1
  92. package/dist/engine/pico8/index.js +3 -0
  93. package/dist/engine/pico8/index.js.map +1 -1
  94. package/dist/engine/pico8/session-daemon-entry.d.ts +1 -1
  95. package/dist/engine/pico8/session-daemon-entry.js +1 -1
  96. package/dist/engine/pico8/session-daemon-main.d.ts +1 -1
  97. package/dist/engine/pico8/session-daemon-main.js +1 -1
  98. package/dist/engine/pico8/session-daemon.d.ts +1 -1
  99. package/dist/engine/pico8/session-daemon.d.ts.map +1 -1
  100. package/dist/engine/pico8/session-daemon.js +19 -19
  101. package/dist/engine/pico8/session-daemon.js.map +1 -1
  102. package/dist/engine/pico8/session.d.ts +1 -1
  103. package/dist/engine/pico8/session.js +1 -1
  104. package/dist/engine/pico8/shell.d.ts +56 -3
  105. package/dist/engine/pico8/shell.d.ts.map +1 -1
  106. package/dist/engine/pico8/shell.js +188 -24
  107. package/dist/engine/pico8/shell.js.map +1 -1
  108. package/dist/engine/pico8/supervisor.d.ts +1 -1
  109. package/dist/engine/pico8/supervisor.js +1 -1
  110. package/dist/engine/shrinko/adapter.d.ts +1 -1
  111. package/dist/engine/shrinko/shell.d.ts +3 -3
  112. package/dist/engine/shrinko/shell.js +3 -3
  113. package/package.json +2 -1
  114. package/src/cli.test.ts +188 -0
  115. package/src/cli.ts +122 -16
  116. package/src/commands/audio.test.ts +76 -1
  117. package/src/commands/audio.ts +79 -57
  118. package/src/commands/cart-includes.ts +27 -0
  119. package/src/commands/doctor.test.ts +149 -0
  120. package/src/commands/doctor.ts +280 -0
  121. package/src/commands/export.test.ts +327 -3
  122. package/src/commands/export.ts +81 -99
  123. package/src/commands/gfx.ts +7 -6
  124. package/src/commands/label-cart.ts +304 -0
  125. package/src/commands/lint.test.ts +6 -0
  126. package/src/commands/lint.ts +7 -6
  127. package/src/commands/minify.test.ts +6 -0
  128. package/src/commands/minify.ts +7 -6
  129. package/src/commands/pico8-env.ts +124 -0
  130. package/src/commands/playtest.test.ts +3 -0
  131. package/src/commands/playtest.ts +10 -34
  132. package/src/commands/run.test.ts +116 -2
  133. package/src/commands/run.ts +62 -24
  134. package/src/commands/serve.test.ts +138 -2
  135. package/src/commands/serve.ts +80 -21
  136. package/src/commands/shrinko-env.test.ts +68 -0
  137. package/src/commands/shrinko-env.ts +40 -0
  138. package/src/commands/tokens.test.ts +19 -0
  139. package/src/commands/tokens.ts +9 -6
  140. package/src/commands/verify.test.ts +12 -0
  141. package/src/commands/verify.ts +7 -4
  142. package/src/engine/child-env.ts +22 -0
  143. package/src/engine/gfx/fixtures/pico8-screenshot-384.png +0 -0
  144. package/src/engine/gfx/label.test.ts +467 -1
  145. package/src/engine/gfx/label.ts +147 -41
  146. package/src/engine/pico8/adapter.ts +25 -3
  147. package/src/engine/pico8/cleanup.test.ts +28 -0
  148. package/src/engine/pico8/cleanup.ts +76 -0
  149. package/src/engine/pico8/display.test.ts +210 -0
  150. package/src/engine/pico8/display.ts +429 -0
  151. package/src/engine/pico8/drive.test.ts +20 -0
  152. package/src/engine/pico8/drive.ts +25 -2
  153. package/src/engine/pico8/fake-xvfb.test-support.ts +55 -0
  154. package/src/engine/pico8/harness.test.ts +26 -0
  155. package/src/engine/pico8/harness.ts +18 -2
  156. package/src/engine/pico8/index.ts +3 -0
  157. package/src/engine/pico8/session-daemon-entry.ts +1 -1
  158. package/src/engine/pico8/session-daemon-main.ts +1 -1
  159. package/src/engine/pico8/session-daemon.test.ts +32 -1
  160. package/src/engine/pico8/session-daemon.ts +25 -17
  161. package/src/engine/pico8/session.test.ts +1 -1
  162. package/src/engine/pico8/session.ts +1 -1
  163. package/src/engine/pico8/shell.test.ts +449 -10
  164. package/src/engine/pico8/shell.ts +255 -26
  165. package/src/engine/pico8/supervisor.ts +1 -1
  166. package/src/engine/pico8/virtual-display.process.test.ts +206 -0
  167. package/src/engine/shrinko/adapter.ts +1 -1
  168. package/src/engine/shrinko/shell.ts +3 -3
  169. package/src/engine/skills/skills.test.ts +10 -0
  170. package/src/skills/picopilot-audio/SKILL.md +4 -8
  171. package/src/skills/picopilot-debug/SKILL.md +18 -1
  172. package/src/skills/picopilot-overview/SKILL.md +9 -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);
@@ -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 = Pico8RecordOk | Pico8NotFound;
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. Live capture is a
260
- * manual/opt-in tier; CI drives a fake runner + the absent boundary.
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
+ });