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,229 @@
1
+ /**
2
+ * The `__label__` builder: turn a 128x128 image into the hex a `.p8` cart's
3
+ * label section holds, so `picopilot export --label <png>` can give a labelless
4
+ * cart a loading splash WITHOUT an interactive PICO-8 (F7) capture.
5
+ *
6
+ * A PICO-8 label is a 128x128 grid of palette indices (0..15), serialised as 128
7
+ * rows of 128 hex nibbles. This module decodes a PNG to RGB (via Node's stdlib
8
+ * `zlib` inflate, no npm dependency), maps each pixel to the NEAREST PICO-8
9
+ * palette colour, and emits that hex. It is the inverse direction of the
10
+ * palette-accurate PNG RENDER in `render.ts`/`png.ts` (which goes indices -> RGB).
11
+ *
12
+ * It intentionally supports only the PNG subset a caller realistically produces
13
+ * for a 128x128 label: 8-bit, non-interlaced, colour types 2 (RGB) and 6 (RGBA),
14
+ * and (via the palette map) 0/3 are rejected with a clear error rather than
15
+ * guessed at. Anything else fails loud with a structured {@link LabelError}.
16
+ */
17
+
18
+ import {inflateSync} from 'node:zlib';
19
+ import {PICO8_PALETTE} from './palette.js';
20
+
21
+ /** The fixed PICO-8 label dimensions: a 128x128 image. */
22
+ export const LABEL_SIZE = 128;
23
+
24
+ /** A structured label-build failure (bad PNG, wrong size, unsupported format). */
25
+ export class LabelError extends Error {
26
+ constructor(
27
+ readonly code:
28
+ 'not-a-png' | 'unsupported-png' | 'wrong-size' | 'truncated-png',
29
+ message: string,
30
+ ) {
31
+ super(message);
32
+ this.name = 'LabelError';
33
+ }
34
+ }
35
+
36
+ const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
37
+
38
+ interface DecodedPng {
39
+ readonly width: number;
40
+ readonly height: number;
41
+ /** Row-major RGBA, 4 bytes per pixel. */
42
+ readonly rgba: Uint8Array;
43
+ }
44
+
45
+ /**
46
+ * Decodes an 8-bit, non-interlaced PNG (colour type 2 RGB or 6 RGBA) to RGBA.
47
+ * Uses `node:zlib` inflate for the IDAT stream and applies the five PNG scanline
48
+ * filters. Throws {@link LabelError} for anything outside this subset.
49
+ */
50
+ function decodePng(bytes: Uint8Array): DecodedPng {
51
+ for (let i = 0; i < PNG_SIGNATURE.length; i++) {
52
+ if (bytes[i] !== PNG_SIGNATURE[i]) {
53
+ throw new LabelError('not-a-png', 'not a PNG file (bad signature)');
54
+ }
55
+ }
56
+
57
+ let off = 8;
58
+ let width = 0;
59
+ let height = 0;
60
+ let bitDepth = 0;
61
+ let colourType = 0;
62
+ let interlace = 0;
63
+ const idat: Uint8Array[] = [];
64
+
65
+ const readU32 = (p: number): number =>
66
+ ((bytes[p]! << 24) |
67
+ (bytes[p + 1]! << 16) |
68
+ (bytes[p + 2]! << 8) |
69
+ bytes[p + 3]!) >>>
70
+ 0;
71
+
72
+ while (off + 8 <= bytes.length) {
73
+ const len = readU32(off);
74
+ const type = String.fromCharCode(
75
+ bytes[off + 4]!,
76
+ bytes[off + 5]!,
77
+ bytes[off + 6]!,
78
+ bytes[off + 7]!,
79
+ );
80
+ const dataStart = off + 8;
81
+ const dataEnd = dataStart + len;
82
+ if (dataEnd + 4 > bytes.length) {
83
+ throw new LabelError('truncated-png', 'PNG chunk runs past end of file');
84
+ }
85
+ if (type === 'IHDR') {
86
+ width = readU32(dataStart);
87
+ height = readU32(dataStart + 4);
88
+ bitDepth = bytes[dataStart + 8]!;
89
+ colourType = bytes[dataStart + 9]!;
90
+ interlace = bytes[dataStart + 12]!;
91
+ } else if (type === 'IDAT') {
92
+ idat.push(bytes.subarray(dataStart, dataEnd));
93
+ } else if (type === 'IEND') {
94
+ break;
95
+ }
96
+ off = dataEnd + 4; // skip the 4-byte CRC
97
+ }
98
+
99
+ if (
100
+ bitDepth !== 8 ||
101
+ interlace !== 0 ||
102
+ (colourType !== 2 && colourType !== 6)
103
+ ) {
104
+ throw new LabelError(
105
+ 'unsupported-png',
106
+ `unsupported PNG: need 8-bit non-interlaced RGB or RGBA (got bitDepth=${bitDepth}, colourType=${colourType}, interlace=${interlace})`,
107
+ );
108
+ }
109
+
110
+ const channels = colourType === 6 ? 4 : 3;
111
+ const compressed = Buffer.concat(idat.map((c) => Buffer.from(c)));
112
+ let raw: Buffer;
113
+ try {
114
+ raw = inflateSync(compressed);
115
+ } catch (e) {
116
+ throw new LabelError(
117
+ 'truncated-png',
118
+ `could not inflate PNG image data: ${(e as Error).message}`,
119
+ );
120
+ }
121
+
122
+ const stride = width * channels;
123
+ const rgba = new Uint8Array(width * height * 4);
124
+ const prev = new Uint8Array(stride);
125
+ let src = 0;
126
+ for (let y = 0; y < height; y++) {
127
+ const filter = raw[src++]!;
128
+ const line = new Uint8Array(stride);
129
+ for (let x = 0; x < stride; x++) {
130
+ const rawByte = raw[src++]!;
131
+ const a = x >= channels ? line[x - channels]! : 0; // left
132
+ const b = prev[x]!; // up
133
+ const c = x >= channels ? prev[x - channels]! : 0; // up-left
134
+ let val = rawByte;
135
+ switch (filter) {
136
+ case 0:
137
+ break; // None
138
+ case 1:
139
+ val = (rawByte + a) & 0xff;
140
+ break; // Sub
141
+ case 2:
142
+ val = (rawByte + b) & 0xff;
143
+ break; // Up
144
+ case 3:
145
+ val = (rawByte + ((a + b) >> 1)) & 0xff;
146
+ break; // Average
147
+ case 4:
148
+ val = (rawByte + paeth(a, b, c)) & 0xff;
149
+ break; // Paeth
150
+ default:
151
+ throw new LabelError(
152
+ 'unsupported-png',
153
+ `unsupported PNG scanline filter ${filter}`,
154
+ );
155
+ }
156
+ line[x] = val;
157
+ }
158
+ // Expand this decoded scanline into RGBA.
159
+ for (let x = 0; x < width; x++) {
160
+ const s = x * channels;
161
+ const d = (y * width + x) * 4;
162
+ rgba[d] = line[s]!;
163
+ rgba[d + 1] = line[s + 1]!;
164
+ rgba[d + 2] = line[s + 2]!;
165
+ rgba[d + 3] = channels === 4 ? line[s + 3]! : 255;
166
+ }
167
+ prev.set(line);
168
+ }
169
+
170
+ return {width, height, rgba};
171
+ }
172
+
173
+ /** The PNG Paeth predictor. */
174
+ function paeth(a: number, b: number, c: number): number {
175
+ const p = a + b - c;
176
+ const pa = Math.abs(p - a);
177
+ const pb = Math.abs(p - b);
178
+ const pc = Math.abs(p - c);
179
+ if (pa <= pb && pa <= pc) return a;
180
+ if (pb <= pc) return b;
181
+ return c;
182
+ }
183
+
184
+ /** The nearest PICO-8 palette index (by squared RGB distance) for an RGB triple. */
185
+ export function nearestPaletteIndex(r: number, g: number, b: number): number {
186
+ let best = 0;
187
+ let bestDist = Infinity;
188
+ for (let i = 0; i < PICO8_PALETTE.length; i++) {
189
+ const c = PICO8_PALETTE[i]!;
190
+ const dr = r - c.r;
191
+ const dg = g - c.g;
192
+ const db = b - c.b;
193
+ const dist = dr * dr + dg * dg + db * db;
194
+ if (dist < bestDist) {
195
+ bestDist = dist;
196
+ best = i;
197
+ }
198
+ }
199
+ return best;
200
+ }
201
+
202
+ const HEX = '0123456789abcdef';
203
+
204
+ /**
205
+ * Builds the `__label__` hex body (128 rows of 128 nibbles, newline-joined, with
206
+ * a trailing newline) from a 128x128 PNG. Each pixel is mapped to its nearest
207
+ * PICO-8 palette colour. The PNG MUST be exactly 128x128 ({@link LABEL_SIZE});
208
+ * anything else is a {@link LabelError} (`wrong-size`) so a mis-sized image fails
209
+ * loud instead of being silently cropped or stretched.
210
+ */
211
+ export function labelHexFromPng(pngBytes: Uint8Array): string {
212
+ const {width, height, rgba} = decodePng(pngBytes);
213
+ if (width !== LABEL_SIZE || height !== LABEL_SIZE) {
214
+ throw new LabelError(
215
+ 'wrong-size',
216
+ `label image must be ${LABEL_SIZE}x${LABEL_SIZE}, got ${width}x${height}`,
217
+ );
218
+ }
219
+ const rows: string[] = [];
220
+ for (let y = 0; y < LABEL_SIZE; y++) {
221
+ let row = '';
222
+ for (let x = 0; x < LABEL_SIZE; x++) {
223
+ const d = (y * LABEL_SIZE + x) * 4;
224
+ row += HEX[nearestPaletteIndex(rgba[d]!, rgba[d + 1]!, rgba[d + 2]!)];
225
+ }
226
+ rows.push(row);
227
+ }
228
+ return `${rows.join('\n')}\n`;
229
+ }
@@ -0,0 +1,71 @@
1
+ import {describe, expect, it} from 'vitest';
2
+
3
+ import {GfxSheet} from '../cart/index.js';
4
+ import {decideOverlap, spriteHasData} from './overlap.js';
5
+
6
+ /** A GfxSheet whose sprite `n` is painted non-zero (data at risk in the shared bank). */
7
+ function sheetWithSpriteData(n: number): GfxSheet {
8
+ const sheet = GfxSheet.fromBody(undefined);
9
+ // A single non-zero pixel is enough to mean "the shared region holds data".
10
+ sheet.setPixel(...spritePixel(n), 8);
11
+ return sheet;
12
+ }
13
+
14
+ /** A pixel coordinate inside sprite `n` (its top-left corner). */
15
+ function spritePixel(n: number): [number, number] {
16
+ const x0 = (n % 16) * 8;
17
+ const y0 = Math.floor(n / 16) * 8;
18
+ return [x0, y0];
19
+ }
20
+
21
+ describe('gfx overlap: spriteHasData reads the target sprite own __gfx__ pixels', () => {
22
+ it('is false for an all-zero sprite', () => {
23
+ const sheet = GfxSheet.fromBody(undefined);
24
+ expect(spriteHasData(sheet, 200)).toBe(false);
25
+ });
26
+ it('is true once any pixel is non-zero', () => {
27
+ const sheet = sheetWithSpriteData(200);
28
+ expect(spriteHasData(sheet, 200)).toBe(true);
29
+ });
30
+ });
31
+
32
+ describe('gfx overlap: decideOverlap branches (ADR-0004 smart-refuse)', () => {
33
+ it('a base-bank sprite (0..127) is always allowed (no overlap possible)', () => {
34
+ const sheet = sheetWithSpriteData(10);
35
+ // Even with data present, a base-bank sprite aliases nothing.
36
+ expect(decideOverlap(sheet, 10, false)).toEqual({kind: 'allowed'});
37
+ });
38
+
39
+ it('a shared-bank sprite with all-zero pixels is allowed-shared (empty)', () => {
40
+ const sheet = GfxSheet.fromBody(undefined);
41
+ expect(decideOverlap(sheet, 130, false)).toEqual({
42
+ kind: 'allowed-shared',
43
+ reason: 'empty',
44
+ });
45
+ });
46
+
47
+ it('a shared-bank sprite with data + authorised is allowed-shared (authorised)', () => {
48
+ const sheet = sheetWithSpriteData(130);
49
+ expect(decideOverlap(sheet, 130, true)).toEqual({
50
+ kind: 'allowed-shared',
51
+ reason: 'authorised',
52
+ });
53
+ });
54
+
55
+ it('a shared-bank sprite with data + NOT authorised is refused (the data-loss corner)', () => {
56
+ const sheet = sheetWithSpriteData(130);
57
+ const decision = decideOverlap(sheet, 130, false);
58
+ expect(decision.kind).toBe('refused');
59
+ if (decision.kind === 'refused') expect(decision.nonZeroPixels).toBe(1);
60
+ });
61
+
62
+ it('sprite 128 (first shared) and 255 (last) both alias the shared bank', () => {
63
+ for (const n of [128, 255]) {
64
+ const sheet = sheetWithSpriteData(n);
65
+ expect(decideOverlap(sheet, n, false).kind).toBe('refused');
66
+ }
67
+ // 127 (last base) does NOT.
68
+ const base = sheetWithSpriteData(127);
69
+ expect(decideOverlap(base, 127, false).kind).toBe('allowed');
70
+ });
71
+ });
@@ -0,0 +1,66 @@
1
+ import {type GfxSheet, spriteAliasesMap} from '../cart/index.js';
2
+
3
+ /**
4
+ * The gfx/map overlap smart-refuse decision (ADR-0004), computed purely from a
5
+ * cart's CURRENT `__gfx__` pixels plus the authorisation flag.
6
+ *
7
+ * Why `__gfx__`, not `__map__` (the corrected seam): sprites 128..255 alias the
8
+ * shared map region, but those shared bytes live in the `__gfx__` `0x1000` upper
9
+ * bank, NOT in the text-format `.p8` `__map__` section (which stores only map
10
+ * rows 0..31). So "the data at risk" when overwriting a shared-bank sprite IS
11
+ * the sprite's OWN current `__gfx__` pixels. This module inspects those pixels
12
+ * directly (via {@link GfxSheet}); it deliberately does NOT consult `__map__`.
13
+ *
14
+ * The single invariant: picopilot never SILENTLY overwrites existing
15
+ * shared-region data that nothing authorised.
16
+ */
17
+
18
+ /** Whether ANY pixel of sprite `n` is non-zero, i.e. the sprite already holds data. */
19
+ export function spriteHasData(sheet: GfxSheet, n: number): boolean {
20
+ const grid = sheet.getSprite(n);
21
+ for (const row of grid) {
22
+ for (const px of row) if (px !== 0) return true;
23
+ }
24
+ return false;
25
+ }
26
+
27
+ /** The verdict of {@link decideOverlap}. */
28
+ export type OverlapDecision =
29
+ | {kind: 'allowed'}
30
+ | {kind: 'allowed-shared'; reason: 'authorised' | 'empty'}
31
+ | {kind: 'refused'; nonZeroPixels: number};
32
+
33
+ /**
34
+ * Decides whether a `gfx set` write to sprite `n` may proceed, given the cart's
35
+ * current `__gfx__` and whether overlap is authorised.
36
+ *
37
+ * - sprite 0..127 (base bank, aliases nothing) → `allowed` (no overlap possible).
38
+ * - sprite 128..255 whose current pixels are ALL-ZERO → `allowed-shared` (empty):
39
+ * the write proceeds; the caller notes the aliasing in its result.
40
+ * - sprite 128..255 with non-zero pixels AND `authorised` → `allowed-shared`
41
+ * (authorised): the write proceeds and overwrites the shared data on purpose.
42
+ * - sprite 128..255 with non-zero pixels AND NOT authorised → `refused`: the
43
+ * genuine data-loss corner. The caller REFUSES (structured + nonzero exit +
44
+ * bytes untouched).
45
+ */
46
+ export function decideOverlap(
47
+ sheet: GfxSheet,
48
+ n: number,
49
+ authorised: boolean,
50
+ ): OverlapDecision {
51
+ if (!spriteAliasesMap(n)) return {kind: 'allowed'};
52
+
53
+ if (!spriteHasData(sheet, n))
54
+ return {kind: 'allowed-shared', reason: 'empty'};
55
+ if (authorised) return {kind: 'allowed-shared', reason: 'authorised'};
56
+
57
+ return {kind: 'refused', nonZeroPixels: countNonZeroPixels(sheet, n)};
58
+ }
59
+
60
+ function countNonZeroPixels(sheet: GfxSheet, n: number): number {
61
+ let count = 0;
62
+ for (const row of sheet.getSprite(n)) {
63
+ for (const px of row) if (px !== 0) count++;
64
+ }
65
+ return count;
66
+ }
@@ -0,0 +1,35 @@
1
+ import {describe, expect, it} from 'vitest';
2
+
3
+ import {PICO8_PALETTE, paletteColor} from './palette.js';
4
+
5
+ describe('PICO-8 palette (single source of truth)', () => {
6
+ it('has exactly 16 entries', () => {
7
+ expect(PICO8_PALETTE).toHaveLength(16);
8
+ });
9
+
10
+ it('matches the fixed PICO-8 constants from the findings table', () => {
11
+ // A representative spot-check against work/notes/findings/pico8-api-reference.md:
12
+ // do NOT re-invent these; they are the load-bearing render source of truth.
13
+ expect(paletteColor(0)).toEqual({r: 0, g: 0, b: 0}); // black
14
+ expect(paletteColor(7)).toEqual({r: 255, g: 241, b: 232}); // white
15
+ expect(paletteColor(8)).toEqual({r: 255, g: 0, b: 77}); // red
16
+ expect(paletteColor(11)).toEqual({r: 0, g: 228, b: 54}); // green
17
+ expect(paletteColor(12)).toEqual({r: 41, g: 173, b: 255}); // blue
18
+ expect(paletteColor(15)).toEqual({r: 255, g: 204, b: 170}); // peach
19
+ });
20
+
21
+ it('every entry is a valid 0..255 RGB triple', () => {
22
+ for (const c of PICO8_PALETTE) {
23
+ for (const v of [c.r, c.g, c.b]) {
24
+ expect(Number.isInteger(v)).toBe(true);
25
+ expect(v).toBeGreaterThanOrEqual(0);
26
+ expect(v).toBeLessThanOrEqual(255);
27
+ }
28
+ }
29
+ });
30
+
31
+ it('throws for an out-of-range index', () => {
32
+ expect(() => paletteColor(-1)).toThrow();
33
+ expect(() => paletteColor(16)).toThrow();
34
+ });
35
+ });
@@ -0,0 +1,54 @@
1
+ /**
2
+ * PICO-8's FIXED 16-colour palette (index → RGB), the single source of truth for
3
+ * the `gfx render` PNG encoder.
4
+ *
5
+ * These are the stable, widely-reproduced PICO-8 0..15 palette constants,
6
+ * transcribed verbatim from `work/notes/findings/pico8-api-reference.md` (the
7
+ * same table the scaffolded `AGENTS.md` reference cites). Do NOT re-invent these
8
+ * values: if a future PICO-8 version alters them, revise the finding, the
9
+ * scaffold reference, and THIS table together.
10
+ *
11
+ * Index 0 doubles as PICO-8's default transparent colour; `gfx render` renders it
12
+ * as its RGB (black) so the PNG shows the actual pixels a `spr()` with no
13
+ * transparency would draw. (Transparency is a draw-time concern, not a property
14
+ * of the spritesheet bytes, so the JUDGE surface renders every index opaquely.)
15
+ */
16
+
17
+ /** One palette entry: the colour index and its RGB triple. */
18
+ export interface PaletteColor {
19
+ readonly r: number;
20
+ readonly g: number;
21
+ readonly b: number;
22
+ }
23
+
24
+ /**
25
+ * The 16 PICO-8 palette RGB triples, indexed 0..15. Frozen so no caller can
26
+ * mutate the shared source of truth.
27
+ */
28
+ export const PICO8_PALETTE: readonly PaletteColor[] = Object.freeze([
29
+ {r: 0, g: 0, b: 0}, // 0 black
30
+ {r: 29, g: 43, b: 83}, // 1 dark-blue
31
+ {r: 126, g: 37, b: 83}, // 2 dark-purple
32
+ {r: 0, g: 135, b: 81}, // 3 dark-green
33
+ {r: 171, g: 82, b: 54}, // 4 brown
34
+ {r: 95, g: 87, b: 79}, // 5 dark-grey
35
+ {r: 194, g: 195, b: 199}, // 6 light-grey
36
+ {r: 255, g: 241, b: 232}, // 7 white
37
+ {r: 255, g: 0, b: 77}, // 8 red
38
+ {r: 255, g: 163, b: 0}, // 9 orange
39
+ {r: 255, g: 236, b: 39}, // 10 yellow
40
+ {r: 0, g: 228, b: 54}, // 11 green
41
+ {r: 41, g: 173, b: 255}, // 12 blue
42
+ {r: 131, g: 118, b: 156}, // 13 lavender
43
+ {r: 255, g: 119, b: 168}, // 14 pink
44
+ {r: 255, g: 204, b: 170}, // 15 peach
45
+ ] as const);
46
+
47
+ /** The RGB triple for colour index 0..15. Throws for an out-of-range index. */
48
+ export function paletteColor(index: number): PaletteColor {
49
+ const c = PICO8_PALETTE[index];
50
+ if (c === undefined) {
51
+ throw new RangeError(`palette index ${index} out of range 0..15`);
52
+ }
53
+ return c;
54
+ }
@@ -0,0 +1,111 @@
1
+ import {inflateSync} from 'node:zlib';
2
+ import {describe, expect, it} from 'vitest';
3
+
4
+ import {encodePng, type RgbImage} from './png.js';
5
+
6
+ /**
7
+ * Decodes the PNG structure enough to recover the raw RGB pixels, so the tests
8
+ * assert against a REAL PNG round-trip (not the encoder's own internals). Uses
9
+ * node's `zlib.inflateSync` as an INDEPENDENT decompressor: it must accept the
10
+ * hand-rolled stored-block zlib stream, proving the file is standards-valid.
11
+ */
12
+ function decodePng(bytes: Uint8Array): RgbImage {
13
+ const sig = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
14
+ for (let i = 0; i < sig.length; i++) {
15
+ expect(bytes[i]).toBe(sig[i]);
16
+ }
17
+
18
+ let off = 8;
19
+ let width = 0;
20
+ let height = 0;
21
+ const idatParts: Uint8Array[] = [];
22
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
23
+
24
+ while (off < bytes.length) {
25
+ const len = view.getUint32(off);
26
+ const type = String.fromCharCode(
27
+ bytes[off + 4]!,
28
+ bytes[off + 5]!,
29
+ bytes[off + 6]!,
30
+ bytes[off + 7]!,
31
+ );
32
+ const data = bytes.subarray(off + 8, off + 8 + len);
33
+ if (type === 'IHDR') {
34
+ const dv = new DataView(data.buffer, data.byteOffset, data.byteLength);
35
+ width = dv.getUint32(0);
36
+ height = dv.getUint32(4);
37
+ expect(data[8]).toBe(8); // bit depth
38
+ expect(data[9]).toBe(2); // colour type RGB
39
+ } else if (type === 'IDAT') {
40
+ idatParts.push(data);
41
+ }
42
+ off += 12 + len; // length(4) + type(4) + data + crc(4)
43
+ if (type === 'IEND') break;
44
+ }
45
+
46
+ const idat = Buffer.concat(idatParts.map((p) => Buffer.from(p)));
47
+ const raw = new Uint8Array(inflateSync(idat));
48
+
49
+ // Strip the per-scanline filter byte (all filter type 0 = None).
50
+ const stride = width * 3;
51
+ const rgb = new Uint8Array(width * height * 3);
52
+ for (let y = 0; y < height; y++) {
53
+ expect(raw[y * (stride + 1)]).toBe(0); // filter None
54
+ rgb.set(
55
+ raw.subarray(y * (stride + 1) + 1, y * (stride + 1) + 1 + stride),
56
+ y * stride,
57
+ );
58
+ }
59
+ return {width, height, rgb};
60
+ }
61
+
62
+ function pixelAt(
63
+ img: RgbImage,
64
+ x: number,
65
+ y: number,
66
+ ): [number, number, number] {
67
+ const off = (y * img.width + x) * 3;
68
+ return [img.rgb[off]!, img.rgb[off + 1]!, img.rgb[off + 2]!];
69
+ }
70
+
71
+ describe('png encoder', () => {
72
+ it('encodes a 2x2 RGB image that a standard inflate decoder reads back exactly', () => {
73
+ const rgb = Uint8Array.from([
74
+ 255, 0, 0, /* red */ 0, 255, 0 /* green */, 0, 0, 255, /* blue */ 255,
75
+ 255, 255 /* white */,
76
+ ]);
77
+ const png = encodePng({width: 2, height: 2, rgb});
78
+ const decoded = decodePng(png);
79
+ expect(decoded.width).toBe(2);
80
+ expect(decoded.height).toBe(2);
81
+ expect(pixelAt(decoded, 0, 0)).toEqual([255, 0, 0]);
82
+ expect(pixelAt(decoded, 1, 0)).toEqual([0, 255, 0]);
83
+ expect(pixelAt(decoded, 0, 1)).toEqual([0, 0, 255]);
84
+ expect(pixelAt(decoded, 1, 1)).toEqual([255, 255, 255]);
85
+ expect(Array.from(decoded.rgb)).toEqual(Array.from(rgb));
86
+ });
87
+
88
+ it('is deterministic: the same input yields byte-identical output', () => {
89
+ const rgb = Uint8Array.from([1, 2, 3, 4, 5, 6]);
90
+ const a = encodePng({width: 2, height: 1, rgb});
91
+ const b = encodePng({width: 2, height: 1, rgb});
92
+ expect(Array.from(a)).toEqual(Array.from(b));
93
+ });
94
+
95
+ it('handles a buffer larger than one stored block (>65535 bytes)', () => {
96
+ // 200x200 RGB = 120000 bytes of pixels + filter bytes > 65535 → 2+ blocks.
97
+ const width = 200;
98
+ const height = 200;
99
+ const rgb = new Uint8Array(width * height * 3);
100
+ for (let i = 0; i < rgb.length; i++) rgb[i] = i % 256;
101
+ const png = encodePng({width, height, rgb});
102
+ const decoded = decodePng(png);
103
+ expect(Array.from(decoded.rgb)).toEqual(Array.from(rgb));
104
+ });
105
+
106
+ it('rejects a mismatched buffer length', () => {
107
+ expect(() =>
108
+ encodePng({width: 2, height: 2, rgb: new Uint8Array(3)}),
109
+ ).toThrow();
110
+ });
111
+ });