picopilot 0.1.5 → 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/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 +4 -9
- 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/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 +4 -9
- 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 +4 -9
- 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/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/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/shrinko/shell.d.ts +3 -3
- package/dist/engine/shrinko/shell.js +3 -3
- package/package.json +1 -1
- package/src/cli.test.ts +188 -0
- package/src/cli.ts +122 -16
- package/src/commands/audio.test.ts +64 -1
- package/src/commands/audio.ts +79 -57
- package/src/commands/doctor.test.ts +149 -0
- package/src/commands/doctor.ts +280 -0
- package/src/commands/export.ts +9 -13
- package/src/commands/gfx.ts +7 -6
- package/src/commands/lint.ts +7 -6
- package/src/commands/minify.ts +7 -6
- package/src/commands/pico8-env.ts +124 -0
- package/src/commands/playtest.ts +8 -11
- package/src/commands/run.test.ts +110 -2
- package/src/commands/run.ts +62 -24
- package/src/commands/serve.ts +8 -11
- package/src/commands/shrinko-env.test.ts +68 -0
- package/src/commands/shrinko-env.ts +40 -0
- package/src/commands/tokens.ts +9 -6
- package/src/commands/verify.ts +7 -4
- package/src/engine/child-env.ts +22 -0
- 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/shell.test.ts +448 -9
- package/src/engine/pico8/shell.ts +255 -26
- package/src/engine/pico8/virtual-display.process.test.ts +206 -0
- package/src/engine/shrinko/shell.ts +3 -3
- package/src/skills/picopilot-audio/SKILL.md +4 -8
- package/src/skills/picopilot-overview/SKILL.md +9 -1
|
@@ -4,6 +4,8 @@ import {
|
|
|
4
4
|
mkdirSync,
|
|
5
5
|
mkdtempSync,
|
|
6
6
|
readdirSync,
|
|
7
|
+
readFileSync,
|
|
8
|
+
rmSync,
|
|
7
9
|
statSync,
|
|
8
10
|
} from 'node:fs';
|
|
9
11
|
import {tmpdir} from 'node:os';
|
|
@@ -21,8 +23,19 @@ import {
|
|
|
21
23
|
type Pico8Result,
|
|
22
24
|
RECORD_WAV_BASENAME,
|
|
23
25
|
type RecordOptions,
|
|
26
|
+
type RecordReport,
|
|
24
27
|
type RunOptions,
|
|
25
28
|
} from './adapter.js';
|
|
29
|
+
import {
|
|
30
|
+
DEFAULT_DISPLAY_MODE,
|
|
31
|
+
type DisplayMode,
|
|
32
|
+
findExecutable,
|
|
33
|
+
needsVirtualDisplay,
|
|
34
|
+
startVirtualDisplay,
|
|
35
|
+
type VirtualDisplayDeps,
|
|
36
|
+
} from './display.js';
|
|
37
|
+
import {overlayEnv} from '../child-env.js';
|
|
38
|
+
import {killOnAbort} from './cleanup.js';
|
|
26
39
|
import {SentinelWatcher} from './sentinel.js';
|
|
27
40
|
|
|
28
41
|
/**
|
|
@@ -30,13 +43,15 @@ import {SentinelWatcher} from './sentinel.js';
|
|
|
30
43
|
* delivers stdout chunks (both `stdout` and `stderr` folded in, since PICO-8's
|
|
31
44
|
* `printh` and its own lines can land on either); `onClose` fires when the
|
|
32
45
|
* process exits on its own; `onError` fires on a spawn failure (ENOENT ⇒ PICO-8
|
|
33
|
-
* absent). `kill` terminates the process tree (the backstop / sentinel kill)
|
|
46
|
+
* absent). `kill` terminates the process tree (the backstop / sentinel kill),
|
|
47
|
+
* with SIGKILL unless a gentler `signal` is given (the `xvfb-run` fallback is
|
|
48
|
+
* first sent SIGTERM so Xvfb can remove its lock file and socket).
|
|
34
49
|
*/
|
|
35
50
|
export interface Pico8Process {
|
|
36
51
|
onStdout(cb: (chunk: string) => void): void;
|
|
37
52
|
onClose(cb: (code: number | null) => void): void;
|
|
38
53
|
onError(cb: (err: NodeJS.ErrnoException) => void): void;
|
|
39
|
-
kill(): void;
|
|
54
|
+
kill(signal?: NodeJS.Signals): void;
|
|
40
55
|
/**
|
|
41
56
|
* Writes raw bytes to the process's stdin, for the `drive` transport (the
|
|
42
57
|
* fixed-size command blocks). Absent/undefined when stdin was detached
|
|
@@ -95,6 +110,76 @@ export function withPico8Home(args: string[]): string[] {
|
|
|
95
110
|
return ['-home', home, ...args];
|
|
96
111
|
}
|
|
97
112
|
|
|
113
|
+
/**
|
|
114
|
+
* The environment PICO-8 is spawned with: the parent env overlaid with the
|
|
115
|
+
* picopilot-resolved values ({@link overlayEnv}). A `-run` session cannot open a
|
|
116
|
+
* window or an audio device without DISPLAY, HOME, XDG_RUNTIME_DIR, SDL_*, ...
|
|
117
|
+
*/
|
|
118
|
+
export function pico8ChildEnv(
|
|
119
|
+
resolved: Readonly<Record<string, string | undefined>>,
|
|
120
|
+
parent: NodeJS.ProcessEnv,
|
|
121
|
+
): NodeJS.ProcessEnv {
|
|
122
|
+
return overlayEnv(resolved, parent);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* A private `XDG_RUNTIME_DIR` (mode 0700, one per picopilot process) for a
|
|
127
|
+
* PICO-8 launched without one. SDL logs "XDG_RUNTIME_DIR is invalid or not set"
|
|
128
|
+
* otherwise, which is noise in every headless report.
|
|
129
|
+
*/
|
|
130
|
+
let cachedRuntimeDir: string | undefined;
|
|
131
|
+
export function pico8RuntimeDir(): string {
|
|
132
|
+
if (cachedRuntimeDir !== undefined && existsSync(cachedRuntimeDir)) {
|
|
133
|
+
return cachedRuntimeDir;
|
|
134
|
+
}
|
|
135
|
+
cachedRuntimeDir = mkdtempSync(join(tmpdir(), 'picopilot-xdg-runtime-'));
|
|
136
|
+
return cachedRuntimeDir;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** `env` with a private {@link pico8RuntimeDir} filled in when XDG_RUNTIME_DIR is absent. */
|
|
140
|
+
export function withRuntimeDir(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
|
141
|
+
const current = env.XDG_RUNTIME_DIR;
|
|
142
|
+
if (current !== undefined && current.trim() !== '') return env;
|
|
143
|
+
return {...env, XDG_RUNTIME_DIR: pico8RuntimeDir()};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** How many lines of PICO-8's `log.txt` a failed record reports. */
|
|
147
|
+
const LOG_TAIL_LINES = 25;
|
|
148
|
+
|
|
149
|
+
/** The last lines of PICO-8's `log.txt` in the isolated `-home`, or undefined if absent. */
|
|
150
|
+
export function readPico8LogTail(
|
|
151
|
+
home: string = pico8HomeDir(),
|
|
152
|
+
lines: number = LOG_TAIL_LINES,
|
|
153
|
+
): string | undefined {
|
|
154
|
+
try {
|
|
155
|
+
const text = readFileSync(join(home, 'log.txt'), 'utf8');
|
|
156
|
+
const all = text.trimEnd().split('\n');
|
|
157
|
+
return all.slice(-lines).join('\n');
|
|
158
|
+
} catch {
|
|
159
|
+
return undefined;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Says WHY a record run produced no WAV, from how it ended. The sentinel is the
|
|
165
|
+
* harness's last act (after `audio_end`), so anything other than a sentinel
|
|
166
|
+
* means the harness never finished; a sentinel with no WAV means `audio_end`
|
|
167
|
+
* saved nothing usable.
|
|
168
|
+
*/
|
|
169
|
+
export function explainNoWav(
|
|
170
|
+
exitReason: ExitReason,
|
|
171
|
+
ctx: {backstopMs: number; wavDir: string; basename: string},
|
|
172
|
+
): string {
|
|
173
|
+
switch (exitReason) {
|
|
174
|
+
case 'timeout':
|
|
175
|
+
return `the backstop fired after ${ctx.backstopMs}ms before the record harness printed its done-sentinel: the cart never reached audio_end (it failed to load, errored at runtime, or its game loop never ran). See pico8LogTail and printh.`;
|
|
176
|
+
case 'exit':
|
|
177
|
+
return 'PICO-8 exited before the record harness printed its done-sentinel: the cart failed to load or crashed, or PICO-8 itself could not start (e.g. no display or audio device). See pico8LogTail and printh.';
|
|
178
|
+
case 'sentinel':
|
|
179
|
+
return `the record harness reached its done-sentinel but no non-empty ${ctx.basename}.wav was written to ${ctx.wavDir}: audio_end saved nothing usable (an empty 0-frame capture).`;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
98
183
|
export const spawnRunner: SpawnRunner = (file, args, env, options) => {
|
|
99
184
|
// `drive` pipes fixed-size command blocks to stdin; `run`/`record` detach it so
|
|
100
185
|
// the cart never blocks on console input.
|
|
@@ -124,12 +209,12 @@ export const spawnRunner: SpawnRunner = (file, args, env, options) => {
|
|
|
124
209
|
// A closed stdin (the cart exited) is not fatal to the run.
|
|
125
210
|
}
|
|
126
211
|
},
|
|
127
|
-
kill() {
|
|
212
|
+
kill(signal = 'SIGKILL') {
|
|
128
213
|
try {
|
|
129
214
|
// Negative pid kills the process GROUP (detached), so any child dies too.
|
|
130
|
-
if (child.pid !== undefined) process.kill(-child.pid,
|
|
215
|
+
if (child.pid !== undefined) process.kill(-child.pid, signal);
|
|
131
216
|
} catch {
|
|
132
|
-
child.kill(
|
|
217
|
+
child.kill(signal); // fall back to killing just the process
|
|
133
218
|
}
|
|
134
219
|
},
|
|
135
220
|
};
|
|
@@ -202,8 +287,24 @@ export interface ShellPico8Options {
|
|
|
202
287
|
readonly env: NodeJS.ProcessEnv;
|
|
203
288
|
/** The spawn runner (defaults to {@link spawnRunner}); tests inject a fake. */
|
|
204
289
|
readonly spawn?: SpawnRunner;
|
|
290
|
+
/**
|
|
291
|
+
* The display mode for `-run` sessions (ADR-0017; defaults to `auto`). Only
|
|
292
|
+
* {@link ShellPico8Adapter.record} consults it: the `-x` paths never need a
|
|
293
|
+
* display and never start one.
|
|
294
|
+
*/
|
|
295
|
+
readonly display?: DisplayMode;
|
|
296
|
+
/** The virtual-display dependencies (Xvfb lookup + spawn); tests inject fakes. */
|
|
297
|
+
readonly virtualDisplay?: VirtualDisplayDeps;
|
|
298
|
+
/**
|
|
299
|
+
* How long a gracefully-ended run (the `xvfb-run` fallback) gets between
|
|
300
|
+
* SIGTERM and SIGKILL, in ms (default {@link DEFAULT_KILL_GRACE_MS}).
|
|
301
|
+
*/
|
|
302
|
+
readonly killGraceMs?: number;
|
|
205
303
|
}
|
|
206
304
|
|
|
305
|
+
/** The default SIGTERM-to-SIGKILL grace for a gracefully-ended run. */
|
|
306
|
+
export const DEFAULT_KILL_GRACE_MS = 2_000;
|
|
307
|
+
|
|
207
308
|
/**
|
|
208
309
|
* The v1 {@link Pico8Adapter}: shells out to the user's native `pico8 -x`
|
|
209
310
|
* (ADR-0006, native path first). It owns the ORCHESTRATION the command should
|
|
@@ -217,10 +318,16 @@ export interface ShellPico8Options {
|
|
|
217
318
|
export class ShellPico8Adapter implements Pico8Adapter {
|
|
218
319
|
private readonly env: NodeJS.ProcessEnv;
|
|
219
320
|
private readonly spawnRun: SpawnRunner;
|
|
321
|
+
private readonly display: DisplayMode;
|
|
322
|
+
private readonly virtualDisplay: VirtualDisplayDeps;
|
|
323
|
+
private readonly killGraceMs: number;
|
|
220
324
|
|
|
221
325
|
constructor(options: ShellPico8Options) {
|
|
326
|
+
this.killGraceMs = options.killGraceMs ?? DEFAULT_KILL_GRACE_MS;
|
|
222
327
|
this.env = options.env;
|
|
223
328
|
this.spawnRun = options.spawn ?? spawnRunner;
|
|
329
|
+
this.display = options.display ?? DEFAULT_DISPLAY_MODE;
|
|
330
|
+
this.virtualDisplay = options.virtualDisplay ?? {};
|
|
224
331
|
}
|
|
225
332
|
|
|
226
333
|
run(options: RunOptions): Promise<Pico8Result> {
|
|
@@ -322,24 +429,95 @@ export class ShellPico8Adapter implements Pico8Adapter {
|
|
|
322
429
|
}
|
|
323
430
|
|
|
324
431
|
record(options: RecordOptions): Promise<Pico8RecordResult> {
|
|
432
|
+
if (needsVirtualDisplay(this.display, this.env)) {
|
|
433
|
+
return this.recordOnVirtualDisplay(options);
|
|
434
|
+
}
|
|
435
|
+
return this.recordWith(options, 'native');
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* The record session on a private virtual display (ADR-0017): start Xvfb
|
|
440
|
+
* (or fall back to `xvfb-run`), run PICO-8 on it with a dummy SDL audio
|
|
441
|
+
* driver (unless the caller chose one; `audio_rec` captures PICO-8's own
|
|
442
|
+
* mixer, not the device), and ALWAYS stop the display afterwards, however
|
|
443
|
+
* the run ended (sentinel, backstop, crash, spawn failure).
|
|
444
|
+
*/
|
|
445
|
+
private async recordOnVirtualDisplay(
|
|
446
|
+
options: RecordOptions,
|
|
447
|
+
): Promise<Pico8RecordResult> {
|
|
448
|
+
const [file] = pico8Candidates(this.env) as [string, ...string[]];
|
|
449
|
+
// Absent PICO-8 is reported as such BEFORE paying for (or failing on) a
|
|
450
|
+
// display, and wrapped in xvfb-run a missing binary would not ENOENT.
|
|
451
|
+
const find = this.virtualDisplay.find ?? findExecutable;
|
|
452
|
+
if (find(file, this.env) === undefined) return pico8NotFound();
|
|
453
|
+
|
|
454
|
+
const started = await startVirtualDisplay(this.env, this.virtualDisplay);
|
|
455
|
+
if (!started.ok) return started;
|
|
456
|
+
const vd = started.display;
|
|
457
|
+
try {
|
|
458
|
+
const env: NodeJS.ProcessEnv = {...this.env, ...vd.env};
|
|
459
|
+
const audioDriver = env.SDL_AUDIODRIVER;
|
|
460
|
+
if (audioDriver === undefined || audioDriver.trim() === '') {
|
|
461
|
+
env.SDL_AUDIODRIVER = 'dummy';
|
|
462
|
+
}
|
|
463
|
+
return await this.recordWith(options, vd.backend, {
|
|
464
|
+
env,
|
|
465
|
+
wrap: (f, a) => vd.wrap(f, a),
|
|
466
|
+
// xvfb-run's Xvfb lives in PICO-8's process group: SIGTERM first so it
|
|
467
|
+
// removes its /tmp/.X<n>-lock and socket (a SIGKILL leaves them behind).
|
|
468
|
+
endGracefully: vd.backend === 'xvfb-run',
|
|
469
|
+
});
|
|
470
|
+
} finally {
|
|
471
|
+
await vd.stop();
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/** The record launch itself, natively or under a virtual display's env/wrapper. */
|
|
476
|
+
private recordWith(
|
|
477
|
+
options: RecordOptions,
|
|
478
|
+
display: RecordReport['display'],
|
|
479
|
+
launch?: {
|
|
480
|
+
env: NodeJS.ProcessEnv;
|
|
481
|
+
wrap: (file: string, args: string[]) => {file: string; args: string[]};
|
|
482
|
+
endGracefully?: boolean;
|
|
483
|
+
},
|
|
484
|
+
): Promise<Pico8RecordResult> {
|
|
325
485
|
const [file] = pico8Candidates(this.env) as [string, ...string[]];
|
|
326
486
|
const basename = options.wavBasename ?? RECORD_WAV_BASENAME;
|
|
327
487
|
// `-root_path <wavDir>` steers the `audio_end(1)`-to-current-folder WAV into
|
|
328
488
|
// the isolated temp dir (never ~/Desktop or the carts root). `-run` is a real
|
|
329
489
|
// A/V session (recording needs audio to capture; `-x` yields an empty WAV).
|
|
330
490
|
const args = ['-root_path', options.wavDir, '-run', options.cartPath];
|
|
491
|
+
// The `-home` is shared by every launch in this process: drop the previous
|
|
492
|
+
// log.txt so a failed record never reports another run's log as its own.
|
|
493
|
+
rmSync(join(pico8HomeDir(), 'log.txt'), {force: true});
|
|
331
494
|
return this.orchestrate<Pico8RecordResult>(
|
|
332
495
|
file,
|
|
333
496
|
args,
|
|
334
497
|
{sentinel: options.sentinel, backstopMs: options.backstopMs},
|
|
335
|
-
(exitReason, printh) =>
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
498
|
+
(exitReason, printh) => {
|
|
499
|
+
const wavPath = collectWav(options.wavDir, basename);
|
|
500
|
+
if (wavPath !== undefined) {
|
|
501
|
+
return {ok: true, value: {wavPath, printh, exitReason, display}};
|
|
502
|
+
}
|
|
503
|
+
const pico8LogTail = readPico8LogTail();
|
|
504
|
+
return {
|
|
505
|
+
ok: true,
|
|
506
|
+
value: {
|
|
507
|
+
wavPath,
|
|
508
|
+
printh,
|
|
509
|
+
exitReason,
|
|
510
|
+
display,
|
|
511
|
+
noWavReason: explainNoWav(exitReason, {
|
|
512
|
+
backstopMs: options.backstopMs,
|
|
513
|
+
wavDir: options.wavDir,
|
|
514
|
+
basename,
|
|
515
|
+
}),
|
|
516
|
+
...(pico8LogTail !== undefined ? {pico8LogTail} : {}),
|
|
517
|
+
},
|
|
518
|
+
};
|
|
519
|
+
},
|
|
520
|
+
launch,
|
|
343
521
|
);
|
|
344
522
|
}
|
|
345
523
|
|
|
@@ -356,22 +534,72 @@ export class ShellPico8Adapter implements Pico8Adapter {
|
|
|
356
534
|
args: string[],
|
|
357
535
|
opts: {sentinel?: string; backstopMs: number},
|
|
358
536
|
buildOk: (exitReason: ExitReason, printh: string) => R,
|
|
359
|
-
spawnOpts?: {
|
|
537
|
+
spawnOpts?: {
|
|
538
|
+
stdin?: boolean;
|
|
539
|
+
onSpawn?: (proc: Pico8Process) => void;
|
|
540
|
+
/** The child env (defaults to the adapter's); a virtual display adds DISPLAY etc. */
|
|
541
|
+
env?: NodeJS.ProcessEnv;
|
|
542
|
+
/** Rewrites the launch (the `xvfb-run` fallback wraps the PICO-8 command). */
|
|
543
|
+
wrap?: (file: string, args: string[]) => {file: string; args: string[]};
|
|
544
|
+
/**
|
|
545
|
+
* End the run with SIGTERM, then SIGKILL after the grace period, and wait
|
|
546
|
+
* for the process to close before resolving (default: an immediate SIGKILL).
|
|
547
|
+
*/
|
|
548
|
+
endGracefully?: boolean;
|
|
549
|
+
},
|
|
360
550
|
): Promise<R | ReturnType<typeof pico8NotFound>> {
|
|
361
551
|
const watcher = new SentinelWatcher(opts.sentinel);
|
|
362
552
|
|
|
363
553
|
return new Promise<R | ReturnType<typeof pico8NotFound>>((resolve) => {
|
|
364
554
|
let settled = false;
|
|
365
555
|
let backstop: NodeJS.Timeout | undefined;
|
|
366
|
-
const
|
|
367
|
-
|
|
368
|
-
|
|
556
|
+
const homeArgs = withPico8Home(args);
|
|
557
|
+
const launch =
|
|
558
|
+
spawnOpts?.wrap !== undefined
|
|
559
|
+
? spawnOpts.wrap(file, homeArgs)
|
|
560
|
+
: {file, args: homeArgs};
|
|
561
|
+
const proc = this.spawnRun(
|
|
562
|
+
launch.file,
|
|
563
|
+
launch.args,
|
|
564
|
+
withRuntimeDir(spawnOpts?.env ?? this.env),
|
|
565
|
+
{stdin: spawnOpts?.stdin},
|
|
566
|
+
);
|
|
567
|
+
|
|
568
|
+
// If picopilot itself is stopped mid-run, take PICO-8 down with it.
|
|
569
|
+
const disposeAbort = killOnAbort(() => proc.kill());
|
|
570
|
+
let onClosed: (() => void) | undefined;
|
|
369
571
|
|
|
370
|
-
|
|
572
|
+
/**
|
|
573
|
+
* Ends the run once. `kill` = PICO-8 is still running (sentinel/backstop):
|
|
574
|
+
* SIGKILL its group, or for a graceful end SIGTERM it and resolve when it
|
|
575
|
+
* has closed (SIGKILL after the grace period).
|
|
576
|
+
*/
|
|
577
|
+
const finish = (exitReason: ExitReason, kill = false): void => {
|
|
371
578
|
if (settled) return;
|
|
372
579
|
settled = true;
|
|
373
580
|
if (backstop !== undefined) clearTimeout(backstop);
|
|
374
|
-
|
|
581
|
+
let resolved = false;
|
|
582
|
+
const done = () => {
|
|
583
|
+
if (resolved) return;
|
|
584
|
+
resolved = true;
|
|
585
|
+
// Only now: a signal during the graceful wait must still kill PICO-8.
|
|
586
|
+
disposeAbort();
|
|
587
|
+
resolve(buildOk(exitReason, watcher.text));
|
|
588
|
+
};
|
|
589
|
+
if (!kill) return done();
|
|
590
|
+
if (spawnOpts?.endGracefully !== true) {
|
|
591
|
+
proc.kill();
|
|
592
|
+
return done();
|
|
593
|
+
}
|
|
594
|
+
proc.kill('SIGTERM');
|
|
595
|
+
const hard = setTimeout(() => {
|
|
596
|
+
proc.kill();
|
|
597
|
+
done();
|
|
598
|
+
}, this.killGraceMs);
|
|
599
|
+
onClosed = () => {
|
|
600
|
+
clearTimeout(hard);
|
|
601
|
+
done();
|
|
602
|
+
};
|
|
375
603
|
};
|
|
376
604
|
|
|
377
605
|
proc.onError((err) => {
|
|
@@ -381,6 +609,7 @@ export class ShellPico8Adapter implements Pico8Adapter {
|
|
|
381
609
|
if (settled) return;
|
|
382
610
|
if (err.code === 'ENOENT') {
|
|
383
611
|
settled = true;
|
|
612
|
+
disposeAbort();
|
|
384
613
|
if (backstop !== undefined) clearTimeout(backstop);
|
|
385
614
|
resolve(pico8NotFound());
|
|
386
615
|
} else {
|
|
@@ -389,13 +618,14 @@ export class ShellPico8Adapter implements Pico8Adapter {
|
|
|
389
618
|
});
|
|
390
619
|
|
|
391
620
|
proc.onStdout((chunk) => {
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
finish('sentinel');
|
|
395
|
-
}
|
|
621
|
+
// sentinel matched: end the run promptly
|
|
622
|
+
if (watcher.push(chunk) && !settled) finish('sentinel', true);
|
|
396
623
|
});
|
|
397
624
|
|
|
398
|
-
proc.onClose(() =>
|
|
625
|
+
proc.onClose(() => {
|
|
626
|
+
if (onClosed !== undefined) return onClosed();
|
|
627
|
+
finish('exit'); // PICO-8 exited on its own
|
|
628
|
+
});
|
|
399
629
|
|
|
400
630
|
// For `drive`: write the command blocks to the live stdin now that it is
|
|
401
631
|
// spawned. A spawn ENOENT resolves via onError before this matters.
|
|
@@ -404,8 +634,7 @@ export class ShellPico8Adapter implements Pico8Adapter {
|
|
|
404
634
|
// Backstop: a cart that neither signals nor exits gets killed.
|
|
405
635
|
backstop = setTimeout(() => {
|
|
406
636
|
if (settled) return;
|
|
407
|
-
|
|
408
|
-
finish('timeout');
|
|
637
|
+
finish('timeout', true);
|
|
409
638
|
}, opts.backstopMs);
|
|
410
639
|
});
|
|
411
640
|
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import {
|
|
2
|
+
chmodSync,
|
|
3
|
+
existsSync,
|
|
4
|
+
mkdirSync,
|
|
5
|
+
mkdtempSync,
|
|
6
|
+
readFileSync,
|
|
7
|
+
rmSync,
|
|
8
|
+
writeFileSync,
|
|
9
|
+
} from 'node:fs';
|
|
10
|
+
import {tmpdir} from 'node:os';
|
|
11
|
+
import {join} from 'node:path';
|
|
12
|
+
import {spawn} from 'node:child_process';
|
|
13
|
+
import {createRequire} from 'node:module';
|
|
14
|
+
import {afterEach, beforeEach, describe, expect, it} from 'vitest';
|
|
15
|
+
import {DONE_SENTINEL} from './adapter.js';
|
|
16
|
+
import {findExecutable} from './display.js';
|
|
17
|
+
import {ShellPico8Adapter} from './shell.js';
|
|
18
|
+
|
|
19
|
+
/** An absolute `sleep` (the scripts run with a minimal PATH; NixOS has no /bin/sleep). */
|
|
20
|
+
const SLEEP = findExecutable('sleep', process.env) ?? '/bin/sleep';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The no-stray-process guarantee (ADR-0017), proven with REAL processes: a fake
|
|
24
|
+
* `Xvfb` (a shell script honouring `-displayfd 3`) and a fake `pico8` (a shell
|
|
25
|
+
* script that prints the sentinel, hangs, or crashes), spawned by the real
|
|
26
|
+
* spawners. After every record run, BOTH pids must be gone. POSIX-only (sh).
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
let dir: string;
|
|
30
|
+
let pidDir: string;
|
|
31
|
+
|
|
32
|
+
/** Writes an executable shell script. */
|
|
33
|
+
function script(name: string, body: string): string {
|
|
34
|
+
const path = join(dir, name);
|
|
35
|
+
writeFileSync(path, `#!/bin/sh\n${body}\n`);
|
|
36
|
+
chmodSync(path, 0o755);
|
|
37
|
+
return path;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
beforeEach(() => {
|
|
41
|
+
dir = mkdtempSync(join(tmpdir(), 'picopilot-vd-proc-'));
|
|
42
|
+
pidDir = join(dir, 'pids');
|
|
43
|
+
mkdirSync(pidDir);
|
|
44
|
+
// The fake X server: record its pid, report display 77 on fd 3, then idle.
|
|
45
|
+
script(
|
|
46
|
+
'Xvfb',
|
|
47
|
+
`echo $$ > "$FAKE_PID_DIR/xvfb.pid"\necho 77 >&3\nexec ${SLEEP} 15`,
|
|
48
|
+
);
|
|
49
|
+
// The fake PICO-8: record its pid and DISPLAY, then behave per FAKE_MODE.
|
|
50
|
+
script(
|
|
51
|
+
'pico8',
|
|
52
|
+
[
|
|
53
|
+
'echo $$ > "$FAKE_PID_DIR/pico8.pid"',
|
|
54
|
+
'echo "$DISPLAY" > "$FAKE_PID_DIR/display"',
|
|
55
|
+
'case "$FAKE_MODE" in',
|
|
56
|
+
` sentinel) echo ${DONE_SENTINEL}; exec ${SLEEP} 15;;`,
|
|
57
|
+
` hang) exec ${SLEEP} 15;;`,
|
|
58
|
+
' crash) exit 3;;',
|
|
59
|
+
'esac',
|
|
60
|
+
].join('\n'),
|
|
61
|
+
);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
afterEach(() => {
|
|
65
|
+
// If an assertion failed before the run cleaned up, never leave the fake
|
|
66
|
+
// Xvfb / PICO-8 behind (they are `sleep`s, but still strays).
|
|
67
|
+
for (const name of ['xvfb', 'pico8']) {
|
|
68
|
+
const pid = readPid(name);
|
|
69
|
+
if (pid !== undefined && Number.isInteger(pid) && pid > 0) {
|
|
70
|
+
try {
|
|
71
|
+
process.kill(pid, 'SIGKILL');
|
|
72
|
+
} catch {
|
|
73
|
+
// already gone (the expected case)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
rmSync(dir, {recursive: true, force: true});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
/** Whether a pid is still alive (signal 0 probes without killing). */
|
|
81
|
+
function alive(pid: number): boolean {
|
|
82
|
+
try {
|
|
83
|
+
process.kill(pid, 0);
|
|
84
|
+
return true;
|
|
85
|
+
} catch {
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Waits (bounded) for a pid to disappear; the kernel may lag the kill slightly. */
|
|
91
|
+
async function gone(pid: number): Promise<boolean> {
|
|
92
|
+
for (let i = 0; i < 100; i++) {
|
|
93
|
+
if (!alive(pid)) return true;
|
|
94
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
95
|
+
}
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function readPid(name: string): number | undefined {
|
|
100
|
+
const p = join(pidDir, `${name}.pid`);
|
|
101
|
+
if (!existsSync(p)) return undefined;
|
|
102
|
+
const pid = Number(readFileSync(p, 'utf8').trim());
|
|
103
|
+
return Number.isInteger(pid) && pid > 0 ? pid : undefined;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function adapter(mode: string, pico8 = join(dir, 'pico8')) {
|
|
107
|
+
return new ShellPico8Adapter({
|
|
108
|
+
display: 'xvfb',
|
|
109
|
+
env: {
|
|
110
|
+
PATH: `${dir}:/usr/bin:/bin`,
|
|
111
|
+
PICO8_PATH: pico8,
|
|
112
|
+
FAKE_PID_DIR: pidDir,
|
|
113
|
+
FAKE_MODE: mode,
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
describe.skipIf(process.platform === 'win32')(
|
|
119
|
+
'virtual display with real processes: nothing is left behind',
|
|
120
|
+
() => {
|
|
121
|
+
for (const [mode, backstopMs, exitReason] of [
|
|
122
|
+
['sentinel', 10_000, 'sentinel'],
|
|
123
|
+
['hang', 300, 'timeout'],
|
|
124
|
+
['crash', 10_000, 'exit'],
|
|
125
|
+
] as const) {
|
|
126
|
+
it(`${mode}: PICO-8 saw the virtual DISPLAY, and both it and Xvfb are gone`, async () => {
|
|
127
|
+
const result = await adapter(mode).record({
|
|
128
|
+
cartPath: join(dir, 'h.p8'),
|
|
129
|
+
wavDir: dir,
|
|
130
|
+
backstopMs,
|
|
131
|
+
});
|
|
132
|
+
expect(result.ok).toBe(true);
|
|
133
|
+
if (result.ok) {
|
|
134
|
+
expect(result.value.exitReason).toBe(exitReason);
|
|
135
|
+
expect(result.value.display).toBe('Xvfb');
|
|
136
|
+
}
|
|
137
|
+
expect(readFileSync(join(pidDir, 'display'), 'utf8').trim()).toBe(
|
|
138
|
+
':77',
|
|
139
|
+
);
|
|
140
|
+
const xvfb = readPid('xvfb');
|
|
141
|
+
const pico8 = readPid('pico8');
|
|
142
|
+
expect(xvfb).toBeDefined();
|
|
143
|
+
expect(pico8).toBeDefined();
|
|
144
|
+
expect(await gone(xvfb!)).toBe(true);
|
|
145
|
+
expect(await gone(pico8!)).toBe(true);
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
it('spawn failure: PICO-8 cannot exec, and Xvfb is still stopped', async () => {
|
|
150
|
+
// Executable, so the pre-flight lookup finds it, but its interpreter does
|
|
151
|
+
// not exist, so the spawn itself fails (ENOENT) after Xvfb is up.
|
|
152
|
+
const broken = join(dir, 'broken-pico8');
|
|
153
|
+
writeFileSync(broken, '#!/nonexistent/interpreter\n');
|
|
154
|
+
chmodSync(broken, 0o755);
|
|
155
|
+
const result = await adapter('sentinel', broken).record({
|
|
156
|
+
cartPath: join(dir, 'h.p8'),
|
|
157
|
+
wavDir: dir,
|
|
158
|
+
backstopMs: 10_000,
|
|
159
|
+
});
|
|
160
|
+
expect(!result.ok && result.reason).toBe('pico8-not-found');
|
|
161
|
+
const xvfb = readPid('xvfb');
|
|
162
|
+
expect(xvfb).toBeDefined();
|
|
163
|
+
expect(await gone(xvfb!)).toBe(true);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('picopilot itself killed mid-run (SIGTERM): PICO-8 and Xvfb die with it', async () => {
|
|
167
|
+
// A separate node process runs the record (hanging cart); we SIGTERM it
|
|
168
|
+
// once both children are up, as an agent harness or `timeout` would.
|
|
169
|
+
const tsx = createRequire(import.meta.url).resolve('tsx');
|
|
170
|
+
const shell = new URL('./shell.ts', import.meta.url).pathname;
|
|
171
|
+
const code = `
|
|
172
|
+
const {ShellPico8Adapter} = await import(${JSON.stringify(shell)});
|
|
173
|
+
await new ShellPico8Adapter({display: 'xvfb', env: ${JSON.stringify({
|
|
174
|
+
PATH: `${dir}:/usr/bin:/bin`,
|
|
175
|
+
PICO8_PATH: join(dir, 'pico8'),
|
|
176
|
+
FAKE_PID_DIR: pidDir,
|
|
177
|
+
FAKE_MODE: 'hang',
|
|
178
|
+
})}}).record({cartPath: 'h.p8', wavDir: ${JSON.stringify(dir)}, backstopMs: 60000});
|
|
179
|
+
`;
|
|
180
|
+
const child = spawn(
|
|
181
|
+
process.execPath,
|
|
182
|
+
['--import', tsx, '--input-type=module', '-e', code],
|
|
183
|
+
{stdio: 'ignore'},
|
|
184
|
+
);
|
|
185
|
+
const exited = new Promise((r) => child.on('exit', r));
|
|
186
|
+
try {
|
|
187
|
+
for (let i = 0; i < 250 && readPid('pico8') === undefined; i++) {
|
|
188
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
189
|
+
}
|
|
190
|
+
const xvfb = readPid('xvfb');
|
|
191
|
+
const pico8 = readPid('pico8');
|
|
192
|
+
expect(xvfb).toBeDefined();
|
|
193
|
+
expect(pico8).toBeDefined();
|
|
194
|
+
child.kill('SIGTERM');
|
|
195
|
+
await exited;
|
|
196
|
+
expect(await gone(xvfb!)).toBe(true);
|
|
197
|
+
expect(await gone(pico8!)).toBe(true);
|
|
198
|
+
} finally {
|
|
199
|
+
// Never leave the runner process behind if an assertion failed.
|
|
200
|
+
if (child.exitCode === null && child.signalCode === null) {
|
|
201
|
+
child.kill('SIGKILL');
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}, 15_000);
|
|
205
|
+
},
|
|
206
|
+
);
|
|
@@ -41,9 +41,9 @@ export type ChildRunner = (
|
|
|
41
41
|
* The default {@link ChildRunner}: an `execFile` that captures stdout/stderr and
|
|
42
42
|
* NEVER rejects. A failed spawn (ENOENT) becomes `spawnError`; a nonzero exit
|
|
43
43
|
* becomes a `code`, so both outcomes are data the adapter maps to a structured
|
|
44
|
-
* result. The child
|
|
45
|
-
*
|
|
46
|
-
*
|
|
44
|
+
* result. The child gets exactly the `env` it is handed and reads nothing
|
|
45
|
+
* ambiently; the command layer builds that env as the parent env overlaid with
|
|
46
|
+
* the resolved `PATH` (`commands/shrinko-env.ts`), and a test injects its own.
|
|
47
47
|
*/
|
|
48
48
|
export const execFileRunner: ChildRunner = (file, args, env) =>
|
|
49
49
|
new Promise((resolve) => {
|
|
@@ -46,11 +46,7 @@ These steps are wired as CTAs (a merged SFX or song points you at `verify` then
|
|
|
46
46
|
|
|
47
47
|
## `audio render` / `audio record`: get a WAV by RECORDING (ADR-0009)
|
|
48
48
|
|
|
49
|
-
PICO-8 has NO working offline WAV export (it SIGFPEs upstream), so picopilot gets
|
|
50
|
-
audio-to-WAV the only way that works: by RECORDING a running session
|
|
51
|
-
(`extcmd("audio_rec")`/`audio_end`). Two commands, both PICO-8-gated and both
|
|
52
|
-
REAL-TIME recordings that need a real audio+video session (a developer machine,
|
|
53
|
-
NOT headless CI), NOT deterministic offline exports:
|
|
49
|
+
PICO-8 has NO working offline WAV export (it SIGFPEs upstream), so picopilot gets audio-to-WAV the only way that works: by RECORDING a running session (`extcmd("audio_rec")`/`audio_end`). Two commands, both PICO-8-gated and both REAL-TIME recordings of playback, NOT deterministic offline exports. They work headless (no display, no sound card): PICO-8 records its own mixer, not the audio device, and when there is no display picopilot runs the session on a private virtual display by itself (ADR-0017). You do not need to do anything for that.
|
|
54
50
|
|
|
55
51
|
- **`picopilot audio render`** injects a THROWAWAY play-harness for a chosen
|
|
56
52
|
target and records it, so you write NO playback code. Pick the target with
|
|
@@ -68,9 +64,9 @@ The WAV goes to an isolated dir (`--wav-dir`, default a temp dir), never
|
|
|
68
64
|
`~/Desktop` or the carts root (the `audio_end(1)`-saves-to-the-current-folder
|
|
69
65
|
trap; picopilot steers it with PICO-8's `root_path`). PICO-8 absent -> a
|
|
70
66
|
structured `pico8-not-found` (remedy `set PICO8_PATH or install PICO-8`), never a
|
|
71
|
-
crash.
|
|
72
|
-
|
|
73
|
-
|
|
67
|
+
crash.
|
|
68
|
+
|
|
69
|
+
When a recording produces no WAV (`captured:false`), the result says why: `noAudioReason` (the backstop fired before the recorder finished, PICO-8 exited early, or the capture was empty) and `pico8LogTail` (the end of PICO-8's own log, where load errors show up). Read both before retrying; a cart that fails to load never reaches the end of the recording, so run `picopilot verify` on it. If the machine has no display AND no Xvfb, the command fails up front with `display-unavailable`, whose message names the package to install; `picopilot doctor` reports the same thing before you try.
|
|
74
70
|
|
|
75
71
|
## `sfx from-mml`: one MML string -> one SFX slot
|
|
76
72
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: picopilot-overview
|
|
3
|
-
description: Start here for any PICO-8 cart built with picopilot.
|
|
3
|
+
description: Start here for any PICO-8 cart built with picopilot. Call picopilot doctor first to learn what this machine can do (everything works headless), then the #include discipline (edit main.lua, never the .p8 binary sections), the per-iteration verify loop, and which deeper skill to load for code, art, audio, or debugging. Use when you open a picopilot cart folder or run picopilot init.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# picopilot overview
|
|
@@ -13,6 +13,14 @@ acceptance gate, so you can build a PICO-8 game and self-correct.
|
|
|
13
13
|
Load this skill first. Then load the group skill for whatever you are doing:
|
|
14
14
|
`picopilot-code`, `picopilot-art`, `picopilot-audio`, or `picopilot-debug`.
|
|
15
15
|
|
|
16
|
+
## First on a new machine: `picopilot doctor`
|
|
17
|
+
|
|
18
|
+
Run `picopilot doctor` before anything else on a machine you have not used yet. It reports what THIS machine can do: whether PICO-8 is found and actually runs (with its version), whether shrinko8 is found, how a PICO-8 audio session would get a display, and the resulting `available` / `unavailable` command lists (each unavailable command says what it is missing). Plan from that list instead of discovering limits by failing.
|
|
19
|
+
|
|
20
|
+
Every picopilot command works on a machine with no display and no sound card (a server, CI, an agent sandbox). Screenshots, `playtest` and `export` run PICO-8 headless. Audio recording (`audio render`, `audio record`, `run --record-audio`) needs a real PICO-8 session with a window, and picopilot provides one by itself: with the default display mode `auto`, when there is no display it starts a private virtual display for that recording and removes it afterwards. You do not need to set anything up. Only if `doctor` lists the audio commands as unavailable with `missing: display` (or a recording fails with `display-unavailable`) does the machine lack Xvfb; the error names the install command to suggest to the human.
|
|
21
|
+
|
|
22
|
+
The display mode can be forced with `--display xvfb` (always virtual) or `--display native` (use the current display as-is), also settable as `PICOPILOT_DISPLAY` or `"display"` in `picopilot.json` (when picopilot runs as an MCP server, only `PICOPILOT_DISPLAY` in the server's environment applies). Leave it at `auto` unless a human asks otherwise.
|
|
23
|
+
|
|
16
24
|
## The #include discipline (how every picopilot cart is laid out)
|
|
17
25
|
|
|
18
26
|
- `main.p8` is the cart. Its `__lua__` section is a single line: `#include main.lua`.
|