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,304 @@
1
+ import {
2
+ existsSync,
3
+ mkdtempSync,
4
+ readFileSync,
5
+ rmSync,
6
+ writeFileSync,
7
+ } from 'node:fs';
8
+ import {tmpdir} from 'node:os';
9
+ import {dirname, isAbsolute, join, resolve} from 'node:path';
10
+ import {Cart, CartParseError} from '../engine/cart/index.js';
11
+ import {labelHexFromPng, LabelError} from '../engine/gfx/index.js';
12
+ import {
13
+ buildDriveHarness,
14
+ DriveError,
15
+ type DriveOptions,
16
+ type Pico8DriveResult,
17
+ } from '../engine/pico8/index.js';
18
+ import {makeReadInclude} from './cart-includes.js';
19
+
20
+ /**
21
+ * The remedy for both label-image errors (`label-wrong-size`,
22
+ * `label-unsupported-png`): the screenshot `run`/`playtest` already wrote is a
23
+ * valid label as-is, so point at that rather than at an external image tool.
24
+ */
25
+ export const LABEL_FIX_CTA = {
26
+ description:
27
+ 'Pass a picopilot screenshot directly; it is scaled for you (any 128*k square: 128x128, 256x256, 384x384, ...). No image tool needed:',
28
+ commands: [
29
+ {
30
+ command: 'run <cart> --shot-dir shots',
31
+ description:
32
+ 'Capture a frame (the cart calls extcmd("screen")), then pass shots/<name>.png to --label unchanged.',
33
+ },
34
+ {
35
+ command: 'export <cart> <dest> --label shots/<name>.png',
36
+ description:
37
+ 'Bake that screenshot in as the label splash (`serve --label` takes it the same way). playtest screenshots work too.',
38
+ },
39
+ ],
40
+ };
41
+
42
+ /** Where the exported cart's `__label__` came from. */
43
+ export type LabelSource = 'cart' | 'image' | 'frame' | 'none';
44
+
45
+ /** A cart ready to hand to PICO-8's export, plus how to tidy up after it. */
46
+ export interface LabeledCart {
47
+ readonly ok: true;
48
+ /** The cart to export: the original, or a labeled throwaway copy. */
49
+ readonly cartPath: string;
50
+ /**
51
+ * Where the label comes from: the cart's own (`cart`), `--label` (`image`),
52
+ * `--label-frame` (`frame`), or nowhere (`none`, the cart stays labelless).
53
+ */
54
+ readonly labelSource: LabelSource;
55
+ /** Removes the throwaway copy (if one was made). Best-effort, idempotent. */
56
+ readonly cleanup: () => void;
57
+ }
58
+
59
+ /** A label-preparation failure, shaped for incur's `error({...})`. */
60
+ export interface LabelCartFailure {
61
+ readonly ok: false;
62
+ readonly code: string;
63
+ readonly message: string;
64
+ readonly cta?: {
65
+ description: string;
66
+ commands: {command: string; description: string}[];
67
+ };
68
+ }
69
+
70
+ /** The label flags `export` and `serve` share. */
71
+ export interface LabelOptions {
72
+ /** `--label <png>`: an image (a run/playtest screenshot as-is, or 128x128). */
73
+ readonly label?: string;
74
+ /** `--label-frame <n>`: capture frame `n` of the running cart, no input. */
75
+ readonly labelFrame?: number;
76
+ /** The backstop for the `--label-frame` capture run. */
77
+ readonly backstopMs: number;
78
+ }
79
+
80
+ /** The base filename the `--label-frame` capture names its screenshot. */
81
+ const LABEL_FRAME_BASENAME = 'label';
82
+
83
+ /**
84
+ * The `--label` / `--label-frame` step shared by `export` and `serve` (ADR-0013,
85
+ * ADR-0015, ADR-0016): give a labelless cart a `__label__` (the export splash)
86
+ * without an interactive F7 capture.
87
+ *
88
+ * - `--label <png>`: decode an image (a `run`/`playtest` screenshot as-is, any
89
+ * square integer multiple of 128x128, or any 128x128 PNG).
90
+ * - `--label-frame <n>`: RUN the cart headlessly through the playtest drive
91
+ * harness (ADR-0011) with NO input, advance `n` frames, screenshot that frame,
92
+ * and decode it. Needs PICO-8 (`drive`); an arbitrary cart does not have to
93
+ * cooperate, since the harness owns its frame loop.
94
+ *
95
+ * The two flags are mutually exclusive (`label-flags-conflict`). The user's cart
96
+ * is NEVER mutated: a labeled copy is written NEXT TO it (same dir, so
97
+ * `#include main.lua` still resolves) and the caller exports that, then calls
98
+ * `cleanup()`. A cart that ALREADY has a label wins (the flags are a fallback,
99
+ * not an override): it is returned untouched and nothing is run.
100
+ *
101
+ * @param drive the adapter's `drive`, only called for `--label-frame`.
102
+ */
103
+ export async function prepareLabeledCart(
104
+ cartPath: string,
105
+ options: LabelOptions,
106
+ drive: (options: DriveOptions) => Promise<Pico8DriveResult>,
107
+ ): Promise<LabeledCart | LabelCartFailure> {
108
+ const keep = (labelSource: LabelSource): LabeledCart => ({
109
+ ok: true,
110
+ cartPath,
111
+ labelSource,
112
+ cleanup: () => {},
113
+ });
114
+
115
+ if (options.label !== undefined && options.labelFrame !== undefined) {
116
+ return {
117
+ ok: false,
118
+ code: 'label-flags-conflict',
119
+ message:
120
+ 'pass either --label <png> (an image) or --label-frame <n> (capture a frame of the running cart), not both',
121
+ };
122
+ }
123
+
124
+ // With no label flag the cart is only read to REPORT `labelSource`; anything
125
+ // odd about it (unreadable, unparseable) is left for PICO-8 to judge, exactly
126
+ // as before the label flags existed.
127
+ const noFlag =
128
+ options.label === undefined && options.labelFrame === undefined;
129
+ let cart: Cart;
130
+ try {
131
+ cart = Cart.parse(readFileSync(cartPath, 'utf8'));
132
+ } catch (e) {
133
+ if (noFlag) return keep('none');
134
+ if (e instanceof CartParseError) {
135
+ return {
136
+ ok: false,
137
+ code: `cart-${e.code}`,
138
+ message: `could not parse the cart to inject a label: ${e.message}`,
139
+ };
140
+ }
141
+ throw e;
142
+ }
143
+
144
+ const existingLabel = cart.getSection('label');
145
+ if (existingLabel !== undefined && existingLabel.trim() !== '') {
146
+ return keep('cart');
147
+ }
148
+
149
+ let png: Uint8Array;
150
+ let labelSource: LabelSource;
151
+ if (options.label !== undefined) {
152
+ const labelPath = isAbsolute(options.label)
153
+ ? options.label
154
+ : resolve(process.cwd(), options.label);
155
+ if (!existsSync(labelPath)) {
156
+ return {
157
+ ok: false,
158
+ code: 'label-not-found',
159
+ message: `no label image at ${labelPath}`,
160
+ };
161
+ }
162
+ png = readFileSync(labelPath);
163
+ labelSource = 'image';
164
+ } else if (options.labelFrame !== undefined) {
165
+ const captured = await captureFrame(
166
+ cartPath,
167
+ options.labelFrame,
168
+ options.backstopMs,
169
+ drive,
170
+ );
171
+ if (!captured.ok) return captured;
172
+ png = captured.png;
173
+ labelSource = 'frame';
174
+ } else {
175
+ return keep('none');
176
+ }
177
+
178
+ let hex: string;
179
+ try {
180
+ hex = labelHexFromPng(png);
181
+ } catch (e) {
182
+ if (e instanceof LabelError) {
183
+ return {
184
+ ok: false,
185
+ code: `label-${e.code}`,
186
+ message: e.message,
187
+ cta: LABEL_FIX_CTA,
188
+ };
189
+ }
190
+ throw e;
191
+ }
192
+
193
+ cart.setSection('label', hex);
194
+ const copy = join(
195
+ dirname(cartPath),
196
+ `.picopilot-export-${process.pid}-${Date.now()}.p8`,
197
+ );
198
+ writeFileSync(copy, cart.serialize());
199
+ return {
200
+ ok: true,
201
+ cartPath: copy,
202
+ labelSource,
203
+ cleanup: () => {
204
+ try {
205
+ rmSync(copy, {force: true});
206
+ } catch {
207
+ // A leftover dotfile is not fatal.
208
+ }
209
+ },
210
+ };
211
+ }
212
+
213
+ /**
214
+ * Runs the cart through the playtest drive harness with NO input for `frame + 1`
215
+ * frames, screenshotting after frame `frame` (0 = the first drawn frame), and
216
+ * returns that PNG. The driven cart and PICO-8's screenshot dir live in a temp
217
+ * dir that is removed before returning.
218
+ */
219
+ async function captureFrame(
220
+ cartPath: string,
221
+ frame: number,
222
+ backstopMs: number,
223
+ drive: (options: DriveOptions) => Promise<Pico8DriveResult>,
224
+ ): Promise<{ok: true; png: Uint8Array} | LabelCartFailure> {
225
+ let harness;
226
+ try {
227
+ harness = buildDriveHarness(readFileSync(cartPath, 'utf8'), {
228
+ presses: [],
229
+ frames: frame + 1,
230
+ shotFrames: [frame],
231
+ shotBasename: LABEL_FRAME_BASENAME,
232
+ readInclude: makeReadInclude(cartPath),
233
+ });
234
+ } catch (e) {
235
+ if (e instanceof DriveError) {
236
+ return {ok: false, code: e.code, message: e.message};
237
+ }
238
+ throw e;
239
+ }
240
+
241
+ const shotDir = mkdtempSync(join(tmpdir(), 'picopilot-label-frame-'));
242
+ try {
243
+ const drivenCart = join(shotDir, 'driven.p8');
244
+ writeFileSync(drivenCart, harness.cartText);
245
+ const result = await drive({
246
+ cartPath: drivenCart,
247
+ shotDir,
248
+ blocks: harness.blocks,
249
+ backstopMs,
250
+ });
251
+ if (!result.ok) {
252
+ return {
253
+ ok: false,
254
+ code: result.reason,
255
+ message: `PICO-8 is not installed, so --label-frame cannot capture a frame. ${result.remedy} (needs: ${result.needs.join(', ')})`,
256
+ };
257
+ }
258
+ const shot = result.value.screenshots[0];
259
+ if (shot === undefined) {
260
+ const {exitReason} = result.value;
261
+ const tail = result.value.printh.trim().split('\n').slice(-5).join('\n');
262
+ const commands = [
263
+ {
264
+ command: 'verify',
265
+ description: 'Run the static gate (tokens + lint + integrity).',
266
+ },
267
+ {
268
+ command: 'playtest start <cart>',
269
+ description:
270
+ 'Reproduce the SAME no-input frame to look at it: start a paused session (nothing held), then step and shoot.',
271
+ },
272
+ {
273
+ command: `playtest step <id> --frames ${frame + 1}`,
274
+ description: `Advance to frame ${frame}, as --label-frame ${frame} does.`,
275
+ },
276
+ {
277
+ command: 'playtest shot <id>',
278
+ description:
279
+ 'Screenshot it; if it looks right, pass that PNG with --label <png> (then `playtest stop <id>`).',
280
+ },
281
+ ];
282
+ if (exitReason === 'timeout') {
283
+ commands.push({
284
+ command: `export <cart> --label-frame ${frame} --backstop-ms <more>`,
285
+ description:
286
+ 'The capture hit the backstop (it runs about one frame per PICO-8 tick): allow more time, or pick an earlier frame.',
287
+ });
288
+ }
289
+ return {
290
+ ok: false,
291
+ code: 'label-frame-not-captured',
292
+ message: `the cart produced no frame ${frame} screenshot (exit: ${exitReason}).${tail === '' ? '' : ` Last output:\n${tail}`}`,
293
+ cta: {
294
+ description:
295
+ 'The cart did not reach that frame. Check it boots, or look at the frame first:',
296
+ commands,
297
+ },
298
+ };
299
+ }
300
+ return {ok: true, png: readFileSync(shot)};
301
+ } finally {
302
+ rmSync(shotDir, {recursive: true, force: true});
303
+ }
304
+ }
@@ -34,6 +34,12 @@ afterEach(() => {
34
34
  /** A stub adapter whose `lint` returns whatever the test wires; `count`/`minify` unused here. */
35
35
  function stubAdapter(result: ShrinkoResult<LintReport>): ShrinkoAdapter {
36
36
  return {
37
+ async exportSpritesheet() {
38
+ throw new Error('exportSpritesheet not used');
39
+ },
40
+ async importSpritesheet() {
41
+ throw new Error('importSpritesheet not used');
42
+ },
37
43
  async count() {
38
44
  throw new Error('not used');
39
45
  },
@@ -1,16 +1,17 @@
1
1
  import {existsSync} from 'node:fs';
2
2
  import {isAbsolute, resolve} from 'node:path';
3
3
  import {type Cli, z} from 'incur';
4
+ import {type ShrinkoAdapter} from '../engine/shrinko/index.js';
4
5
  import {
5
- type ShrinkoAdapter,
6
- ShellShrinkoAdapter,
7
- } from '../engine/shrinko/index.js';
6
+ createShellShrinkoFactory,
7
+ type ShrinkoAdapterFactory,
8
+ } from './shrinko-env.js';
8
9
 
9
10
  /** See {@link import('./tokens.js').ShrinkoAdapterFactory}: lint reuses the same seam. */
10
- export type ShrinkoAdapterFactory = (env: NodeJS.ProcessEnv) => ShrinkoAdapter;
11
+ export type {ShrinkoAdapterFactory} from './shrinko-env.js';
11
12
 
12
- const defaultAdapterFactory: ShrinkoAdapterFactory = (env) =>
13
- new ShellShrinkoAdapter({env});
13
+ const defaultAdapterFactory: ShrinkoAdapterFactory =
14
+ createShellShrinkoFactory();
14
15
 
15
16
  /**
16
17
  * Registers `picopilot lint` (US #4): surface PICO-8-specific issues (undefined
@@ -41,6 +41,12 @@ function stubAdapter(
41
41
  seen?: {outPath?: string},
42
42
  ): ShrinkoAdapter {
43
43
  return {
44
+ async exportSpritesheet() {
45
+ throw new Error('exportSpritesheet not used');
46
+ },
47
+ async importSpritesheet() {
48
+ throw new Error('importSpritesheet not used');
49
+ },
44
50
  async count() {
45
51
  throw new Error('not used');
46
52
  },
@@ -1,16 +1,17 @@
1
1
  import {existsSync} from 'node:fs';
2
2
  import {dirname, isAbsolute, join, resolve} from 'node:path';
3
3
  import {type Cli, z} from 'incur';
4
+ import {type ShrinkoAdapter} from '../engine/shrinko/index.js';
4
5
  import {
5
- type ShrinkoAdapter,
6
- ShellShrinkoAdapter,
7
- } from '../engine/shrinko/index.js';
6
+ createShellShrinkoFactory,
7
+ type ShrinkoAdapterFactory,
8
+ } from './shrinko-env.js';
8
9
 
9
10
  /** See {@link import('./tokens.js').ShrinkoAdapterFactory}: minify reuses the same seam. */
10
- export type ShrinkoAdapterFactory = (env: NodeJS.ProcessEnv) => ShrinkoAdapter;
11
+ export type {ShrinkoAdapterFactory} from './shrinko-env.js';
11
12
 
12
- const defaultAdapterFactory: ShrinkoAdapterFactory = (env) =>
13
- new ShellShrinkoAdapter({env});
13
+ const defaultAdapterFactory: ShrinkoAdapterFactory =
14
+ createShellShrinkoFactory();
14
15
 
15
16
  /**
16
17
  * Derives the default output path `<name>.min.p8` next to the source cart:
@@ -0,0 +1,124 @@
1
+ import {z} from 'incur';
2
+ import {
3
+ DEFAULT_DISPLAY_MODE,
4
+ type DisplayMode,
5
+ isDisplayMode,
6
+ type Pico8Adapter,
7
+ pico8ChildEnv,
8
+ ShellPico8Adapter,
9
+ type SpawnRunner,
10
+ type VirtualDisplayDeps,
11
+ } from '../engine/pico8/index.js';
12
+
13
+ /**
14
+ * The env schema every PICO-8-gated command declares. These are the values
15
+ * picopilot itself RESOLVES (incur reads them from its env source, which tests
16
+ * override); they are NOT the whole child environment. The child gets the
17
+ * parent environment with these overlaid ({@link pico8ChildEnv}), so PICO-8
18
+ * still sees DISPLAY, HOME, XDG_RUNTIME_DIR, SDL_* and the rest.
19
+ */
20
+ export const PICO8_ENV = z.object({
21
+ PICO8_PATH: z
22
+ .string()
23
+ .optional()
24
+ .describe('Explicit path to the PICO-8 binary (else `pico8` on PATH).'),
25
+ PATH: z.string().optional().describe('Used to locate `pico8`.'),
26
+ });
27
+
28
+ /** What a command knows beyond the env when it builds an adapter. */
29
+ export interface Pico8AdapterContext {
30
+ /** The resolved display mode for `-run` sessions (ADR-0017). */
31
+ readonly display: DisplayMode;
32
+ }
33
+
34
+ /**
35
+ * Injects the PICO-8 adapter a command uses. A test passes a stub to drive the
36
+ * sentinel/backstop/absent paths without the paid binary; the default is
37
+ * {@link createShellAdapterFactory}.
38
+ */
39
+ export type Pico8AdapterFactory = (
40
+ env: NodeJS.ProcessEnv,
41
+ context?: Pico8AdapterContext,
42
+ ) => Pico8Adapter;
43
+
44
+ /** Dependencies for {@link createShellAdapterFactory}, injectable for tests. */
45
+ export interface ShellAdapterFactoryDeps {
46
+ /** The parent environment PICO-8 inherits (defaults to `process.env`). */
47
+ readonly parentEnv?: NodeJS.ProcessEnv;
48
+ /** The PICO-8 spawn seam (defaults to the real spawn). */
49
+ readonly spawn?: SpawnRunner;
50
+ /** The virtual-display seam (Xvfb lookup + spawn). */
51
+ readonly virtualDisplay?: VirtualDisplayDeps;
52
+ }
53
+
54
+ /**
55
+ * The default adapter factory: the shell adapter over the user's PICO-8, with
56
+ * the child env = parent env + the command's resolved values, and the display
57
+ * mode from the context.
58
+ */
59
+ export function createShellAdapterFactory(
60
+ deps: ShellAdapterFactoryDeps = {},
61
+ ): Pico8AdapterFactory {
62
+ return (env, context) =>
63
+ new ShellPico8Adapter({
64
+ env: pico8ChildEnv(
65
+ env as Record<string, string | undefined>,
66
+ deps.parentEnv ?? process.env,
67
+ ),
68
+ spawn: deps.spawn,
69
+ display: context?.display ?? DEFAULT_DISPLAY_MODE,
70
+ virtualDisplay: deps.virtualDisplay,
71
+ });
72
+ }
73
+
74
+ /**
75
+ * The display mode a command handler sees. The root middleware in `createCli`
76
+ * resolves it (flag > env > config > default) into the `display` var; a command
77
+ * mounted on a bare CLI (some tests) has no var and gets the default.
78
+ */
79
+ export function displayModeOf(vars: unknown): DisplayMode {
80
+ const value =
81
+ vars !== null && typeof vars === 'object'
82
+ ? (vars as {display?: unknown}).display
83
+ : undefined;
84
+ return isDisplayMode(value) ? value : DEFAULT_DISPLAY_MODE;
85
+ }
86
+
87
+ /**
88
+ * The `error(...)` payload for a `display-unavailable` result (ADR-0017): the
89
+ * session needs a display, there is none, and no Xvfb is installed to provide a
90
+ * virtual one. The message and CTA name the fix, so the agent never has to
91
+ * discover it by a silent timeout.
92
+ */
93
+ export function displayUnavailableError(
94
+ what: string,
95
+ result: {detail: string; remedy: string},
96
+ ) {
97
+ return {
98
+ code: 'display-unavailable' as const,
99
+ message: `${what} needs a display (a real audio+video -run session): ${result.detail}. Remedy: ${result.remedy}.`,
100
+ exitCode: 1,
101
+ cta: {
102
+ description: `No display and no Xvfb on this machine. Fix: ${result.remedy}. Then re-run; picopilot starts a private virtual display by itself (--display auto). Check what this machine can do:`,
103
+ commands: [
104
+ {
105
+ command: 'doctor',
106
+ description:
107
+ 'Report PICO-8, shrinko8 and display capabilities, and which commands are available here.',
108
+ },
109
+ ],
110
+ },
111
+ };
112
+ }
113
+
114
+ /** The `error(...)` payload for a `pico8-not-found` result (the exact remedy + needs). */
115
+ export function pico8NotFoundError(result: {
116
+ remedy: string;
117
+ needs: readonly string[];
118
+ }) {
119
+ return {
120
+ code: 'pico8-not-found' as const,
121
+ message: `PICO-8 is not installed. ${result.remedy} (needs: ${result.needs.join(', ')})`,
122
+ exitCode: 1,
123
+ };
124
+ }
@@ -58,6 +58,9 @@ function stubAdapter(
58
58
  seen?: {options?: DriveOptions},
59
59
  ): Pico8Adapter {
60
60
  return {
61
+ async export() {
62
+ throw new Error('export not used');
63
+ },
61
64
  async run() {
62
65
  throw new Error('run not used by playtest');
63
66
  },
@@ -6,7 +6,7 @@ import {
6
6
  writeFileSync,
7
7
  } from 'node:fs';
8
8
  import {tmpdir} from 'node:os';
9
- import {dirname, isAbsolute, join, resolve} from 'node:path';
9
+ import {isAbsolute, join, resolve} from 'node:path';
10
10
  import {type Cli, Cli as IncurCli, z} from 'incur';
11
11
  import {
12
12
  buildDriveHarness,
@@ -22,12 +22,17 @@ import {
22
22
  SessionRegistry,
23
23
  sessionExists,
24
24
  SHOT_BASENAME,
25
- ShellPico8Adapter,
26
25
  spawnSessionDaemon,
27
26
  type SessionRequest,
28
27
  type SessionResponse,
29
28
  type SessionResponseValue,
30
29
  } from '../engine/pico8/index.js';
30
+ import {
31
+ createShellAdapterFactory,
32
+ PICO8_ENV,
33
+ type Pico8AdapterFactory,
34
+ } from './pico8-env.js';
35
+ import {makeReadInclude} from './cart-includes.js';
31
36
 
32
37
  /**
33
38
  * Injects the PICO-8 adapter `playtest` uses (defaults to the shell adapter over
@@ -35,10 +40,9 @@ import {
35
40
  * stub to drive the drive-orchestration + the absent path WITHOUT the paid
36
41
  * binary, and to isolate the child `env` (its `PICO8_PATH`/`PATH`).
37
42
  */
38
- export type Pico8AdapterFactory = (env: NodeJS.ProcessEnv) => Pico8Adapter;
43
+ export type {Pico8AdapterFactory} from './pico8-env.js';
39
44
 
40
- const defaultAdapterFactory: Pico8AdapterFactory = (env) =>
41
- new ShellPico8Adapter({env});
45
+ const defaultAdapterFactory: Pico8AdapterFactory = createShellAdapterFactory();
42
46
 
43
47
  /**
44
48
  * The default backstop: kill a driven cart that never signals/exits after this
@@ -57,28 +61,6 @@ function resolvePath(p: string): string {
57
61
  return isAbsolute(p) ? p : resolve(process.cwd(), p);
58
62
  }
59
63
 
60
- /**
61
- * A `#include` resolver for the drive-transform, bound to the SOURCE cart's dir:
62
- * picopilot's standard scaffold is `main.p8` = `#include main.lua`, and the
63
- * driven cart runs from a throwaway temp dir where `main.lua` is absent, so the
64
- * transform inlines includes to be self-contained. Reads the included file
65
- * relative to the cart (absolute paths honoured); returns `undefined` if it
66
- * cannot be read (the directive is then left verbatim).
67
- */
68
- function makeReadInclude(cartPath: string): (p: string) => string | undefined {
69
- const base = dirname(cartPath);
70
- return (includePath) => {
71
- const full = isAbsolute(includePath)
72
- ? includePath
73
- : join(base, includePath);
74
- try {
75
- return readFileSync(full, 'utf8');
76
- } catch {
77
- return undefined;
78
- }
79
- };
80
- }
81
-
82
64
  /** A small sleep for the `start` readiness poll. */
83
65
  function sleep(ms: number): Promise<void> {
84
66
  return new Promise((r) => setTimeout(r, ms));
@@ -240,13 +222,7 @@ function buildPlaytestGroup(adapterFactory: Pico8AdapterFactory): Cli.Cli {
240
222
  }
241
223
 
242
224
  /** The env schema the whole `playtest` group shares (locates the binary, isolated). */
243
- const PLAYTEST_ENV = z.object({
244
- PICO8_PATH: z
245
- .string()
246
- .optional()
247
- .describe('Explicit path to the PICO-8 binary (else `pico8` on PATH).'),
248
- PATH: z.string().optional().describe('Used to locate `pico8`.'),
249
- });
225
+ const PLAYTEST_ENV = PICO8_ENV;
250
226
 
251
227
  /**
252
228
  * The ONE-SHOT root handler (unchanged behaviour from the one-shot task): build