picopilot 0.1.4 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (172) hide show
  1. package/dist/cli.d.ts.map +1 -1
  2. package/dist/cli.js +111 -16
  3. package/dist/cli.js.map +1 -1
  4. package/dist/commands/audio.d.ts +3 -3
  5. package/dist/commands/audio.d.ts.map +1 -1
  6. package/dist/commands/audio.js +61 -44
  7. package/dist/commands/audio.js.map +1 -1
  8. package/dist/commands/cart-includes.d.ts +11 -0
  9. package/dist/commands/cart-includes.d.ts.map +1 -0
  10. package/dist/commands/cart-includes.js +26 -0
  11. package/dist/commands/cart-includes.js.map +1 -0
  12. package/dist/commands/doctor.d.ts +22 -0
  13. package/dist/commands/doctor.d.ts.map +1 -0
  14. package/dist/commands/doctor.js +222 -0
  15. package/dist/commands/doctor.js.map +1 -0
  16. package/dist/commands/export.d.ts +3 -4
  17. package/dist/commands/export.d.ts.map +1 -1
  18. package/dist/commands/export.js +66 -86
  19. package/dist/commands/export.js.map +1 -1
  20. package/dist/commands/gfx.d.ts +2 -2
  21. package/dist/commands/gfx.d.ts.map +1 -1
  22. package/dist/commands/gfx.js +3 -2
  23. package/dist/commands/gfx.js.map +1 -1
  24. package/dist/commands/label-cart.d.ts +72 -0
  25. package/dist/commands/label-cart.d.ts.map +1 -0
  26. package/dist/commands/label-cart.js +228 -0
  27. package/dist/commands/label-cart.js.map +1 -0
  28. package/dist/commands/lint.d.ts +2 -2
  29. package/dist/commands/lint.d.ts.map +1 -1
  30. package/dist/commands/lint.js +3 -2
  31. package/dist/commands/lint.js.map +1 -1
  32. package/dist/commands/minify.d.ts +2 -2
  33. package/dist/commands/minify.d.ts.map +1 -1
  34. package/dist/commands/minify.js +3 -2
  35. package/dist/commands/minify.js.map +1 -1
  36. package/dist/commands/pico8-env.d.ts +76 -0
  37. package/dist/commands/pico8-env.d.ts.map +1 -0
  38. package/dist/commands/pico8-env.js +71 -0
  39. package/dist/commands/pico8-env.js.map +1 -0
  40. package/dist/commands/playtest.d.ts +2 -2
  41. package/dist/commands/playtest.d.ts.map +1 -1
  42. package/dist/commands/playtest.js +6 -32
  43. package/dist/commands/playtest.js.map +1 -1
  44. package/dist/commands/run.d.ts +3 -4
  45. package/dist/commands/run.d.ts.map +1 -1
  46. package/dist/commands/run.js +46 -19
  47. package/dist/commands/run.js.map +1 -1
  48. package/dist/commands/serve.d.ts +2 -2
  49. package/dist/commands/serve.d.ts.map +1 -1
  50. package/dist/commands/serve.js +65 -19
  51. package/dist/commands/serve.js.map +1 -1
  52. package/dist/commands/shrinko-env.d.ts +22 -0
  53. package/dist/commands/shrinko-env.d.ts.map +1 -0
  54. package/dist/commands/shrinko-env.js +15 -0
  55. package/dist/commands/shrinko-env.js.map +1 -0
  56. package/dist/commands/tokens.d.ts +4 -4
  57. package/dist/commands/tokens.d.ts.map +1 -1
  58. package/dist/commands/tokens.js +3 -2
  59. package/dist/commands/tokens.js.map +1 -1
  60. package/dist/commands/verify.d.ts +2 -2
  61. package/dist/commands/verify.d.ts.map +1 -1
  62. package/dist/commands/verify.js +3 -2
  63. package/dist/commands/verify.js.map +1 -1
  64. package/dist/engine/child-env.d.ts +14 -0
  65. package/dist/engine/child-env.d.ts.map +1 -0
  66. package/dist/engine/child-env.js +21 -0
  67. package/dist/engine/child-env.js.map +1 -0
  68. package/dist/engine/gfx/label.d.ts +15 -9
  69. package/dist/engine/gfx/label.d.ts.map +1 -1
  70. package/dist/engine/gfx/label.js +130 -38
  71. package/dist/engine/gfx/label.js.map +1 -1
  72. package/dist/engine/pico8/adapter.d.ts +23 -3
  73. package/dist/engine/pico8/adapter.d.ts.map +1 -1
  74. package/dist/engine/pico8/adapter.js.map +1 -1
  75. package/dist/engine/pico8/cleanup.d.ts +16 -0
  76. package/dist/engine/pico8/cleanup.d.ts.map +1 -0
  77. package/dist/engine/pico8/cleanup.js +74 -0
  78. package/dist/engine/pico8/cleanup.js.map +1 -0
  79. package/dist/engine/pico8/display.d.ts +149 -0
  80. package/dist/engine/pico8/display.d.ts.map +1 -0
  81. package/dist/engine/pico8/display.js +311 -0
  82. package/dist/engine/pico8/display.js.map +1 -0
  83. package/dist/engine/pico8/drive.d.ts.map +1 -1
  84. package/dist/engine/pico8/drive.js +28 -2
  85. package/dist/engine/pico8/drive.js.map +1 -1
  86. package/dist/engine/pico8/harness.d.ts +8 -0
  87. package/dist/engine/pico8/harness.d.ts.map +1 -1
  88. package/dist/engine/pico8/harness.js +9 -1
  89. package/dist/engine/pico8/harness.js.map +1 -1
  90. package/dist/engine/pico8/index.d.ts +3 -0
  91. package/dist/engine/pico8/index.d.ts.map +1 -1
  92. package/dist/engine/pico8/index.js +3 -0
  93. package/dist/engine/pico8/index.js.map +1 -1
  94. package/dist/engine/pico8/session-daemon-entry.d.ts +1 -1
  95. package/dist/engine/pico8/session-daemon-entry.js +1 -1
  96. package/dist/engine/pico8/session-daemon-main.d.ts +1 -1
  97. package/dist/engine/pico8/session-daemon-main.js +1 -1
  98. package/dist/engine/pico8/session-daemon.d.ts +1 -1
  99. package/dist/engine/pico8/session-daemon.d.ts.map +1 -1
  100. package/dist/engine/pico8/session-daemon.js +19 -19
  101. package/dist/engine/pico8/session-daemon.js.map +1 -1
  102. package/dist/engine/pico8/session.d.ts +1 -1
  103. package/dist/engine/pico8/session.js +1 -1
  104. package/dist/engine/pico8/shell.d.ts +56 -3
  105. package/dist/engine/pico8/shell.d.ts.map +1 -1
  106. package/dist/engine/pico8/shell.js +188 -24
  107. package/dist/engine/pico8/shell.js.map +1 -1
  108. package/dist/engine/pico8/supervisor.d.ts +1 -1
  109. package/dist/engine/pico8/supervisor.js +1 -1
  110. package/dist/engine/shrinko/adapter.d.ts +1 -1
  111. package/dist/engine/shrinko/shell.d.ts +3 -3
  112. package/dist/engine/shrinko/shell.js +3 -3
  113. package/package.json +2 -1
  114. package/src/cli.test.ts +188 -0
  115. package/src/cli.ts +122 -16
  116. package/src/commands/audio.test.ts +76 -1
  117. package/src/commands/audio.ts +79 -57
  118. package/src/commands/cart-includes.ts +27 -0
  119. package/src/commands/doctor.test.ts +149 -0
  120. package/src/commands/doctor.ts +280 -0
  121. package/src/commands/export.test.ts +327 -3
  122. package/src/commands/export.ts +81 -99
  123. package/src/commands/gfx.ts +7 -6
  124. package/src/commands/label-cart.ts +304 -0
  125. package/src/commands/lint.test.ts +6 -0
  126. package/src/commands/lint.ts +7 -6
  127. package/src/commands/minify.test.ts +6 -0
  128. package/src/commands/minify.ts +7 -6
  129. package/src/commands/pico8-env.ts +124 -0
  130. package/src/commands/playtest.test.ts +3 -0
  131. package/src/commands/playtest.ts +10 -34
  132. package/src/commands/run.test.ts +116 -2
  133. package/src/commands/run.ts +62 -24
  134. package/src/commands/serve.test.ts +138 -2
  135. package/src/commands/serve.ts +80 -21
  136. package/src/commands/shrinko-env.test.ts +68 -0
  137. package/src/commands/shrinko-env.ts +40 -0
  138. package/src/commands/tokens.test.ts +19 -0
  139. package/src/commands/tokens.ts +9 -6
  140. package/src/commands/verify.test.ts +12 -0
  141. package/src/commands/verify.ts +7 -4
  142. package/src/engine/child-env.ts +22 -0
  143. package/src/engine/gfx/fixtures/pico8-screenshot-384.png +0 -0
  144. package/src/engine/gfx/label.test.ts +467 -1
  145. package/src/engine/gfx/label.ts +147 -41
  146. package/src/engine/pico8/adapter.ts +25 -3
  147. package/src/engine/pico8/cleanup.test.ts +28 -0
  148. package/src/engine/pico8/cleanup.ts +76 -0
  149. package/src/engine/pico8/display.test.ts +210 -0
  150. package/src/engine/pico8/display.ts +429 -0
  151. package/src/engine/pico8/drive.test.ts +20 -0
  152. package/src/engine/pico8/drive.ts +25 -2
  153. package/src/engine/pico8/fake-xvfb.test-support.ts +55 -0
  154. package/src/engine/pico8/harness.test.ts +26 -0
  155. package/src/engine/pico8/harness.ts +18 -2
  156. package/src/engine/pico8/index.ts +3 -0
  157. package/src/engine/pico8/session-daemon-entry.ts +1 -1
  158. package/src/engine/pico8/session-daemon-main.ts +1 -1
  159. package/src/engine/pico8/session-daemon.test.ts +32 -1
  160. package/src/engine/pico8/session-daemon.ts +25 -17
  161. package/src/engine/pico8/session.test.ts +1 -1
  162. package/src/engine/pico8/session.ts +1 -1
  163. package/src/engine/pico8/shell.test.ts +449 -10
  164. package/src/engine/pico8/shell.ts +255 -26
  165. package/src/engine/pico8/supervisor.ts +1 -1
  166. package/src/engine/pico8/virtual-display.process.test.ts +206 -0
  167. package/src/engine/shrinko/adapter.ts +1 -1
  168. package/src/engine/shrinko/shell.ts +3 -3
  169. package/src/engine/skills/skills.test.ts +10 -0
  170. package/src/skills/picopilot-audio/SKILL.md +4 -8
  171. package/src/skills/picopilot-debug/SKILL.md +18 -1
  172. package/src/skills/picopilot-overview/SKILL.md +9 -1
