picopilot 0.1.4 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) 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/shrinko/adapter.d.ts +1 -1
  35. package/package.json +2 -1
  36. package/src/commands/audio.test.ts +12 -0
  37. package/src/commands/cart-includes.ts +27 -0
  38. package/src/commands/export.test.ts +327 -3
  39. package/src/commands/export.ts +72 -86
  40. package/src/commands/label-cart.ts +304 -0
  41. package/src/commands/lint.test.ts +6 -0
  42. package/src/commands/minify.test.ts +6 -0
  43. package/src/commands/playtest.test.ts +3 -0
  44. package/src/commands/playtest.ts +2 -23
  45. package/src/commands/run.test.ts +6 -0
  46. package/src/commands/serve.test.ts +138 -2
  47. package/src/commands/serve.ts +72 -10
  48. package/src/commands/tokens.test.ts +19 -0
  49. package/src/commands/verify.test.ts +12 -0
  50. package/src/engine/gfx/fixtures/pico8-screenshot-384.png +0 -0
  51. package/src/engine/gfx/label.test.ts +467 -1
  52. package/src/engine/gfx/label.ts +147 -41
  53. package/src/engine/pico8/session-daemon-entry.ts +1 -1
  54. package/src/engine/pico8/session-daemon-main.ts +1 -1
  55. package/src/engine/pico8/session-daemon.test.ts +32 -1
  56. package/src/engine/pico8/session-daemon.ts +25 -17
  57. package/src/engine/pico8/session.test.ts +1 -1
  58. package/src/engine/pico8/session.ts +1 -1
  59. package/src/engine/pico8/shell.test.ts +1 -1
  60. package/src/engine/pico8/supervisor.ts +1 -1
  61. package/src/engine/shrinko/adapter.ts +1 -1
  62. package/src/engine/skills/skills.test.ts +10 -0
  63. package/src/skills/picopilot-debug/SKILL.md +18 -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,22 +1,14 @@
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,
8
+ type Pico8ExportResult,
18
9
  ShellPico8Adapter,
19
10
  } from '../engine/pico8/index.js';
11
+ import {prepareLabeledCart} from './label-cart.js';
20
12
 
