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,415 @@
1
+ import {describe, expect, it} from 'vitest';
2
+ import {Cart} from '../cart/index.js';
3
+ import {DONE_SENTINEL} from './adapter.js';
4
+ import {
5
+ ACK,
6
+ BLOCK_SIZE,
7
+ BUTTON_BITS,
8
+ BlockDecoder,
9
+ buildDriveHarness,
10
+ buildDriveScript,
11
+ btnpEdges,
12
+ DriveError,
13
+ encodeScript,
14
+ GENERIC_INPUT,
15
+ OPCODE,
16
+ parseButtons,
17
+ parseInputScript,
18
+ pressesToHeld,
19
+ resolveIncludes,
20
+ SHOT_BASENAME,
21
+ } from './drive.js';
22
+
23
+ /**
24
+ * The `playtest` drive-transform is PURE (cart text + spec in, driven cart +
25
+ * command blocks out), so every load-bearing verified fact (ADR-0011) is
26
+ * unit-tested WITHOUT the paid PICO-8 binary: the btn/btnp shim, the callback
27
+ * wrap, btnp-edge reconstruction, the fixed-size opcode/block codec, and the
28
+ * opt-in srand. Live drive-and-capture is the manual/opt-in tier.
29
+ */
30
+
31
+ const HEADER = 'pico-8 cartridge // http://www.pico-8.com\nversion 42\n';
32
+
33
+ /** A cart with a normal `_update`/`_draw` reading real buttons. */
34
+ const NORMAL_CART =
35
+ `${HEADER}__lua__\n` +
36
+ 'x=64\n' +
37
+ 'function _update() if btnp(4) then x+=1 end end\n' +
38
+ 'function _draw() cls() circfill(x,64,4,8) end\n';
39
+
40
+ describe('btnpEdges (the btnp-edge reconstruction, ADR-0011)', () => {
41
+ it('a single press = exactly one edge frame', () => {
42
+ // held: off, on, off -> edge only on the 0->1 frame.
43
+ expect(btnpEdges([0, 0b1, 0])).toEqual([0, 0b1, 0]);
44
+ });
45
+
46
+ it('a hold = one edge then held (no repeat)', () => {
47
+ // held on for four frames -> edge on the first, then nothing.
48
+ expect(btnpEdges([0b1, 0b1, 0b1, 0b1])).toEqual([0b1, 0, 0, 0]);
49
+ });
50
+
51
+ it('release + press = two edges', () => {
52
+ // on, off, on -> edge on frame 0 and frame 2 (the re-press).
53
+ expect(btnpEdges([0b1, 0, 0b1])).toEqual([0b1, 0, 0b1]);
54
+ });
55
+
56
+ it('tracks each button bit independently', () => {
57
+ // O (bit4) held while Right (bit1) pulses: each has its own edge.
58
+ const held = [0b10000, 0b10010, 0b10000, 0b10010];
59
+ expect(btnpEdges(held)).toEqual([0b10000, 0b00010, 0, 0b00010]);
60
+ });
61
+ });
62
+
63
+ describe('pressesToHeld (level-signal expansion)', () => {
64
+ it('sets a bit for every frame in a press window', () => {
65
+ const held = pressesToHeld([{from: 1, to: 3, bit: BUTTON_BITS.o}], 5);
66
+ expect(held).toEqual([0, 0b10000, 0b10000, 0b10000, 0]);
67
+ });
68
+
69
+ it('ORs overlapping presses of different buttons', () => {
70
+ const held = pressesToHeld(
71
+ [
72
+ {from: 0, to: 1, bit: BUTTON_BITS.o},
73
+ {from: 1, to: 1, bit: BUTTON_BITS.right},
74
+ ],
75
+ 2,
76
+ );
77
+ expect(held).toEqual([0b10000, 0b10010]);
78
+ });
79
+ });
80
+
81
+ describe('parseInputScript (the --input grammar)', () => {
82
+ it('parses single-frame presses and hold windows', () => {
83
+ expect(parseInputScript('3:4, 18-22:4, 20:1')).toEqual([
84
+ {from: 3, to: 3, bit: 4},
85
+ {from: 18, to: 22, bit: 4},
86
+ {from: 20, to: 20, bit: 1},
87
+ ]);
88
+ });
89
+
90
+ it('rejects an out-of-range button bit (structured, not silent)', () => {
91
+ expect(() => parseInputScript('3:9')).toThrowError(DriveError);
92
+ try {
93
+ parseInputScript('3:9');
94
+ } catch (e) {
95
+ expect((e as DriveError).code).toBe('playtest-input-invalid');
96
+ }
97
+ });
98
+
99
+ it('rejects a malformed token', () => {
100
+ expect(() => parseInputScript('press-start')).toThrowError(DriveError);
101
+ });
102
+
103
+ it('rejects a backwards hold window', () => {
104
+ expect(() => parseInputScript('22-18:4')).toThrowError(DriveError);
105
+ });
106
+
107
+ it('accepts button NAMES + keys in the timeline (unified vocabulary)', () => {
108
+ // 3:o == 3:4, 20:right == 20:1, and z (PICO-8 key) == the O button (4).
109
+ expect(parseInputScript('3:o, 20:right')).toEqual([
110
+ {from: 3, to: 3, bit: BUTTON_BITS.o},
111
+ {from: 20, to: 20, bit: BUTTON_BITS.right},
112
+ ]);
113
+ expect(parseInputScript('5:z')).toEqual([{from: 5, to: 5, bit: 4}]);
114
+ });
115
+
116
+ it('accepts a BARE button (no frame:) as a press at frame 0', () => {
117
+ // The "just press this" form, matching the session `input` verb, so an agent
118
+ // that types `--input "z"` (the natural attempt) gets a press, not an error.
119
+ expect(parseInputScript('z')).toEqual([{from: 0, to: 0, bit: 4}]);
120
+ });
121
+
122
+ it('accepts a bare button LIST (space- or comma-separated) at frame 0', () => {
123
+ expect(parseInputScript('o right')).toEqual([
124
+ {from: 0, to: 0, bit: BUTTON_BITS.o},
125
+ {from: 0, to: 0, bit: BUTTON_BITS.right},
126
+ ]);
127
+ });
128
+ });
129
+
130
+ describe('parseButtons (the live-session input grammar, ADR-0011 US #6)', () => {
131
+ it('parses button NAMES into a single held byte', () => {
132
+ expect(parseButtons('right o')).toBe(
133
+ (1 << BUTTON_BITS.right) | (1 << BUTTON_BITS.o),
134
+ );
135
+ });
136
+
137
+ it('accepts single-letter aliases + commas + case', () => {
138
+ expect(parseButtons('L, R')).toBe(
139
+ (1 << BUTTON_BITS.left) | (1 << BUTTON_BITS.right),
140
+ );
141
+ });
142
+
143
+ it('also accepts the z key + bit digits (shared vocabulary with --input)', () => {
144
+ expect(parseButtons('z')).toBe(1 << BUTTON_BITS.o);
145
+ expect(parseButtons('4 1')).toBe(
146
+ (1 << BUTTON_BITS.o) | (1 << BUTTON_BITS.right),
147
+ );
148
+ });
149
+
150
+ it('an empty spec is the byte 0 (release all)', () => {
151
+ expect(parseButtons('')).toBe(0);
152
+ expect(parseButtons(' , ')).toBe(0);
153
+ });
154
+
155
+ it('rejects an unknown button (structured, not silent)', () => {
156
+ expect(() => parseButtons('jump')).toThrowError(DriveError);
157
+ try {
158
+ parseButtons('jump');
159
+ } catch (e) {
160
+ expect((e as DriveError).code).toBe('playtest-input-invalid');
161
+ }
162
+ });
163
+ });
164
+
165
+ describe('the drive shim ACK handshake (the resumable session waits on it)', () => {
166
+ it('a STEP acks on COMPLETION (budget reaches 0), not at read time', () => {
167
+ const {cartText} = buildDriveHarness(NORMAL_CART);
168
+ // The STEP-DONE ack is printed when the budget hits 0 (frames advanced),
169
+ // so the host learns the N frames ACTUALLY ran, never mid-step.
170
+ expect(cartText).toContain(
171
+ `if __drv_budget==0 then printh("${ACK.stepDone}")`,
172
+ );
173
+ // The STEP opcode branch itself does NOT printh a read-time step ack.
174
+ expect(cartText).not.toContain(`__drv_budget=arg printh`);
175
+ });
176
+
177
+ it('INPUT/SHOT/PAUSE ack at read time (they complete their frame)', () => {
178
+ const {cartText} = buildDriveHarness(NORMAL_CART);
179
+ expect(cartText).toContain(`printh("${ACK.input}")`);
180
+ expect(cartText).toContain(`printh("${ACK.shot}")`);
181
+ expect(cartText).toContain(`printh("${ACK.pause}")`);
182
+ });
183
+ });
184
+
185
+ describe('the opcode/block codec (fixed-size, ADR-0011)', () => {
186
+ it('encodes each command into exactly BLOCK_SIZE bytes', () => {
187
+ const blocks = encodeScript([
188
+ {op: 'input', held: 0b10000},
189
+ {op: 'step', frames: 3},
190
+ {op: 'shot'},
191
+ {op: 'quit'},
192
+ ]);
193
+ expect(blocks.length).toBe(4 * BLOCK_SIZE);
194
+ // input block: opcode, held, pad, pad
195
+ expect([...blocks.slice(0, BLOCK_SIZE)]).toEqual([
196
+ OPCODE.input,
197
+ 0b10000,
198
+ 0,
199
+ 0,
200
+ ]);
201
+ // step block carries the frame count as its arg
202
+ expect([...blocks.slice(BLOCK_SIZE, 2 * BLOCK_SIZE)]).toEqual([
203
+ OPCODE.step,
204
+ 3,
205
+ 0,
206
+ 0,
207
+ ]);
208
+ });
209
+
210
+ it('a script decodes back to the exact commands', () => {
211
+ const script = buildDriveScript({
212
+ presses: [{from: 0, to: 0, bit: BUTTON_BITS.o}],
213
+ frames: 2,
214
+ shotFrames: [1],
215
+ });
216
+ const decoded = new BlockDecoder().push(encodeScript(script));
217
+ expect(decoded).toEqual(script);
218
+ });
219
+
220
+ it('a block split across two reads still decodes exactly once', () => {
221
+ // The load-bearing coalesce/split property: a block delivered in two
222
+ // arbitrary-sized chunks yields exactly one command, no more, no less.
223
+ const blocks = encodeScript([{op: 'step', frames: 7}, {op: 'shot'}]);
224
+ const decoder = new BlockDecoder();
225
+ // Split at byte 3 (mid-way through the first block) and again mid-second.
226
+ expect(decoder.push(blocks.slice(0, 3))).toEqual([]); // partial: nothing yet
227
+ const afterFirst = decoder.push(blocks.slice(3, 5)); // completes block 1, starts 2
228
+ expect(afterFirst).toEqual([{op: 'step', frames: 7}]);
229
+ const afterRest = decoder.push(blocks.slice(5));
230
+ expect(afterRest).toEqual([{op: 'shot'}]);
231
+ });
232
+ });
233
+
234
+ describe('buildDriveScript (the one-shot command sequence)', () => {
235
+ it('interleaves INPUT/STEP and SHOT at the shot frames, then QUIT', () => {
236
+ const script = buildDriveScript({
237
+ presses: [{from: 0, to: 0, bit: BUTTON_BITS.o}],
238
+ frames: 3,
239
+ shotFrames: [2],
240
+ });
241
+ // It ends with a single QUIT.
242
+ expect(script[script.length - 1]).toEqual({op: 'quit'});
243
+ // Exactly one SHOT (at frame 2).
244
+ expect(script.filter((c) => c.op === 'shot')).toHaveLength(1);
245
+ // One STEP per frame (3 frames).
246
+ expect(script.filter((c) => c.op === 'step')).toHaveLength(3);
247
+ });
248
+
249
+ it('re-sends INPUT only when the held level changes (compact stream)', () => {
250
+ // Frame 0 press O, frames 1-2 nothing: INPUT changes 0b10000 -> 0 once each.
251
+ const script = buildDriveScript({
252
+ presses: [{from: 0, to: 0, bit: BUTTON_BITS.o}],
253
+ frames: 3,
254
+ shotFrames: [],
255
+ });
256
+ const inputs = script.filter((c) => c.op === 'input');
257
+ expect(inputs).toEqual([
258
+ {op: 'input', held: 0b10000},
259
+ {op: 'input', held: 0},
260
+ ]);
261
+ });
262
+ });
263
+
264
+ describe('resolveIncludes (#include inlining so the driven cart is self-contained)', () => {
265
+ it('inlines a single #include with the resolved file text', () => {
266
+ const out = resolveIncludes('#include main.lua', (p) =>
267
+ p === 'main.lua' ? 'function _update() end' : undefined,
268
+ );
269
+ expect(out).toBe('function _update() end');
270
+ });
271
+
272
+ it('leaves an unresolvable #include verbatim (best-effort)', () => {
273
+ const out = resolveIncludes('#include gone.lua', () => undefined);
274
+ expect(out).toBe('#include gone.lua');
275
+ });
276
+
277
+ it('leaves the lua unchanged when no resolver is given', () => {
278
+ expect(resolveIncludes('#include main.lua', undefined)).toBe(
279
+ '#include main.lua',
280
+ );
281
+ });
282
+
283
+ it('resolves recursively (an included file may itself #include)', () => {
284
+ const files: Record<string, string> = {
285
+ 'main.lua': 'a=1\n#include more.lua',
286
+ 'more.lua': 'b=2',
287
+ };
288
+ expect(resolveIncludes('#include main.lua', (p) => files[p])).toBe(
289
+ 'a=1\nb=2',
290
+ );
291
+ });
292
+
293
+ it('guards against an include cycle (leaves the repeat verbatim)', () => {
294
+ const files: Record<string, string> = {'a.lua': '#include a.lua'};
295
+ // Does not infinite-loop; the self-reference is left as-is.
296
+ expect(resolveIncludes('#include a.lua', (p) => files[p])).toBe(
297
+ '#include a.lua',
298
+ );
299
+ });
300
+ });
301
+
302
+ describe('buildDriveHarness (the throwaway driven cart, ADR-0011)', () => {
303
+ it('inlines #include so a scaffold cart\u2019s callbacks are wrapped + self-contained', () => {
304
+ // picopilot\u2019s standard scaffold: __lua__ is a single `#include main.lua`,
305
+ // the real _update/_draw live in main.lua. Without inlining, definedCallbacks
306
+ // sees no callback AND the driven cart can\u2019t #include from its temp dir.
307
+ const scaffold = `${HEADER}__lua__\n#include main.lua\n`;
308
+ const {cartText} = buildDriveHarness(scaffold, {
309
+ readInclude: (p) =>
310
+ p === 'main.lua'
311
+ ? 'function _update() end\nfunction _draw() cls() end'
312
+ : undefined,
313
+ });
314
+ // The include is gone (inlined), the real callback is present + wrapped.
315
+ expect(cartText).not.toContain('#include');
316
+ expect(cartText).toContain('__drv_tick=_update');
317
+ expect(cartText).toContain('function _draw()');
318
+ });
319
+
320
+ it('prepends the btn/btnp shim reading serial(0x804)', () => {
321
+ const {cartText} = buildDriveHarness(NORMAL_CART);
322
+ expect(cartText).toContain('serial(0x804');
323
+ expect(cartText).toContain('function btn(i,p)');
324
+ expect(cartText).toContain('function btnp(i,p)');
325
+ // btnp is an EDGE (held & ~prev), not the raw level.
326
+ expect(cartText).toContain('__drv_prev');
327
+ });
328
+
329
+ it('wraps whichever ticked callback the cart defines (_update)', () => {
330
+ const {cartText} = buildDriveHarness(NORMAL_CART);
331
+ // Captures the cart's own _update as the ticked callback, then owns _update.
332
+ expect(cartText).toContain('__drv_tick=_update');
333
+ expect(cartText).toContain('function _update()');
334
+ });
335
+
336
+ it('wraps _update60 for a 60fps cart', () => {
337
+ const cart = `${HEADER}__lua__\nfunction _update60() end\nfunction _draw() end\n`;
338
+ const {cartText} = buildDriveHarness(cart);
339
+ expect(cartText).toContain('__drv_tick=_update60');
340
+ });
341
+
342
+ it('is no-op-safe for a draw-only cart (no _update at all)', () => {
343
+ const drawOnly = `${HEADER}__lua__\nfunction _draw() cls() print("hi") end\n`;
344
+ const {cartText} = buildDriveHarness(drawOnly);
345
+ // No ticked callback is captured, but the harness still installs _update
346
+ // (so the drain/shot loop runs) and wraps _draw.
347
+ expect(cartText).not.toContain('__drv_tick=');
348
+ expect(cartText).toContain('function _update()');
349
+ expect(cartText).toContain('__drv_draw=_draw');
350
+ // The produced cart still parses.
351
+ expect(() => Cart.parse(cartText)).not.toThrow();
352
+ });
353
+
354
+ it('encodes the given script into the exact fixed-size blocks', () => {
355
+ const {blocks, script} = buildDriveHarness(NORMAL_CART, {
356
+ presses: [{from: 0, to: 0, bit: BUTTON_BITS.o}],
357
+ frames: 2,
358
+ shotFrames: [1],
359
+ });
360
+ expect(blocks.length).toBe(script.length * BLOCK_SIZE);
361
+ expect(new BlockDecoder().push(blocks)).toEqual(script);
362
+ });
363
+
364
+ it('injects srand(n) iff a seed is given (opt-in, never silent)', () => {
365
+ expect(buildDriveHarness(NORMAL_CART, {seed: 42}).cartText).toContain(
366
+ 'srand(42)',
367
+ );
368
+ expect(buildDriveHarness(NORMAL_CART).cartText).not.toContain('srand(');
369
+ });
370
+
371
+ it('leaves the entry cart bytes UNTOUCHED (transforms a copy)', () => {
372
+ const before = NORMAL_CART;
373
+ buildDriveHarness(before, {seed: 1});
374
+ expect(before).toBe(NORMAL_CART); // the input string is not mutated
375
+ // And the entry's non-lua sections survive byte-for-byte.
376
+ const original = Cart.parse(NORMAL_CART);
377
+ const driven = Cart.parse(buildDriveHarness(NORMAL_CART).cartText);
378
+ expect(driven.getSection('gfx')).toBe(original.getSection('gfx'));
379
+ });
380
+
381
+ it('freezes BOTH the ticked callback and _draw when paused (budget-gated)', () => {
382
+ const {cartText} = buildDriveHarness(NORMAL_CART);
383
+ // The ticked callback only advances while __drv_budget>0.
384
+ expect(cartText).toContain('if __drv_budget>0 then');
385
+ // _draw is gated so a paused frame is stable (not re-run every frame).
386
+ expect(cartText).toContain('__drv_advanced');
387
+ });
388
+
389
+ it('names screenshots with the shot basename and prints the sentinel on quit', () => {
390
+ const {cartText, shotBasename} = buildDriveHarness(NORMAL_CART);
391
+ expect(shotBasename).toBe(SHOT_BASENAME);
392
+ // The SHOT names a pending screenshot `<basename><n>` then screenshots it
393
+ // AFTER the cart's draw (so the capture is the frame just rendered).
394
+ expect(cartText).toContain(`"${SHOT_BASENAME}"..__drv_shot`);
395
+ expect(cartText).toContain('extcmd("set_filename",__drv_pshot)');
396
+ expect(cartText).toContain(`printh("${DONE_SENTINEL}")`);
397
+ });
398
+
399
+ it('uses the generic input default when no presses are given', () => {
400
+ const {script} = buildDriveHarness(NORMAL_CART);
401
+ // The generic default presses O to start (bit 4) at frame 3.
402
+ const firstPress = script.find((c) => c.op === 'input' && c.held !== 0);
403
+ expect(firstPress).toEqual({op: 'input', held: 1 << BUTTON_BITS.o});
404
+ expect(GENERIC_INPUT[0]).toEqual({from: 3, to: 3, bit: BUTTON_BITS.o});
405
+ });
406
+
407
+ it('refuses an unparseable cart (structured, not a crash)', () => {
408
+ try {
409
+ buildDriveHarness('not a cart');
410
+ throw new Error('expected a refusal');
411
+ } catch (e) {
412
+ expect((e as DriveError).code).toBe('playtest-cart-parse-error');
413
+ }
414
+ });
415
+ });