package/src/cli.ts CHANGED
@@ -1,5 +1,7 @@
1
- import {Cli} from 'incur';
1
+ import {readFileSync} from 'node:fs';
2
+ import {Cli, z} from 'incur';
2
3
  import {registerAudio} from './commands/audio.js';
4
+ import {registerDoctor} from './commands/doctor.js';
3
5
  import {registerExport} from './commands/export.js';
4
6
  import {registerGfx} from './commands/gfx.js';
5
7
  import {registerInit} from './commands/init.js';
@@ -13,8 +15,58 @@ import {registerSfx} from './commands/sfx.js';
13
15
  import {registerTokens} from './commands/tokens.js';
14
16
  import {registerVerify} from './commands/verify.js';
15
17
  import {registerVersion} from './commands/version.js';
18
+ import {
19
+ DEFAULT_DISPLAY_MODE,
20
+ DISPLAY_CONFIG_KEY,
21
+ DISPLAY_ENV_VAR,
22
+ DISPLAY_MODES,
23
+ resolveDisplayMode,
24
+ } from './engine/pico8/display.js';
16
25
  import {VERSION} from './version.js';
17
26
 
27
+ /** The commands that use the display mode (they start a `-run` session, or report on it). */
28
+ const DISPLAY_COMMANDS: ReadonlySet<string> = new Set([
29
+ 'run',
30
+ 'audio record',
31
+ 'audio render',
32
+ 'doctor',
33
+ ]);
34
+
35
+ /**
36
+ * Reads a picopilot.json for incur's config layer AND captures its top-level
37
+ * {@link DISPLAY_CONFIG_KEY}. incur only maps per-command `options` from the
38
+ * config file; the display mode is a GLOBAL setting, so the loader hands the
39
+ * value to the display middleware through `sink`.
40
+ */
41
+ function loadConfig(
42
+ path: string | undefined,
43
+ sink: {display?: unknown},
44
+ ): Record<string, unknown> | undefined {
45
+ sink.display = undefined;
46
+ if (path === undefined) return undefined;
47
+ let raw: string;
48
+ try {
49
+ raw = readFileSync(path, 'utf8');
50
+ } catch (e) {
51
+ // incur only hands the loader an existing file unless --config named one.
52
+ if ((e as NodeJS.ErrnoException).code === 'ENOENT') {
53
+ throw new Error(`Config file not found: ${path}`);
54
+ }
55
+ throw e;
56
+ }
57
+ let parsed: unknown;
58
+ try {
59
+ parsed = JSON.parse(raw);
60
+ } catch {
61
+ throw new Error(`Invalid JSON config file: ${path}`);
62
+ }
63
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
64
+ throw new Error(`Config file must hold a JSON object: ${path}`);
65
+ }
66
+ sink.display = (parsed as Record<string, unknown>)[DISPLAY_CONFIG_KEY];
67
+ return parsed as Record<string, unknown>;
68
+ }
69
+
18
70
  /**
19
71
  * Builds the root picopilot CLI.
20
72
  *
@@ -28,6 +80,7 @@ import {VERSION} from './version.js';
28
80
  * CLI and drive it with `cli.serve(argv, { stdout, exit, env })` DI overrides.
29
81
  */
