picopilot 0.0.0 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (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 +202 -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 +217 -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 +460 -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 +238 -0
  243. package/src/commands/run.ts +259 -0
  244. package/src/commands/serve.test.ts +218 -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 +236 -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,645 @@
1
+ import {existsSync, readFileSync, writeFileSync} from 'node:fs';
2
+ import {basename, dirname, extname, isAbsolute, join, resolve} from 'node:path';
3
+ import {Cli, z} from 'incur';
4
+
5
+ import {
6
+ Cart,
7
+ CartParseError,
8
+ GFX_WIDTH,
9
+ GfxSheet,
10
+ SPRITE_SIZE,
11
+ spriteAliasesMap,
12
+ } from '../engine/cart/index.js';
13
+ import {
14
+ decideOverlap,
15
+ GfxGridError,
16
+ gridToNibbles,
17
+ nibblesToGrid,
18
+ renderSheetPng,
19
+ renderSpritePng,
20
+ SHEET_RENDER_SCALE,
21
+ SPRITE_RENDER_SCALE,
22
+ } from '../engine/gfx/index.js';
23
+ import {
24
+ type ShrinkoAdapter,
25
+ ShellShrinkoAdapter,
26
+ } from '../engine/shrinko/index.js';
27
+
28
+ /** Injects the shrinko adapter `gfx import`/`gfx export` use (the spritesheet round-trip). */
29
+ export type ShrinkoAdapterFactory = (env: NodeJS.ProcessEnv) => ShrinkoAdapter;
30
+
31
+ const defaultShrinkoFactory: ShrinkoAdapterFactory = (env) =>
32
+ new ShellShrinkoAdapter({env});
33
+
34
+ /**
35
+ * The exit code for the `map-overlap` refusal. Chosen as 1 (a plain failure),
36
+ * matching every other picopilot structured refusal (`cart-not-found`,
37
+ * `already-initialised`, `verify-failed`): the refusal is a "you asked for
38
+ * something I won't do" failure, not a distinct capability-gap the way `verify`'s
39
+ * `gate-incapable` (exit 2) is. What makes it safe is not a special exit code
40
+ * but that it is NON-ZERO (an agent must not read it as done) AND the sprite's
41
+ * bytes are left untouched.
42
+ */
43
+ export const MAP_OVERLAP_EXIT = 1;
44
+
45
+ /** Resolves a cart argument to an absolute path (relative to cwd). */
46
+ function resolveCart(cart: string): string {
47
+ return isAbsolute(cart) ? cart : resolve(process.cwd(), cart);
48
+ }
49
+
50
+ /**
51
+ * The default PNG output path for a `gfx render`: alongside the cart, named
52
+ * `<cart-stem>-sprite-<n>.png` or `<cart-stem>-sheet.png`. Predictable + stable
53
+ * so an agent can render → look → fix → re-render at the SAME path without
54
+ * juggling filenames.
55
+ */
56
+ function defaultRenderPath(cartPath: string, target: 'sheet' | number): string {
57
+ const stem = basename(cartPath, extname(cartPath));
58
+ const suffix = target === 'sheet' ? 'sheet' : `sprite-${target}`;
59
+ return join(dirname(cartPath), `${stem}-${suffix}.png`);
60
+ }
61
+
62
+ /**
63
+ * Loads + parses a cart from `cartPath`, returning either the parsed cart and
64
+ * its source text or a structured reason the caller turns into an `error(...)`.
65
+ * Keeps the fs + parse boilerplate out of both command handlers.
66
+ */
67
+ function loadCart(
68
+ cartPath: string,
69
+ ):
70
+ | {ok: true; cart: Cart; text: string}
71
+ | {ok: false; code: string; message: string} {
72
+ if (!existsSync(cartPath)) {
73
+ return {
74
+ ok: false,
75
+ code: 'cart-not-found',
76
+ message: `no cart at ${cartPath}`,
77
+ };
78
+ }
79
+ const text = readFileSync(cartPath, 'utf8');
80
+ try {
81
+ return {ok: true, cart: Cart.parse(text), text};
82
+ } catch (e) {
83
+ if (e instanceof CartParseError) {
84
+ return {
85
+ ok: false,
86
+ code: 'cart-parse-failed',
87
+ message: `cart at ${cartPath} does not parse (${e.code}): ${e.message}`,
88
+ };
89
+ }
90
+ throw e;
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Builds the `gfx` command group: `gfx show` (read a sprite as a char grid),
96
+ * `gfx set` (write a char grid back into `__gfx__`, with the map-overlap
97
+ * smart-refuse), and `gfx render` (upscaled palette-accurate PNG, the JUDGE
98
+ * surface). Returned as its own `Cli` so it mounts under the root as a
99
+ * group, which also gives the `gfx.set.options.allowMapOverlap` config path its
100
+ * `picopilot.json` reads through (incur keys config by command path).
101
+ */
102
+ function buildGfxGroup(shrinkoFactory: ShrinkoAdapterFactory): Cli.Cli {
103
+ const gfx = Cli.create('gfx', {
104
+ description:
105
+ 'Read and write sprites as a readable char grid (the pixel-art EDIT surface).',
106
+ });
107
+
108
+ gfx.command('show', {
109
+ description:
110
+ 'Render a sprite from __gfx__ into a readable char grid (. = transparent, 0-f = the 16 colours). CTAs to `gfx set`.',
111
+ args: z.object({
112
+ sprite: z.coerce
113
+ .number()
114
+ .int()
115
+ .min(0)
116
+ .max(255)
117
+ .describe('The sprite index 0-255 to render.'),
118
+ cart: z
119
+ .string()
120
+ .default('main.p8')
121
+ .describe(
122
+ 'The .p8 cart to read. Defaults to main.p8 in the current folder.',
123
+ ),
124
+ }),
125
+ output: z.object({
126
+ sprite: z.number().describe('The sprite index that was rendered.'),
127
+ grid: z
128
+ .string()
129
+ .describe(
130
+ 'The char grid: 8 lines of 8 chars, . = transparent, 0-f = colours 1-15.',
131
+ ),
132
+ aliasesMap: z
133
+ .boolean()
134
+ .describe(
135
+ 'True for sprites 128-255, which alias the shared map region (see `gfx set`).',
136
+ ),
137
+ }),
138
+ examples: [
139
+ {description: 'Show sprite 1 of main.p8', args: {sprite: 1}},
140
+ {
141
+ description: 'Show sprite 3 of a specific cart',
142
+ args: {sprite: 3, cart: 'game.p8'},
143
+ },
144
+ ],
145
+ run({args, error, ok}) {
146
+ const cartPath = resolveCart(args.cart);
147
+ const loaded = loadCart(cartPath);
148
+ if (!loaded.ok) {
149
+ return error({code: loaded.code, message: loaded.message, exitCode: 1});
150
+ }
151
+
152
+ const sheet = GfxSheet.fromBody(loaded.cart.getSection('gfx'));
153
+ const grid = nibblesToGrid(sheet.getSprite(args.sprite));
154
+
155
+ // The read → fix → write loop: `gfx show` always CTAs to `gfx set` so an
156
+ // agent that sees a broken sprite is led straight to the edit surface.
157
+ return ok(
158
+ {
159
+ sprite: args.sprite,
160
+ grid,
161
+ aliasesMap: spriteAliasesMap(args.sprite),
162
+ },
163
+ {
164
+ cta: {
165
+ description: 'Edit the grid, then write it back with:',
166
+ commands: [
167
+ {
168
+ command: `gfx set ${args.sprite}`,
169
+ description: 'Write an edited char grid back into __gfx__.',
170
+ },
171
+ ],
172
+ },
173
+ },
174
+ );
175
+ },
176
+ });
177
+
178
+ gfx.command('set', {
179
+ description:
180
+ 'Write a char grid back into a sprite in __gfx__. Sprites 128-255 alias the shared map region: a write that would clobber existing data is refused unless authorised (--allow-map-overlap / allowMapOverlap config).',
181
+ args: z.object({
182
+ sprite: z.coerce
183
+ .number()
184
+ .int()
185
+ .min(0)
186
+ .max(255)
187
+ .describe('The sprite index 0-255 to write.'),
188
+ grid: z
189
+ .string()
190
+ .describe(
191
+ 'The char grid: 8 lines of 8 chars, . = transparent, 0-f = colours 1-15.',
192
+ ),
193
+ cart: z
194
+ .string()
195
+ .default('main.p8')
196
+ .describe(
197
+ 'The .p8 cart to write. Defaults to main.p8 in the current folder.',
198
+ ),
199
+ }),
200
+ options: z.object({
201
+ allowMapOverlap: z
202
+ .boolean()
203
+ .default(false)
204
+ .describe(
205
+ 'Authorise overwriting existing shared-region data when writing sprites 128-255 (ADR-0004). Also settable as allowMapOverlap in picopilot.json.',
206
+ ),
207
+ }),
208
+ output: z.object({
209
+ sprite: z.number().describe('The sprite index that was written.'),
210
+ cart: z
211
+ .string()
212
+ .describe('The absolute path of the cart that was written.'),
213
+ aliasesMap: z
214
+ .boolean()
215
+ .describe(
216
+ 'True for sprites 128-255, which alias the shared map region.',
217
+ ),
218
+ overwroteSharedData: z
219
+ .boolean()
220
+ .describe(
221
+ 'True when this write overwrote existing shared-region data (authorised).',
222
+ ),
223
+ note: z
224
+ .string()
225
+ .optional()
226
+ .describe(
227
+ 'An advisory note (e.g. the shared-region aliasing) when relevant.',
228
+ ),
229
+ }),
230
+ examples: [
231
+ {
232
+ description: 'Write an edited grid into sprite 1',
233
+ args: {
234
+ sprite: 1,
235
+ grid: '........\n........\n........\n........\n........\n........\n........\n........',
236
+ },
237
+ },
238
+ ],
239
+ run({args, options, error, ok}) {
240
+ const cartPath = resolveCart(args.cart);
241
+
242
+ // Decode the incoming grid FIRST: a malformed grid is a caller error and
243
+ // must never touch the cart. (No bytes change on a bad grid.)
244
+ let nibbles: number[][];
245
+ try {
246
+ nibbles = gridToNibbles(args.grid);
247
+ } catch (e) {
248
+ if (e instanceof GfxGridError) {
249
+ return error({
250
+ code: 'invalid-grid',
251
+ message: `the char grid is malformed: ${e.message}`,
252
+ exitCode: 1,
253
+ });
254
+ }
255
+ throw e;
256
+ }
257
+
258
+ const loaded = loadCart(cartPath);
259
+ if (!loaded.ok) {
260
+ return error({code: loaded.code, message: loaded.message, exitCode: 1});
261
+ }
262
+
263
+ const sheet = GfxSheet.fromBody(loaded.cart.getSection('gfx'));
264
+
265
+ // The overlap decision reads the target sprite's CURRENT __gfx__ pixels
266
+ // (the shared-bank bytes at risk), NOT __map__ (ADR-0004). The flag and
267
+ // the picopilot.json config both feed `options.allowMapOverlap` (incur
268
+ // resolves argv > config > default), so both authorisation paths converge
269
+ // here.
270
+ const decision = decideOverlap(
271
+ sheet,
272
+ args.sprite,
273
+ options.allowMapOverlap,
274
+ );
275
+
276
+ if (decision.kind === 'refused') {
277
+ // The silent-corruption guard: REFUSE and DO NOT write. The cart file is
278
+ // never opened for writing on this path, so the sprite's __gfx__ bytes
279
+ // stay byte-identical. Structured envelope (code + detail + remedy) with
280
+ // a nonzero exit so an agent stops rather than treating exit-0 as done.
281
+ return error({
282
+ code: 'map-overlap',
283
+ message: `sprite ${args.sprite} aliases the shared map region and its current __gfx__ pixels hold data (${decision.nonZeroPixels} non-zero); refusing to silently overwrite it. Remedy: pass --allow-map-overlap, set allowMapOverlap in picopilot.json, or target a sprite 0-127.`,
284
+ exitCode: MAP_OVERLAP_EXIT,
285
+ cta: {
286
+ description: 'To proceed anyway (this overwrites the shared data):',
287
+ commands: [
288
+ {
289
+ command: `gfx set ${args.sprite} --allow-map-overlap`,
290
+ description: 'Re-run authorising the shared-region overwrite.',
291
+ },
292
+ ],
293
+ },
294
+ });
295
+ }
296
+
297
+ // Allowed: write the sprite and persist. Preserve every other section by
298
+ // committing through the cart model (only __gfx__ changes).
299
+ sheet.setSprite(args.sprite, nibbles);
300
+ sheet.commit(loaded.cart);
301
+ writeFileSync(cartPath, loaded.cart.serialize());
302
+
303
+ const overwroteSharedData =
304
+ decision.kind === 'allowed-shared' && decision.reason === 'authorised';
305
+ const note =
306
+ decision.kind === 'allowed-shared'
307
+ ? decision.reason === 'authorised'
308
+ ? `sprite ${args.sprite} aliases the shared map region; overwrote existing shared data (authorised).`
309
+ : `sprite ${args.sprite} aliases the shared map region; it was empty, so the write is safe.`
310
+ : undefined;
311
+
312
+ return ok({
313
+ sprite: args.sprite,
314
+ cart: cartPath,
315
+ aliasesMap: spriteAliasesMap(args.sprite),
316
+ overwroteSharedData,
317
+ note,
318
+ });
319
+ },
320
+ });
321
+
322
+ gfx.command('render', {
323
+ description:
324
+ 'Render a sprite (0-255) or the whole `sheet` to an UPSCALED, palette-accurate PNG a multimodal agent can LOOK at (the JUDGE surface). Always reports BOTH the PNG path AND the char grid. CTAs: render -> set -> render.',
325
+ args: z.object({
326
+ target: z
327
+ .string()
328
+ .describe(
329
+ 'A sprite index 0-255 to render, or the literal `sheet` for the whole 128x128 spritesheet.',
330
+ ),
331
+ cart: z
332
+ .string()
333
+ .default('main.p8')
334
+ .describe(
335
+ 'The .p8 cart to read. Defaults to main.p8 in the current folder.',
336
+ ),
337
+ }),
338
+ options: z.object({
339
+ out: z
340
+ .string()
341
+ .optional()
342
+ .describe(
343
+ 'Output PNG path. Defaults to <cart-stem>-sprite-<n>.png / <cart-stem>-sheet.png next to the cart.',
344
+ ),
345
+ }),
346
+ output: z.object({
347
+ target: z
348
+ .string()
349
+ .describe(
350
+ 'What was rendered: a sprite index (as a string) or `sheet`.',
351
+ ),
352
+ png: z
353
+ .string()
354
+ .describe(
355
+ 'The absolute path of the PNG written (the JUDGE surface: LOOK at it).',
356
+ ),
357
+ width: z
358
+ .number()
359
+ .describe('The rendered PNG width in pixels (upscaled).'),
360
+ height: z
361
+ .number()
362
+ .describe('The rendered PNG height in pixels (upscaled).'),
363
+ grid: z
364
+ .string()
365
+ .optional()
366
+ .describe(
367
+ 'The sprite char grid (. = transparent, 0-f = colours) so a non-multimodal agent can reason over it; absent for `sheet`.',
368
+ ),
369
+ aliasesMap: z
370
+ .boolean()
371
+ .describe(
372
+ 'True for a single sprite 128-255, which aliases the shared map region (see `gfx set`).',
373
+ ),
374
+ }),
375
+ examples: [
376
+ {description: 'Render sprite 1 to a viewable PNG', args: {target: '1'}},
377
+ {description: 'Render the whole spritesheet', args: {target: 'sheet'}},
378
+ {
379
+ description: 'Render sprite 3 of a specific cart to a chosen path',
380
+ args: {target: '3', cart: 'game.p8'},
381
+ options: {out: 'sprite3.png'},
382
+ },
383
+ ],
384
+ run({args, options, error, ok}) {
385
+ // Parse the target: the literal `sheet` or a sprite index 0-255. A bad
386
+ // target is a caller error and never opens the cart.
387
+ const isSheet = args.target === 'sheet';
388
+ let sprite = -1;
389
+ if (!isSheet) {
390
+ sprite = Number(args.target);
391
+ if (!Number.isInteger(sprite) || sprite < 0 || sprite > 255) {
392
+ return error({
393
+ code: 'invalid-target',
394
+ message: `render target must be \`sheet\` or a sprite index 0-255, got "${args.target}"`,
395
+ exitCode: 1,
396
+ });
397
+ }
398
+ }
399
+
400
+ const cartPath = resolveCart(args.cart);
401
+ const loaded = loadCart(cartPath);
402
+ if (!loaded.ok) {
403
+ return error({code: loaded.code, message: loaded.message, exitCode: 1});
404
+ }
405
+
406
+ const sheet = GfxSheet.fromBody(loaded.cart.getSection('gfx'));
407
+
408
+ // Render the palette-accurate, nearest-neighbour-upscaled PNG and write it
409
+ // to the known path. The encoder is shrinko-FREE (pure TS hex -> PNG).
410
+ const png = isSheet
411
+ ? renderSheetPng(sheet)
412
+ : renderSpritePng(sheet, sprite);
413
+ const outPath = options.out
414
+ ? resolveCart(options.out)
415
+ : defaultRenderPath(cartPath, isSheet ? 'sheet' : sprite);
416
+ writeFileSync(outPath, png);
417
+
418
+ // ALWAYS emit BOTH the PNG path and the grid (for a single sprite), so the
419
+ // picopilot-art skill can branch view-vs-imagine. A `sheet` render has no
420
+ // single 8x8 grid, so the grid is omitted there (use `gfx show <n>`).
421
+ const width = isSheet
422
+ ? GFX_WIDTH * SHEET_RENDER_SCALE
423
+ : SPRITE_SIZE * SPRITE_RENDER_SCALE;
424
+ const height = width;
425
+ const grid = isSheet ? undefined : nibblesToGrid(sheet.getSprite(sprite));
426
+ const aliasesMap = isSheet ? false : spriteAliasesMap(sprite);
427
+
428
+ // The see-and-fix loop: render (look) -> set (fix) -> render (re-look). A
429
+ // single sprite CTAs to `gfx set` then back to `gfx render` at the SAME
430
+ // target; `sheet` CTAs to `gfx show`/`gfx set` a specific sprite.
431
+ const cta = isSheet
432
+ ? {
433
+ description:
434
+ 'To edit a specific sprite from the sheet, then re-render:',
435
+ commands: [
436
+ {
437
+ command: 'gfx show <n>',
438
+ description: 'Read sprite n as an editable char grid.',
439
+ },
440
+ {
441
+ command: 'gfx render sheet',
442
+ description: 'Re-render the sheet to re-look.',
443
+ },
444
+ ],
445
+ }
446
+ : {
447
+ description:
448
+ 'Look at the PNG, then fix via the grid and re-render:',
449
+ commands: [
450
+ {
451
+ command: `gfx set ${sprite}`,
452
+ description: 'Write an edited char grid back into __gfx__.',
453
+ },
454
+ {
455
+ command: `gfx render ${sprite}`,
456
+ description: 'Re-render to re-look after the fix.',
457
+ },
458
+ ],
459
+ };
460
+
461
+ return ok(
462
+ {
463
+ target: isSheet ? 'sheet' : String(sprite),
464
+ png: outPath,
465
+ width,
466
+ height,
467
+ grid,
468
+ aliasesMap,
469
+ },
470
+ {cta},
471
+ );
472
+ },
473
+ });
474
+
475
+ // gfx export: the raw 128x128 spritesheet PNG round-trip for external tools
476
+ // (shrinko-backed; DISTINCT from `gfx render`'s upscaled JUDGE image).
477
+ gfx.command('export', {
478
+ description:
479
+ 'Export the cart spritesheet as a raw 128x128 PNG for external tools (via shrinko). Requires shrinko.',
480
+ args: z.object({
481
+ cart: z
482
+ .string()
483
+ .default('main.p8')
484
+ .describe('The .p8 cart to export from. Defaults to main.p8.'),
485
+ }),
486
+ options: z.object({
487
+ out: z
488
+ .string()
489
+ .optional()
490
+ .describe(
491
+ 'Output PNG path. Defaults to <name>-sheet.png next to the cart.',
492
+ ),
493
+ force: z
494
+ .boolean()
495
+ .default(false)
496
+ .describe(
497
+ 'Overwrite an existing output PNG. Default off (no-clobber).',
498
+ ),
499
+ }),
500
+ env: z.object({
501
+ PATH: z.string().optional().describe('Used to locate shrinko8.'),
502
+ }),
503
+ output: z.object({
504
+ outPath: z
505
+ .string()
506
+ .describe('Absolute path of the spritesheet PNG written.'),
507
+ }),
508
+ examples: [{description: 'Export main.p8 spritesheet to main-sheet.png'}],
509
+ async run({args, options, env, error, ok}) {
510
+ const cartPath = isAbsolute(args.cart)
511
+ ? args.cart
512
+ : resolve(process.cwd(), args.cart);
513
+ if (!existsSync(cartPath)) {
514
+ return error({
515
+ code: 'cart-not-found',
516
+ message: `no cart at ${cartPath}`,
517
+ exitCode: 1,
518
+ });
519
+ }
520
+ const outPath =
521
+ options.out !== undefined
522
+ ? isAbsolute(options.out)
523
+ ? options.out
524
+ : resolve(process.cwd(), options.out)
525
+ : join(
526
+ dirname(cartPath),
527
+ `${basename(cartPath, extname(cartPath))}-sheet.png`,
528
+ );
529
+ if (existsSync(outPath) && !options.force) {
530
+ return error({
531
+ code: 'output-exists',
532
+ message: `refusing to overwrite existing ${outPath}; pass --force to overwrite`,
533
+ exitCode: 1,
534
+ });
535
+ }
536
+ const result = await shrinkoFactory(
537
+ env as NodeJS.ProcessEnv,
538
+ ).exportSpritesheet(cartPath, outPath);
539
+ if (!result.ok) {
540
+ return error({
541
+ code: result.reason,
542
+ message: `shrinko is not installed. ${result.remedy} (needs: ${result.needs.join(', ')})`,
543
+ exitCode: 1,
544
+ });
545
+ }
546
+ return ok({outPath});
547
+ },
548
+ });
549
+
550
+ // gfx import: merge an external spritesheet PNG back into the cart's __gfx__.
551
+ // MERGE (not replace): code/map/sfx/music are preserved; only gfx changes.
552
+ gfx.command('import', {
553
+ description:
554
+ "Import a 128x128 spritesheet PNG into the cart's __gfx__ (merge; via shrinko). Requires shrinko.",
555
+ args: z.object({
556
+ png: z.string().describe('The spritesheet PNG to import.'),
557
+ cart: z
558
+ .string()
559
+ .default('main.p8')
560
+ .describe('The .p8 cart to import INTO. Defaults to main.p8.'),
561
+ }),
562
+ options: z.object({
563
+ out: z
564
+ .string()
565
+ .optional()
566
+ .describe(
567
+ 'Output cart. Defaults to writing back into the cart in place (like `gfx set`).',
568
+ ),
569
+ }),
570
+ env: z.object({
571
+ PATH: z.string().optional().describe('Used to locate shrinko8.'),
572
+ }),
573
+ output: z.object({
574
+ outPath: z
575
+ .string()
576
+ .describe('The cart written (the source cart when in place).'),
577
+ }),
578
+ examples: [
579
+ {description: 'Import sheet.png into main.p8', args: {png: 'sheet.png'}},
580
+ ],
581
+ async run({args, options, env, error, ok}) {
582
+ const cartPath = isAbsolute(args.cart)
583
+ ? args.cart
584
+ : resolve(process.cwd(), args.cart);
585
+ const pngPath = isAbsolute(args.png)
586
+ ? args.png
587
+ : resolve(process.cwd(), args.png);
588
+ if (!existsSync(cartPath)) {
589
+ return error({
590
+ code: 'cart-not-found',
591
+ message: `no cart at ${cartPath}`,
592
+ exitCode: 1,
593
+ });
594
+ }
595
+ if (!existsSync(pngPath)) {
596
+ return error({
597
+ code: 'png-not-found',
598
+ message: `no PNG at ${pngPath}`,
599
+ exitCode: 1,
600
+ });
601
+ }
602
+ // Default: write back into the cart in place, matching `gfx set` (import IS
603
+ // a gfx edit that produces the readable cart, not a build artifact). `--out`
604
+ // writes a separate cart instead.
605
+ const outPath =
606
+ options.out !== undefined
607
+ ? isAbsolute(options.out)
608
+ ? options.out
609
+ : resolve(process.cwd(), options.out)
610
+ : cartPath;
611
+ const result = await shrinkoFactory(
612
+ env as NodeJS.ProcessEnv,
613
+ ).importSpritesheet(cartPath, pngPath, outPath);
614
+ if (!result.ok) {
615
+ return error({
616
+ code: result.reason,
617
+ message: `shrinko is not installed. ${result.remedy} (needs: ${result.needs.join(', ')})`,
618
+ exitCode: 1,
619
+ });
620
+ }
621
+ return ok({outPath});
622
+ },
623
+ });
624
+
625
+ return gfx;
626
+ }
627
+
628
+ /**
629
+ * Registers the `gfx` command group (`gfx show` / `gfx set`) on the root CLI.
630
+ *
631
+ * `gfx` is a shrinko-FREE command group (ADR-0002): it reads/writes `__gfx__`
632
+ * entirely through the cart model + the TS char-grid codec, so it works with
633
+ * shrinko absent and never spawns a child process. `gfx set` carries the
634
+ * gfx/map overlap smart-refuse (ADR-0004): a write to a shared-bank sprite
635
+ * (128-255) whose current pixels hold data is refused unless authorised, so
636
+ * picopilot never SILENTLY destroys shared-region data. `gfx render` is likewise
637
+ * shrinko-free: a pure-TS hex->PNG encoder using PICO-8's fixed 16-colour palette
638
+ * (the eyes-loop stays dependency-light, ADR/US #7).
639
+ */
640
+ export function registerGfx(
641
+ cli: Cli.Cli,
642
+ shrinkoFactory: ShrinkoAdapterFactory = defaultShrinkoFactory,
643
+ ): void {
644
+ cli.command(buildGfxGroup(shrinkoFactory));
645
+ }