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
@@ -13,11 +13,14 @@ import {afterEach, beforeEach, describe, expect, it} from 'vitest';
13
13
  import {encodePng} from '../engine/gfx/index.js';
14
14
  import {PICO8_PALETTE} from '../engine/gfx/index.js';
15
15
  import type {
16
+ DriveCommand,
17
+ DriveOptions,
16
18
  ExportOptions,
17
19
  Pico8Adapter,
20
+ Pico8DriveResult,
18
21
  Pico8ExportResult,
19
22
  } from '../engine/pico8/index.js';
20
- import {pico8NotFound} from '../engine/pico8/index.js';
23
+ import {BlockDecoder, pico8NotFound} from '../engine/pico8/index.js';
21
24
  import {type Pico8AdapterFactory, registerExport} from './export.js';
22
25
 
23
26
  /** A trivial valid cart the command reads (existence + a path to hand the adapter). */
@@ -265,6 +268,92 @@ describe('picopilot export: --label (bake a PNG in as the __label__ splash)', ()
265
268
  expect(exitCode).not.toBe(0);
266
269
  expect(stdout).toContain('128x128');
267
270
  });
271
+
272
+ it('accepts a PICO-8 screenshot as-is (384x384, as `run` writes it) and bakes it in', async () => {
273
+ const shot = readFileSync(
274
+ new URL(
275
+ '../engine/gfx/fixtures/pico8-screenshot-384.png',
276
+ import.meta.url,
277
+ ),
278
+ );
279
+ const labelFile = join(dir, 'shot.png');
280
+ writeFileSync(labelFile, shot);
281
+ let labeledCart = '';
282
+ const {exitCode} = await runExport(() => {
283
+ const inner = stubAdapter('writes-bundle');
284
+ return {
285
+ ...inner,
286
+ export: async (o: ExportOptions) => {
287
+ labeledCart = readFileSync(o.cartPath, 'utf8');
288
+ return inner.export(o);
289
+ },
290
+ } as Pico8Adapter;
291
+ }, ['export', cartPath, join(dir, 'out'), '--label', labelFile, '--json']);
292
+ expect(exitCode).toBe(0);
293
+ const label = labeledCart.split('__label__\n')[1]!.split('\n');
294
+ // The fixture: red (8) top-left quadrant, blue (12) bottom-right, dark blue (1) bg.
295
+ expect(label[10]![10]).toBe('8');
296
+ expect(label[100]![100]).toBe('c');
297
+ expect(label[100]![10]).toBe('1');
298
+ });
299
+
300
+ it('removes the labeled copy even when the export step throws', async () => {
301
+ const labelFile = join(dir, 'label.png');
302
+ writeFileSync(labelFile, labelPng());
303
+ await runExport(
304
+ () =>
305
+ ({
306
+ ...stubAdapter('writes-bundle'),
307
+ export: async () => {
308
+ throw new Error('boom');
309
+ },
310
+ }) as Pico8Adapter,
311
+ ['export', cartPath, join(dir, 'out'), '--label', labelFile, '--json'],
312
+ ).catch(() => undefined);
313
+ expect(
314
+ readdirSync(dir).filter((f) => f.startsWith('.picopilot-export-')),
315
+ ).toEqual([]);
316
+ });
317
+
318
+ it('points a wrong-size label error at passing a picopilot screenshot directly', async () => {
319
+ const odd = encodePng({
320
+ width: 200,
321
+ height: 200,
322
+ rgb: new Uint8Array(200 * 200 * 3),
323
+ });
324
+ const labelFile = join(dir, 'odd.png');
325
+ writeFileSync(labelFile, odd);
326
+ const {stdout, exitCode} = await runExport(
327
+ () => stubAdapter('writes-bundle'),
328
+ ['export', cartPath, join(dir, 'out'), '--label', labelFile, '--json'],
329
+ );
330
+ expect(exitCode).not.toBe(0);
331
+ expect(stdout).toContain('label-wrong-size');
332
+ expect(stdout).toContain('384x384');
333
+ expect(stdout.toLowerCase()).toContain('screenshot');
334
+ expect(stdout).toContain('scaled for you');
335
+ });
336
+
337
+ it('points an unsupported-PNG label error at the same fix', async () => {
338
+ // A valid PNG signature + IHDR claiming an interlaced image: unsupported.
339
+ const png = encodePng({
340
+ width: 128,
341
+ height: 128,
342
+ rgb: new Uint8Array(128 * 128 * 3),
343
+ });
344
+ // IHDR data starts at byte 16; the interlace byte is the 13th (offset 28).
345
+ png[28] = 1;
346
+ const labelFile = join(dir, 'interlaced.png');
347
+ writeFileSync(labelFile, png);
348
+ const {stdout, exitCode} = await runExport(
349
+ () => stubAdapter('writes-bundle'),
350
+ ['export', cartPath, join(dir, 'out'), '--label', labelFile, '--json'],
351
+ );
352
+ expect(exitCode).not.toBe(0);
353
+ expect(stdout).toContain('label-unsupported-png');
354
+ expect(stdout.toLowerCase()).toContain('screenshot');
355
+ expect(stdout).toContain('scaled for you');
356
+ });
268
357
  });
