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
@@ -0,0 +1,429 @@
1
+ /**
2
+ * The DISPLAY MODE seam (ADR-0017): how a PICO-8 `-run` session (a real
3
+ * audio+video session, today only the audio-record path) gets a display on a
4
+ * machine that may have none (a server, CI, an agent sandbox).
5
+ *
6
+ * - `native`: spawn PICO-8 as-is (it uses whatever DISPLAY/WAYLAND_DISPLAY the
7
+ * caller has, or fails to open a window if there is none).
8
+ * - `xvfb`: always run a `-run` session on a private virtual X display.
9
+ * - `auto`: the default. Use a virtual display only when the session needs one
10
+ * and none is available (no DISPLAY and no WAYLAND_DISPLAY); otherwise native.
11
+ *
12
+ * Headless `-x` passes (run screenshots, playtest, export, the label capture)
13
+ * never need a display, so they never consult this module and never pay the
14
+ * Xvfb startup cost.
15
+ *
16
+ * The virtual display prefers spawning `Xvfb -displayfd <fd>` directly: the X
17
+ * server picks a free display number itself and writes it back on the fd, so
18
+ * parallel runs never race for a fixed `:99`. When only the Debian `xvfb-run`
19
+ * script exists, it is the fallback (it wraps the PICO-8 command instead).
20
+ * Either way the virtual display lives exactly as long as the run.
21
+ */
22
+
23
+ import {spawn} from 'node:child_process';
24
+ import {accessSync, constants, statSync} from 'node:fs';
25
+ import {delimiter, join} from 'node:path';
26
+ import {killOnAbort} from './cleanup.js';
27
+
28
+ /** The three display modes. `auto` is the default. */
29
+ export const DISPLAY_MODES = ['auto', 'xvfb', 'native'] as const;
30
+
31
+ /** A display mode: how a `-run` session gets a display (see the module doc). */
32
+ export type DisplayMode = (typeof DISPLAY_MODES)[number];
33
+
34
+ /** The default display mode when no flag, env var or config sets one. */
35
+ export const DEFAULT_DISPLAY_MODE: DisplayMode = 'auto';
36
+
37
+ /** The env var that sets the display mode (below the `--display` flag, above picopilot.json). */
38
+ export const DISPLAY_ENV_VAR = 'PICOPILOT_DISPLAY' as const;
39
+
40
+ /** The top-level picopilot.json key that sets the display mode (lowest precedence). */
41
+ export const DISPLAY_CONFIG_KEY = 'display' as const;
42
+
43
+ /** Whether `value` names a valid {@link DisplayMode}. */
44
+ export function isDisplayMode(value: unknown): value is DisplayMode {
45
+ return (
46
+ typeof value === 'string' &&
47
+ (DISPLAY_MODES as readonly string[]).includes(value)
48
+ );
49
+ }
50
+
51
+ /** The outcome of {@link resolveDisplayMode}: a mode and where it came from, or an invalid value. */
52
+ export type DisplayModeResolution =
53
+ | {
54
+ readonly ok: true;
55
+ readonly mode: DisplayMode;
56
+ readonly source: 'flag' | 'env' | 'config' | 'default';
57
+ }
58
+ | {
59
+ readonly ok: false;
60
+ readonly source: 'flag' | 'env' | 'config';
61
+ readonly value: string;
62
+ };
63
+
64
+ /**
65
+ * Resolves the display mode with the precedence `flag > env > config > default`
66
+ * (the same order every picopilot setting uses). An empty/whitespace value is
67
+ * treated as unset; an unknown value is reported (never silently ignored), so a
68
+ * typo like `PICOPILOT_DISPLAY=xfvb` is a visible error rather than `auto`.
69
+ */
70
+ export function resolveDisplayMode(sources: {
71
+ readonly flag?: unknown;
72
+ readonly env?: unknown;
73
+ readonly config?: unknown;
74
+ }): DisplayModeResolution {
75
+ const ordered = [
76
+ ['flag', sources.flag],
77
+ ['env', sources.env],
78
+ ['config', sources.config],
79
+ ] as const;
80
+ for (const [source, raw] of ordered) {
81
+ if (raw === undefined || raw === null) continue;
82
+ const value = String(raw).trim();
83
+ if (value.length === 0) continue;
84
+ if (isDisplayMode(value)) return {ok: true, mode: value, source};
85
+ return {ok: false, source, value};
86
+ }
87
+ return {ok: true, mode: DEFAULT_DISPLAY_MODE, source: 'default'};
88
+ }
89
+
90
+ /** Whether the environment already offers a display (X11 `DISPLAY` or a Wayland socket). */
91
+ export function hasNativeDisplay(env: NodeJS.ProcessEnv): boolean {
92
+ const set = (v: string | undefined) => v !== undefined && v.trim() !== '';
93
+ return set(env.DISPLAY) || set(env.WAYLAND_DISPLAY);
94
+ }
95
+
96
+ /**
97
+ * Whether a `-run` session in this `mode` + `env` should run on a virtual
98
+ * display: always for `xvfb`, never for `native`, and for `auto` only when
99
+ * there is no native display.
100
+ */
101
+ export function needsVirtualDisplay(
102
+ mode: DisplayMode,
103
+ env: NodeJS.ProcessEnv,
104
+ ): boolean {
105
+ if (mode === 'xvfb') return true;
106
+ if (mode === 'native') return false;
107
+ return !hasNativeDisplay(env);
108
+ }
109
+
110
+ /**
111
+ * Finds an executable the way a shell would: a name containing `/` is checked
112
+ * as a path, anything else is searched on `env.PATH`. Returns the resolved path,
113
+ * or `undefined`. Pure lookup (no spawn), so it is cheap enough for `doctor` and
114
+ * for the failure path.
115
+ */
116
+ export function findExecutable(
117
+ name: string,
118
+ env: NodeJS.ProcessEnv,
119
+ ): string | undefined {
120
+ const isExec = (p: string): boolean => {
121
+ try {
122
+ if (!statSync(p).isFile()) return false;
123
+ accessSync(p, constants.X_OK);
124
+ return true;
125
+ } catch {
126
+ return false;
127
+ }
128
+ };
129
+ if (name.includes('/')) return isExec(name) ? name : undefined;
130
+ for (const dir of (env.PATH ?? '').split(delimiter)) {
131
+ if (dir === '') continue;
132
+ const candidate = join(dir, name);
133
+ if (isExec(candidate)) return candidate;
134
+ }
135
+ return undefined;
136
+ }
137
+
138
+ /** Which virtual-display backend this machine offers, if any (direct Xvfb is preferred). */
139
+ export type VirtualDisplayBackend = 'Xvfb' | 'xvfb-run';
140
+
141
+ /** Detects the best available virtual-display backend on `env.PATH`. */
142
+ export function detectVirtualDisplayBackend(
143
+ env: NodeJS.ProcessEnv,
144
+ find: (
145
+ name: string,
146
+ env: NodeJS.ProcessEnv,
147
+ ) => string | undefined = findExecutable,
148
+ ): VirtualDisplayBackend | undefined {
149
+ if (find('Xvfb', env) !== undefined) return 'Xvfb';
150
+ if (find('xvfb-run', env) !== undefined) return 'xvfb-run';
151
+ return undefined;
152
+ }
153
+
154
+ /** The remedy a `display-unavailable` error names. */
155
+ export const DISPLAY_REMEDY =
156
+ 'install Xvfb (Debian/Ubuntu: apt install xvfb; Fedora: dnf install xorg-x11-server-Xvfb; NixOS: add xvfb-run or xorg.xvfb), or run under a desktop session (DISPLAY or WAYLAND_DISPLAY set)' as const;
157
+
158
+ /**
159
+ * The structured "this session needs a display and there is none" failure
160
+ * (mirrors {@link import('./adapter.js').Pico8NotFound}). `detail` says what was
161
+ * tried (Xvfb missing, or Xvfb failed to start and why).
162
+ */
163
+ export interface DisplayUnavailable {
164
+ readonly ok: false;
165
+ readonly reason: 'display-unavailable';
166
+ readonly remedy: typeof DISPLAY_REMEDY;
167
+ readonly detail: string;
168
+ }
169
+
170
+ /** Builds a {@link DisplayUnavailable} value. */
171
+ export function displayUnavailable(detail: string): DisplayUnavailable {
172
+ return {
173
+ ok: false,
174
+ reason: 'display-unavailable',
175
+ remedy: DISPLAY_REMEDY,
176
+ detail,
177
+ };
178
+ }
179
+
180
+ /**
181
+ * A running virtual display, scoped to ONE PICO-8 session. `env` holds the
182
+ * variables the child must see (DISPLAY for direct Xvfb); `wrap` rewrites the
183
+ * PICO-8 command (identity for direct Xvfb, `xvfb-run -a ...` for the fallback);
184
+ * `stop` tears the X server down and is idempotent.
185
+ */
186
+ export interface VirtualDisplay {
187
+ readonly backend: VirtualDisplayBackend;
188
+ readonly env: Readonly<Record<string, string>>;
189
+ wrap(file: string, args: string[]): {file: string; args: string[]};
190
+ stop(): Promise<void>;
191
+ }
192
+
193
+ /**
194
+ * A spawned Xvfb process, abstracted so tests inject a fake. `onDisplayFd`
195
+ * delivers what the server writes on its `-displayfd` pipe (the display number
196
+ * followed by a newline); `onExit` fires when it exits; `kill` sends a signal.
197
+ */
198
+ export interface XvfbProcess {
199
+ readonly pid: number | undefined;
200
+ onDisplayFd(cb: (chunk: string) => void): void;
201
+ onStderr(cb: (chunk: string) => void): void;
202
+ onExit(cb: (code: number | null) => void): void;
203
+ onError(cb: (err: NodeJS.ErrnoException) => void): void;
204
+ kill(signal: NodeJS.Signals): void;
205
+ }
206
+
207
+ /** The Xvfb spawn seam: tests pass a fake that writes a display number (or never does). */
208
+ export type XvfbSpawner = (
209
+ file: string,
210
+ args: string[],
211
+ env: NodeJS.ProcessEnv,
212
+ ) => XvfbProcess;
213
+
214
+ /** The fd number Xvfb writes its display number to (`-displayfd 3`). */
215
+ const DISPLAY_FD = 3;
216
+
217
+ /** The screen geometry for the virtual display (PICO-8's default window fits easily). */
218
+ const XVFB_SCREEN = '640x540x24';
219
+
220
+ /**
221
+ * The real {@link XvfbSpawner}: stdio `[ignore, ignore, pipe, pipe]` so fd 3 is
222
+ * the display-number pipe. NOT detached, so a terminal Ctrl-C reaches it with
223
+ * the rest of picopilot's process group.
224
+ */
225
+ export const spawnXvfb: XvfbSpawner = (file, args, env) => {
226
+ const child = spawn(file, args, {
227
+ env,
228
+ stdio: ['ignore', 'ignore', 'pipe', 'pipe'],
229
+ });
230
+ const fd = child.stdio[DISPLAY_FD] as NodeJS.ReadableStream | null;
231
+ fd?.setEncoding('utf8');
232
+ child.stderr?.setEncoding('utf8');
233
+ return {
234
+ pid: child.pid,
235
+ onDisplayFd(cb) {
236
+ fd?.on('data', cb);
237
+ },
238
+ onStderr(cb) {
239
+ child.stderr?.on('data', cb);
240
+ },
241
+ onExit(cb) {
242
+ child.on('exit', cb);
243
+ },
244
+ onError(cb) {
245
+ child.on('error', cb as (e: Error) => void);
246
+ },
247
+ kill(signal) {
248
+ try {
249
+ child.kill(signal);
250
+ } catch {
251
+ // already gone
252
+ }
253
+ },
254
+ };
255
+ };
256
+
257
+ /** Dependencies for {@link startVirtualDisplay}, injectable for tests. */
258
+ export interface VirtualDisplayDeps {
259
+ /** Locates `Xvfb` / `xvfb-run` (defaults to a PATH search). */
260
+ readonly find?: (name: string, env: NodeJS.ProcessEnv) => string | undefined;
261
+ /** Spawns Xvfb (defaults to {@link spawnXvfb}). */
262
+ readonly spawnXvfb?: XvfbSpawner;
263
+ /** How long to wait for Xvfb to report its display number (ms). */
264
+ readonly startTimeoutMs?: number;
265
+ /** How long to wait after SIGTERM before SIGKILL on stop (ms). */
266
+ readonly stopGraceMs?: number;
267
+ }
268
+
269
+ /** The default time Xvfb gets to come up before the start is declared failed. */
270
+ const DEFAULT_XVFB_START_TIMEOUT_MS = 10_000;
271
+
272
+ /** The default time Xvfb gets to exit on SIGTERM before SIGKILL. */
273
+ const DEFAULT_XVFB_STOP_GRACE_MS = 2_000;
274
+
275
+ /**
276
+ * Starts a private virtual display for one PICO-8 session. Prefers `Xvfb
277
+ * -displayfd` (no fixed display number, so parallel runs never collide), falls
278
+ * back to wrapping the command in `xvfb-run -a`, and resolves the structured
279
+ * {@link DisplayUnavailable} when neither exists or Xvfb fails to start. Never
280
+ * throws. The returned display MUST be stopped by the caller (the shell adapter
281
+ * does it in a `finally`); a kill-on-abort hook also kills a direct Xvfb so an
282
+ * abrupt picopilot exit or signal never leaves one behind.
283
+ */
284
+ export async function startVirtualDisplay(
285
+ env: NodeJS.ProcessEnv,
286
+ deps: VirtualDisplayDeps = {},
287
+ ): Promise<{ok: true; display: VirtualDisplay} | DisplayUnavailable> {
288
+ const find = deps.find ?? findExecutable;
289
+ const xvfb = find('Xvfb', env);
290
+ if (xvfb !== undefined) {
291
+ return startDirectXvfb(xvfb, env, deps);
292
+ }
293
+ const xvfbRun = find('xvfb-run', env);
294
+ if (xvfbRun !== undefined) {
295
+ return {ok: true, display: xvfbRunDisplay(xvfbRun)};
296
+ }
297
+ return displayUnavailable(
298
+ 'there is no display (DISPLAY and WAYLAND_DISPLAY are unset) and neither Xvfb nor xvfb-run is on PATH to provide a virtual one',
299
+ );
300
+ }
301
+
302
+ /**
303
+ * The `xvfb-run` fallback: no server to manage here, the script starts Xvfb,
304
+ * runs the command with DISPLAY set, and kills Xvfb when the command exits. The
305
+ * whole thing runs in PICO-8's process group, so the adapter's group kill
306
+ * (sentinel / backstop) takes Xvfb down with it.
307
+ */
308
+ function xvfbRunDisplay(xvfbRun: string): VirtualDisplay {
309
+ return {
310
+ backend: 'xvfb-run',
311
+ env: {},
312
+ wrap(file, args) {
313
+ return {
314
+ file: xvfbRun,
315
+ args: [
316
+ '-a',
317
+ '-s',
318
+ `-screen 0 ${XVFB_SCREEN} -nolisten tcp`,
319
+ file,
320
+ ...args,
321
+ ],
322
+ };
323
+ },
324
+ async stop() {},
325
+ };
326
+ }
327
+
328
+ /** Starts `Xvfb -displayfd 3` and waits for it to report the display number it picked. */
329
+ function startDirectXvfb(
330
+ file: string,
331
+ env: NodeJS.ProcessEnv,
332
+ deps: VirtualDisplayDeps,
333
+ ): Promise<{ok: true; display: VirtualDisplay} | DisplayUnavailable> {
334
+ const spawner = deps.spawnXvfb ?? spawnXvfb;
335
+ const startTimeoutMs = deps.startTimeoutMs ?? DEFAULT_XVFB_START_TIMEOUT_MS;
336
+ const stopGraceMs = deps.stopGraceMs ?? DEFAULT_XVFB_STOP_GRACE_MS;
337
+ const args = [
338
+ '-displayfd',
339
+ String(DISPLAY_FD),
340
+ '-screen',
341
+ '0',
342
+ XVFB_SCREEN,
343
+ '-nolisten',
344
+ 'tcp',
345
+ ];
346
+
347
+ return new Promise((resolve) => {
348
+ let exited = false;
349
+ let settled = false;
350
+ let fdText = '';
351
+ let stderr = '';
352
+ const exitWaiters: (() => void)[] = [];
353
+ let timer: NodeJS.Timeout | undefined;
354
+ const proc = spawner(file, args, env);
355
+
356
+ // Safety net: if picopilot exits or is signalled mid-run, kill the server
357
+ // synchronously so no stray Xvfb is left behind.
358
+ const disposeAbort = killOnAbort(() => proc.kill('SIGKILL'));
359
+
360
+ const stop = (): Promise<void> => {
361
+ disposeAbort();
362
+ if (exited) return Promise.resolve();
363
+ return new Promise<void>((done) => {
364
+ exitWaiters.push(done);
365
+ proc.kill('SIGTERM');
366
+ const hard = setTimeout(() => {
367
+ if (!exited) proc.kill('SIGKILL');
368
+ }, stopGraceMs);
369
+ exitWaiters.push(() => clearTimeout(hard));
370
+ });
371
+ };
372
+
373
+ const fail = (detail: string) => {
374
+ if (settled) return;
375
+ settled = true;
376
+ clearTimeout(timer);
377
+ void stop().then(() => resolve(displayUnavailable(detail)));
378
+ };
379
+
380
+ proc.onExit(() => {
381
+ exited = true;
382
+ for (const w of exitWaiters.splice(0)) w();
383
+ if (!settled) {
384
+ disposeAbort();
385
+ fail(`Xvfb exited before reporting a display${stderrTail(stderr)}`);
386
+ }
387
+ });
388
+ proc.onError((err) => {
389
+ exited = true;
390
+ for (const w of exitWaiters.splice(0)) w();
391
+ fail(`Xvfb failed to start (${err.code ?? err.message})`);
392
+ });
393
+ proc.onStderr((chunk) => {
394
+ stderr += chunk;
395
+ });
396
+ proc.onDisplayFd((chunk) => {
397
+ if (settled) return;
398
+ fdText += chunk;
399
+ const m = /^\s*(\d+)\s*\n/.exec(fdText);
400
+ if (m === null) return;
401
+ settled = true;
402
+ clearTimeout(timer);
403
+ const display = `:${m[1]}`;
404
+ resolve({
405
+ ok: true,
406
+ display: {
407
+ backend: 'Xvfb',
408
+ env: {DISPLAY: display},
409
+ wrap: (f, a) => ({file: f, args: a}),
410
+ stop,
411
+ },
412
+ });
413
+ });
414
+
415
+ timer = setTimeout(
416
+ () =>
417
+ fail(
418
+ `Xvfb did not report a display within ${startTimeoutMs}ms${stderrTail(stderr)}`,
419
+ ),
420
+ startTimeoutMs,
421
+ );
422
+ });
423
+ }
424
+
425
+ /** The last few lines of Xvfb's stderr, as a `: ...` suffix (or empty). */
426
+ function stderrTail(stderr: string): string {
427
+ const lines = stderr.trim().split('\n').filter(Boolean).slice(-3);
428
+ return lines.length === 0 ? '' : `: ${lines.join(' | ')}`;
429
+ }
@@ -290,6 +290,26 @@ describe('resolveIncludes (#include inlining so the driven cart is self-containe
290
290
  );
