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,272 @@
1
+ import {execFileSync} from 'node:child_process';
2
+ import {mkdtempSync, readFileSync, writeFileSync} from 'node:fs';
3
+ import {tmpdir} from 'node:os';
4
+ import {join} from 'node:path';
5
+ import {afterEach, beforeEach, describe, expect, it} from 'vitest';
6
+
7
+ import {mmlToSfxRow, patternsToMusic} from '../engine/audio/index.js';
8
+ import {createCli} from '../cli.js';
9
+
10
+ /** A minimal valid cart with lua + gfx + a real __sfx__ (to prove they stay untouched). */
11
+ function cartText(opts?: {sfx?: string; music?: string}): string {
12
+ let text =
13
+ 'pico-8 cartridge // http://www.pico-8.com\nversion 42\n__lua__\nprint("hi")\n__gfx__\n0011223344556677\n';
14
+ if (opts?.sfx !== undefined) text += `__sfx__\n${opts.sfx}`;
15
+ if (opts?.music !== undefined) text += `__music__\n${opts.music}`;
16
+ return text;
17
+ }
18
+
19
+ let dir: string;
20
+ let cartPath: string;
21
+
22
+ beforeEach(() => {
23
+ dir = mkdtempSync(join(tmpdir(), 'picopilot-music-'));
24
+ cartPath = join(dir, 'main.p8');
25
+ });
26
+
27
+ afterEach(() => {
28
+ try {
29
+ execFileSync('rm', ['-rf', dir]);
30
+ } catch {
31
+ // ignore
32
+ }
33
+ });
34
+
35
+ /** Drives a `picopilot music ...` invocation through incur's serve DI. */
36
+ async function runMusic(argv: string[]) {
37
+ let stdout = '';
38
+ let exitCode = 0;
39
+ await createCli().serve(argv, {
40
+ stdout(s) {
41
+ stdout += s;
42
+ },
43
+ exit(code) {
44
+ exitCode = code;
45
+ },
46
+ env: {},
47
+ });
48
+ return {stdout, exitCode};
49
+ }
50
+
51
+ /** Extracts the __music__ body lines from a cart file. */
52
+ function musicLines(text: string): string[] {
53
+ const m = text.match(/__music__\n([\s\S]*?)(?:\n__|$)/);
54
+ return (m?.[1] ?? '').split('\n').filter((l) => l.length > 0);
55
+ }
56
+
57
+ describe('picopilot music from-patterns: transpile + merge', () => {
58
+ it('merges the assembled __music__ into the cart and reports it', async () => {
59
+ writeFileSync(cartPath, cartText());
60
+ const list =
61
+ '[{"channels":[0,1,2,3],"loopStart":true},{"channels":[0,1,2,3],"loopBack":true}]';
62
+ const {stdout, exitCode} = await runMusic([
63
+ 'music',
64
+ 'from-patterns',
65
+ list,
66
+ '--cart',
67
+ cartPath,
68
+ '--json',
69
+ ]);
70
+ expect(exitCode).toBe(0);
71
+ const out = JSON.parse(stdout);
72
+ expect(out.patterns).toBe(2);
73
+ expect(out.section).toBe(
74
+ patternsToMusic([
75
+ {channels: [0, 1, 2, 3], loopStart: true},
76
+ {channels: [0, 1, 2, 3], loopBack: true},
77
+ ]).section,
78
+ );
79
+ const lines = musicLines(readFileSync(cartPath, 'utf8'));
80
+ expect(lines).toEqual(['01 00010203', '02 00010203']);
81
+ });
82
+
83
+ it('writes an OFF channel (null) as byte 40, distinct from sfx 0', async () => {
84
+ writeFileSync(cartPath, cartText());
85
+ await runMusic([
86
+ 'music',
87
+ 'from-patterns',
88
+ '[{"channels":[0,1,null,3]}]',
89
+ '--cart',
90
+ cartPath,
91
+ '--json',
92
+ ]);
93
+ // null is ch2 -> byte 40 at channel-2 position, distinct from sfx 0 (byte 00).
94
+ expect(musicLines(readFileSync(cartPath, 'utf8'))).toEqual(['00 00014003']);
95
+ });
96
+
97
+ it('CTAs toward verify + audio render', async () => {
98
+ writeFileSync(cartPath, cartText());
99
+ const {stdout} = await runMusic([
100
+ 'music',
101
+ 'from-patterns',
102
+ '[{"channels":[0,0,0,0]}]',
103
+ '--cart',
104
+ cartPath,
105
+ '--json',
106
+ ]);
107
+ const cta = JSON.stringify(JSON.parse(stdout).cta);
108
+ expect(cta).toContain('verify');
109
+ expect(cta).toContain('audio render');
110
+ });
111
+ });
112
+
113
+ describe('picopilot music from-patterns: leaves every OTHER section byte-identical', () => {
114
+ it('only __music__ changes; lua + gfx + __sfx__ + header preserved', async () => {
115
+ const sfxRow = mmlToSfxRow('s8 @1 v6 c d e').row;
116
+ writeFileSync(cartPath, cartText({sfx: `${sfxRow}\n`}));
117
+ const before = readFileSync(cartPath, 'utf8');
118
+ await runMusic([
119
+ 'music',
120
+ 'from-patterns',
121
+ '[{"channels":[0,1,2,3]}]',
122
+ '--cart',
123
+ cartPath,
124
+ '--json',
125
+ ]);
126
+ const after = readFileSync(cartPath, 'utf8');
127
+ // __music__ appended after __sfx__; everything before it is byte-identical.
128
+ expect(after.startsWith(before)).toBe(true);
129
+ expect(after).toContain('print("hi")');
130
+ expect(after).toContain('__gfx__\n0011223344556677\n');
131
+ expect(after).toContain(`__sfx__\n${sfxRow}\n`);
132
+ });
133
+
134
+ it('replaces an existing __music__ body, leaving __sfx__ untouched', async () => {
135
+ const sfxRow = mmlToSfxRow('@2 c').row;
136
+ writeFileSync(
137
+ cartPath,
138
+ cartText({sfx: `${sfxRow}\n`, music: '00 00000000\n'}),
139
+ );
140
+ await runMusic([
141
+ 'music',
142
+ 'from-patterns',
143
+ '[{"channels":[5,6,7,8],"stop":true}]',
144
+ '--cart',
145
+ cartPath,
146
+ '--json',
147
+ ]);
148
+ const text = readFileSync(cartPath, 'utf8');
149
+ expect(musicLines(text)).toEqual(['04 05060708']);
150
+ expect(text).toContain(`__sfx__\n${sfxRow}\n`);
151
+ });
152
+ });
153
+
154
+ describe('picopilot music from-patterns: structured refusals (NO silent clamp)', () => {
155
+ it('out-of-range sfx index -> audio-music-sfx-out-of-range + nonzero exit, no write', async () => {
156
+ writeFileSync(cartPath, cartText());
157
+ const before = readFileSync(cartPath, 'utf8');
158
+ const {stdout, exitCode} = await runMusic([
159
+ 'music',
160
+ 'from-patterns',
161
+ '[{"channels":[64,0,0,0]}]',
162
+ '--cart',
163
+ cartPath,
164
+ '--json',
165
+ ]);
166
+ expect(exitCode).not.toBe(0);
167
+ const out = JSON.parse(stdout);
168
+ expect(out.code).toBe('audio-music-sfx-out-of-range');
169
+ expect(out.message).toContain('0..63');
170
+ // The cart is byte-untouched (refusal never writes).
171
+ expect(readFileSync(cartPath, 'utf8')).toBe(before);
172
+ });
173
+
174
+ it('a wrong channel count -> audio-music-bad-channel-count + nonzero exit', async () => {
175
+ writeFileSync(cartPath, cartText());
176
+ const {stdout, exitCode} = await runMusic([
177
+ 'music',
178
+ 'from-patterns',
179
+ '[{"channels":[0,1,2]}]',
180
+ '--cart',
181
+ cartPath,
182
+ '--json',
183
+ ]);
184
+ expect(exitCode).not.toBe(0);
185
+ expect(JSON.parse(stdout).code).toBe('audio-music-bad-channel-count');
186
+ });
187
+
188
+ it('malformed JSON -> audio-music-parse-error + nonzero exit', async () => {
189
+ writeFileSync(cartPath, cartText());
190
+ const {stdout, exitCode} = await runMusic([
191
+ 'music',
192
+ 'from-patterns',
193
+ 'not json',
194
+ '--cart',
195
+ cartPath,
196
+ '--json',
197
+ ]);
198
+ expect(exitCode).not.toBe(0);
199
+ expect(JSON.parse(stdout).code).toBe('audio-music-parse-error');
200
+ });
201
+ });
202
+
203
+ describe('picopilot music from-patterns: loopBack-without-loopStart WARNS (not a crash)', () => {
204
+ it('emits + warns about the pattern-0 fall-back', async () => {
205
+ writeFileSync(cartPath, cartText());
206
+ const {stdout, exitCode} = await runMusic([
207
+ 'music',
208
+ 'from-patterns',
209
+ '[{"channels":[0,0,0,0]},{"channels":[1,1,1,1],"loopBack":true}]',
210
+ '--cart',
211
+ cartPath,
212
+ '--json',
213
+ ]);
214
+ expect(exitCode).toBe(0);
215
+ const out = JSON.parse(stdout);
216
+ expect(out.patterns).toBe(2);
217
+ expect(out.warnings.length).toBeGreaterThan(0);
218
+ expect(out.warnings[0]).toContain('pattern 0');
219
+ // It DID write (the warning does not block the merge).
220
+ expect(musicLines(readFileSync(cartPath, 'utf8'))).toEqual([
221
+ '00 00000000',
222
+ '02 01010101',
223
+ ]);
224
+ });
225
+ });
226
+
227
+ describe('picopilot music from-patterns: input surface (arg vs --file)', () => {
228
+ it('reads the pattern list from --file', async () => {
229
+ writeFileSync(cartPath, cartText());
230
+ const listPath = join(dir, 'song.json');
231
+ writeFileSync(listPath, '[{"channels":[3,3,3,3],"stop":true}]');
232
+ const {stdout, exitCode} = await runMusic([
233
+ 'music',
234
+ 'from-patterns',
235
+ '--cart',
236
+ cartPath,
237
+ '--file',
238
+ listPath,
239
+ '--json',
240
+ ]);
241
+ expect(exitCode).toBe(0);
242
+ expect(JSON.parse(stdout).section).toBe(
243
+ patternsToMusic([{channels: [3, 3, 3, 3], stop: true}]).section,
244
+ );
245
+ });
246
+
247
+ it('errors when neither the patterns arg nor --file is given', async () => {
248
+ writeFileSync(cartPath, cartText());
249
+ const {stdout, exitCode} = await runMusic([
250
+ 'music',
251
+ 'from-patterns',
252
+ '--cart',
253
+ cartPath,
254
+ '--json',
255
+ ]);
256
+ expect(exitCode).not.toBe(0);
257
+ expect(JSON.parse(stdout).code).toBe('no-patterns');
258
+ });
259
+
260
+ it('a missing cart is a distinct cart-not-found error', async () => {
261
+ const {stdout, exitCode} = await runMusic([
262
+ 'music',
263
+ 'from-patterns',
264
+ '[{"channels":[0,0,0,0]}]',
265
+ '--cart',
266
+ join(dir, 'nope.p8'),
267
+ '--json',
268
+ ]);
269
+ expect(exitCode).not.toBe(0);
270
+ expect(JSON.parse(stdout).code).toBe('cart-not-found');
271
+ });
272
+ });
@@ -0,0 +1,288 @@
1
+ import {existsSync, readFileSync, writeFileSync} from 'node:fs';
2
+ import {isAbsolute, resolve} from 'node:path';
3
+ import {Cli, z} from 'incur';
4
+
5
+ import {
6
+ MusicError,
7
+ patternsToMusic,
8
+ type Pattern,
9
+ SFX_INDEX_MAX,
10
+ setMusicSection,
11
+ } from '../engine/audio/index.js';
12
+ import {Cart, CartParseError} from '../engine/cart/index.js';
13
+
14
+ /** Resolves a path argument to an absolute path (relative to cwd). */
15
+ function resolvePath(p: string): string {
16
+ return isAbsolute(p) ? p : resolve(process.cwd(), p);
17
+ }
18
+
19
+ /**
20
+ * Loads + parses a cart, returning either the parsed cart or a structured reason
21
+ * the caller turns into an `error(...)`. Mirrors the sfx/gfx command loaders.
22
+ */
23
+ function loadCart(
24
+ cartPath: string,
25
+ ): {ok: true; cart: Cart} | {ok: false; code: string; message: string} {
26
+ if (!existsSync(cartPath)) {
27
+ return {
28
+ ok: false,
29
+ code: 'cart-not-found',
30
+ message: `no cart at ${cartPath}`,
31
+ };
32
+ }
33
+ const text = readFileSync(cartPath, 'utf8');
34
+ try {
35
+ return {ok: true, cart: Cart.parse(text)};
36
+ } catch (e) {
37
+ if (e instanceof CartParseError) {
38
+ return {
39
+ ok: false,
40
+ code: 'cart-parse-failed',
41
+ message: `cart at ${cartPath} does not parse (${e.code}): ${e.message}`,
42
+ };
43
+ }
44
+ throw e;
45
+ }
46
+ }
47
+
48
+ /**
49
+ * A parsed pattern list, OR a structured reason it could not be read as the
50
+ * documented JSON shape (a syntactically-bad payload). Value validation (channel
51
+ * count, sfx range) is the codec's job (structured `MusicError`s); this only
52
+ * gets the payload from text into the `Pattern[]` shape.
53
+ */
54
+ type ParsedPatterns =
55
+ {ok: true; patterns: Pattern[]} | {ok: false; code: string; message: string};
56
+
57
+ /**
58
+ * Reads the pattern-list JSON into the codec's `Pattern[]` model (finding B.6).
59
+ *
60
+ * The serialization is JSON: an array of `{channels:[c0,c1,c2,c3], loopStart?,
61
+ * loopBack?, stop?}` (see the command help). This maps 1:1 to the finding's B.6
62
+ * model, so an author (human or agent) writes exactly that structure. An SFX
63
+ * reference is a number 0..63; an OFF channel is `null` (distinct from sfx 0, per
64
+ * finding B.7 #9). This parser is deliberately structural-only: it accepts the
65
+ * shape and hands it to the codec, which owns range/count validation.
66
+ */
67
+ function parsePatterns(text: string): ParsedPatterns {
68
+ let raw: unknown;
69
+ try {
70
+ raw = JSON.parse(text);
71
+ } catch (e) {
72
+ return {
73
+ ok: false,
74
+ code: 'audio-music-parse-error',
75
+ message: `pattern list is not valid JSON: ${
76
+ e instanceof Error ? e.message : String(e)
77
+ }. Expected an array of patterns, e.g. [{"channels":[0,1,null,3],"loopStart":true}].`,
78
+ };
79
+ }
80
+ if (!Array.isArray(raw)) {
81
+ return {
82
+ ok: false,
83
+ code: 'audio-music-parse-error',
84
+ message:
85
+ 'pattern list must be a JSON array of patterns, e.g. [{"channels":[0,1,null,3]}]. Each channel is an SFX index 0..63 or null (off).',
86
+ };
87
+ }
88
+ // Hand the array straight to the codec as Pattern[]; the codec validates
89
+ // channel count + sfx range structurally and refuses out-of-range values.
90
+ return {ok: true, patterns: raw as Pattern[]};
91
+ }
92
+
93
+ /**
94
+ * Builds the `music` command group. Currently just `music from-patterns` (the
95
+ * structural pattern-list to `__music__` codec + cart merge).
96
+ */
97
+ function buildMusicGroup(): Cli.Cli {
98
+ const music = Cli.create('music', {
99
+ description:
100
+ 'Assemble music STRUCTURALLY: an ordered list of up-to-4 SFX-channel references per pattern -> __music__. There is NO melodic content in __music__; melody lives in the referenced SFX (built by `sfx from-mml`). Pattern LENGTH is inherited from the referenced SFX (the left-most non-looping channel), NOT stored per pattern.',
101
+ });
102
+
103
+ music.command('from-patterns', {
104
+ description:
105
+ 'Assemble a JSON pattern list into __music__ and merge it into the cart (only __music__ changes; every other section incl. __sfx__ stays byte-identical). A pattern is {"channels":[c0,c1,c2,c3], loopStart?, loopBack?, stop?}: each channel is an SFX index 0-63 OR null = OFF (silent this pattern; null is NOT sfx 0). Flags: loopStart (0x01), loopBack (0x02, jump back to loop-start), stop (0x04). Pattern order = song order. Pattern length is INHERITED from the referenced SFX (its speed + LEN via `sfx from-mml`), so no per-pattern length is stored. Out-of-range SFX index is refused; loopBack with no loopStart warns (PICO-8 falls back to pattern 0), not a crash.',
106
+ args: z.object({
107
+ patterns: z
108
+ .string()
109
+ .optional()
110
+ .describe(
111
+ 'The pattern list as JSON: an array of {"channels":[c0,c1,c2,c3], loopStart?, loopBack?, stop?}. A channel is an SFX index 0-63 or null (off). Omit and pass --file to read from a file instead. Example: \'[{"channels":[0,1,null,3],"loopStart":true},{"channels":[2,2,2,2],"loopBack":true,"stop":true}]\'.',
112
+ ),
113
+ }),
114
+ options: z.object({
115
+ cart: z
116
+ .string()
117
+ .default('main.p8')
118
+ .describe(
119
+ 'The .p8 cart to merge into. Defaults to main.p8 in the current folder. (An option, not a positional, so it never collides with an omitted `patterns` when using --file.)',
120
+ ),
121
+ file: z
122
+ .string()
123
+ .optional()
124
+ .describe(
125
+ 'Read the JSON pattern list from this file instead of the `patterns` argument (for longer songs).',
126
+ ),
127
+ }),
128
+ output: z.object({
129
+ cart: z
130
+ .string()
131
+ .describe('The absolute path of the cart that was written.'),
132
+ patterns: z
133
+ .number()
134
+ .describe('The number of patterns written (= song length).'),
135
+ section: z
136
+ .string()
137
+ .describe('The __music__ section body that was merged.'),
138
+ warnings: z
139
+ .array(z.string())
140
+ .describe(
141
+ 'Advisory warnings (e.g. loopBack with no loopStart -> PICO-8 loops to pattern 0).',
142
+ ),
143
+ }),
144
+ examples: [
145
+ {
146
+ description: 'A two-pattern loop (pattern 0 loops back to itself)',
147
+ args: {
148
+ patterns:
149
+ '[{"channels":[0,1,2,3],"loopStart":true},{"channels":[0,1,2,3],"loopBack":true}]',
150
+ },
151
+ },
152
+ {
153
+ description: 'A pattern with channel 2 OFF (silent), not sfx 0',
154
+ args: {patterns: '[{"channels":[0,1,null,3]}]'},
155
+ options: {cart: 'game.p8'},
156
+ },
157
+ {
158
+ description: 'Read a longer song from a file',
159
+ args: {},
160
+ options: {file: 'song.json'},
161
+ },
162
+ ],
163
+ run({args, options, error, ok}) {
164
+ // Resolve the pattern-list source: the `patterns` arg OR `--file` (exactly
165
+ // one). Pure text, shrinko-free + PICO-8-free (mirrors `sfx from-mml`).
166
+ let text: string;
167
+ if (options.file !== undefined) {
168
+ if (args.patterns !== undefined) {
169
+ return error({
170
+ code: 'ambiguous-input',
171
+ message:
172
+ 'pass EITHER the patterns argument OR --file, not both (they are two ways to give the same source).',
173
+ exitCode: 1,
174
+ });
175
+ }
176
+ const filePath = resolvePath(options.file);
177
+ if (!existsSync(filePath)) {
178
+ return error({
179
+ code: 'patterns-file-not-found',
180
+ message: `no pattern-list file at ${filePath}`,
181
+ exitCode: 1,
182
+ });
183
+ }
184
+ text = readFileSync(filePath, 'utf8');
185
+ } else if (args.patterns !== undefined) {
186
+ text = args.patterns;
187
+ } else {
188
+ return error({
189
+ code: 'no-patterns',
190
+ message:
191
+ 'no pattern list: pass it as the `patterns` argument (JSON) or via --file.',
192
+ exitCode: 1,
193
+ });
194
+ }
195
+
196
+ const parsed = parsePatterns(text);
197
+ if (!parsed.ok) {
198
+ return error({code: parsed.code, message: parsed.message, exitCode: 1});
199
+ }
200
+
201
+ // Transpile FIRST: a bad list is a caller error and must never touch the
202
+ // cart (no bytes change on a refusal), mirroring `sfx from-mml`.
203
+ let result: ReturnType<typeof patternsToMusic>;
204
+ try {
205
+ result = patternsToMusic(parsed.patterns);
206
+ } catch (e) {
207
+ if (e instanceof MusicError) {
208
+ const cta =
209
+ e.code === 'audio-music-sfx-out-of-range'
210
+ ? {
211
+ description: `SFX indices are a hard range (0..${SFX_INDEX_MAX}); to silence a channel use null, not an out-of-range index:`,
212
+ commands: [
213
+ {
214
+ command: 'sfx from-mml <slot> "<mml>"',
215
+ description: `Author the SFX you reference first (slots 0..${SFX_INDEX_MAX}), then arrange them here.`,
216
+ },
217
+ ],
218
+ }
219
+ : undefined;
220
+ return error({
221
+ code: e.code,
222
+ message: e.message,
223
+ exitCode: 1,
224
+ ...(cta ? {cta} : {}),
225
+ });
226
+ }
227
+ throw e;
228
+ }
229
+
230
+ const cartPath = resolvePath(options.cart);
231
+ const loaded = loadCart(cartPath);
232
+ if (!loaded.ok) {
233
+ return error({code: loaded.code, message: loaded.message, exitCode: 1});
234
+ }
235
+
236
+ // Replace __music__ ONLY, through the cart model, so every other section
237
+ // (and __sfx__) stays byte-identical.
238
+ setMusicSection(loaded.cart, result.section);
239
+ writeFileSync(cartPath, loaded.cart.serialize());
240
+
241
+ const warnings = result.warnings.map((w) => w.message);
242
+
243
+ // CTA toward the verify/hear loop: a merged song is next confirmed by
244
+ // verify (static integrity) and heard via `audio render`.
245
+ return ok(
246
+ {
247
+ cart: cartPath,
248
+ patterns: result.patterns,
249
+ section: result.section,
250
+ warnings,
251
+ },
252
+ {
253
+ cta: {
254
+ description: 'The song is merged. Confirm it, then hear it:',
255
+ commands: [
256
+ {
257
+ command: 'verify',
258
+ description:
259
+ 'Static gate: the cart still parses + is within budget.',
260
+ },
261
+ {
262
+ command: 'audio render',
263
+ description:
264
+ 'Record a WAV to hear the result (real-time capture, requires PICO-8).',
265
+ },
266
+ ],
267
+ },
268
+ },
269
+ );
270
+ },
271
+ });
272
+
273
+ return music;
274
+ }
275
+
276
+ /**
277
+ * Registers the `music` command group (`music from-patterns`) on the root CLI.
278
+ *
279
+ * `music` is a shrinko-FREE and PICO-8-FREE command group: it assembles a
280
+ * structural pattern list into `__music__` and merges it through the cart model
281
+ * entirely in TS, so it works with both absent and never spawns a child process.
282
+ * Music is STRUCTURAL (ADR-0005 + the audio spike finding A.5/B.6): a pattern
283
+ * list of SFX references, NOT a notation. "Off" (bit6) is distinct from "sfx 0",
284
+ * and pattern length is inherited from the referenced SFX (stored nowhere here).
285
+ */
286
+ export function registerMusic(cli: Cli.Cli): void {
287
+ cli.command(buildMusicGroup());
288
+ }