269
358
 
270
359
  describe('picopilot export: labelless cart + failure paths', () => {
@@ -276,7 +365,7 @@ describe('picopilot export: labelless cart + failure paths', () => {
276
365
  expect(exitCode).toBe(0);
277
366
  const out = JSON.parse(stdout);
278
367
  expect(out.labelWarning).toBe(true);
279
- expect(stdout.toLowerCase()).toContain('label');
368
+ expect(stdout).toContain('--label-frame 0');
280
369
  });
281
370
 
282
371
  it('errors when PICO-8 produced no js payload (labelless bail)', async () => {
@@ -285,7 +374,7 @@ describe('picopilot export: labelless cart + failure paths', () => {
285
374
  ['export', cartPath, join(dir, 'out'), '--json'],
286
375
  );
287
376
  expect(exitCode).not.toBe(0);
288
- expect(stdout.toLowerCase()).toContain('label');
377
+ expect(stdout).toContain('--label-frame 0');
289
378
  });
290
379
  });
291
380
 
@@ -308,3 +397,238 @@ describe('picopilot export: cart-not-found (picopilot-side error, distinct from
308
397
  expect(called).toBe(false);
309
398
  });
310
399
  });
400
+
401
+ /** The real PICO-8 384x384 screenshot fixture: red (8) top-left, blue (12) bottom-right, dark blue (1) bg. */
402
+ const SHOT_FIXTURE = new URL(
403
+ '../engine/gfx/fixtures/pico8-screenshot-384.png',
404
+ import.meta.url,
405
+ );
406
+
407
+ /**
408
+ * Wraps {@link stubAdapter} with a `drive` that plays the label-frame capture:
409
+ * records the {@link DriveOptions} and the driven cart text, then (by default)
410
+ * writes the screenshot fixture into `shotDir` as PICO-8 would and reports it.
411
+ * Also records the cart text handed to `export`.
412
+ */
413
+ function frameAdapter(
414
+ seen: {
415
+ drive?: DriveOptions;
416
+ drivenCart?: string;
417
+ exportedCart?: string;
418
+ exportCalled?: boolean;
419
+ },
420
+ driveResult: 'captures' | Pico8DriveResult = 'captures',
421
+ labelWarning = false,
422
+ ): Pico8Adapter {
423
+ const inner = stubAdapter('writes-bundle', undefined, labelWarning);
424
+ return {
425
+ ...inner,
426
+ async drive(o: DriveOptions): Promise<Pico8DriveResult> {
427
+ seen.drive = o;
428
+ seen.drivenCart = readFileSync(o.cartPath, 'utf8');
429
+ if (driveResult !== 'captures') return driveResult;
430
+ const shot = join(o.shotDir, 'label0.png');
431
+ writeFileSync(shot, readFileSync(SHOT_FIXTURE));
432
+ return {
433
+ ok: true,
434
+ value: {screenshots: [shot], printh: '', exitReason: 'sentinel'},
435
+ };
436
+ },
437
+ async export(o: ExportOptions) {
438
+ seen.exportCalled = true;
439
+ seen.exportedCart = readFileSync(o.cartPath, 'utf8');
440
+ return inner.export(o);
441
+ },
442
+ };
443
+ }
444
+
445
+ /** Decodes the command blocks a drive call was handed. */
446
+ function decodeScript(blocks: Uint8Array): DriveCommand[] {
447
+ return new BlockDecoder().push(blocks);
448
+ }
449
+
450
+ describe('picopilot export: --label-frame (capture the label from the game itself)', () => {
451
+ it('advances N frames with NO input, shoots once, and bakes that frame in as the label', async () => {
452
+ const seen: Parameters<typeof frameAdapter>[0] = {};
453
+ const {stdout, exitCode} = await runExport(
454
+ () => frameAdapter(seen),
455
+ ['export', cartPath, join(dir, 'out'), '--label-frame', '5', '--json'],
456
+ );
457
+ expect(exitCode).toBe(0);
458
+ // The script: 6 single-frame steps (frames 0..5), a SHOT after the 6th,
459
+ // then QUIT; the only input is "nothing held".
460
+ const script = decodeScript(seen.drive!.blocks);
461
+ expect(script.filter((c) => c.op === 'step').length).toBe(6);
462
+ expect(script.filter((c) => c.op === 'shot').length).toBe(1);
463
+ const shotAt = script.findIndex((c) => c.op === 'shot');
464
+ expect(script.slice(0, shotAt).filter((c) => c.op === 'step').length).toBe(
465
+ 6,
466
+ );
467
+ expect(
468
+ script
469
+ .filter((c) => c.op === 'input')
470
+ .every((c) => c.op === 'input' && c.held === 0),
471
+ ).toBe(true);
472
+ expect(script[script.length - 1]!.op).toBe('quit');
473
+ // The exported cart carries the captured frame as its label.
474
+ const label = seen.exportedCart!.split('__label__\n')[1]!.split('\n');
475
+ expect(label[10]![10]).toBe('8');
476
+ expect(label[100]![100]).toBe('c');
477
+ expect(label[100]![10]).toBe('1');
478
+ expect(JSON.parse(stdout).labelSource).toBe('frame');
479
+ // The user's cart is untouched and no temp files are left beside it.
480
+ expect(readFileSync(cartPath, 'utf8')).not.toContain('__label__');
481
+ expect(readdirSync(dir).filter((f) => f.startsWith('.'))).toEqual([]);
482
+ // The capture dir (PICO-8's -desktop) is removed after reading the frame.
483
+ expect(existsSync(seen.drive!.shotDir)).toBe(false);
484
+ });
485
+
486
+ it('--label-frame 0 shoots right after the first step (the first drawn frame)', async () => {
487
+ const seen: Parameters<typeof frameAdapter>[0] = {};
488
+ await runExport(
489
+ () => frameAdapter(seen),
490
+ ['export', cartPath, join(dir, 'out'), '--label-frame', '0', '--json'],
491
+ );
492
+ const ops = decodeScript(seen.drive!.blocks).map((c) => c.op);
493
+ expect(ops).toEqual(['input', 'step', 'shot', 'quit']);
494
+ });
495
+
496
+ it("inlines the cart's #include so the throwaway driven cart is self-contained", async () => {
497
+ writeFileSync(join(dir, 'main.lua'), 'function _draw() cls(9) end\n');
498
+ writeFileSync(
499
+ cartPath,
500
+ 'pico-8 cartridge // http://www.pico-8.com\nversion 42\n__lua__\n#include main.lua\n',
501
+ );
502
+ const seen: Parameters<typeof frameAdapter>[0] = {};
503
+ await runExport(
504
+ () => frameAdapter(seen),
505
+ ['export', cartPath, join(dir, 'out'), '--label-frame', '3', '--json'],
506
+ );
507
+ expect(seen.drivenCart).toContain('cls(9)');
508
+ // The driven cart is NOT the one exported: the export copy keeps the include.
509
+ expect(seen.exportedCart).toContain('#include main.lua');
510
+ expect(seen.exportedCart).not.toContain('serial(');
511
+ });
512
+
513
+ it("does not run the cart when it already has a label (the cart's label wins)", async () => {
514
+ const labeled = join(dir, 'labeled.p8');
515
+ writeFileSync(labeled, `${CART_TEXT}__label__\n${'0'.repeat(128)}\n`);
516
+ const seen: Parameters<typeof frameAdapter>[0] = {};
517
+ const {stdout, exitCode} = await runExport(
518
+ () => frameAdapter(seen),
519
+ ['export', labeled, join(dir, 'out'), '--label-frame', '5', '--json'],
520
+ );
521
+ expect(exitCode).toBe(0);
522
+ expect(seen.drive).toBeUndefined();
523
+ expect(JSON.parse(stdout).labelSource).toBe('cart');
524
+ });
525
+
526
+ it('rejects --label together with --label-frame, before running anything', async () => {
527
+ const labelFile = join(dir, 'label.png');
528
+ writeFileSync(labelFile, labelPng());
529
+ const seen: Parameters<typeof frameAdapter>[0] = {};
530
+ const {stdout, exitCode} = await runExport(
531
+ () => frameAdapter(seen),
532
+ [
533
+ 'export',
534
+ cartPath,
535
+ join(dir, 'out'),
536
+ '--label',
537
+ labelFile,
538
+ '--label-frame',
539
+ '5',
540
+ '--json',
541
+ ],
542
+ );
543
+ expect(exitCode).not.toBe(0);
544
+ expect(stdout).toContain('label-flags-conflict');
545
+ expect(seen.drive).toBeUndefined();
546
+ expect(seen.exportCalled).toBeUndefined();
547
+ });
548
+
549
+ it('returns pico8-not-found when PICO-8 is absent for the capture (no export attempted)', async () => {
550
+ const seen: Parameters<typeof frameAdapter>[0] = {};
551
+ const {stdout, exitCode} = await runExport(
552
+ () => frameAdapter(seen, pico8NotFound()),
553
+ ['export', cartPath, join(dir, 'out'), '--label-frame', '5', '--json'],
554
+ );
555
+ expect(exitCode).not.toBe(0);
556
+ expect(stdout).toContain('pico8-not-found');
557
+ expect(seen.exportCalled).toBeUndefined();
558
+ expect(existsSync(seen.drive!.shotDir)).toBe(false);
559
+ });
560
+
561
+ it('removes the capture dir and leaves no copy when the capture run throws', async () => {
562
+ const seen: Parameters<typeof frameAdapter>[0] = {};
563
+ await runExport(
564
+ () => ({
565
+ ...frameAdapter(seen),
566
+ async drive(o: DriveOptions): Promise<Pico8DriveResult> {
567
+ seen.drive = o;
568
+ throw new Error('boom');
569
+ },
570
+ }),
571
+ ['export', cartPath, join(dir, 'out'), '--label-frame', '5', '--json'],
572
+ ).catch(() => undefined);
573
+ expect(existsSync(seen.drive!.shotDir)).toBe(false);
574
+ expect(readdirSync(dir).filter((f) => f.startsWith('.'))).toEqual([]);
575
+ expect(seen.exportCalled).toBeUndefined();
576
+ });
577
+
578
+ it('errors as label-frame-not-captured (with a CTA) when no frame was captured', async () => {
579
+ const seen: Parameters<typeof frameAdapter>[0] = {};
580
+ const {stdout, exitCode} = await runExport(
581
+ () =>
582
+ frameAdapter(seen, {
583
+ ok: true,
584
+ value: {
585
+ screenshots: [],
586
+ printh: 'runtime error line 3',
587
+ exitReason: 'timeout',
588
+ },
589
+ }),
590
+ ['export', cartPath, join(dir, 'out'), '--label-frame', '5', '--json'],
591
+ );
592
+ expect(exitCode).not.toBe(0);
593
+ expect(stdout).toContain('label-frame-not-captured');
594
+ expect(stdout).toContain('timeout');
595
+ expect(stdout).toContain('runtime error line 3');
596
+ // The CTA reproduces the SAME no-input frame via a session (not
597
+ // `playtest run`, whose generic input and fixed shot frames differ).
598
+ expect(stdout).toContain('playtest step <id> --frames 6');
599
+ expect(stdout).toContain('playtest shot <id>');
600
+ expect(stdout).not.toContain('playtest run');
601
+ // A timeout also points at the backstop.
602
+ expect(stdout).toContain('--backstop-ms');
603
+ expect(seen.exportCalled).toBeUndefined();
604
+ expect(existsSync(seen.drive!.shotDir)).toBe(false);
605
+ expect(readdirSync(dir).filter((f) => f.startsWith('.'))).toEqual([]);
606
+ });
607
+
608
+ it('leaves an unreadable cart to PICO-8 when no label flag is passed (no throw)', async () => {
609
+ const dirCart = join(dir, 'weird.p8');
610
+ execFileSync('mkdir', [dirCart]);
611
+ const seen: {options?: ExportOptions} = {};
612
+ const {exitCode} = await runExport(
613
+ () => stubAdapter('writes-bundle', seen),
614
+ ['export', dirCart, join(dir, 'out'), '--json'],
615
+ );
616
+ expect(exitCode).toBe(0);
617
+ expect(seen.options!.cartPath).toBe(dirCart);
618
+ });
619
+
620
+ it('reports labelSource for the other paths (image / none)', async () => {
621
+ const labelFile = join(dir, 'label.png');
622
+ writeFileSync(labelFile, labelPng());
623
+ const withImage = await runExport(
624
+ () => stubAdapter('writes-bundle'),
625
+ ['export', cartPath, join(dir, 'a'), '--label', labelFile, '--json'],
626
+ );
627
+ expect(JSON.parse(withImage.stdout).labelSource).toBe('image');
628
+ const none = await runExport(
629
+ () => stubAdapter('writes-bundle', undefined, true),
630
+ ['export', cartPath, join(dir, 'b'), '--json'],
631
+ );
632
+ expect(JSON.parse(none.stdout).labelSource).toBe('none');
633
+ });
634
+ });
@@ -1,33 +1,27 @@
1
- import {
2
- existsSync,
3
- mkdirSync,
4
- mkdtempSync,
5
- readdirSync,
6
- readFileSync,
7
- rmSync,
8
- writeFileSync,
9
- } from 'node:fs';
1
+ import {existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync} from 'node:fs';
10
2
  import {tmpdir} from 'node:os';