291
291
  });
292
292
 
293
+ it('includes only the __lua__ of a .p8 file, or one tab of it with :n', () => {
294
+ const cart = [
295
+ 'pico-8 cartridge // http://www.pico-8.com',
296
+ 'version 42',
297
+ '__lua__',
298
+ 'tab0=1',
299
+ '-->8',
300
+ 'tab1=1',
301
+ '__gfx__',
302
+ '0000',
303
+ '',
304
+ ].join('\n');
305
+ const read = (p: string) => (p === 'lib.p8' ? cart : undefined);
306
+ expect(resolveIncludes('#include lib.p8', read)).toBe(
307
+ 'tab0=1\n-->8\ntab1=1',
308
+ );
309
+ expect(resolveIncludes('#include lib.p8:1', read)).toBe('tab1=1');
310
+ expect(resolveIncludes('#include lib.p8:0', read)).toBe('tab0=1');
311
+ });
312
+
293
313
  it('guards against an include cycle (leaves the repeat verbatim)', () => {
294
314
  const files: Record<string, string> = {'a.lua': '#include a.lua'};
295
315
  // Does not infinite-loop; the self-reference is left as-is.
@@ -449,13 +449,36 @@ export function resolveIncludes(
449
449
  if (m === null) return line;
450
450
  const path = m[1] as string;
451
451
  if (seen.has(path)) return line; // cycle guard: leave it
452
- const content = readInclude(path);
453
- if (content === undefined) return line; // unreadable: leave verbatim
452
+ // `#include other.p8` takes that cart's code, `other.p8:n` only tab n.
453
+ const p8 = /^(.+\.p8)(?::(\d+))?$/i.exec(path);
454
+ const raw = readInclude(p8 !== null ? (p8[1] as string) : path);
455
+ if (raw === undefined) return line; // unreadable: leave verbatim
456
+ const content = p8 !== null ? p8Code(raw, p8[2]) : raw;
457
+ if (content === undefined) return line;
454
458
  return resolveIncludes(content, readInclude, new Set([...seen, path]));
455
459
  })
456
460
  .join('\n');
457
461
  }
