picopilot 0.0.0 → 0.1.1

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 (334) hide show
  1. package/LICENSE +661 -0
  2. package/dist/bin.d.ts +3 -0
  3. package/dist/bin.d.ts.map +1 -0
  4. package/dist/bin.js +4 -0
  5. package/dist/bin.js.map +1 -0
  6. package/dist/cli.d.ts +15 -0
  7. package/dist/cli.d.ts.map +1 -0
  8. package/dist/cli.js +59 -0
  9. package/dist/cli.js.map +1 -0
  10. package/dist/commands/audio.d.ts +17 -0
  11. package/dist/commands/audio.d.ts.map +1 -0
  12. package/dist/commands/audio.js +315 -0
  13. package/dist/commands/audio.js.map +1 -0
  14. package/dist/commands/export.d.ts +36 -0
  15. package/dist/commands/export.d.ts.map +1 -0
  16. package/dist/commands/export.js +257 -0
  17. package/dist/commands/export.js.map +1 -0
  18. package/dist/commands/gfx.d.ts +28 -0
  19. package/dist/commands/gfx.d.ts.map +1 -0
  20. package/dist/commands/gfx.js +521 -0
  21. package/dist/commands/gfx.js.map +1 -0
  22. package/dist/commands/init.d.ts +34 -0
  23. package/dist/commands/init.d.ts.map +1 -0
  24. package/dist/commands/init.js +151 -0
  25. package/dist/commands/init.js.map +1 -0
  26. package/dist/commands/lint.d.ts +20 -0
  27. package/dist/commands/lint.d.ts.map +1 -0
  28. package/dist/commands/lint.js +90 -0
  29. package/dist/commands/lint.js.map +1 -0
  30. package/dist/commands/minify.d.ts +20 -0
  31. package/dist/commands/minify.d.ts.map +1 -0
  32. package/dist/commands/minify.js +129 -0
  33. package/dist/commands/minify.js.map +1 -0
  34. package/dist/commands/music.d.ts +13 -0
  35. package/dist/commands/music.d.ts.map +1 -0
  36. package/dist/commands/music.js +241 -0
  37. package/dist/commands/music.js.map +1 -0
  38. package/dist/commands/playtest.d.ts +29 -0
  39. package/dist/commands/playtest.d.ts.map +1 -0
  40. package/dist/commands/playtest.js +631 -0
  41. package/dist/commands/playtest.js.map +1 -0
  42. package/dist/commands/run.d.ts +30 -0
  43. package/dist/commands/run.d.ts.map +1 -0
  44. package/dist/commands/run.js +224 -0
  45. package/dist/commands/run.js.map +1 -0
  46. package/dist/commands/serve.d.ts +50 -0
  47. package/dist/commands/serve.d.ts.map +1 -0
  48. package/dist/commands/serve.js +213 -0
  49. package/dist/commands/serve.js.map +1 -0
  50. package/dist/commands/sfx.d.ts +13 -0
  51. package/dist/commands/sfx.d.ts.map +1 -0
  52. package/dist/commands/sfx.js +254 -0
  53. package/dist/commands/sfx.js.map +1 -0
  54. package/dist/commands/tokens.d.ts +36 -0
  55. package/dist/commands/tokens.d.ts.map +1 -0
  56. package/dist/commands/tokens.js +129 -0
  57. package/dist/commands/tokens.js.map +1 -0
  58. package/dist/commands/verify.d.ts +57 -0
  59. package/dist/commands/verify.d.ts.map +1 -0
  60. package/dist/commands/verify.js +213 -0
  61. package/dist/commands/verify.js.map +1 -0
  62. package/dist/commands/version.d.ts +12 -0
  63. package/dist/commands/version.d.ts.map +1 -0
  64. package/dist/commands/version.js +24 -0
  65. package/dist/commands/version.js.map +1 -0
  66. package/dist/engine/audio/index.d.ts +14 -0
  67. package/dist/engine/audio/index.d.ts.map +1 -0
  68. package/dist/engine/audio/index.js +14 -0
  69. package/dist/engine/audio/index.js.map +1 -0
  70. package/dist/engine/audio/music.d.ts +116 -0
  71. package/dist/engine/audio/music.d.ts.map +1 -0
  72. package/dist/engine/audio/music.js +138 -0
  73. package/dist/engine/audio/music.js.map +1 -0
  74. package/dist/engine/audio/section.d.ts +34 -0
  75. package/dist/engine/audio/section.d.ts.map +1 -0
  76. package/dist/engine/audio/section.js +67 -0
  77. package/dist/engine/audio/section.js.map +1 -0
  78. package/dist/engine/audio/sfx.d.ts +141 -0
  79. package/dist/engine/audio/sfx.d.ts.map +1 -0
  80. package/dist/engine/audio/sfx.js +506 -0
  81. package/dist/engine/audio/sfx.js.map +1 -0
  82. package/dist/engine/cart/cart.d.ts +75 -0
  83. package/dist/engine/cart/cart.d.ts.map +1 -0
  84. package/dist/engine/cart/cart.js +153 -0
  85. package/dist/engine/cart/cart.js.map +1 -0
  86. package/dist/engine/cart/errors.d.ts +29 -0
  87. package/dist/engine/cart/errors.d.ts.map +1 -0
  88. package/dist/engine/cart/errors.js +26 -0
  89. package/dist/engine/cart/errors.js.map +1 -0
  90. package/dist/engine/cart/gfx.d.ts +79 -0
  91. package/dist/engine/cart/gfx.d.ts.map +1 -0
  92. package/dist/engine/cart/gfx.js +171 -0
  93. package/dist/engine/cart/gfx.js.map +1 -0
  94. package/dist/engine/cart/index.d.ts +15 -0
  95. package/dist/engine/cart/index.d.ts.map +1 -0
  96. package/dist/engine/cart/index.js +15 -0
  97. package/dist/engine/cart/index.js.map +1 -0
  98. package/dist/engine/cart/map.d.ts +46 -0
  99. package/dist/engine/cart/map.d.ts.map +1 -0
  100. package/dist/engine/cart/map.js +118 -0
  101. package/dist/engine/cart/map.js.map +1 -0
  102. package/dist/engine/config/config.d.ts +74 -0
  103. package/dist/engine/config/config.d.ts.map +1 -0
  104. package/dist/engine/config/config.js +103 -0
  105. package/dist/engine/config/config.js.map +1 -0
  106. package/dist/engine/config/index.d.ts +7 -0
  107. package/dist/engine/config/index.d.ts.map +1 -0
  108. package/dist/engine/config/index.js +7 -0
  109. package/dist/engine/config/index.js.map +1 -0
  110. package/dist/engine/gfx/grid.d.ts +51 -0
  111. package/dist/engine/gfx/grid.d.ts.map +1 -0
  112. package/dist/engine/gfx/grid.js +116 -0
  113. package/dist/engine/gfx/grid.js.map +1 -0
  114. package/dist/engine/gfx/index.d.ts +17 -0
  115. package/dist/engine/gfx/index.d.ts.map +1 -0
  116. package/dist/engine/gfx/index.js +17 -0
  117. package/dist/engine/gfx/index.js.map +1 -0
  118. package/dist/engine/gfx/label.d.ts +34 -0
  119. package/dist/engine/gfx/label.d.ts.map +1 -0
  120. package/dist/engine/gfx/label.js +190 -0
  121. package/dist/engine/gfx/label.js.map +1 -0
  122. package/dist/engine/gfx/overlap.d.ts +42 -0
  123. package/dist/engine/gfx/overlap.d.ts.map +1 -0
  124. package/dist/engine/gfx/overlap.js +57 -0
  125. package/dist/engine/gfx/overlap.js.map +1 -0
  126. package/dist/engine/gfx/palette.d.ts +29 -0
  127. package/dist/engine/gfx/palette.d.ts.map +1 -0
  128. package/dist/engine/gfx/palette.js +46 -0
  129. package/dist/engine/gfx/palette.js.map +1 -0
  130. package/dist/engine/gfx/png.d.ts +31 -0
  131. package/dist/engine/gfx/png.d.ts.map +1 -0
  132. package/dist/engine/gfx/png.js +165 -0
  133. package/dist/engine/gfx/png.js.map +1 -0
  134. package/dist/engine/gfx/render.d.ts +42 -0
  135. package/dist/engine/gfx/render.d.ts.map +1 -0
  136. package/dist/engine/gfx/render.js +72 -0
  137. package/dist/engine/gfx/render.js.map +1 -0
  138. package/dist/engine/index.d.ts +22 -0
  139. package/dist/engine/index.d.ts.map +1 -0
  140. package/dist/engine/index.js +22 -0
  141. package/dist/engine/index.js.map +1 -0
  142. package/dist/engine/pico8/adapter.d.ts +267 -0
  143. package/dist/engine/pico8/adapter.d.ts.map +1 -0
  144. package/dist/engine/pico8/adapter.js +37 -0
  145. package/dist/engine/pico8/adapter.js.map +1 -0
  146. package/dist/engine/pico8/drive.d.ts +308 -0
  147. package/dist/engine/pico8/drive.d.ts.map +1 -0
  148. package/dist/engine/pico8/drive.js +581 -0
  149. package/dist/engine/pico8/drive.js.map +1 -0
  150. package/dist/engine/pico8/harness.d.ts +146 -0
  151. package/dist/engine/pico8/harness.d.ts.map +1 -0
  152. package/dist/engine/pico8/harness.js +251 -0
  153. package/dist/engine/pico8/harness.js.map +1 -0
  154. package/dist/engine/pico8/index.d.ts +21 -0
  155. package/dist/engine/pico8/index.d.ts.map +1 -0
  156. package/dist/engine/pico8/index.js +21 -0
  157. package/dist/engine/pico8/index.js.map +1 -0
  158. package/dist/engine/pico8/sentinel.d.ts +63 -0
  159. package/dist/engine/pico8/sentinel.d.ts.map +1 -0
  160. package/dist/engine/pico8/sentinel.js +86 -0
  161. package/dist/engine/pico8/sentinel.js.map +1 -0
  162. package/dist/engine/pico8/session-daemon-entry.d.ts +10 -0
  163. package/dist/engine/pico8/session-daemon-entry.d.ts.map +1 -0
  164. package/dist/engine/pico8/session-daemon-entry.js +11 -0
  165. package/dist/engine/pico8/session-daemon-entry.js.map +1 -0
  166. package/dist/engine/pico8/session-daemon-main.d.ts +44 -0
  167. package/dist/engine/pico8/session-daemon-main.d.ts.map +1 -0
  168. package/dist/engine/pico8/session-daemon-main.js +71 -0
  169. package/dist/engine/pico8/session-daemon-main.js.map +1 -0
  170. package/dist/engine/pico8/session-daemon.d.ts +68 -0
  171. package/dist/engine/pico8/session-daemon.d.ts.map +1 -0
  172. package/dist/engine/pico8/session-daemon.js +264 -0
  173. package/dist/engine/pico8/session-daemon.js.map +1 -0
  174. package/dist/engine/pico8/session.d.ts +140 -0
  175. package/dist/engine/pico8/session.d.ts.map +1 -0
  176. package/dist/engine/pico8/session.js +250 -0
  177. package/dist/engine/pico8/session.js.map +1 -0
  178. package/dist/engine/pico8/shell.d.ts +103 -0
  179. package/dist/engine/pico8/shell.d.ts.map +1 -0
  180. package/dist/engine/pico8/shell.js +324 -0
  181. package/dist/engine/pico8/shell.js.map +1 -0
  182. package/dist/engine/pico8/supervisor.d.ts +137 -0
  183. package/dist/engine/pico8/supervisor.d.ts.map +1 -0
  184. package/dist/engine/pico8/supervisor.js +140 -0
  185. package/dist/engine/pico8/supervisor.js.map +1 -0
  186. package/dist/engine/scaffold/index.d.ts +8 -0
  187. package/dist/engine/scaffold/index.d.ts.map +1 -0
  188. package/dist/engine/scaffold/index.js +8 -0
  189. package/dist/engine/scaffold/index.js.map +1 -0
  190. package/dist/engine/scaffold/scaffold.d.ts +49 -0
  191. package/dist/engine/scaffold/scaffold.d.ts.map +1 -0
  192. package/dist/engine/scaffold/scaffold.js +259 -0
  193. package/dist/engine/scaffold/scaffold.js.map +1 -0
  194. package/dist/engine/shrinko/adapter.d.ts +167 -0
  195. package/dist/engine/shrinko/adapter.d.ts.map +1 -0
  196. package/dist/engine/shrinko/adapter.js +107 -0
  197. package/dist/engine/shrinko/adapter.js.map +1 -0
  198. package/dist/engine/shrinko/index.d.ts +13 -0
  199. package/dist/engine/shrinko/index.d.ts.map +1 -0
  200. package/dist/engine/shrinko/index.js +13 -0
  201. package/dist/engine/shrinko/index.js.map +1 -0
  202. package/dist/engine/shrinko/shell.d.ts +69 -0
  203. package/dist/engine/shrinko/shell.d.ts.map +1 -0
  204. package/dist/engine/shrinko/shell.js +156 -0
  205. package/dist/engine/shrinko/shell.js.map +1 -0
  206. package/dist/engine/skills/index.d.ts +8 -0
  207. package/dist/engine/skills/index.d.ts.map +1 -0
  208. package/dist/engine/skills/index.js +8 -0
  209. package/dist/engine/skills/index.js.map +1 -0
  210. package/dist/engine/skills/skills.d.ts +104 -0
  211. package/dist/engine/skills/skills.d.ts.map +1 -0
  212. package/dist/engine/skills/skills.js +149 -0
  213. package/dist/engine/skills/skills.js.map +1 -0
  214. package/dist/index.d.ts +10 -0
  215. package/dist/index.d.ts.map +1 -0
  216. package/dist/index.js +10 -0
  217. package/dist/index.js.map +1 -0
  218. package/dist/version.d.ts +2 -0
  219. package/dist/version.d.ts.map +1 -0
  220. package/dist/version.js +19 -0
  221. package/dist/version.js.map +1 -0
  222. package/package.json +48 -2
  223. package/src/bin.ts +4 -0
  224. package/src/cli.ts +61 -0
  225. package/src/commands/audio.test.ts +280 -0
  226. package/src/commands/audio.ts +410 -0
  227. package/src/commands/export.test.ts +310 -0
  228. package/src/commands/export.ts +316 -0
  229. package/src/commands/gfx-io.test.ts +178 -0
  230. package/src/commands/gfx.test.ts +470 -0
  231. package/src/commands/gfx.ts +645 -0
  232. package/src/commands/init.test.ts +482 -0
  233. package/src/commands/init.ts +193 -0
  234. package/src/commands/lint.test.ts +137 -0
  235. package/src/commands/lint.ts +117 -0
  236. package/src/commands/minify.test.ts +166 -0
  237. package/src/commands/minify.ts +156 -0
  238. package/src/commands/music.test.ts +272 -0
  239. package/src/commands/music.ts +288 -0
  240. package/src/commands/playtest.test.ts +317 -0
  241. package/src/commands/playtest.ts +773 -0
  242. package/src/commands/run.test.ts +264 -0
  243. package/src/commands/run.ts +285 -0
  244. package/src/commands/serve.test.ts +227 -0
  245. package/src/commands/serve.ts +272 -0
  246. package/src/commands/sfx.test.ts +349 -0
  247. package/src/commands/sfx.ts +300 -0
  248. package/src/commands/tokens.test.ts +212 -0
  249. package/src/commands/tokens.ts +168 -0
  250. package/src/commands/verify.test.ts +238 -0
  251. package/src/commands/verify.ts +256 -0
  252. package/src/commands/version.test.ts +70 -0
  253. package/src/commands/version.ts +24 -0
  254. package/src/engine/audio/index.ts +47 -0
  255. package/src/engine/audio/music.test.ts +188 -0
  256. package/src/engine/audio/music.ts +218 -0
  257. package/src/engine/audio/section.test.ts +124 -0
  258. package/src/engine/audio/section.ts +79 -0
  259. package/src/engine/audio/sfx.test.ts +410 -0
  260. package/src/engine/audio/sfx.ts +694 -0
  261. package/src/engine/cart/cart.test.ts +304 -0
  262. package/src/engine/cart/cart.ts +211 -0
  263. package/src/engine/cart/errors.ts +39 -0
  264. package/src/engine/cart/fixtures/crlf.p8 +6 -0
  265. package/src/engine/cart/fixtures/full.p8 +174 -0
  266. package/src/engine/cart/fixtures/header-only.p8 +2 -0
  267. package/src/engine/cart/fixtures/malformed-no-header.p8 +2 -0
  268. package/src/engine/cart/fixtures/minimal.p8 +10 -0
  269. package/src/engine/cart/fixtures/no-trailing-newline.p8 +6 -0
  270. package/src/engine/cart/gfx.ts +195 -0
  271. package/src/engine/cart/index.ts +27 -0
  272. package/src/engine/cart/map.ts +134 -0
  273. package/src/engine/config/config.ts +115 -0
  274. package/src/engine/config/index.ts +12 -0
  275. package/src/engine/gfx/grid.test.ts +128 -0
  276. package/src/engine/gfx/grid.ts +136 -0
  277. package/src/engine/gfx/index.ts +34 -0
  278. package/src/engine/gfx/label.test.ts +105 -0
  279. package/src/engine/gfx/label.ts +229 -0
  280. package/src/engine/gfx/overlap.test.ts +71 -0
  281. package/src/engine/gfx/overlap.ts +66 -0
  282. package/src/engine/gfx/palette.test.ts +35 -0
  283. package/src/engine/gfx/palette.ts +54 -0
  284. package/src/engine/gfx/png.test.ts +111 -0
  285. package/src/engine/gfx/png.ts +196 -0
  286. package/src/engine/gfx/render.test.ts +180 -0
  287. package/src/engine/gfx/render.ts +106 -0
  288. package/src/engine/index.ts +21 -0
  289. package/src/engine/pico8/adapter.ts +303 -0
  290. package/src/engine/pico8/drive.test.ts +415 -0
  291. package/src/engine/pico8/drive.ts +729 -0
  292. package/src/engine/pico8/harness.test.ts +181 -0
  293. package/src/engine/pico8/harness.ts +354 -0
  294. package/src/engine/pico8/index.ts +20 -0
  295. package/src/engine/pico8/sentinel.test.ts +80 -0
  296. package/src/engine/pico8/sentinel.ts +93 -0
  297. package/src/engine/pico8/session-daemon-entry.ts +12 -0
  298. package/src/engine/pico8/session-daemon-main.ts +98 -0
  299. package/src/engine/pico8/session-daemon.test.ts +212 -0
  300. package/src/engine/pico8/session-daemon.ts +306 -0
  301. package/src/engine/pico8/session.test.ts +239 -0
  302. package/src/engine/pico8/session.ts +321 -0
  303. package/src/engine/pico8/shell.test.ts +583 -0
  304. package/src/engine/pico8/shell.ts +412 -0
  305. package/src/engine/pico8/supervisor.test.ts +105 -0
  306. package/src/engine/pico8/supervisor.ts +197 -0
  307. package/src/engine/scaffold/index.ts +15 -0
  308. package/src/engine/scaffold/scaffold.ts +278 -0
  309. package/src/engine/shrinko/adapter.ts +241 -0
  310. package/src/engine/shrinko/fixtures/count-no-compressed.txt +2 -0
  311. package/src/engine/shrinko/fixtures/count-over-budget.txt +3 -0
  312. package/src/engine/shrinko/fixtures/count-under-budget.txt +3 -0
  313. package/src/engine/shrinko/index.ts +33 -0
  314. package/src/engine/shrinko/shell.ts +224 -0
  315. package/src/engine/shrinko/shrinko.test.ts +182 -0
  316. package/src/engine/skills/index.ts +14 -0
  317. package/src/engine/skills/skills.test.ts +376 -0
  318. package/src/engine/skills/skills.ts +205 -0
  319. package/src/index.ts +13 -0
  320. package/src/skills/game-design-reference/SKILL.md +70 -0
  321. package/src/skills/game-jam/SKILL.md +45 -0
  322. package/src/skills/picopilot-art/SKILL.md +62 -0
  323. package/src/skills/picopilot-audio/SKILL.md +194 -0
  324. package/src/skills/picopilot-code/SKILL.md +97 -0
  325. package/src/skills/picopilot-code/reference/README.md +42 -0
  326. package/src/skills/picopilot-code/reference/mode7-racing.md +95 -0
  327. package/src/skills/picopilot-code/reference/platformer.md +119 -0
  328. package/src/skills/picopilot-code/reference/puzzle-grid.md +106 -0
  329. package/src/skills/picopilot-code/reference/rpg-menus-dialog.md +122 -0
  330. package/src/skills/picopilot-code/reference/top-down-adventure.md +117 -0
  331. package/src/skills/picopilot-code/reference/twin-stick-arcade.md +128 -0
  332. package/src/skills/picopilot-debug/SKILL.md +195 -0
  333. package/src/skills/picopilot-overview/SKILL.md +52 -0
  334. package/src/version.ts +22 -0