11
- import {dirname, isAbsolute, join, resolve} from 'node:path';
3
+ import {isAbsolute, join, resolve} from 'node:path';
12
4
  import {type Cli, z} from 'incur';
13
- import {Cart, CartParseError} from '../engine/cart/index.js';
14
- import {labelHexFromPng, LabelError} from '../engine/gfx/index.js';
15
5
  import {
16
6
  EXPORT_HTML_NAME,
17
7
  type Pico8Adapter,
18
- ShellPico8Adapter,
8
+ type Pico8ExportResult,
19
9
  } from '../engine/pico8/index.js';
10
+ import {
11
+ createShellAdapterFactory,
12
+ PICO8_ENV,
13
+ type Pico8AdapterFactory,
14
+ } from './pico8-env.js';
15
+ import {prepareLabeledCart} from './label-cart.js';
20
16
 
21
17
  /**
22
18
  * Injects the PICO-8 adapter `export` uses (defaults to the shell adapter over
23
19
  * native `pico8 <cart> -export <name>.html -x`). Mirrors the `run` seam: a test
24
- * passes a stub to drive the present/absent paths without the paid binary, and
25
- * to isolate the child `env` (its `PICO8_PATH`/`PATH`).
20
+ * passes a stub to drive the present/absent paths without the paid binary, (the child env is the parent env + PICO8_PATH/PATH).
26
21
  */