458
462
 
463
+ /**
464
+ * The code an `#include` of a `.p8` cart pulls in: its `__lua__` section, or
465
+ * only tab `tab` (tabs are separated by `-->8` lines). `undefined` when the
466
+ * text does not parse as a cart or the tab does not exist.
467
+ */
468
+ function p8Code(cartText: string, tab: string | undefined): string | undefined {
469
+ let lua: string | undefined;
470
+ try {
471
+ lua = Cart.parse(cartText).getSection('lua');
472
+ } catch {
473
+ return undefined;
474
+ }
475
+ if (lua === undefined) return undefined;
476
+ const body = lua.replace(/\n$/, '');
477
+ if (tab === undefined) return body;
478
+ const tabs = body.split(/\r?\n-->8\r?\n/);
479
+ return tabs[Number(tab)];
480
+ }
481
+
459
482
  /** The default base filename SHOT names each screenshot (`play0.png`, ...). */
460
483
  export const SHOT_BASENAME = 'play' as const;
461
484
 
@@ -0,0 +1,55 @@
1
+ import type {XvfbProcess} from './display.js';
2
+
3
+ /**
4
+ * Test support (excluded from the build): a controllable fake Xvfb. A test makes
5
+ * it report a display number, write stderr, exit, or fail to spawn, and reads
6
+ * back the signals it received, so virtual-display teardown is provable without
7
+ * a real X server.
8
+ */
9
+ export class FakeXvfb implements XvfbProcess {
10
+ readonly pid = 4242;
11
+ signals: string[] = [];
12
+ exited = false;
13
+ args: string[] = [];
14
+ private fdCb: (c: string) => void = () => {};
15
+ private exitCb: (code: number | null) => void = () => {};
16
+ private errorCb: (e: NodeJS.ErrnoException) => void = () => {};
17
+ private stderrCb: (c: string) => void = () => {};
18
+ /** Exit as soon as SIGTERM arrives (a well-behaved server). */
19
+ exitOnTerm = true;
20
+
21
+ onDisplayFd(cb: (c: string) => void) {
22
+ this.fdCb = cb;
23
+ }
24
+ onStderr(cb: (c: string) => void) {
25
+ this.stderrCb = cb;
26
+ }
27
+ onExit(cb: (code: number | null) => void) {
28
+ this.exitCb = cb;
29
+ }
30
+ onError(cb: (e: NodeJS.ErrnoException) => void) {
31
+ this.errorCb = cb;
32
+ }
33
+ kill(signal: NodeJS.Signals) {
34
+ this.signals.push(signal);
35
+ if (this.exited) return;
36
+ if (signal === 'SIGKILL' || this.exitOnTerm) this.exit(null);
37
+ }
38
+ // drivers
39
+ reportDisplay(n: number) {
40
+ this.fdCb(`${n}\n`);
41
+ }
42
+ writeStderr(s: string) {
43
+ this.stderrCb(s);
44
+ }
45
+ exit(code: number | null) {
46
+ if (this.exited) return;
47
+ this.exited = true;
48
+ setImmediate(() => this.exitCb(code));
49
+ }
50
+ fail(code: string) {
51
+ const e = new Error(code) as NodeJS.ErrnoException;
52
+ e.code = code;
53
+ this.errorCb(e);
54
+ }
55
+ }
@@ -179,3 +179,29 @@ describe('buildRecordHarness', () => {
179
179
  }
180
180
  });