21
13
  /**
22
14
  * Injects the PICO-8 adapter `export` uses (defaults to the shell adapter over
@@ -83,7 +75,15 @@ export function registerExport(
83
75
  .string()
84
76
  .optional()
85
77
  .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.',
78
+ '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.',
79
+ ),
80
+ labelFrame: z
81
+ .number()
82
+ .int()
83
+ .min(0)
84
+ .optional()
85
+ .describe(
86
+ '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
87
  ),
88
88
  payloadOnly: z
89
89
  .boolean()
@@ -97,7 +97,7 @@ export function registerExport(
97
97
  .positive()
98
98
  .default(DEFAULT_BACKSTOP_MS)
99
99
  .describe(
100
- 'Safety backstop: kill PICO-8 after this many ms if the export never completes.',
100
+ 'Safety backstop: kill PICO-8 after this many ms if the export (or the --label-frame capture run) never completes.',
101
101
  ),
102
102
  }),
103
103
  // incur resolves these from the (test-overridable) env source and hands
@@ -123,6 +123,11 @@ export function registerExport(
123
123
  files: z
124
124
  .array(z.string())
125
125
  .describe('Absolute paths of every file the bundle contains.'),
126
+ labelSource: z
127
+ .enum(['cart', 'image', 'frame', 'none'])
128
+ .describe(
129
+ "Where the bundle's label came from: the cart's own __label__, --label, --label-frame, or none.",
130
+ ),
126
131
  labelWarning: z
127
132
  .boolean()
128
133
  .describe(
@@ -136,9 +141,16 @@ export function registerExport(
136
141
  args: {cart: 'game.p8', dest: './website/static/games/my-game'},
137
142
  },
138
143
  {
139
- description: 'Bake a PNG as the label splash for a labelless cart',
144
+ description:
145
+ 'Bake a `run` screenshot in as the label splash for a labelless cart',
140
146
  args: {cart: 'game.p8', dest: './website/static/games/my-game'},
141
- options: {label: './label.png'},
147
+ options: {label: './shots/<name>.png'},
148
+ },
149
+ {
150
+ description:
151
+ 'Capture the title screen (frame 0) as the label splash, no image needed',
152
+ args: {cart: 'game.p8', dest: './website/static/games/my-game'},
153
+ options: {labelFrame: 0},
142
154
  },
143
155
  {
144
156
  description:
@@ -161,56 +173,27 @@ export function registerExport(
161
173
  });
162
174
  }
163
175
 
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
- }
176
+ // --label <png> / --label-frame <n>: bake an image, or a captured frame of
177
+ // the running cart, in as the __label__ of a throwaway copy (the user's
178
+ // cart is never mutated; an existing label wins). Shared with `serve`,
179
+ // see label-cart.ts.
180
+ const adapter = adapterFactory(env as NodeJS.ProcessEnv);
181
+ const labeled = await prepareLabeledCart(
182
+ cartPath,
183
+ {
184
+ label: options.label,
185
+ labelFrame: options.labelFrame,
186
+ backstopMs: options.backstopMs,
187
+ },
188
+ (o) => adapter.drive(o),
189
+ );
190
+ if (!labeled.ok) {
191
+ return error({
192
+ code: labeled.code,
193
+ message: labeled.message,
194
+ exitCode: 1,
195
+ ...(labeled.cta ? {cta: labeled.cta} : {}),
196
+ });
214
197
  }
215
198
 
216
199
  // Output dir: a user-given dest (point it at the showcase), else an
@@ -221,24 +204,21 @@ export function registerExport(
221
204
  ? args.dest
222
205
  : resolve(process.cwd(), args.dest)
223
206
  : 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
207
 
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
- }
208
+ // The labeled copy lives in the user's cart dir, so remove it on EVERY
209
+ // path once PICO-8 has read it, including a throw from the dir setup or
210
+ // the adapter.
211
+ let result: Pico8ExportResult;
212
+ try {
213
+ if (!existsSync(outDir)) mkdirSync(outDir, {recursive: true});
214
+ result = await adapter.export({
215
+ cartPath: labeled.cartPath,
216
+ outDir,
217
+ htmlName: EXPORT_HTML_NAME,
218
+ backstopMs: options.backstopMs,
219
+ });
220
+ } finally {
221
+ labeled.cleanup();
242
222
  }
243
223
 
244
224
  if (!result.ok) {
@@ -259,7 +239,7 @@ export function registerExport(
259
239
  return error({
260
240
  code: 'export-failed',
261
241
  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.'
242
+ ? '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
243
  : 'PICO-8 produced no export bundle. Check the cart runs without a boot error.',
264
244
  exitCode: 1,
265
245
  });
@@ -285,9 +265,14 @@ export function registerExport(
285
265
  'The cart has no __label__, so the export splash is blank. Add one for a nicer bundle:',
286
266
  commands: [
287
267
  {
288
- command: 'export --label <128x128.png>',
268
+ command: 'export <cart> <dest> --label-frame 0',
269
+ description:
270
+ 'Capture the title screen (frame 0) of the running cart as the label, no image needed.',
271
+ },
272
+ {
273
+ command: 'export --label <screenshot.png>',
289
274
  description:
290
- 'Bake a PNG in as the label splash, or capture one in PICO-8 (F7) before exporting.',
275
+ '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
276
  },
292
277
  ],
293
278
  }
@@ -299,6 +284,7 @@ export function registerExport(
299
284
  html: htmlPath,
300
285
  js: jsPath,
301
286
  files: [...files],
287
+ labelSource: labeled.labelSource,
302
288
  labelWarning,
303
289
  },
304
290
  cta === undefined ? undefined : {cta},