@@ -0,0 +1,410 @@
1
+ import {
2
+ existsSync,
3
+ mkdirSync,
4
+ mkdtempSync,
5
+ readFileSync,
6
+ writeFileSync,
7
+ } from 'node:fs';
8
+ import {tmpdir} from 'node:os';
9
+ import {isAbsolute, join, resolve} from 'node:path';
10
+ import {Cli, z} from 'incur';
11
+
12
+ import {
13
+ buildRecordHarness,
14
+ buildRenderHarness,
15
+ DONE_SENTINEL,
16
+ HarnessError,
17
+ type Pico8Adapter,
18
+ type RenderTarget,
19
+ ShellPico8Adapter,
20
+ } from '../engine/pico8/index.js';
21
+
22
+ /**
23
+ * Injects the PICO-8 adapter the audio commands use (defaults to the shell
24
+ * adapter over native `pico8 -run`). Mirrors `run`'s `Pico8AdapterFactory` seam:
25
+ * a test passes a fake to drive the record orchestration + the absent path
26
+ * WITHOUT the paid binary, and to isolate the child `env`.
27
+ */
28
+ export type Pico8AdapterFactory = (env: NodeJS.ProcessEnv) => Pico8Adapter;
29
+
30
+ const defaultAdapterFactory: Pico8AdapterFactory = (env) =>
31
+ new ShellPico8Adapter({env});
32
+
33
+ /**
34
+ * The default record backstop: kill a capture that never signals after this many
35
+ * ms. Larger than `run`'s default because a record window is a real-time playback
36
+ * (up to several seconds) plus PICO-8 startup, not a quick screenshot pass.
37
+ */
38
+ const DEFAULT_BACKSTOP_MS = 30_000;
39
+
40
+ /** The honest one-liner both `record` and `render` carry (ADR-0009): not offline. */
41
+ 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.';
43
+
44
+ /** Resolves a path argument to an absolute path (relative to cwd). */
45
+ function resolvePath(p: string): string {
46
+ return isAbsolute(p) ? p : resolve(process.cwd(), p);
47
+ }
48
+
49
+ /**
50
+ * Chooses (and creates) the isolated dir PICO-8 records the WAV into. Because
51
+ * `extcmd("audio_end", 1)` writes to PICO-8's CURRENT folder (steered via
52
+ * `-root_path`), the WAV MUST land here, never `~/Desktop` or the carts root
53
+ * (the shared-write discipline, the `audio_end(1)` trap). A user-given path is
54
+ * honoured; otherwise a fresh temp dir.
55
+ */
56
+ function chooseWavDir(given: string | undefined): string {
57
+ const dir =
58
+ given !== undefined
59
+ ? isAbsolute(given)
60
+ ? given
61
+ : resolve(process.cwd(), given)
62
+ : mkdtempSync(join(tmpdir(), 'picopilot-audio-'));
63
+ if (!existsSync(dir)) mkdirSync(dir, {recursive: true});
64
+ return dir;
65
+ }
66
+
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
+ });
75
+
76
+ /** The structured output shape shared by `record` and `render`. */
77
+ const AUDIO_OUTPUT = z.object({
78
+ wav: z
79
+ .string()
80
+ .optional()
81
+ .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
+ ),
84
+ captured: z
85
+ .boolean()
86
+ .describe(
87
+ 'Whether a non-empty WAV was captured (false = silent/empty run).',
88
+ ),
89
+ exitReason: z
90
+ .enum(['sentinel', 'timeout', 'exit'])
91
+ .describe(
92
+ 'sentinel = the harness signalled done; timeout = backstop fired; exit = PICO-8 quit.',
93
+ ),
94
+ target: z
95
+ .string()
96
+ .describe('What was recorded (the running cart, or the render target).'),
97
+ seconds: z
98
+ .number()
99
+ .describe('The record window in seconds the capture ran for.'),
100
+ printh: z.string().describe('Captured stdout from the session.'),
101
+ });
102
+
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
+ /**
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.
115
+ */
116
+ function noAudioCta(caveat: string) {
117
+ return {
118
+ description: `No audio was captured (${caveat}) Recording needs a real audio+video session:`,
119
+ commands: [
120
+ {
121
+ command: 'audio render --sfx <n>',
122
+ description:
123
+ 'Run on a developer machine with a display + audio device (not headless CI).',
124
+ },
125
+ ],
126
+ };
127
+ }
128
+
129
+ /** Shapes an adapter record report into the structured output object. */
130
+ function recordOutput(
131
+ report: {
132
+ wavPath: string | undefined;
133
+ printh: string;
134
+ exitReason: 'sentinel' | 'timeout' | 'exit';
135
+ },
136
+ harness: {seconds: number; description: string},
137
+ ) {
138
+ return {
139
+ wav: report.wavPath,
140
+ captured: report.wavPath !== undefined,
141
+ exitReason: report.exitReason,
142
+ target: harness.description,
143
+ seconds: harness.seconds,
144
+ printh: report.printh,
145
+ };
146
+ }
147
+
148
+ /**
149
+ * Builds the `audio` command group: `audio record <cart>` (the record primitive)
150
+ * and `audio render` (US #13, the convenience harness over it). Both are
151
+ * PICO-8-gated (ADR-0009): PICO-8 absent returns the structured `pico8-not-found`
152
+ * + nonzero exit. Both are record-based, real-time captures, NOT offline exports.
153
+ */
154
+ function buildAudioGroup(adapterFactory: Pico8AdapterFactory): Cli.Cli {
155
+ const audio = Cli.create('audio', {
156
+ 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.',
158
+ });
159
+
160
+ // --- audio record <cart> ---------------------------------------------------
161
+ audio.command('record', {
162
+ description: `Record a running cart's own audio to a WAV. Injects a throwaway cooperative recorder (audio_rec/audio_end) around the cart, runs it in a real A/V session, and returns the WAV path. ${HONEST_CAVEAT} Requires PICO-8.`,
163
+ args: z.object({
164
+ cart: z
165
+ .string()
166
+ .default('main.p8')
167
+ .describe(
168
+ 'The .p8 cart to run and record. Defaults to main.p8 in the current folder.',
169
+ ),
170
+ }),
171
+ options: z.object({
172
+ seconds: z.coerce
173
+ .number()
174
+ .positive()
175
+ .default(8)
176
+ .describe('How long (seconds) to record the running cart.'),
177
+ wavDir: z
178
+ .string()
179
+ .optional()
180
+ .describe(
181
+ 'Where PICO-8 writes the WAV. Defaults to an isolated temp dir; NEVER ~/Desktop or the carts root.',
182
+ ),
183
+ sentinel: z
184
+ .string()
185
+ .default(DONE_SENTINEL)
186
+ .describe('The stdout line that ends the recording.'),
187
+ backstopMs: z
188
+ .number()
189
+ .int()
190
+ .positive()
191
+ .default(DEFAULT_BACKSTOP_MS)
192
+ .describe(
193
+ 'Safety backstop: kill PICO-8 after this many ms if it never signals.',
194
+ ),
195
+ }),
196
+ env: AUDIO_ENV,
197
+ output: AUDIO_OUTPUT,
198
+ examples: [
199
+ {description: 'Record 8 seconds of main.p8'},
200
+ {
201
+ description: 'Record a specific cart for 4 seconds',
202
+ args: {cart: 'game.p8'},
203
+ options: {seconds: 4},
204
+ },
205
+ ],
206
+ async run({args, options, env, error, ok}) {
207
+ const cartPath = resolvePath(args.cart);
208
+ if (!existsSync(cartPath)) {
209
+ return error({
210
+ code: 'cart-not-found',
211
+ message: `no cart at ${cartPath}`,
212
+ exitCode: 1,
213
+ });
214
+ }
215
+
216
+ let harness: ReturnType<typeof buildRecordHarness>;
217
+ try {
218
+ harness = buildRecordHarness(readFileSync(cartPath, 'utf8'), {
219
+ seconds: options.seconds,
220
+ sentinel: options.sentinel,
221
+ });
222
+ } catch (e) {
223
+ if (e instanceof HarnessError) {
224
+ return error({code: e.code, message: e.message, exitCode: 1});
225
+ }
226
+ throw e;
227
+ }
228
+
229
+ // The throwaway harness cart + the WAV both live in the isolated dir, so a
230
+ // record run never mutates the user's cart or writes outside the temp dir.
231
+ const wavDir = chooseWavDir(options.wavDir);
232
+ const harnessCart = join(wavDir, 'record-harness.p8');
233
+ writeFileSync(harnessCart, harness.cartText);
234
+
235
+ const adapter = adapterFactory(env as NodeJS.ProcessEnv);
236
+ const result = await adapter.record({
237
+ cartPath: harnessCart,
238
+ wavDir,
239
+ wavBasename: harness.wavBasename,
240
+ sentinel: options.sentinel,
241
+ backstopMs: options.backstopMs,
242
+ });
243
+
244
+ if (!result.ok) {
245
+ return error({
246
+ code: 'pico8-not-found',
247
+ message: pico8NotFoundMessage(result),
248
+ exitCode: 1,
249
+ });
250
+ }
251
+
252
+ const out = recordOutput(result.value, harness);
253
+ return ok(
254
+ out,
255
+ out.captured ? undefined : {cta: noAudioCta(HONEST_CAVEAT)},
256
+ );
257
+ },
258
+ });
259
+
260
+ // --- audio render ----------------------------------------------------------
261
+ audio.command('render', {
262
+ description: `Render an authored target (sfx / music pattern / the whole song) to a WAV by injecting a play-harness, recording it, and returning the WAV path (US #13). The author writes NO playback code. ${HONEST_CAVEAT} With no --sfx/--pattern it renders the whole song (music 0). Requires PICO-8.`,
263
+ args: z.object({
264
+ cart: z
265
+ .string()
266
+ .default('main.p8')
267
+ .describe(
268
+ 'The .p8 cart whose authored __sfx__/__music__ to render. Defaults to main.p8.',
269
+ ),
270
+ }),
271
+ options: z.object({
272
+ sfx: z.coerce
273
+ .number()
274
+ .int()
275
+ .min(0)
276
+ .max(63)
277
+ .optional()
278
+ .describe(
279
+ 'Render this SFX slot (0-63). Mutually exclusive with --pattern.',
280
+ ),
281
+ pattern: z.coerce
282
+ .number()
283
+ .int()
284
+ .min(0)
285
+ .max(63)
286
+ .optional()
287
+ .describe(
288
+ 'Render this music pattern (0-63). Mutually exclusive with --sfx.',
289
+ ),
290
+ seconds: z.coerce
291
+ .number()
292
+ .positive()
293
+ .optional()
294
+ .describe(
295
+ 'Override the record window (seconds). Default: derived from the SFX speed+rows for --sfx, else 8s for a pattern/song.',
296
+ ),
297
+ wavDir: z
298
+ .string()
299
+ .optional()
300
+ .describe(
301
+ 'Where PICO-8 writes the WAV. Defaults to an isolated temp dir; NEVER ~/Desktop or the carts root.',
302
+ ),
303
+ sentinel: z
304
+ .string()
305
+ .default(DONE_SENTINEL)
306
+ .describe('The stdout line that ends the recording.'),
307
+ backstopMs: z
308
+ .number()
309
+ .int()
310
+ .positive()
311
+ .default(DEFAULT_BACKSTOP_MS)
312
+ .describe(
313
+ 'Safety backstop: kill PICO-8 after this many ms if it never signals.',
314
+ ),
315
+ }),
316
+ env: AUDIO_ENV,
317
+ output: AUDIO_OUTPUT,
318
+ examples: [
319
+ {description: 'Render the whole song (music 0) of main.p8'},
320
+ {description: 'Render SFX slot 5', options: {sfx: 5}},
321
+ {
322
+ description: 'Render music pattern 2 of a cart for 6 seconds',
323
+ args: {cart: 'game.p8'},
324
+ options: {pattern: 2, seconds: 6},
325
+ },
326
+ ],
327
+ async run({args, options, env, error, ok}) {
328
+ if (options.sfx !== undefined && options.pattern !== undefined) {
329
+ return error({
330
+ code: 'ambiguous-target',
331
+ message:
332
+ 'pass EITHER --sfx OR --pattern, not both (a render targets one of: an SFX slot, a music pattern, or the whole song).',
333
+ exitCode: 1,
334
+ });
335
+ }
336
+ const target: RenderTarget =
337
+ options.sfx !== undefined
338
+ ? {kind: 'sfx', index: options.sfx}
339
+ : options.pattern !== undefined
340
+ ? {kind: 'pattern', index: options.pattern}
341
+ : {kind: 'song'};
342
+
343
+ const cartPath = resolvePath(args.cart);
344
+ if (!existsSync(cartPath)) {
345
+ return error({
346
+ code: 'cart-not-found',
347
+ message: `no cart at ${cartPath}`,
348
+ exitCode: 1,
349
+ });
350
+ }
351
+
352
+ let harness: ReturnType<typeof buildRenderHarness>;
353
+ try {
354
+ harness = buildRenderHarness(readFileSync(cartPath, 'utf8'), target, {
355
+ seconds: options.seconds,
356
+ sentinel: options.sentinel,
357
+ });
358
+ } catch (e) {
359
+ if (e instanceof HarnessError) {
360
+ // A target refusal (out-of-range / empty slot / bad cart): structured,
361
+ // never a silent recording of silence.
362
+ return error({code: e.code, message: e.message, exitCode: 1});
363
+ }
364
+ throw e;
365
+ }
366
+
367
+ const wavDir = chooseWavDir(options.wavDir);
368
+ const harnessCart = join(wavDir, 'render-harness.p8');
369
+ writeFileSync(harnessCart, harness.cartText);
370
+
371
+ const adapter = adapterFactory(env as NodeJS.ProcessEnv);
372
+ const result = await adapter.record({
373
+ cartPath: harnessCart,
374
+ wavDir,
375
+ wavBasename: harness.wavBasename,
376
+ sentinel: options.sentinel,
377
+ backstopMs: options.backstopMs,
378
+ });
379
+
380
+ if (!result.ok) {
381
+ return error({
382
+ code: 'pico8-not-found',
383
+ message: pico8NotFoundMessage(result),
384
+ exitCode: 1,
385
+ });
386
+ }
387
+
388
+ const out = recordOutput(result.value, harness);
389
+ return ok(
390
+ out,
391
+ out.captured ? undefined : {cta: noAudioCta(HONEST_CAVEAT)},
392
+ );
393
+ },
394
+ });
395
+
396
+ return audio;
397
+ }
398
+
399
+ /**
400
+ * Registers the `audio` command group (`audio record`, `audio render`) on the
401
+ * root CLI. PICO-8-gated (ADR-0009): audio-to-WAV is a record of a running
402
+ * session, never an offline export. `adapterFactory` is injected so tests drive
403
+ * the orchestration against a fake runner (no paid binary in CI).
404
+ */
405
+ export function registerAudio(
406
+ cli: Cli.Cli,
407
+ adapterFactory: Pico8AdapterFactory = defaultAdapterFactory,
408
+ ): void {
409
+ cli.command(buildAudioGroup(adapterFactory));
410
+ }