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.
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +111 -16
- package/dist/cli.js.map +1 -1
- package/dist/commands/audio.d.ts +3 -3
- package/dist/commands/audio.d.ts.map +1 -1
- package/dist/commands/audio.js +61 -44
- package/dist/commands/audio.js.map +1 -1
- package/dist/commands/cart-includes.d.ts +11 -0
- package/dist/commands/cart-includes.d.ts.map +1 -0
- package/dist/commands/cart-includes.js +26 -0
- package/dist/commands/cart-includes.js.map +1 -0
- package/dist/commands/doctor.d.ts +22 -0
- package/dist/commands/doctor.d.ts.map +1 -0
- package/dist/commands/doctor.js +222 -0
- package/dist/commands/doctor.js.map +1 -0
- package/dist/commands/export.d.ts +3 -4
- package/dist/commands/export.d.ts.map +1 -1
- package/dist/commands/export.js +66 -86
- package/dist/commands/export.js.map +1 -1
- package/dist/commands/gfx.d.ts +2 -2
- package/dist/commands/gfx.d.ts.map +1 -1
- package/dist/commands/gfx.js +3 -2
- package/dist/commands/gfx.js.map +1 -1
- package/dist/commands/label-cart.d.ts +72 -0
- package/dist/commands/label-cart.d.ts.map +1 -0
- package/dist/commands/label-cart.js +228 -0
- package/dist/commands/label-cart.js.map +1 -0
- package/dist/commands/lint.d.ts +2 -2
- package/dist/commands/lint.d.ts.map +1 -1
- package/dist/commands/lint.js +3 -2
- package/dist/commands/lint.js.map +1 -1
- package/dist/commands/minify.d.ts +2 -2
- package/dist/commands/minify.d.ts.map +1 -1
- package/dist/commands/minify.js +3 -2
- package/dist/commands/minify.js.map +1 -1
- package/dist/commands/pico8-env.d.ts +76 -0
- package/dist/commands/pico8-env.d.ts.map +1 -0
- package/dist/commands/pico8-env.js +71 -0
- package/dist/commands/pico8-env.js.map +1 -0
- package/dist/commands/playtest.d.ts +2 -2
- package/dist/commands/playtest.d.ts.map +1 -1
- package/dist/commands/playtest.js +6 -32
- package/dist/commands/playtest.js.map +1 -1
- package/dist/commands/run.d.ts +3 -4
- package/dist/commands/run.d.ts.map +1 -1
- package/dist/commands/run.js +46 -19
- package/dist/commands/run.js.map +1 -1
- package/dist/commands/serve.d.ts +2 -2
- package/dist/commands/serve.d.ts.map +1 -1
- package/dist/commands/serve.js +65 -19
- package/dist/commands/serve.js.map +1 -1
- package/dist/commands/shrinko-env.d.ts +22 -0
- package/dist/commands/shrinko-env.d.ts.map +1 -0
- package/dist/commands/shrinko-env.js +15 -0
- package/dist/commands/shrinko-env.js.map +1 -0
- package/dist/commands/tokens.d.ts +4 -4
- package/dist/commands/tokens.d.ts.map +1 -1
- package/dist/commands/tokens.js +3 -2
- package/dist/commands/tokens.js.map +1 -1
- package/dist/commands/verify.d.ts +2 -2
- package/dist/commands/verify.d.ts.map +1 -1
- package/dist/commands/verify.js +3 -2
- package/dist/commands/verify.js.map +1 -1
- package/dist/engine/child-env.d.ts +14 -0
- package/dist/engine/child-env.d.ts.map +1 -0
- package/dist/engine/child-env.js +21 -0
- package/dist/engine/child-env.js.map +1 -0
- package/dist/engine/gfx/label.d.ts +15 -9
- package/dist/engine/gfx/label.d.ts.map +1 -1
- package/dist/engine/gfx/label.js +130 -38
- package/dist/engine/gfx/label.js.map +1 -1
- package/dist/engine/pico8/adapter.d.ts +23 -3
- package/dist/engine/pico8/adapter.d.ts.map +1 -1
- package/dist/engine/pico8/adapter.js.map +1 -1
- package/dist/engine/pico8/cleanup.d.ts +16 -0
- package/dist/engine/pico8/cleanup.d.ts.map +1 -0
- package/dist/engine/pico8/cleanup.js +74 -0
- package/dist/engine/pico8/cleanup.js.map +1 -0
- package/dist/engine/pico8/display.d.ts +149 -0
- package/dist/engine/pico8/display.d.ts.map +1 -0
- package/dist/engine/pico8/display.js +311 -0
- package/dist/engine/pico8/display.js.map +1 -0
- package/dist/engine/pico8/drive.d.ts.map +1 -1
- package/dist/engine/pico8/drive.js +28 -2
- package/dist/engine/pico8/drive.js.map +1 -1
- package/dist/engine/pico8/harness.d.ts +8 -0
- package/dist/engine/pico8/harness.d.ts.map +1 -1
- package/dist/engine/pico8/harness.js +9 -1
- package/dist/engine/pico8/harness.js.map +1 -1
- package/dist/engine/pico8/index.d.ts +3 -0
- package/dist/engine/pico8/index.d.ts.map +1 -1
- package/dist/engine/pico8/index.js +3 -0
- package/dist/engine/pico8/index.js.map +1 -1
- package/dist/engine/pico8/session-daemon-entry.d.ts +1 -1
- package/dist/engine/pico8/session-daemon-entry.js +1 -1
- package/dist/engine/pico8/session-daemon-main.d.ts +1 -1
- package/dist/engine/pico8/session-daemon-main.js +1 -1
- package/dist/engine/pico8/session-daemon.d.ts +1 -1
- package/dist/engine/pico8/session-daemon.d.ts.map +1 -1
- package/dist/engine/pico8/session-daemon.js +19 -19
- package/dist/engine/pico8/session-daemon.js.map +1 -1
- package/dist/engine/pico8/session.d.ts +1 -1
- package/dist/engine/pico8/session.js +1 -1
- package/dist/engine/pico8/shell.d.ts +56 -3
- package/dist/engine/pico8/shell.d.ts.map +1 -1
- package/dist/engine/pico8/shell.js +188 -24
- package/dist/engine/pico8/shell.js.map +1 -1
- package/dist/engine/pico8/supervisor.d.ts +1 -1
- package/dist/engine/pico8/supervisor.js +1 -1
- package/dist/engine/shrinko/adapter.d.ts +1 -1
- package/dist/engine/shrinko/shell.d.ts +3 -3
- package/dist/engine/shrinko/shell.js +3 -3
- package/package.json +2 -1
- package/src/cli.test.ts +188 -0
- package/src/cli.ts +122 -16
- package/src/commands/audio.test.ts +76 -1
- package/src/commands/audio.ts +79 -57
- package/src/commands/cart-includes.ts +27 -0
- package/src/commands/doctor.test.ts +149 -0
- package/src/commands/doctor.ts +280 -0
- package/src/commands/export.test.ts +327 -3
- package/src/commands/export.ts +81 -99
- package/src/commands/gfx.ts +7 -6
- package/src/commands/label-cart.ts +304 -0
- package/src/commands/lint.test.ts +6 -0
- package/src/commands/lint.ts +7 -6
- package/src/commands/minify.test.ts +6 -0
- package/src/commands/minify.ts +7 -6
- package/src/commands/pico8-env.ts +124 -0
- package/src/commands/playtest.test.ts +3 -0
- package/src/commands/playtest.ts +10 -34
- package/src/commands/run.test.ts +116 -2
- package/src/commands/run.ts +62 -24
- package/src/commands/serve.test.ts +138 -2
- package/src/commands/serve.ts +80 -21
- package/src/commands/shrinko-env.test.ts +68 -0
- package/src/commands/shrinko-env.ts +40 -0
- package/src/commands/tokens.test.ts +19 -0
- package/src/commands/tokens.ts +9 -6
- package/src/commands/verify.test.ts +12 -0
- package/src/commands/verify.ts +7 -4
- package/src/engine/child-env.ts +22 -0
- package/src/engine/gfx/fixtures/pico8-screenshot-384.png +0 -0
- package/src/engine/gfx/label.test.ts +467 -1
- package/src/engine/gfx/label.ts +147 -41
- package/src/engine/pico8/adapter.ts +25 -3
- package/src/engine/pico8/cleanup.test.ts +28 -0
- package/src/engine/pico8/cleanup.ts +76 -0
- package/src/engine/pico8/display.test.ts +210 -0
- package/src/engine/pico8/display.ts +429 -0
- package/src/engine/pico8/drive.test.ts +20 -0
- package/src/engine/pico8/drive.ts +25 -2
- package/src/engine/pico8/fake-xvfb.test-support.ts +55 -0
- package/src/engine/pico8/harness.test.ts +26 -0
- package/src/engine/pico8/harness.ts +18 -2
- package/src/engine/pico8/index.ts +3 -0
- package/src/engine/pico8/session-daemon-entry.ts +1 -1
- package/src/engine/pico8/session-daemon-main.ts +1 -1
- package/src/engine/pico8/session-daemon.test.ts +32 -1
- package/src/engine/pico8/session-daemon.ts +25 -17
- package/src/engine/pico8/session.test.ts +1 -1
- package/src/engine/pico8/session.ts +1 -1
- package/src/engine/pico8/shell.test.ts +449 -10
- package/src/engine/pico8/shell.ts +255 -26
- package/src/engine/pico8/supervisor.ts +1 -1
- package/src/engine/pico8/virtual-display.process.test.ts +206 -0
- package/src/engine/shrinko/adapter.ts +1 -1
- package/src/engine/shrinko/shell.ts +3 -3
- package/src/engine/skills/skills.test.ts +10 -0
- package/src/skills/picopilot-audio/SKILL.md +4 -8
- package/src/skills/picopilot-debug/SKILL.md +18 -1
- 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
|
|
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
|
|
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
|
+
});
|
package/src/commands/export.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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,
|
|
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
|
|
22
|
+
export type {Pico8AdapterFactory} from './pico8-env.js';
|
|
28
23
|
|
|
29
|
-
const defaultAdapterFactory: Pico8AdapterFactory = (
|
|
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
|
-
'
|
|
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:
|
|
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:
|
|
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: './
|
|
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
|
|
165
|
-
//
|
|
166
|
-
//
|
|
167
|
-
//
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
//
|
|
235
|
-
// path
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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 <
|
|
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
|
|
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
|
|
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},
|
package/src/commands/gfx.ts
CHANGED
|
@@ -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
|
-
|
|
25
|
-
|
|
26
|
-
} from '
|
|
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
|
|
30
|
+
export type {ShrinkoAdapterFactory} from './shrinko-env.js';
|
|
30
31
|
|
|
31
|
-
const defaultShrinkoFactory: ShrinkoAdapterFactory =
|
|
32
|
-
|
|
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),
|