30
82
  export function createCli(): Cli.Cli {
83
+ const configSink: {display?: unknown} = {};
31
84
  const cli = Cli.create('picopilot', {
32
85
  version: VERSION,
33
86
  description: 'An agent-first toolchain for PICO-8 game development.',
@@ -39,23 +92,76 @@ export function createCli(): Cli.Cli {
39
92
  config: {
40
93
  flag: 'config',
41
94
  files: ['picopilot.json'],
95
+ loader: async (path) => loadConfig(path, configSink),
42
96
  },
97
+ // The display mode (ADR-0017) is global: it applies to every command that
98
+ // starts a PICO-8 `-run` session. Precedence: --display > PICOPILOT_DISPLAY
99
+ // > picopilot.json `display` > auto.
100
+ globals: z.object({
101
+ display: z
102
+ .enum(DISPLAY_MODES)
103
+ .optional()
104
+ .describe(
105
+ 'How PICO-8 -run sessions (audio record/render, run --record-audio) get a display: auto (default: a private virtual display only when there is none), xvfb (always virtual), native (as-is). Also PICOPILOT_DISPLAY or picopilot.json "display" (under --mcp, only PICOPILOT_DISPLAY applies).',
106
+ ),
107
+ }),
108
+ env: z.object({
109
+ [DISPLAY_ENV_VAR]: z
110
+ .string()
111
+ .optional()
112
+ .describe('Display mode: auto | xvfb | native (below --display).'),
113
+ }),
114
+ vars: z.object({
115
+ display: z.enum(DISPLAY_MODES).default(DEFAULT_DISPLAY_MODE),
116
+ }),
117
+ });
118
+
119
+ cli.use(async (c, next) => {
120
+ const configured = configSink.display;
121
+ configSink.display = undefined; // never leak into a later invocation
122
+ const resolved = resolveDisplayMode({
123
+ // incur's MCP transport passes no globals (and loads no config file), so
124
+ // under `--mcp` only PICOPILOT_DISPLAY applies.
125
+ flag: c.globals?.display,
126
+ env: (c.env as Record<string, string | undefined>)[DISPLAY_ENV_VAR],
127
+ config: configured,
128
+ });
129
+ if (!resolved.ok) {
130
+ // A bad value only fails the commands that use it; the rest run as usual.
131
+ if (!DISPLAY_COMMANDS.has(c.command)) return next();
132
+ // (An invalid --display never gets here: incur's enum rejects it first.)
133
+ const where =
134
+ resolved.source === 'env'
135
+ ? DISPLAY_ENV_VAR
136
+ : `picopilot.json "${DISPLAY_CONFIG_KEY}"`;
137
+ return c.error({
138
+ code: 'invalid-display-mode',
139
+ message: `${where} is "${resolved.value}"; expected one of: ${DISPLAY_MODES.join(', ')}.`,
140
+ exitCode: 1,
141
+ });
142
+ }
143
+ c.set('display', resolved.mode);
144
+ await next();
43
145
  });
44
146
 
45
- registerVersion(cli);
46
- registerInit(cli);
47
- registerGfx(cli);
48
- registerTokens(cli);
49
- registerLint(cli);
50
- registerMinify(cli);
51
- registerVerify(cli);
52
- registerRun(cli);
53
- registerExport(cli);
54
- registerServe(cli);
55
- registerPlaytest(cli);
56
- registerSfx(cli);
57
- registerMusic(cli);
58
- registerAudio(cli);
147
+ // The register* helpers take the untyped Cli: the display var is read through
148
+ // `displayModeOf`, so they do not depend on this CLI's generics.
149
+ const root = cli as unknown as Cli.Cli;
150
+ registerVersion(root);
151
+ registerDoctor(root);
152
+ registerInit(root);
153
+ registerGfx(root);
154
+ registerTokens(root);
155
+ registerLint(root);
156
+ registerMinify(root);
157
+ registerVerify(root);
158
+ registerRun(root);
159
+ registerExport(root);
160
+ registerServe(root);
161
+ registerPlaytest(root);
162
+ registerSfx(root);
163
+ registerMusic(root);
164
+ registerAudio(root);
59
165
 
60
- return cli;
166
+ return root;
61
167
  }
@@ -19,7 +19,7 @@ import type {
19
19
  RecordOptions,
20
20
  RunOptions,
21
21
  } from '../engine/pico8/index.js';
22
- import {pico8NotFound} from '../engine/pico8/index.js';
22
+ import {displayUnavailable, pico8NotFound} from '../engine/pico8/index.js';
23
23
  import {type Pico8AdapterFactory, registerAudio} from './audio.js';
24
24
 
25
25
  const HEADER = 'pico-8 cartridge // http://www.pico-8.com\nversion 42\n';
@@ -54,6 +54,12 @@ function stubAdapter(
54
54
  seen?: {record?: RecordOptions},
55
55
  ): Pico8Adapter {
56
56
  return {
57
+ async drive() {
58
+ throw new Error('drive not used');
59
+ },
60
+ async export() {
61
+ throw new Error('export not used');
62
+ },
57
63
  async run(_options: RunOptions) {
58
64
  return {
59
65
  ok: true,
@@ -171,6 +177,12 @@ describe('audio render: orchestration against a fake runner', () => {
171
177
  let recordCalled = false;
172
178
  const {stdout, exitCode} = await runAudio(
173
179
  () => ({
180
+ async drive() {
181
+ throw new Error('drive not used');
182
+ },
183
+ async export() {
184
+ throw new Error('export not used');
185
+ },
174
186
  async run() {
175
187
  return {
176
188
  ok: true,
@@ -278,3 +290,66 @@ describe('audio: honest help text (record-based, NOT an offline export)', () =>
278
290
  expect(lower).toContain('offline export');
279
291
  });
280
292
  });
293
+
294
+ describe('audio record/render: headless (ADR-0017)', () => {
295
+ it('record inlines `#include main.lua` so the harness loads from the temp wavDir', async () => {
296
+ writeFileSync(cartPath, `${HEADER}__lua__\n#include main.lua\n`);
297
+ writeFileSync(
298
+ join(dir, 'main.lua'),
299
+ 'function _update() from_main=1 end\n',
300
+ );
301
+ const seen: {record?: RecordOptions} = {};
302
+ await runAudio(
303
+ () => stubAdapter(recorded(undefined), seen),
304
+ ['audio', 'record', cartPath, '--json'],
305
+ );
306
+ const harness = readFileSync(seen.record!.cartPath, 'utf8');
307
+ expect(harness).not.toMatch(/#include/);
308
+ expect(harness).toContain('from_main=1');
309
+ });
310
+
311
+ for (const sub of [
312
+ ['audio', 'record'],
313
+ ['audio', 'render', '--sfx', '0'],
314
+ ]) {
315
+ it(`${sub.join(' ')}: display-unavailable is a structured error naming the fix`, async () => {
316
+ const {stdout, exitCode} = await runAudio(
317
+ () => stubAdapter(displayUnavailable('no display, no Xvfb')),
318
+ [
319
+ ...sub.slice(0, 2),
320
+ cartPath,
321
+ ...sub.slice(2),
322
+ '--json',
323
+ '--full-output',
324
+ ],
325
+ );
326
+ expect(exitCode).not.toBe(0);
327
+ const out = JSON.parse(stdout);
328
+ expect(out.error.code).toBe('display-unavailable');
329
+ expect(out.meta.cta.description).toMatch(/install Xvfb/);
330
+ });
331
+ }
332
+
333
+ it('a run with no WAV reports noAudioReason, pico8LogTail and the display used', async () => {
334
+ const {stdout} = await runAudio(
335
+ () =>
336
+ stubAdapter({
337
+ ok: true,
338
+ value: {
339
+ wavPath: undefined,
340
+ printh: '',
341
+ exitReason: 'timeout',
342
+ display: 'Xvfb',
343
+ noWavReason: 'the backstop fired',
344
+ pico8LogTail: 'could not include main.lua',
345
+ },
346
+ }),
347
+ ['audio', 'record', cartPath, '--json'],
348
+ );
349
+ const out = JSON.parse(stdout);
350
+ expect(out.captured).toBe(false);
351
+ expect(out.display).toBe('Xvfb');
352
+ expect(out.noAudioReason).toBe('the backstop fired');
353
+ expect(out.pico8LogTail).toContain('main.lua');
354
+ });
355
+ });
@@ -14,21 +14,28 @@ import {
14
14
  buildRenderHarness,
15
15
  DONE_SENTINEL,
16
16
  HarnessError,
17
- type Pico8Adapter,
17
+ type Pico8RecordResult,
18
18
  type RenderTarget,
19
- ShellPico8Adapter,
20
19
  } from '../engine/pico8/index.js';
20
+ import {makeReadInclude} from './cart-includes.js';
21
+ import {
22
+ createShellAdapterFactory,
23
+ displayModeOf,
24
+ displayUnavailableError,
25
+ PICO8_ENV,
26
+ type Pico8AdapterFactory,
27
+ pico8NotFoundError,
28
+ } from './pico8-env.js';
21
29
 
22
30
  /**
23
31
  * Injects the PICO-8 adapter the audio commands use (defaults to the shell
24
32
  * adapter over native `pico8 -run`). Mirrors `run`'s `Pico8AdapterFactory` seam:
25
33
  * a test passes a fake to drive the record orchestration + the absent path
26
- * WITHOUT the paid binary, and to isolate the child `env`.
34
+ * WITHOUT the paid binary.
27
35
  */
28
- export type Pico8AdapterFactory = (env: NodeJS.ProcessEnv) => Pico8Adapter;
36
+ export type {Pico8AdapterFactory} from './pico8-env.js';
29
37
 
30
- const defaultAdapterFactory: Pico8AdapterFactory = (env) =>
31
- new ShellPico8Adapter({env});
38
+ const defaultAdapterFactory: Pico8AdapterFactory = createShellAdapterFactory();
32
39
 
33
40
  /**
34
41
  * The default record backstop: kill a capture that never signals after this many
@@ -39,7 +46,7 @@ const DEFAULT_BACKSTOP_MS = 30_000;
39
46
 
40
47
  /** The honest one-liner both `record` and `render` carry (ADR-0009): not offline. */
41
48
  const HONEST_CAVEAT =
42
- 'This is a REAL-TIME recording of playback (needs a real audio+video session, not headless), NOT a deterministic offline export.';
49
+ 'This is a REAL-TIME recording of playback in a -run session, NOT a deterministic offline export. Works headless: with no display, picopilot runs the session on a private virtual display (ADR-0017, --display).';
43
50
 
44
51
  /** Resolves a path argument to an absolute path (relative to cwd). */
45
52
  function resolvePath(p: string): string {
@@ -64,14 +71,8 @@ function chooseWavDir(given: string | undefined): string {
64
71
  return dir;
65
72
  }
66
73
 
67
- /** The env schema both audio subcommands share (locates the binary, in isolation). */
68
- const AUDIO_ENV = z.object({
69
- PICO8_PATH: z
70
- .string()
71
- .optional()
72
- .describe('Explicit path to the PICO-8 binary (else `pico8` on PATH).'),
73
- PATH: z.string().optional().describe('Used to locate `pico8`.'),
74
- });
74
+ /** The env schema both audio subcommands share (locates the binary). */
75
+ const AUDIO_ENV = PICO8_ENV;
75
76
 
76
77
  /** The structured output shape shared by `record` and `render`. */
77
78
  const AUDIO_OUTPUT = z.object({
@@ -79,7 +80,7 @@ const AUDIO_OUTPUT = z.object({
79
80
  .string()
80
81
  .optional()
81
82
  .describe(
82
- 'Absolute path of the recorded WAV, or omitted if no audio was captured (e.g. a headless session with no audio device).',
83
+ 'Absolute path of the recorded WAV, or omitted if no audio was captured (see noAudioReason).',
83
84
  ),
84
85
  captured: z
85
86
  .boolean()
@@ -98,41 +99,60 @@ const AUDIO_OUTPUT = z.object({
98
99
  .number()
99
100
  .describe('The record window in seconds the capture ran for.'),
100
101
  printh: z.string().describe('Captured stdout from the session.'),
102
+ display: z
103
+ .enum(['native', 'Xvfb', 'xvfb-run'])
104
+ .optional()
105
+ .describe(
106
+ 'Which display the -run session used: native, or a private virtual display (Xvfb / xvfb-run).',
107
+ ),
108
+ noAudioReason: z
109
+ .string()
110
+ .optional()
111
+ .describe(
112
+ 'When no WAV was captured: why (backstop fired / PICO-8 exited before the done-sentinel / empty capture).',
113
+ ),
114
+ pico8LogTail: z
115
+ .string()
116
+ .optional()
117
+ .describe(
118
+ "When no WAV was captured: the tail of PICO-8's own log.txt (load errors, SDL init failures).",
119
+ ),
101
120
  });
102
121
 
103
- /** The message a `pico8-not-found` error carries (mirrors `run`): exact remedy + needs. */
104
- function pico8NotFoundMessage(notFound: {
105
- remedy: string;
106
- needs: readonly string[];
107
- }): string {
108
- return `PICO-8 is not installed. ${notFound.remedy} (needs: ${notFound.needs.join(', ')})`;
109
- }
110
-
111
122
  /**
112
- * The advisory CTA when a record/render run captured NO audio (a headless /
113
- * no-audio-device session): the orchestration worked, there is just nothing to
114
- * hear (ADR-0009's live-tier caveat), so it is a nudge, not a hard error.
123
+ * The advisory CTA when a record/render run captured NO audio. The report's
124
+ * `noAudioReason` + `pico8LogTail` say why; this points at the next step.
115
125
  */
116
- function noAudioCta(caveat: string) {
126
+ function noAudioCta(reason: string | undefined) {
117
127
  return {
118
- description: `No audio was captured (${caveat}) Recording needs a real audio+video session:`,
128
+ description: `No audio was captured${reason !== undefined ? ` (${reason})` : ''}. Next:`,
119
129
  commands: [
120
130
  {
121
- command: 'audio render --sfx <n>',
131
+ command: 'verify',
122
132
  description:
123
- 'Run on a developer machine with a display + audio device (not headless CI).',
133
+ 'Run the static gate: a cart that fails to load or errors never reaches audio_end.',
134
+ },
135
+ {
136
+ command: 'doctor',
137
+ description: 'Check PICO-8 and display capabilities on this machine.',
124
138
  },
125
139
  ],
126
140
  };
127
141
  }
128
142
 
143
+ /** Maps a failed record result onto the structured `error(...)` payload. */
144
+ function recordFailure(
145
+ what: string,
146
+ result: Exclude<Pico8RecordResult, {ok: true}>,
147
+ ) {
148
+ return result.reason === 'display-unavailable'
149
+ ? displayUnavailableError(what, result)
150
+ : pico8NotFoundError(result);
151
+ }
152
+
129
153
  /** Shapes an adapter record report into the structured output object. */
130
154
  function recordOutput(
131
- report: {
132
- wavPath: string | undefined;
133
- printh: string;
134
- exitReason: 'sentinel' | 'timeout' | 'exit';
135
- },
155
+ report: Extract<Pico8RecordResult, {ok: true}>['value'],
136
156
  harness: {seconds: number; description: string},
137
157
  ) {
138
158
  return {
@@ -142,6 +162,13 @@ function recordOutput(
142
162
  target: harness.description,
143
163
  seconds: harness.seconds,
144
164
  printh: report.printh,
165
+ ...(report.display !== undefined ? {display: report.display} : {}),
166
+ ...(report.noWavReason !== undefined
167
+ ? {noAudioReason: report.noWavReason}
168
+ : {}),
169
+ ...(report.pico8LogTail !== undefined
170
+ ? {pico8LogTail: report.pico8LogTail}
171
+ : {}),
145
172
  };
146
173
  }
147
174
 
@@ -154,7 +181,7 @@ function recordOutput(
154
181
  function buildAudioGroup(adapterFactory: Pico8AdapterFactory): Cli.Cli {
155
182
  const audio = Cli.create('audio', {
156
183
  description:
157
- 'Get a WAV out of a cart by RECORDING a running session (ADR-0009). PICO-8 has no working offline WAV export, so both commands are REAL-TIME recordings that need a real audio+video session (not headless), NOT deterministic offline exports. Requires PICO-8.',
184
+ 'Get a WAV out of a cart by RECORDING a running session (ADR-0009). PICO-8 has no working offline WAV export, so both commands are REAL-TIME recordings of a -run session, NOT deterministic offline exports. They work headless: with no display, picopilot runs the session on a private virtual display (ADR-0017). Requires PICO-8.',
158
185
  });
159
186
 
160
187
  // --- audio record <cart> ---------------------------------------------------
@@ -203,7 +230,7 @@ function buildAudioGroup(adapterFactory: Pico8AdapterFactory): Cli.Cli {
203
230
  options: {seconds: 4},
204
231
  },
205
232
  ],
206
- async run({args, options, env, error, ok}) {
233
+ async run({args, options, env, error, ok, var: vars}) {
207
234
  const cartPath = resolvePath(args.cart);
208
235
  if (!existsSync(cartPath)) {
209
236
  return error({
@@ -215,9 +242,12 @@ function buildAudioGroup(adapterFactory: Pico8AdapterFactory): Cli.Cli {
215
242
 
216
243
  let harness: ReturnType<typeof buildRecordHarness>;
217
244
  try {
245
+ // `#include`s are inlined against the SOURCE cart's folder: the harness
246
+ // runs from the isolated wavDir, where `main.lua` does not exist.
218
247
  harness = buildRecordHarness(readFileSync(cartPath, 'utf8'), {
219
248
  seconds: options.seconds,
220
249
  sentinel: options.sentinel,
250
+ readInclude: makeReadInclude(cartPath),
221
251
  });
222
252
  } catch (e) {
223
253
  if (e instanceof HarnessError) {
@@ -232,7 +262,9 @@ function buildAudioGroup(adapterFactory: Pico8AdapterFactory): Cli.Cli {
232
262
  const harnessCart = join(wavDir, 'record-harness.p8');
233
263
  writeFileSync(harnessCart, harness.cartText);
234
264
 
235
- const adapter = adapterFactory(env as NodeJS.ProcessEnv);
265
+ const adapter = adapterFactory(env as NodeJS.ProcessEnv, {
266
+ display: displayModeOf(vars),
267
+ });
236
268
  const result = await adapter.record({
237
269
  cartPath: harnessCart,
238
270
  wavDir,
@@ -241,18 +273,12 @@ function buildAudioGroup(adapterFactory: Pico8AdapterFactory): Cli.Cli {
241
273
  backstopMs: options.backstopMs,
242
274
  });
243
275
 
244
- if (!result.ok) {
245
- return error({
246
- code: 'pico8-not-found',
247
- message: pico8NotFoundMessage(result),
248
- exitCode: 1,
249
- });
250
- }
276
+ if (!result.ok) return error(recordFailure('audio record', result));
251
277
 
252
278
  const out = recordOutput(result.value, harness);
253
279
  return ok(
254
280
  out,
255
- out.captured ? undefined : {cta: noAudioCta(HONEST_CAVEAT)},
281
+ out.captured ? undefined : {cta: noAudioCta(out.noAudioReason)},
256
282
  );
257
283
  },
258
284
  });
@@ -324,7 +350,7 @@ function buildAudioGroup(adapterFactory: Pico8AdapterFactory): Cli.Cli {
324
350
  options: {pattern: 2, seconds: 6},
325
351
  },
326
352
  ],
327
- async run({args, options, env, error, ok}) {
353
+ async run({args, options, env, error, ok, var: vars}) {
328
354
  if (options.sfx !== undefined && options.pattern !== undefined) {
329
355
  return error({
330
356
  code: 'ambiguous-target',
@@ -368,7 +394,9 @@ function buildAudioGroup(adapterFactory: Pico8AdapterFactory): Cli.Cli {
368
394
  const harnessCart = join(wavDir, 'render-harness.p8');
369
395
  writeFileSync(harnessCart, harness.cartText);
370
396
 
371
- const adapter = adapterFactory(env as NodeJS.ProcessEnv);
397
+ const adapter = adapterFactory(env as NodeJS.ProcessEnv, {
398
+ display: displayModeOf(vars),
399
+ });
372
400
  const result = await adapter.record({
373
401
  cartPath: harnessCart,
374
402
  wavDir,
@@ -377,18 +405,12 @@ function buildAudioGroup(adapterFactory: Pico8AdapterFactory): Cli.Cli {
377
405
  backstopMs: options.backstopMs,
378
406
  });
379
407
 
380
- if (!result.ok) {
381
- return error({
382
- code: 'pico8-not-found',
383
- message: pico8NotFoundMessage(result),
384
- exitCode: 1,
385
- });
386
- }
408
+ if (!result.ok) return error(recordFailure('audio render', result));
387
409
 
388
410
  const out = recordOutput(result.value, harness);
389
411
  return ok(
390
412
  out,
391
- out.captured ? undefined : {cta: noAudioCta(HONEST_CAVEAT)},
413
+ out.captured ? undefined : {cta: noAudioCta(out.noAudioReason)},
392
414
  );
393
415
  },
394
416
  });
@@ -0,0 +1,27 @@
1
+ import {readFileSync} from 'node:fs';
2
+ import {dirname, isAbsolute, join} from 'node:path';
3
+
4
+ /**
5
+ * A `#include` resolver for the drive-transform, bound to the SOURCE cart's dir:
6
+ * picopilot's standard scaffold is `main.p8` = `#include main.lua`, and the
7
+ * driven cart runs from a throwaway temp dir where `main.lua` is absent, so the
8
+ * transform inlines includes to be self-contained. Reads the included file
9
+ * relative to the cart (absolute paths honoured); returns `undefined` if it
10
+ * cannot be read (the directive is then left verbatim). Shared by `playtest` and
11
+ * the `--label-frame` capture.
12
+ */
13
+ export function makeReadInclude(
14
+ cartPath: string,
15
+ ): (p: string) => string | undefined {
16
+ const base = dirname(cartPath);
17
+ return (includePath) => {
18
+ const full = isAbsolute(includePath)
19
+ ? includePath
20
+ : join(base, includePath);
21
+ try {
22
+ return readFileSync(full, 'utf8');
23
+ } catch {
24
+ return undefined;
25
+ }
26
+ };
27
+ }