27
- export type Pico8AdapterFactory = (env: NodeJS.ProcessEnv) => Pico8Adapter;
22
+ export type {Pico8AdapterFactory} from './pico8-env.js';
28
23
 
29
- const defaultAdapterFactory: Pico8AdapterFactory = (env) =>
30
- new ShellPico8Adapter({env});
24
+ const defaultAdapterFactory: Pico8AdapterFactory = createShellAdapterFactory();
31
25
 
32
26
  /** The default backstop: kill an export that never completes/exits after this many ms. */
33
27
  const DEFAULT_BACKSTOP_MS = 30_000;
@@ -83,7 +77,15 @@ export function registerExport(
83
77
  .string()
84
78
  .optional()
85
79
  .describe(
86
- 'Path to a 128x128 PNG to bake in as the cart __label__ (the export splash), for a cart that has none. Ignored if the cart already has a label.',
80
+ 'PNG to bake in as the cart __label__ (the export splash), for a cart that has none: a `run`/`playtest` screenshot as-is (any 128*k square, e.g. 384x384, is scaled down for you) or any 128x128 PNG. Ignored if the cart already has a label.',
81
+ ),
82
+ labelFrame: z
83
+ .number()
84
+ .int()
85
+ .min(0)
86
+ .optional()
87
+ .describe(
88
+ 'Capture the label from the game itself: run the cart headlessly with NO input and use frame N (0-based: 0 = the first drawn frame, usually the title screen) as the __label__. Needs PICO-8; the cart need not cooperate. Exclusive with --label; ignored if the cart already has a label.',
87
89
  ),
88
90
  payloadOnly: z
89
91
  .boolean()
@@ -97,18 +99,12 @@ export function registerExport(
97
99
  .positive()
98
100
  .default(DEFAULT_BACKSTOP_MS)
99
101
  .describe(
100
- 'Safety backstop: kill PICO-8 after this many ms if the export never completes.',
102
+ 'Safety backstop: kill PICO-8 after this many ms if the export (or the --label-frame capture run) never completes.',
101
103
  ),
102
104
  }),