181
181
  });
182
+
183
+ describe('buildRecordHarness: #include carts (the `picopilot init` scaffold)', () => {
184
+ const scaffold = `${HEADER}__lua__\n#include main.lua\n`;
185
+ const mainLua = 'function _update() x=1 end\nfunction _draw() cls() end\n';
186
+
187
+ it('inlines `#include main.lua` so the harness loads from any folder', () => {
188
+ const h = buildRecordHarness(scaffold, {
189
+ readInclude: (p) => (p === 'main.lua' ? mainLua : undefined),
190
+ });
191
+ const lua = Cart.parse(h.cartText).getSection('lua') ?? '';
192
+ // No directive is left for PICO-8 to resolve relative to the harness.
193
+ expect(lua).not.toMatch(/#include/);
194
+ expect(lua).toContain('function _update() x=1 end');
195
+ // The recorder still wraps the (now inlined) cart _update.
196
+ expect(lua.indexOf('x=1')).toBeLessThan(
197
+ lua.indexOf('__pp_rec_update=_update'),
198
+ );
199
+ });
200
+
201
+ it('without a resolver the directive is left as-is (the old, broken-in-a-temp-dir behaviour)', () => {
202
+ const h = buildRecordHarness(scaffold);
203
+ expect(Cart.parse(h.cartText).getSection('lua')).toContain(
204
+ '#include main.lua',
205
+ );
206
+ });
207
+ });
@@ -20,6 +20,7 @@ import {
20
20
  RECORD_WAV_BASENAME,
21
21
  } from './adapter.js';
22
22
  import {SFX_ROW_LENGTH} from '../audio/sfx.js';
23
+ import {resolveIncludes} from './drive.js';
23
24
 
24
25
  /**
25
26
  * A render target: a single SFX slot, a single music PATTERN, or the whole song
@@ -301,11 +302,23 @@ export function buildRenderHarness(
301
302
  * calls the original. This is the record analogue of the debug skill's "add
302
303
  * capture lines, run, revert" recipe, done on a THROWAWAY copy.
303
304
  *
305
+ * The harness runs from a different folder than the source cart (an isolated
306
+ * temp dir), so a relative `#include main.lua` (picopilot's standard scaffold)
307
+ * would not resolve there and the harness would fail to load. Pass
308
+ * `readInclude` (bound to the SOURCE cart's folder, see `makeReadInclude`) and
309
+ * every `#include` is inlined, exactly as the playtest drive transform does, so
310
+ * the harness is self-contained wherever it runs.
311
+ *
304
312
  * @throws {HarnessError} an unparseable cart.
305
313
  */
306
314
  export function buildRecordHarness(
307
315
  cartText: string,
308
- options: {seconds?: number; sentinel?: string; wavBasename?: string} = {},
316
+ options: {
317
+ seconds?: number;
318
+ sentinel?: string;
319
+ wavBasename?: string;
320
+ readInclude?: (includePath: string) => string | undefined;
321
+ } = {},
309
322
  ): RenderHarness {
310
323
  let cart: Cart;
311
324
  try {
@@ -322,7 +335,10 @@ export function buildRecordHarness(
322
335
  const sentinel = options.sentinel ?? DONE_SENTINEL;
323
336
  const wavBasename = options.wavBasename ?? RECORD_WAV_BASENAME;
324
337
 
325
- const existing = cart.getSection('lua') ?? '';
338
+ const existing = resolveIncludes(
339
+ cart.getSection('lua') ?? '',
340
+ options.readInclude,
341
+ );
326
342
  // Append the recorder AFTER the cart's own code. It wraps _update so it works
327
343
  // whether or not the cart defines one, and never edits the cart's own logic.
328
344
  const recorder = [
@@ -6,10 +6,13 @@
6
6
  * - `adapter`: the `Pico8Adapter` interface, `Pico8Result`, the structured
7
7
  * `pico8-not-found` value, and the run-report shapes.
8
8
  * - `sentinel`: the pure sentinel-watch core (CI-testable without the binary).
9
+ * - `display`: the display mode (auto/xvfb/native) + the private virtual
10
+ * display a `-run` session uses on a display-less box (ADR-0017).
9
11
  * - `shell`: the v1 adapter shelling out to native `pico8 -x`, with an
10
12
  * injectable spawn seam for isolation-testing.
11
13
  */
12
14
  export * from './adapter.js';
15
+ export * from './display.js';
13
16
  export * from './drive.js';
14
17
  export * from './harness.js';
15
18
  export * from './sentinel.js';
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The detached daemon process ENTRY (ADR-0011, prd US #6). `playtest start`
2
+ * The detached daemon process ENTRY (ADR-0011, spec US #6). `playtest start`
3
3
  * spawns this file (via `process.execPath [+ execArgv] <this> <configJson>`, so
4
4
  * it re-uses the exact loader the parent ran under, dev `tsx` or built `dist`) as
5
5
  * a DETACHED background process that owns the live driven PICO-8 for the session's
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The playtest session DAEMON ENTRYPOINT (ADR-0011, prd US #6): the small
2
+ * The playtest session DAEMON ENTRYPOINT (ADR-0011, spec US #6): the small
3
3
  * detached process `playtest start` spawns to OWN a live driven PICO-8 for a
4
4
  * session's lifetime. It is invoked via the hidden `playtest __daemon` verb (so it
5
5
  * re-uses the exact same executable/loader the parent ran under, dev `tsx` or