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.
- 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/export.d.ts.map +1 -1
- package/dist/commands/export.js +62 -77
- package/dist/commands/export.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/playtest.d.ts.map +1 -1
- package/dist/commands/playtest.js +2 -23
- package/dist/commands/playtest.js.map +1 -1
- package/dist/commands/serve.d.ts.map +1 -1
- package/dist/commands/serve.js +61 -10
- package/dist/commands/serve.js.map +1 -1
- 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/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/supervisor.d.ts +1 -1
- package/dist/engine/pico8/supervisor.js +1 -1
- package/dist/engine/shrinko/adapter.d.ts +1 -1
- package/package.json +2 -1
- package/src/commands/audio.test.ts +12 -0
- package/src/commands/cart-includes.ts +27 -0
- package/src/commands/export.test.ts +327 -3
- package/src/commands/export.ts +72 -86
- package/src/commands/label-cart.ts +304 -0
- package/src/commands/lint.test.ts +6 -0
- package/src/commands/minify.test.ts +6 -0
- package/src/commands/playtest.test.ts +3 -0
- package/src/commands/playtest.ts +2 -23
- package/src/commands/run.test.ts +6 -0
- package/src/commands/serve.test.ts +138 -2
- package/src/commands/serve.ts +72 -10
- package/src/commands/tokens.test.ts +19 -0
- package/src/commands/verify.test.ts +12 -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/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 +1 -1
- package/src/engine/pico8/supervisor.ts +1 -1
- package/src/engine/shrinko/adapter.ts +1 -1
- package/src/engine/skills/skills.test.ts +10 -0
- package/src/skills/picopilot-debug/SKILL.md +18 -1
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
import {
|
|
2
|
+
existsSync,
|
|
3
|
+
mkdtempSync,
|
|
4
|
+
readFileSync,
|
|
5
|
+
rmSync,
|
|
6
|
+
writeFileSync,
|
|
7
|
+
} from 'node:fs';
|
|
8
|
+
import {tmpdir} from 'node:os';
|
|
9
|
+
import {dirname, isAbsolute, join, resolve} from 'node:path';
|
|
10
|
+
import {Cart, CartParseError} from '../engine/cart/index.js';
|
|
11
|
+
import {labelHexFromPng, LabelError} from '../engine/gfx/index.js';
|
|
12
|
+
import {
|
|
13
|
+
buildDriveHarness,
|
|
14
|
+
DriveError,
|
|
15
|
+
type DriveOptions,
|
|
16
|
+
type Pico8DriveResult,
|
|
17
|
+
} from '../engine/pico8/index.js';
|
|
18
|
+
import {makeReadInclude} from './cart-includes.js';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The remedy for both label-image errors (`label-wrong-size`,
|
|
22
|
+
* `label-unsupported-png`): the screenshot `run`/`playtest` already wrote is a
|
|
23
|
+
* valid label as-is, so point at that rather than at an external image tool.
|
|
24
|
+
*/
|
|
25
|
+
export const LABEL_FIX_CTA = {
|
|
26
|
+
description:
|
|
27
|
+
'Pass a picopilot screenshot directly; it is scaled for you (any 128*k square: 128x128, 256x256, 384x384, ...). No image tool needed:',
|
|
28
|
+
commands: [
|
|
29
|
+
{
|
|
30
|
+
command: 'run <cart> --shot-dir shots',
|
|
31
|
+
description:
|
|
32
|
+
'Capture a frame (the cart calls extcmd("screen")), then pass shots/<name>.png to --label unchanged.',
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
command: 'export <cart> <dest> --label shots/<name>.png',
|
|
36
|
+
description:
|
|
37
|
+
'Bake that screenshot in as the label splash (`serve --label` takes it the same way). playtest screenshots work too.',
|
|
38
|
+
},
|
|
39
|
+
],
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/** Where the exported cart's `__label__` came from. */
|
|
43
|
+
export type LabelSource = 'cart' | 'image' | 'frame' | 'none';
|
|
44
|
+
|
|
45
|
+
/** A cart ready to hand to PICO-8's export, plus how to tidy up after it. */
|
|
46
|
+
export interface LabeledCart {
|
|
47
|
+
readonly ok: true;
|
|
48
|
+
/** The cart to export: the original, or a labeled throwaway copy. */
|
|
49
|
+
readonly cartPath: string;
|
|
50
|
+
/**
|
|
51
|
+
* Where the label comes from: the cart's own (`cart`), `--label` (`image`),
|
|
52
|
+
* `--label-frame` (`frame`), or nowhere (`none`, the cart stays labelless).
|
|
53
|
+
*/
|
|
54
|
+
readonly labelSource: LabelSource;
|
|
55
|
+
/** Removes the throwaway copy (if one was made). Best-effort, idempotent. */
|
|
56
|
+
readonly cleanup: () => void;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** A label-preparation failure, shaped for incur's `error({...})`. */
|
|
60
|
+
export interface LabelCartFailure {
|
|
61
|
+
readonly ok: false;
|
|
62
|
+
readonly code: string;
|
|
63
|
+
readonly message: string;
|
|
64
|
+
readonly cta?: {
|
|
65
|
+
description: string;
|
|
66
|
+
commands: {command: string; description: string}[];
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The label flags `export` and `serve` share. */
|
|
71
|
+
export interface LabelOptions {
|
|
72
|
+
/** `--label <png>`: an image (a run/playtest screenshot as-is, or 128x128). */
|
|
73
|
+
readonly label?: string;
|
|
74
|
+
/** `--label-frame <n>`: capture frame `n` of the running cart, no input. */
|
|
75
|
+
readonly labelFrame?: number;
|
|
76
|
+
/** The backstop for the `--label-frame` capture run. */
|
|
77
|
+
readonly backstopMs: number;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The base filename the `--label-frame` capture names its screenshot. */
|
|
81
|
+
const LABEL_FRAME_BASENAME = 'label';
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The `--label` / `--label-frame` step shared by `export` and `serve` (ADR-0013,
|
|
85
|
+
* ADR-0015, ADR-0016): give a labelless cart a `__label__` (the export splash)
|
|
86
|
+
* without an interactive F7 capture.
|
|
87
|
+
*
|
|
88
|
+
* - `--label <png>`: decode an image (a `run`/`playtest` screenshot as-is, any
|
|
89
|
+
* square integer multiple of 128x128, or any 128x128 PNG).
|
|
90
|
+
* - `--label-frame <n>`: RUN the cart headlessly through the playtest drive
|
|
91
|
+
* harness (ADR-0011) with NO input, advance `n` frames, screenshot that frame,
|
|
92
|
+
* and decode it. Needs PICO-8 (`drive`); an arbitrary cart does not have to
|
|
93
|
+
* cooperate, since the harness owns its frame loop.
|
|
94
|
+
*
|
|
95
|
+
* The two flags are mutually exclusive (`label-flags-conflict`). The user's cart
|
|
96
|
+
* is NEVER mutated: a labeled copy is written NEXT TO it (same dir, so
|
|
97
|
+
* `#include main.lua` still resolves) and the caller exports that, then calls
|
|
98
|
+
* `cleanup()`. A cart that ALREADY has a label wins (the flags are a fallback,
|
|
99
|
+
* not an override): it is returned untouched and nothing is run.
|
|
100
|
+
*
|
|
101
|
+
* @param drive the adapter's `drive`, only called for `--label-frame`.
|
|
102
|
+
*/
|
|
103
|
+
export async function prepareLabeledCart(
|
|
104
|
+
cartPath: string,
|
|
105
|
+
options: LabelOptions,
|
|
106
|
+
drive: (options: DriveOptions) => Promise<Pico8DriveResult>,
|
|
107
|
+
): Promise<LabeledCart | LabelCartFailure> {
|
|
108
|
+
const keep = (labelSource: LabelSource): LabeledCart => ({
|
|
109
|
+
ok: true,
|
|
110
|
+
cartPath,
|
|
111
|
+
labelSource,
|
|
112
|
+
cleanup: () => {},
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
if (options.label !== undefined && options.labelFrame !== undefined) {
|
|
116
|
+
return {
|
|
117
|
+
ok: false,
|
|
118
|
+
code: 'label-flags-conflict',
|
|
119
|
+
message:
|
|
120
|
+
'pass either --label <png> (an image) or --label-frame <n> (capture a frame of the running cart), not both',
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// With no label flag the cart is only read to REPORT `labelSource`; anything
|
|
125
|
+
// odd about it (unreadable, unparseable) is left for PICO-8 to judge, exactly
|
|
126
|
+
// as before the label flags existed.
|
|
127
|
+
const noFlag =
|
|
128
|
+
options.label === undefined && options.labelFrame === undefined;
|
|
129
|
+
let cart: Cart;
|
|
130
|
+
try {
|
|
131
|
+
cart = Cart.parse(readFileSync(cartPath, 'utf8'));
|
|
132
|
+
} catch (e) {
|
|
133
|
+
if (noFlag) return keep('none');
|
|
134
|
+
if (e instanceof CartParseError) {
|
|
135
|
+
return {
|
|
136
|
+
ok: false,
|
|
137
|
+
code: `cart-${e.code}`,
|
|
138
|
+
message: `could not parse the cart to inject a label: ${e.message}`,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
throw e;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const existingLabel = cart.getSection('label');
|
|
145
|
+
if (existingLabel !== undefined && existingLabel.trim() !== '') {
|
|
146
|
+
return keep('cart');
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
let png: Uint8Array;
|
|
150
|
+
let labelSource: LabelSource;
|
|
151
|
+
if (options.label !== undefined) {
|
|
152
|
+
const labelPath = isAbsolute(options.label)
|
|
153
|
+
? options.label
|
|
154
|
+
: resolve(process.cwd(), options.label);
|
|
155
|
+
if (!existsSync(labelPath)) {
|
|
156
|
+
return {
|
|
157
|
+
ok: false,
|
|
158
|
+
code: 'label-not-found',
|
|
159
|
+
message: `no label image at ${labelPath}`,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
png = readFileSync(labelPath);
|
|
163
|
+
labelSource = 'image';
|
|
164
|
+
} else if (options.labelFrame !== undefined) {
|
|
165
|
+
const captured = await captureFrame(
|
|
166
|
+
cartPath,
|
|
167
|
+
options.labelFrame,
|
|
168
|
+
options.backstopMs,
|
|
169
|
+
drive,
|
|
170
|
+
);
|
|
171
|
+
if (!captured.ok) return captured;
|
|
172
|
+
png = captured.png;
|
|
173
|
+
labelSource = 'frame';
|
|
174
|
+
} else {
|
|
175
|
+
return keep('none');
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
let hex: string;
|
|
179
|
+
try {
|
|
180
|
+
hex = labelHexFromPng(png);
|
|
181
|
+
} catch (e) {
|
|
182
|
+
if (e instanceof LabelError) {
|
|
183
|
+
return {
|
|
184
|
+
ok: false,
|
|
185
|
+
code: `label-${e.code}`,
|
|
186
|
+
message: e.message,
|
|
187
|
+
cta: LABEL_FIX_CTA,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
throw e;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
cart.setSection('label', hex);
|
|
194
|
+
const copy = join(
|
|
195
|
+
dirname(cartPath),
|
|
196
|
+
`.picopilot-export-${process.pid}-${Date.now()}.p8`,
|
|
197
|
+
);
|
|
198
|
+
writeFileSync(copy, cart.serialize());
|
|
199
|
+
return {
|
|
200
|
+
ok: true,
|
|
201
|
+
cartPath: copy,
|
|
202
|
+
labelSource,
|
|
203
|
+
cleanup: () => {
|
|
204
|
+
try {
|
|
205
|
+
rmSync(copy, {force: true});
|
|
206
|
+
} catch {
|
|
207
|
+
// A leftover dotfile is not fatal.
|
|
208
|
+
}
|
|
209
|
+
},
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Runs the cart through the playtest drive harness with NO input for `frame + 1`
|
|
215
|
+
* frames, screenshotting after frame `frame` (0 = the first drawn frame), and
|
|
216
|
+
* returns that PNG. The driven cart and PICO-8's screenshot dir live in a temp
|
|
217
|
+
* dir that is removed before returning.
|
|
218
|
+
*/
|
|
219
|
+
async function captureFrame(
|
|
220
|
+
cartPath: string,
|
|
221
|
+
frame: number,
|
|
222
|
+
backstopMs: number,
|
|
223
|
+
drive: (options: DriveOptions) => Promise<Pico8DriveResult>,
|
|
224
|
+
): Promise<{ok: true; png: Uint8Array} | LabelCartFailure> {
|
|
225
|
+
let harness;
|
|
226
|
+
try {
|
|
227
|
+
harness = buildDriveHarness(readFileSync(cartPath, 'utf8'), {
|
|
228
|
+
presses: [],
|
|
229
|
+
frames: frame + 1,
|
|
230
|
+
shotFrames: [frame],
|
|
231
|
+
shotBasename: LABEL_FRAME_BASENAME,
|
|
232
|
+
readInclude: makeReadInclude(cartPath),
|
|
233
|
+
});
|
|
234
|
+
} catch (e) {
|
|
235
|
+
if (e instanceof DriveError) {
|
|
236
|
+
return {ok: false, code: e.code, message: e.message};
|
|
237
|
+
}
|
|
238
|
+
throw e;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const shotDir = mkdtempSync(join(tmpdir(), 'picopilot-label-frame-'));
|
|
242
|
+
try {
|
|
243
|
+
const drivenCart = join(shotDir, 'driven.p8');
|
|
244
|
+
writeFileSync(drivenCart, harness.cartText);
|
|
245
|
+
const result = await drive({
|
|
246
|
+
cartPath: drivenCart,
|
|
247
|
+
shotDir,
|
|
248
|
+
blocks: harness.blocks,
|
|
249
|
+
backstopMs,
|
|
250
|
+
});
|
|
251
|
+
if (!result.ok) {
|
|
252
|
+
return {
|
|
253
|
+
ok: false,
|
|
254
|
+
code: result.reason,
|
|
255
|
+
message: `PICO-8 is not installed, so --label-frame cannot capture a frame. ${result.remedy} (needs: ${result.needs.join(', ')})`,
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
const shot = result.value.screenshots[0];
|
|
259
|
+
if (shot === undefined) {
|
|
260
|
+
const {exitReason} = result.value;
|
|
261
|
+
const tail = result.value.printh.trim().split('\n').slice(-5).join('\n');
|
|
262
|
+
const commands = [
|
|
263
|
+
{
|
|
264
|
+
command: 'verify',
|
|
265
|
+
description: 'Run the static gate (tokens + lint + integrity).',
|
|
266
|
+
},
|
|
267
|
+
{
|
|
268
|
+
command: 'playtest start <cart>',
|
|
269
|
+
description:
|
|
270
|
+
'Reproduce the SAME no-input frame to look at it: start a paused session (nothing held), then step and shoot.',
|
|
271
|
+
},
|
|
272
|
+
{
|
|
273
|
+
command: `playtest step <id> --frames ${frame + 1}`,
|
|
274
|
+
description: `Advance to frame ${frame}, as --label-frame ${frame} does.`,
|
|
275
|
+
},
|
|
276
|
+
{
|
|
277
|
+
command: 'playtest shot <id>',
|
|
278
|
+
description:
|
|
279
|
+
'Screenshot it; if it looks right, pass that PNG with --label <png> (then `playtest stop <id>`).',
|
|
280
|
+
},
|
|
281
|
+
];
|
|
282
|
+
if (exitReason === 'timeout') {
|
|
283
|
+
commands.push({
|
|
284
|
+
command: `export <cart> --label-frame ${frame} --backstop-ms <more>`,
|
|
285
|
+
description:
|
|
286
|
+
'The capture hit the backstop (it runs about one frame per PICO-8 tick): allow more time, or pick an earlier frame.',
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
return {
|
|
290
|
+
ok: false,
|
|
291
|
+
code: 'label-frame-not-captured',
|
|
292
|
+
message: `the cart produced no frame ${frame} screenshot (exit: ${exitReason}).${tail === '' ? '' : ` Last output:\n${tail}`}`,
|
|
293
|
+
cta: {
|
|
294
|
+
description:
|
|
295
|
+
'The cart did not reach that frame. Check it boots, or look at the frame first:',
|
|
296
|
+
commands,
|
|
297
|
+
},
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
return {ok: true, png: readFileSync(shot)};
|
|
301
|
+
} finally {
|
|
302
|
+
rmSync(shotDir, {recursive: true, force: true});
|
|
303
|
+
}
|
|
304
|
+
}
|
|
@@ -34,6 +34,12 @@ afterEach(() => {
|
|
|
34
34
|
/** A stub adapter whose `lint` returns whatever the test wires; `count`/`minify` unused here. */
|
|
35
35
|
function stubAdapter(result: ShrinkoResult<LintReport>): ShrinkoAdapter {
|
|
36
36
|
return {
|
|
37
|
+
async exportSpritesheet() {
|
|
38
|
+
throw new Error('exportSpritesheet not used');
|
|
39
|
+
},
|
|
40
|
+
async importSpritesheet() {
|
|
41
|
+
throw new Error('importSpritesheet not used');
|
|
42
|
+
},
|
|
37
43
|
async count() {
|
|
38
44
|
throw new Error('not used');
|
|
39
45
|
},
|
|
@@ -41,6 +41,12 @@ function stubAdapter(
|
|
|
41
41
|
seen?: {outPath?: string},
|
|
42
42
|
): ShrinkoAdapter {
|
|
43
43
|
return {
|
|
44
|
+
async exportSpritesheet() {
|
|
45
|
+
throw new Error('exportSpritesheet not used');
|
|
46
|
+
},
|
|
47
|
+
async importSpritesheet() {
|
|
48
|
+
throw new Error('importSpritesheet not used');
|
|
49
|
+
},
|
|
44
50
|
async count() {
|
|
45
51
|
throw new Error('not used');
|
|
46
52
|
},
|
package/src/commands/playtest.ts
CHANGED
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
writeFileSync,
|
|
7
7
|
} from 'node:fs';
|
|
8
8
|
import {tmpdir} from 'node:os';
|
|
9
|
-
import {
|
|
9
|
+
import {isAbsolute, join, resolve} from 'node:path';
|
|
10
10
|
import {type Cli, Cli as IncurCli, z} from 'incur';
|
|
11
11
|
import {
|
|
12
12
|
buildDriveHarness,
|
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
type SessionResponse,
|
|
29
29
|
type SessionResponseValue,
|
|
30
30
|
} from '../engine/pico8/index.js';
|
|
31
|
+
import {makeReadInclude} from './cart-includes.js';
|
|
31
32
|
|
|
32
33
|
/**
|
|
33
34
|
* Injects the PICO-8 adapter `playtest` uses (defaults to the shell adapter over
|
|
@@ -57,28 +58,6 @@ function resolvePath(p: string): string {
|
|
|
57
58
|
return isAbsolute(p) ? p : resolve(process.cwd(), p);
|
|
58
59
|
}
|
|
59
60
|
|
|
60
|
-
/**
|
|
61
|
-
* A `#include` resolver for the drive-transform, bound to the SOURCE cart's dir:
|
|
62
|
-
* picopilot's standard scaffold is `main.p8` = `#include main.lua`, and the
|
|
63
|
-
* driven cart runs from a throwaway temp dir where `main.lua` is absent, so the
|
|
64
|
-
* transform inlines includes to be self-contained. Reads the included file
|
|
65
|
-
* relative to the cart (absolute paths honoured); returns `undefined` if it
|
|
66
|
-
* cannot be read (the directive is then left verbatim).
|
|
67
|
-
*/
|
|
68
|
-
function makeReadInclude(cartPath: string): (p: string) => string | undefined {
|
|
69
|
-
const base = dirname(cartPath);
|
|
70
|
-
return (includePath) => {
|
|
71
|
-
const full = isAbsolute(includePath)
|
|
72
|
-
? includePath
|
|
73
|
-
: join(base, includePath);
|
|
74
|
-
try {
|
|
75
|
-
return readFileSync(full, 'utf8');
|
|
76
|
-
} catch {
|
|
77
|
-
return undefined;
|
|
78
|
-
}
|
|
79
|
-
};
|
|
80
|
-
}
|
|
81
|
-
|
|
82
61
|
/** A small sleep for the `start` readiness poll. */
|
|
83
62
|
function sleep(ms: number): Promise<void> {
|
|
84
63
|
return new Promise((r) => setTimeout(r, ms));
|
package/src/commands/run.test.ts
CHANGED
|
@@ -47,6 +47,12 @@ function stubAdapter(
|
|
|
47
47
|
recordResult?: Pico8RecordResult,
|
|
48
48
|
): Pico8Adapter {
|
|
49
49
|
return {
|
|
50
|
+
async drive() {
|
|
51
|
+
throw new Error('drive not used');
|
|
52
|
+
},
|
|
53
|
+
async export() {
|
|
54
|
+
throw new Error('export not used');
|
|
55
|
+
},
|
|
50
56
|
async run(options) {
|
|
51
57
|
if (seen !== undefined) seen.options = options;
|
|
52
58
|
return result;
|
|
@@ -1,12 +1,21 @@
|
|
|
1
1
|
import {execFileSync} from 'node:child_process';
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
existsSync,
|
|
4
|
+
mkdtempSync,
|
|
5
|
+
readdirSync,
|
|
6
|
+
readFileSync,
|
|
7
|
+
writeFileSync,
|
|
8
|
+
} from 'node:fs';
|
|
3
9
|
import {tmpdir} from 'node:os';
|
|
4
10
|
import {join} from 'node:path';
|
|
5
11
|
import {Cli} from 'incur';
|
|
6
12
|
import {afterEach, beforeEach, describe, expect, it} from 'vitest';
|
|
13
|
+
import {encodePng} from '../engine/gfx/index.js';
|
|
7
14
|
import type {
|
|
15
|
+
DriveOptions,
|
|
8
16
|
ExportOptions,
|
|
9
17
|
Pico8Adapter,
|
|
18
|
+
Pico8DriveResult,
|
|
10
19
|
Pico8ExportResult,
|
|
11
20
|
} from '../engine/pico8/index.js';
|
|
12
21
|
import {pico8NotFound} from '../engine/pico8/index.js';
|
|
@@ -211,7 +220,12 @@ describe('picopilot serve: failure paths', () => {
|
|
|
211
220
|
const lower = stdout.toLowerCase();
|
|
212
221
|
expect(lower).toContain('picopilot run');
|
|
213
222
|
expect(lower).toContain('playtest');
|
|
214
|
-
|
|
223
|
+
// The remedy must be one that WORKS: `export --label` never writes the label
|
|
224
|
+
// into the cart, so "export --label, then re-serve" would fail again. serve
|
|
225
|
+
// takes the label itself.
|
|
226
|
+
expect(lower).toMatch(/picopilot serve [^`]*--label/);
|
|
227
|
+
expect(lower).toContain('--label-frame 0');
|
|
228
|
+
expect(lower).not.toContain('then re-serve');
|
|
215
229
|
expect(lower).toContain('do not hand-edit main.p8');
|
|
216
230
|
});
|
|
217
231
|
|
|
@@ -225,3 +239,125 @@ describe('picopilot serve: failure paths', () => {
|
|
|
225
239
|
expect(stdout).toContain('no cart at');
|
|
226
240
|
});
|
|
227
241
|
});
|
|
242
|
+
|
|
243
|
+
describe('picopilot serve: --label (bake a screenshot in for a labelless cart)', () => {
|
|
244
|
+
const shotPath = new URL(
|
|
245
|
+
'../engine/gfx/fixtures/pico8-screenshot-384.png',
|
|
246
|
+
import.meta.url,
|
|
247
|
+
);
|
|
248
|
+
|
|
249
|
+
it('exports a labeled COPY (the screenshot as-is), leaves the cart untouched, and cleans the copy up', async () => {
|
|
250
|
+
const labelFile = join(dir, 'shot.png');
|
|
251
|
+
writeFileSync(labelFile, readFileSync(shotPath));
|
|
252
|
+
let exportedCart = '';
|
|
253
|
+
const exportSeen: {options?: ExportOptions} = {};
|
|
254
|
+
const {exitCode} = await runServe(
|
|
255
|
+
() => {
|
|
256
|
+
const inner = stubAdapter('writes-bundle', exportSeen);
|
|
257
|
+
return {
|
|
258
|
+
...inner,
|
|
259
|
+
export: async (o: ExportOptions) => {
|
|
260
|
+
exportedCart = readFileSync(o.cartPath, 'utf8');
|
|
261
|
+
return inner.export(o);
|
|
262
|
+
},
|
|
263
|
+
} as Pico8Adapter;
|
|
264
|
+
},
|
|
265
|
+
fakeServer(),
|
|
266
|
+
['serve', cartPath, '--label', labelFile, '--json'],
|
|
267
|
+
);
|
|
268
|
+
expect(exitCode).toBe(0);
|
|
269
|
+
expect(exportSeen.options!.cartPath).not.toBe(cartPath);
|
|
270
|
+
const label = exportedCart.split('__label__\n')[1]!.split('\n');
|
|
271
|
+
expect(label[10]![10]).toBe('8');
|
|
272
|
+
expect(label[100]![100]).toBe('c');
|
|
273
|
+
expect(readFileSync(cartPath, 'utf8')).not.toContain('__label__');
|
|
274
|
+
// Only the cart and the label image remain: the labeled copy is gone.
|
|
275
|
+
expect(readdirSync(dir).sort()).toEqual(['main.p8', 'shot.png']);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it('removes the labeled copy even when the export step throws', async () => {
|
|
279
|
+
const labelFile = join(dir, 'shot.png');
|
|
280
|
+
writeFileSync(labelFile, readFileSync(shotPath));
|
|
281
|
+
await runServe(
|
|
282
|
+
() =>
|
|
283
|
+
({
|
|
284
|
+
...stubAdapter('writes-bundle'),
|
|
285
|
+
export: async () => {
|
|
286
|
+
throw new Error('boom');
|
|
287
|
+
},
|
|
288
|
+
}) as Pico8Adapter,
|
|
289
|
+
fakeServer(),
|
|
290
|
+
['serve', cartPath, '--label', labelFile, '--json'],
|
|
291
|
+
).catch(() => undefined);
|
|
292
|
+
expect(readdirSync(dir).sort()).toEqual(['main.p8', 'shot.png']);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it('surfaces a bad label image as label-wrong-size with the screenshot CTA, before exporting', async () => {
|
|
296
|
+
const labelFile = join(dir, 'odd.png');
|
|
297
|
+
writeFileSync(
|
|
298
|
+
labelFile,
|
|
299
|
+
encodePng({width: 200, height: 200, rgb: new Uint8Array(200 * 200 * 3)}),
|
|
300
|
+
);
|
|
301
|
+
const exportSeen: {options?: ExportOptions} = {};
|
|
302
|
+
const {stdout, exitCode} = await runServe(
|
|
303
|
+
() => stubAdapter('writes-bundle', exportSeen),
|
|
304
|
+
fakeServer(),
|
|
305
|
+
['serve', cartPath, '--label', labelFile, '--json'],
|
|
306
|
+
);
|
|
307
|
+
expect(exitCode).not.toBe(0);
|
|
308
|
+
expect(stdout).toContain('label-wrong-size');
|
|
309
|
+
expect(stdout).toContain('scaled for you');
|
|
310
|
+
expect(exportSeen.options).toBeUndefined();
|
|
311
|
+
});
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
describe('picopilot serve: --label-frame (capture the label from the game)', () => {
|
|
315
|
+
it('captures frame N with no input and serves the export of a labeled copy', async () => {
|
|
316
|
+
let drive: DriveOptions | undefined;
|
|
317
|
+
let exportedCart = '';
|
|
318
|
+
const {exitCode} = await runServe(
|
|
319
|
+
() => ({
|
|
320
|
+
...stubAdapter('writes-bundle'),
|
|
321
|
+
async drive(o: DriveOptions): Promise<Pico8DriveResult> {
|
|
322
|
+
drive = o;
|
|
323
|
+
const shot = join(o.shotDir, 'label0.png');
|
|
324
|
+
writeFileSync(
|
|
325
|
+
shot,
|
|
326
|
+
readFileSync(
|
|
327
|
+
new URL(
|
|
328
|
+
'../engine/gfx/fixtures/pico8-screenshot-384.png',
|
|
329
|
+
import.meta.url,
|
|
330
|
+
),
|
|
331
|
+
),
|
|
332
|
+
);
|
|
333
|
+
return {
|
|
334
|
+
ok: true,
|
|
335
|
+
value: {screenshots: [shot], printh: '', exitReason: 'sentinel'},
|
|
336
|
+
};
|
|
337
|
+
},
|
|
338
|
+
async export(o: ExportOptions) {
|
|
339
|
+
exportedCart = readFileSync(o.cartPath, 'utf8');
|
|
340
|
+
return stubAdapter('writes-bundle').export(o);
|
|
341
|
+
},
|
|
342
|
+
}),
|
|
343
|
+
fakeServer(),
|
|
344
|
+
['serve', cartPath, '--label-frame', '2', '--json'],
|
|
345
|
+
);
|
|
346
|
+
expect(exitCode).toBe(0);
|
|
347
|
+
expect(drive).toBeDefined();
|
|
348
|
+
expect(exportedCart.split('__label__\n')[1]!.split('\n')[10]![10]).toBe(
|
|
349
|
+
'8',
|
|
350
|
+
);
|
|
351
|
+
expect(readdirSync(dir)).toEqual(['main.p8']);
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
it('rejects --label with --label-frame', async () => {
|
|
355
|
+
const {stdout, exitCode} = await runServe(
|
|
356
|
+
() => stubAdapter('writes-bundle'),
|
|
357
|
+
fakeServer(),
|
|
358
|
+
['serve', cartPath, '--label', 'x.png', '--label-frame', '1', '--json'],
|
|
359
|
+
);
|
|
360
|
+
expect(exitCode).not.toBe(0);
|
|
361
|
+
expect(stdout).toContain('label-flags-conflict');
|
|
362
|
+
});
|
|
363
|
+
});
|
package/src/commands/serve.ts
CHANGED
|
@@ -12,8 +12,10 @@ import {type Cli, z} from 'incur';
|
|
|
12
12
|
import {
|
|
13
13
|
EXPORT_HTML_NAME,
|
|
14
14
|
type Pico8Adapter,
|
|
15
|
+
type Pico8ExportResult,
|
|
15
16
|
ShellPico8Adapter,
|
|
16
17
|
} from '../engine/pico8/index.js';
|
|
18
|
+
import {prepareLabeledCart} from './label-cart.js';
|
|
17
19
|
|
|
18
20
|
/**
|
|
19
21
|
* Injects the PICO-8 adapter `serve` uses (defaults to the shell adapter). Same
|
|
@@ -174,6 +176,20 @@ export function registerServe(
|
|
|
174
176
|
),
|
|
175
177
|
}),
|
|
176
178
|
options: z.object({
|
|
179
|
+
label: z
|
|
180
|
+
.string()
|
|
181
|
+
.optional()
|
|
182
|
+
.describe(
|
|
183
|
+
'PNG to bake in as the __label__ (the export splash) for a cart that has none: a `run`/`playtest` screenshot as-is, or any 128x128 PNG. The cart itself is not modified. Ignored if the cart already has a label.',
|
|
184
|
+
),
|
|
185
|
+
labelFrame: z
|
|
186
|
+
.number()
|
|
187
|
+
.int()
|
|
188
|
+
.min(0)
|
|
189
|
+
.optional()
|
|
190
|
+
.describe(
|
|
191
|
+
'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.',
|
|
192
|
+
),
|
|
177
193
|
port: z
|
|
178
194
|
.number()
|
|
179
195
|
.int()
|
|
@@ -186,7 +202,9 @@ export function registerServe(
|
|
|
186
202
|
.int()
|
|
187
203
|
.positive()
|
|
188
204
|
.default(DEFAULT_BACKSTOP_MS)
|
|
189
|
-
.describe(
|
|
205
|
+
.describe(
|
|
206
|
+
'Safety backstop for the export step (and the --label-frame capture run).',
|
|
207
|
+
),
|
|
190
208
|
}),
|
|
191
209
|
env: z.object({
|
|
192
210
|
PICO8_PATH: z
|
|
@@ -210,6 +228,18 @@ export function registerServe(
|
|
|
210
228
|
args: {cart: 'game.p8'},
|
|
211
229
|
options: {port: 8080},
|
|
212
230
|
},
|
|
231
|
+
{
|
|
232
|
+
description:
|
|
233
|
+
'Serve a labelless cart, using a `run` screenshot as the splash',
|
|
234
|
+
args: {cart: 'game.p8'},
|
|
235
|
+
options: {label: './shots/<name>.png'},
|
|
236
|
+
},
|
|
237
|
+
{
|
|
238
|
+
description:
|
|
239
|
+
'Serve a labelless cart, using its title screen (frame 0) as the splash',
|
|
240
|
+
args: {cart: 'game.p8'},
|
|
241
|
+
options: {labelFrame: 0},
|
|
242
|
+
},
|
|
213
243
|
],
|
|
214
244
|
async run({args, options, env, error, ok}) {
|
|
215
245
|
const cartPath = isAbsolute(args.cart)
|
|
@@ -224,16 +254,48 @@ export function registerServe(
|
|
|
224
254
|
});
|
|
225
255
|
}
|
|
226
256
|
|
|
227
|
-
//
|
|
228
|
-
//
|
|
229
|
-
|
|
257
|
+
// --label / --label-frame: export a labeled throwaway copy (shared with
|
|
258
|
+
// `export`; the cart is never mutated). A bad image or a failed capture
|
|
259
|
+
// fails before anything is exported.
|
|
230
260
|
const adapter = adapterFactory(env as NodeJS.ProcessEnv);
|
|
231
|
-
const
|
|
261
|
+
const labeled = await prepareLabeledCart(
|
|
232
262
|
cartPath,
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
263
|
+
{
|
|
264
|
+
label: options.label,
|
|
265
|
+
labelFrame: options.labelFrame,
|
|
266
|
+
backstopMs: options.backstopMs,
|
|
267
|
+
},
|
|
268
|
+
(o) => adapter.drive(o),
|
|
269
|
+
);
|
|
270
|
+
if (!labeled.ok) {
|
|
271
|
+
return error({
|
|
272
|
+
code: labeled.code,
|
|
273
|
+
message: labeled.message,
|
|
274
|
+
exitCode: 1,
|
|
275
|
+
...(labeled.cta ? {cta: labeled.cta} : {}),
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Always export first, into an isolated temp dir (never a user path or
|
|
280
|
+
// ~/Desktop). The bundle is the standalone pair the static server plays.
|
|
281
|
+
// The labeled copy (in the user's cart dir) is removed on every path; a
|
|
282
|
+
// throw also reaps the temp dir, since nothing will be served from it.
|
|
283
|
+
let result: Pico8ExportResult;
|
|
284
|
+
let serveDir: string | undefined;
|
|
285
|
+
try {
|
|
286
|
+
serveDir = mkdtempSync(join(tmpdir(), 'picopilot-serve-'));
|
|
287
|
+
result = await adapter.export({
|
|
288
|
+
cartPath: labeled.cartPath,
|
|
289
|
+
outDir: serveDir,
|
|
290
|
+
htmlName: EXPORT_HTML_NAME,
|
|
291
|
+
backstopMs: options.backstopMs,
|
|
292
|
+
});
|
|
293
|
+
} catch (e) {
|
|
294
|
+
if (serveDir !== undefined) removeDir(serveDir);
|
|
295
|
+
throw e;
|
|
296
|
+
} finally {
|
|
297
|
+
labeled.cleanup();
|
|
298
|
+
}
|
|
237
299
|
|
|
238
300
|
if (!result.ok) {
|
|
239
301
|
// Nothing is being served: remove the temp dir now, not on exit.
|
|
@@ -250,7 +312,7 @@ export function registerServe(
|
|
|
250
312
|
return error({
|
|
251
313
|
code: 'export-failed',
|
|
252
314
|
message: result.value.labelWarning
|
|
253
|
-
? 'PICO-8 produced no bundle: this cart has no __label__ (the export splash), which the browser export needs. To just SEE your own build work, use `picopilot run` or `picopilot playtest` (no label needed) instead of serve. If you genuinely want a browser build to SHARE,
|
|
315
|
+
? 'PICO-8 produced no bundle: this cart has no __label__ (the export splash), which the browser export needs. To just SEE your own build work, use `picopilot run` or `picopilot playtest` (no label needed) instead of serve. If you genuinely want a browser build to SHARE, give it a splash: `picopilot serve <cart> --label-frame 0` captures the title screen for you, or `picopilot serve <cart> --label <screenshot.png>` takes any path `run`/`playtest` lists under `screenshots` as-is (do NOT hand-edit main.p8).'
|
|
254
316
|
: 'PICO-8 produced no export bundle. Check the cart runs without a boot error (via `picopilot run`).',
|
|
255
317
|
exitCode: 1,
|
|
256
318
|
});
|