103
105
  // incur resolves these from the (test-overridable) env source and hands
104
106
  // them to the child, so PICO8_PATH/PATH locate the binary in isolation.
105
- env: z.object({
106
- PICO8_PATH: z
107
- .string()
108
- .optional()
109
- .describe('Explicit path to the PICO-8 binary (else `pico8` on PATH).'),
110
- PATH: z.string().optional().describe('Used to locate `pico8`.'),
111
- }),
107
+ env: PICO8_ENV,
112
108
  output: z.object({
113
109
  outDir: z
114
110
  .string()
@@ -123,6 +119,11 @@ export function registerExport(
123
119
  files: z
124
120
  .array(z.string())
125
121
  .describe('Absolute paths of every file the bundle contains.'),
122
+ labelSource: z
123
+ .enum(['cart', 'image', 'frame', 'none'])
124
+ .describe(
125
+ "Where the bundle's label came from: the cart's own __label__, --label, --label-frame, or none.",
126
+ ),
126
127
  labelWarning: z
127
128
  .boolean()
128
129
  .describe(
@@ -136,9 +137,16 @@ export function registerExport(
136
137
  args: {cart: 'game.p8', dest: './website/static/games/my-game'},
137
138
  },
138
139
  {
139
- description: 'Bake a PNG as the label splash for a labelless cart',
140
+ description:
141
+ 'Bake a `run` screenshot in as the label splash for a labelless cart',
140
142
  args: {cart: 'game.p8', dest: './website/static/games/my-game'},
141
- options: {label: './label.png'},
143
+ options: {label: './shots/<name>.png'},
144
+ },
145
+ {
146
+ description:
147
+ 'Capture the title screen (frame 0) as the label splash, no image needed',
148
+ args: {cart: 'game.p8', dest: './website/static/games/my-game'},
149
+ options: {labelFrame: 0},
142
150
  },
143
151
  {
144
152
  description:
@@ -161,56 +169,27 @@ export function registerExport(
161
169
  });
162
170
  }
163
171
 
164
- // --label <png>: bake a 128x128 PNG in as the cart's __label__ so a
165
- // labelless cart gets a splash without an interactive F7 capture. We do
166
- // NOT mutate the user's cart: we write a labeled copy NEXT TO it (same dir,
167
- // so `#include main.lua` still resolves) and export that, then clean it up.
168
- // A cart that ALREADY has a label wins (the flag is a fallback, not an
169
- // override), matching the export precedence PICO-8 itself uses.
170
- let exportCartPath = cartPath;
171
- let labeledCopy: string | undefined;
172
- if (options.label !== undefined) {
173
- const labelPath = isAbsolute(options.label)
174
- ? options.label
175
- : resolve(process.cwd(), options.label);
176
- if (!existsSync(labelPath)) {
177
- return error({
178
- code: 'label-not-found',
179
- message: `no label image at ${labelPath}`,
180
- exitCode: 1,
181
- });
182
- }
183
- try {
184
- const cart = Cart.parse(readFileSync(cartPath, 'utf8'));
185
- const existingLabel = cart.getSection('label');
186
- if (existingLabel === undefined || existingLabel.trim() === '') {
187
- const hex = labelHexFromPng(readFileSync(labelPath));
188
- cart.setSection('label', hex);
189
- labeledCopy = join(
190
- dirname(cartPath),
191
- `.picopilot-export-${process.pid}-${Date.now()}.p8`,
192
- );
193
- writeFileSync(labeledCopy, cart.serialize());
194
- exportCartPath = labeledCopy;
195
- }
196
- // else: cart already has a label; keep it, ignore --label.
197
- } catch (e) {
198
- if (e instanceof LabelError) {
199
- return error({
200
- code: `label-${e.code}`,
201
- message: e.message,
202
- exitCode: 1,
203
- });
204
- }
205
- if (e instanceof CartParseError) {
206
- return error({
207
- code: `cart-${e.code}`,
208
- message: `could not parse the cart to inject a label: ${e.message}`,
209
- exitCode: 1,
210
- });
211
- }
212
- throw e;
213
- }
172
+ // --label <png> / --label-frame <n>: bake an image, or a captured frame of
173
+ // the running cart, in as the __label__ of a throwaway copy (the user's
174
+ // cart is never mutated; an existing label wins). Shared with `serve`,
175
+ // see label-cart.ts.
176
+ const adapter = adapterFactory(env as NodeJS.ProcessEnv);
177
+ const labeled = await prepareLabeledCart(
178
+ cartPath,
179
+ {
180
+ label: options.label,
181
+ labelFrame: options.labelFrame,
182
+ backstopMs: options.backstopMs,
183
+ },
184
+ (o) => adapter.drive(o),
185
+ );
186
+ if (!labeled.ok) {
187
+ return error({
188
+ code: labeled.code,
189
+ message: labeled.message,
190
+ exitCode: 1,
191
+ ...(labeled.cta ? {cta: labeled.cta} : {}),
192
+ });
214
193
  }
215
194
 
216
195
  // Output dir: a user-given dest (point it at the showcase), else an
@@ -221,24 +200,21 @@ export function registerExport(
221
200
  ? args.dest
222
201
  : resolve(process.cwd(), args.dest)
223
202
  : mkdtempSync(join(tmpdir(), 'picopilot-export-'));
224
- if (!existsSync(outDir)) mkdirSync(outDir, {recursive: true});
225
-
226
- const adapter = adapterFactory(env as NodeJS.ProcessEnv);
227
- const result = await adapter.export({
228
- cartPath: exportCartPath,
229
- outDir,
230
- htmlName: EXPORT_HTML_NAME,
231
- backstopMs: options.backstopMs,
232
- });
233
203
 
234
- // Remove the temporary labeled copy now that PICO-8 has read it (on every
235
- // path below); best-effort, a leftover dotfile is not fatal.
236
- if (labeledCopy !== undefined) {
237
- try {
238
- rmSync(labeledCopy);
239
- } catch {
240
- // ignore
241
- }
204
+ // The labeled copy lives in the user's cart dir, so remove it on EVERY
205
+ // path once PICO-8 has read it, including a throw from the dir setup or
206
+ // the adapter.
207
+ let result: Pico8ExportResult;
208
+ try {
209
+ if (!existsSync(outDir)) mkdirSync(outDir, {recursive: true});
210
+ result = await adapter.export({
211
+ cartPath: labeled.cartPath,
212
+ outDir,
213
+ htmlName: EXPORT_HTML_NAME,
214
+ backstopMs: options.backstopMs,
215
+ });
216
+ } finally {
217
+ labeled.cleanup();
242
218
  }
243
219
 
244
220
  if (!result.ok) {
@@ -259,7 +235,7 @@ export function registerExport(
259
235
  return error({
260
236
  code: 'export-failed',
261
237
  message: labelWarning
262
- ? 'PICO-8 produced no bundle (the cart has no __label__ to embed). Give it one with --label <128x128.png>, or capture a label in PICO-8 (F7), then re-export.'
238
+ ? 'PICO-8 produced no bundle (the cart has no __label__ to embed). Give it one with --label-frame 0 (captures the title screen for you), or --label <screenshot.png> (a `picopilot run`/`playtest` screenshot works as-is), then re-export.'
263
239
  : 'PICO-8 produced no export bundle. Check the cart runs without a boot error.',
264
240
  exitCode: 1,
265
241
  });
@@ -285,9 +261,14 @@ export function registerExport(
285
261
  'The cart has no __label__, so the export splash is blank. Add one for a nicer bundle:',
286
262
  commands: [
287
263
  {
288
- command: 'export --label <128x128.png>',
264
+ command: 'export <cart> <dest> --label-frame 0',
265
+ description:
266
+ 'Capture the title screen (frame 0) of the running cart as the label, no image needed.',
267
+ },
268
+ {
269
+ command: 'export --label <screenshot.png>',
289
270
  description:
290
- 'Bake a PNG in as the label splash, or capture one in PICO-8 (F7) before exporting.',
271
+ 'Bake a `run`/`playtest` screenshot in as the label splash (passed as-is, it is scaled for you), or capture one in PICO-8 (F7) before exporting.',
291
272
  },
292
273
  ],
293
274
  }
@@ -299,6 +280,7 @@ export function registerExport(
299
280
  html: htmlPath,
300
281
  js: jsPath,
301
282
  files: [...files],
283
+ labelSource: labeled.labelSource,
302
284
  labelWarning,
303
285
  },
304
286
  cta === undefined ? undefined : {cta},
@@ -20,16 +20,17 @@ import {
20
20
  SHEET_RENDER_SCALE,
21
21
  SPRITE_RENDER_SCALE,
22
22
  } from '../engine/gfx/index.js';
23
+ import {type ShrinkoAdapter} from '../engine/shrinko/index.js';
23
24
  import {
24
- type ShrinkoAdapter,
25
- ShellShrinkoAdapter,
26
- } from '../engine/shrinko/index.js';
25
+ createShellShrinkoFactory,
26
+ type ShrinkoAdapterFactory,
27
+ } from './shrinko-env.js';
27
28
 
28
29
  /** Injects the shrinko adapter `gfx import`/`gfx export` use (the spritesheet round-trip). */
29
- export type ShrinkoAdapterFactory = (env: NodeJS.ProcessEnv) => ShrinkoAdapter;
30
+ export type {ShrinkoAdapterFactory} from './shrinko-env.js';
30
31
 
31
- const defaultShrinkoFactory: ShrinkoAdapterFactory = (env) =>
32
- new ShellShrinkoAdapter({env});
32
+ const defaultShrinkoFactory: ShrinkoAdapterFactory =
33
+ createShellShrinkoFactory();
33
34
 
34
35
  /**
35
36
  * The exit code for the `map-overlap` refusal. Chosen as 1 (a plain failure),