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,304 @@
1
+ import {readFileSync} from 'node:fs';
2
+ import {fileURLToPath} from 'node:url';
3
+ import {describe, expect, it} from 'vitest';
4
+ import {
5
+ Cart,
6
+ CART_SECTION_NAMES,
7
+ CartParseError,
8
+ CartRangeError,
9
+ GFX_HEIGHT,
10
+ GfxSheet,
11
+ MapData,
12
+ mapRowsForSprite,
13
+ spriteAliasesMap,
14
+ spriteOrigin,
15
+ } from './index.js';
16
+
17
+ /** Reads a fixture `.p8` as a raw string (no newline normalisation). */
18
+ function fixture(name: string): string {
19
+ return readFileSync(
20
+ fileURLToPath(new URL(`./fixtures/${name}`, import.meta.url)),
21
+ 'utf8',
22
+ );
23
+ }
24
+
25
+ const ALL_FIXTURES = [
26
+ 'minimal.p8',
27
+ 'full.p8',
28
+ 'crlf.p8',
29
+ 'no-trailing-newline.p8',
30
+ 'header-only.p8',
31
+ ];
32
+
33
+ describe('Cart round-trip identity', () => {
34
+ for (const name of ALL_FIXTURES) {
35
+ it(`parse -> serialize is byte-identical for ${name}`, () => {
36
+ const text = fixture(name);
37
+ expect(Cart.parse(text).serialize()).toBe(text);
38
+ });
39
+ }
40
+
41
+ it('round-trips CRLF newlines without normalising them', () => {
42
+ const text = fixture('crlf.p8');
43
+ expect(text).toContain('\r\n');
44
+ const out = Cart.parse(text).serialize();
45
+ expect(out).toBe(text);
46
+ expect(out).toContain('\r\n');
47
+ });
48
+
49
+ it('round-trips a cart with no trailing newline', () => {
50
+ const text = fixture('no-trailing-newline.p8');
51
+ expect(text.endsWith('\n')).toBe(false);
52
+ expect(Cart.parse(text).serialize()).toBe(text);
53
+ });
54
+
55
+ it('does not mistake a `__x__` token inside Lua for a section marker', () => {
56
+ const text = [
57
+ 'pico-8 cartridge // http://www.pico-8.com',
58
+ 'version 42',
59
+ '__lua__',
60
+ 'x = "__gfx__ inside a string, indented"',
61
+ ' __map__ = 1 -- indented, not a marker',
62
+ '__gfx__',
63
+ '00000000',
64
+ '',
65
+ ].join('\n');
66
+ const cart = Cart.parse(text);
67
+ // Only the two real, line-anchored markers are sections.
68
+ expect(cart.sectionNames()).toEqual(['lua', 'gfx']);
69
+ expect(cart.getSection('lua')).toContain('__gfx__ inside a string');
70
+ expect(cart.serialize()).toBe(text);
71
+ });
72
+ });
73
+
74
+ describe('Cart section-level read/write', () => {
75
+ it('reads individual section bodies', () => {
76
+ const cart = Cart.parse(fixture('minimal.p8'));
77
+ expect(cart.sectionNames()).toEqual(['lua', 'gfx']);
78
+ expect(cart.getSection('lua')).toContain('function _init()');
79
+ expect(cart.getSection('gfx')).toContain('00000000');
80
+ expect(cart.getSection('sfx')).toBeUndefined();
81
+ expect(cart.hasSection('map')).toBe(false);
82
+ });
83
+
84
+ it('replaces __gfx__ without disturbing __lua__ or the header', () => {
85
+ const text = fixture('minimal.p8');
86
+ const cart = Cart.parse(text);
87
+ const luaBefore = cart.getSection('lua');
88
+ cart.setSection('gfx', 'ffffffff\n');
89
+ expect(cart.getSection('gfx')).toBe('ffffffff\n');
90
+ // Other section + header untouched.
91
+ expect(cart.getSection('lua')).toBe(luaBefore);
92
+ expect(cart.header).toBe(Cart.parse(text).header);
93
+ // The only diff from the original is inside __gfx__.
94
+ const out = cart.serialize();
95
+ expect(out).toContain('__lua__\n-- minimal cart');
96
+ expect(out).toContain('__gfx__\nffffffff\n');
97
+ expect(out).not.toContain('00000000');
98
+ });
99
+
100
+ it('appends a new section in the file newline style, leaving others intact', () => {
101
+ const text = fixture('minimal.p8');
102
+ const cart = Cart.parse(text);
103
+ cart.setSection('sfx', '000100000\n');
104
+ expect(cart.sectionNames()).toEqual(['lua', 'gfx', 'sfx']);
105
+ // Original text is a strict prefix; the new section is appended verbatim.
106
+ expect(cart.serialize()).toBe(`${text}__sfx__\n000100000\n`);
107
+ });
108
+
109
+ it('setSection is idempotent for an unchanged body (still round-trips)', () => {
110
+ const text = fixture('full.p8');
111
+ const cart = Cart.parse(text);
112
+ const gfx = cart.getSection('gfx');
113
+ if (gfx === undefined) throw new Error('fixture missing __gfx__');
114
+ cart.setSection('gfx', gfx);
115
+ expect(cart.serialize()).toBe(text);
116
+ });
117
+ });
118
+
119
+ describe('Cart structured errors (no crash on malformed input)', () => {
120
+ it('throws missing-header when the header line is absent', () => {
121
+ try {
122
+ Cart.parse(fixture('malformed-no-header.p8'));
123
+ throw new Error('expected CartParseError');
124
+ } catch (e) {
125
+ expect(e).toBeInstanceOf(CartParseError);
126
+ expect((e as CartParseError).code).toBe('missing-header');
127
+ }
128
+ });
129
+
130
+ it('throws missing-version when the version line is absent', () => {
131
+ const text = 'pico-8 cartridge // http://www.pico-8.com\n__lua__\nx=1\n';
132
+ expect(() => Cart.parse(text)).toThrow(CartParseError);
133
+ try {
134
+ Cart.parse(text);
135
+ } catch (e) {
136
+ expect((e as CartParseError).code).toBe('missing-version');
137
+ }
138
+ });
139
+
140
+ it('throws unknown-section for an unmodelled marker', () => {
141
+ const text =
142
+ 'pico-8 cartridge // http://www.pico-8.com\nversion 42\n__bogus__\nx\n';
143
+ try {
144
+ Cart.parse(text);
145
+ throw new Error('expected CartParseError');
146
+ } catch (e) {
147
+ expect(e).toBeInstanceOf(CartParseError);
148
+ expect((e as CartParseError).code).toBe('unknown-section');
149
+ }
150
+ });
151
+
152
+ it('throws duplicate-section when a marker repeats', () => {
153
+ const text =
154
+ 'pico-8 cartridge // http://www.pico-8.com\nversion 42\n__lua__\nx\n__lua__\ny\n';
155
+ try {
156
+ Cart.parse(text);
157
+ throw new Error('expected CartParseError');
158
+ } catch (e) {
159
+ expect((e as CartParseError).code).toBe('duplicate-section');
160
+ }
161
+ });
162
+
163
+ it('parses a header-only cart (no sections) and round-trips it', () => {
164
+ const text = fixture('header-only.p8');
165
+ const cart = Cart.parse(text);
166
+ expect(cart.sectionNames()).toEqual([]);
167
+ expect(cart.serialize()).toBe(text);
168
+ });
169
+
170
+ it('recognises every modelled section name', () => {
171
+ expect([...CART_SECTION_NAMES].sort()).toEqual(
172
+ ['gff', 'gfx', 'label', 'lua', 'map', 'music', 'sfx'].sort(),
173
+ );
174
+ });
175
+ });
176
+
177
+ describe('GfxSheet addressable region', () => {
178
+ it('reads a sprite from the parsed sheet as an 8x8 colour grid', () => {
179
+ const cart = Cart.parse(fixture('full.p8'));
180
+ const sheet = GfxSheet.fromBody(cart.getSection('gfx'));
181
+ const sprite1 = sheet.getSprite(1);
182
+ expect(sprite1.length).toBe(8);
183
+ expect(sprite1[0]?.length).toBe(8);
184
+ // Fixture painted sprite 1 with e (14) / 8 (8) in a checker.
185
+ expect(sprite1[0]?.[0]).toBe(14);
186
+ expect(sprite1[0]?.[1]).toBe(8);
187
+ });
188
+
189
+ it('gfx grid round-trips to the same __gfx__ body (identity)', () => {
190
+ const cart = Cart.parse(fixture('full.p8'));
191
+ const body = cart.getSection('gfx');
192
+ if (body === undefined) throw new Error('fixture missing __gfx__');
193
+ expect(GfxSheet.fromBody(body).toBody()).toBe(body);
194
+ });
195
+
196
+ it('editing one sprite and committing changes only __gfx__', () => {
197
+ const text = fixture('full.p8');
198
+ const cart = Cart.parse(text);
199
+ const luaBefore = cart.getSection('lua');
200
+ const mapBefore = cart.getSection('map');
201
+ const sheet = GfxSheet.fromBody(cart.getSection('gfx'));
202
+ const solid = Array.from({length: 8}, () =>
203
+ Array.from({length: 8}, () => 9),
204
+ );
205
+ sheet.setSprite(0, solid);
206
+ sheet.commit(cart);
207
+ expect(GfxSheet.fromBody(cart.getSection('gfx')).getSprite(0)).toEqual(
208
+ solid,
209
+ );
210
+ // Sprite 1 (unrelated) and other sections untouched.
211
+ expect(GfxSheet.fromBody(cart.getSection('gfx')).getSprite(1)[0]?.[0]).toBe(
212
+ 14,
213
+ );
214
+ expect(cart.getSection('lua')).toBe(luaBefore);
215
+ expect(cart.getSection('map')).toBe(mapBefore);
216
+ });
217
+
218
+ it('setPixel round-trips through hex', () => {
219
+ const sheet = GfxSheet.fromBody(undefined);
220
+ expect(sheet.getPixel(0, 0)).toBe(0);
221
+ sheet.setPixel(3, 5, 12);
222
+ expect(sheet.getPixel(3, 5)).toBe(12);
223
+ const reparsed = GfxSheet.fromBody(sheet.toBody());
224
+ expect(reparsed.getPixel(3, 5)).toBe(12);
225
+ });
226
+
227
+ it('rejects out-of-range pixels, sprites, and colours', () => {
228
+ const sheet = GfxSheet.fromBody(undefined);
229
+ expect(() => sheet.getPixel(-1, 0)).toThrow(CartRangeError);
230
+ expect(() => sheet.getPixel(0, GFX_HEIGHT)).toThrow(CartRangeError);
231
+ expect(() => sheet.setPixel(0, 0, 16)).toThrow(CartRangeError);
232
+ expect(() => sheet.getSprite(256)).toThrow(CartRangeError);
233
+ });
234
+ });
235
+
236
+ describe('sprite <-> pixel <-> map addressing (0x1000 overlap)', () => {
237
+ it('maps sprite index to its pixel origin', () => {
238
+ expect(spriteOrigin(0)).toEqual({x0: 0, y0: 0});
239
+ expect(spriteOrigin(1)).toEqual({x0: 8, y0: 0});
240
+ expect(spriteOrigin(16)).toEqual({x0: 0, y0: 8});
241
+ expect(spriteOrigin(128)).toEqual({x0: 0, y0: 64});
242
+ expect(spriteOrigin(255)).toEqual({x0: 120, y0: 120});
243
+ });
244
+
245
+ it('flags the shared upper bank (128..255) as map-aliasing', () => {
246
+ expect(spriteAliasesMap(0)).toBe(false);
247
+ expect(spriteAliasesMap(127)).toBe(false);
248
+ expect(spriteAliasesMap(128)).toBe(true);
249
+ expect(spriteAliasesMap(255)).toBe(true);
250
+ });
251
+
252
+ it('base-bank sprites alias no map rows; shared-bank sprites alias 4 rows', () => {
253
+ expect(mapRowsForSprite(0)).toBeUndefined();
254
+ expect(mapRowsForSprite(127)).toBeUndefined();
255
+ // sprite 128 sits at gfx row 64 -> map rows 32..35.
256
+ expect(mapRowsForSprite(128)).toEqual({start: 32, end: 36});
257
+ // sprite 255 sits at gfx row 120 -> map rows 60..63.
258
+ expect(mapRowsForSprite(255)).toEqual({start: 60, end: 64});
259
+ });
260
+ });
261
+
262
+ describe('MapData addressable region + overlap inspection', () => {
263
+ it('reads a tile from the parsed map', () => {
264
+ const cart = Cart.parse(fixture('full.p8'));
265
+ const map = MapData.fromBody(cart.getSection('map'));
266
+ expect(map.getTile(0, 0)).toBe(5); // fixture set tile (0,0) = 05
267
+ expect(map.getTile(1, 0)).toBe(0);
268
+ });
269
+
270
+ it('map grid round-trips to the same __map__ body (identity)', () => {
271
+ const cart = Cart.parse(fixture('full.p8'));
272
+ const body = cart.getSection('map');
273
+ if (body === undefined) throw new Error('fixture missing __map__');
274
+ expect(MapData.fromBody(body).toBody()).toBe(body);
275
+ });
276
+
277
+ it('counts non-zero tiles across a row range (overlap primitive)', () => {
278
+ const cart = Cart.parse(fixture('full.p8'));
279
+ const map = MapData.fromBody(cart.getSection('map'));
280
+ // Only tile (0,0) is non-zero, in row 0.
281
+ expect(map.nonZeroTilesInRows(0, 1)).toBe(1);
282
+ expect(map.nonZeroTilesInRows(1, 32)).toBe(0);
283
+ // Shared-bank rows (>= 32) are not stored in text __map__, so zero.
284
+ expect(map.nonZeroTilesInRows(32, 64)).toBe(0);
285
+ });
286
+
287
+ it('editing a tile and committing changes only __map__', () => {
288
+ const text = fixture('full.p8');
289
+ const cart = Cart.parse(text);
290
+ const gfxBefore = cart.getSection('gfx');
291
+ const map = MapData.fromBody(cart.getSection('map'));
292
+ map.setTile(5, 5, 42);
293
+ map.commit(cart);
294
+ expect(MapData.fromBody(cart.getSection('map')).getTile(5, 5)).toBe(42);
295
+ expect(cart.getSection('gfx')).toBe(gfxBefore);
296
+ });
297
+
298
+ it('rejects out-of-range map cells and tile values', () => {
299
+ const map = MapData.fromBody(undefined);
300
+ expect(() => map.getTile(-1, 0)).toThrow(CartRangeError);
301
+ expect(() => map.setTile(0, 0, 256)).toThrow(CartRangeError);
302
+ expect(() => map.getRow(32)).toThrow(CartRangeError);
303
+ });
304
+ });
@@ -0,0 +1,211 @@
1
+ import {CartParseError} from './errors.js';
2
+
3
+ /**
4
+ * The section names picopilot models in a text-format `.p8` cart. These are the
5
+ * markers PICO-8 writes (`__lua__`, `__gfx__`, ...); `__label__` is the cart
6
+ * label image, also hex.
7
+ */
8
+ export const CART_SECTION_NAMES = [
9
+ 'lua',
10
+ 'gfx',
11
+ 'gff',
12
+ 'label',
13
+ 'map',
14
+ 'sfx',
15
+ 'music',
16
+ ] as const;
17
+
18
+ /** A recognised `.p8` section name (without the surrounding `__`). */
19
+ export type CartSectionName = (typeof CART_SECTION_NAMES)[number];
20
+
21
+ const KNOWN_SECTIONS = new Set<string>(CART_SECTION_NAMES);
22
+
23
+ /**
24
+ * One parsed section of a cart, kept as three verbatim pieces so re-joining
25
+ * them reproduces the original bytes exactly:
26
+ *
27
+ * - `marker`: the section-marker line INCLUDING its line terminator, e.g.
28
+ * `"__gfx__\n"` or `"__gfx__\r\n"`.
29
+ * - `body`: everything from after the marker line up to (not including) the
30
+ * next marker, verbatim (its own trailing newline(s) live here).
31
+ *
32
+ * The public API exposes the section body as a string via {@link Cart.getSection}
33
+ * / {@link Cart.setSection}; the `marker` is internal book-keeping so callers
34
+ * never have to reason about newline style to keep the round-trip identical.
35
+ */
36
+ interface CartSection {
37
+ readonly name: CartSectionName;
38
+ marker: string;
39
+ body: string;
40
+ }
41
+
42
+ /**
43
+ * Matches a whole line that is a section marker: `__name__` optionally followed
44
+ * by a `\r` (CRLF) and the line's `\n`, anchored so a `__x__` appearing mid-line
45
+ * (e.g. inside Lua) is never mistaken for a marker. The trailing newline is
46
+ * captured into the match so the marker line, terminator included, is preserved.
47
+ */
48
+ const SECTION_MARKER = /^__([a-zA-Z0-9]+)__[^\S\r\n]*(\r?\n|$)/gm;
49
+
50
+ /**
51
+ * The in-memory model of a text-format `.p8` cart: the load-bearing seam every
52
+ * cart-touching command (init, gfx, tokens, verify) reads and writes through.
53
+ *
54
+ * Design contract:
55
+ *
56
+ * - **Round-trip is identity.** `Cart.parse(text).serialize() === text`, byte
57
+ * for byte, for any well-formed cart, regardless of newline style or trailing
58
+ * newline. Achieved by preserving the header, each marker line, and each
59
+ * section body verbatim, and never normalising whitespace on the way through.
60
+ * - **Sections are independently addressable.** {@link getSection} /
61
+ * {@link setSection} read and replace one section without disturbing the
62
+ * others (or the header).
63
+ * - **gfx/map are addressable as regions, not opaque strings.** The `__gfx__`
64
+ * and `__map__` hex bodies are exposed as row/pixel grids (see `gfx.ts` /
65
+ * `map.ts`) so the gfx codec and the 0x1000 overlap check can target specific
66
+ * sprites/map rows.
67
+ */
68
+ export class Cart {
69
+ /**
70
+ * The raw text before the first section marker (the `pico-8 cartridge ...`
71
+ * line, the `version N` line, and any trailing blank lines), preserved
72
+ * verbatim so it round-trips exactly.
73
+ */
74
+ readonly header: string;
75
+
76
+ private readonly sections: CartSection[];
77
+
78
+ private constructor(header: string, sections: CartSection[]) {
79
+ this.header = header;
80
+ this.sections = sections;
81
+ }
82
+
83
+ /**
84
+ * Parses a `.p8` text blob into a {@link Cart}.
85
+ *
86
+ * Malformed input throws a {@link CartParseError} with a machine-readable
87
+ * `code` (missing header/version, unknown or duplicate section) rather than
88
+ * crashing, so the command layer can surface a structured failure.
89
+ */
90
+ static parse(text: string): Cart {
91
+ if (!/^pico-8 cartridge/.test(text)) {
92
+ throw new CartParseError(
93
+ 'missing-header',
94
+ 'not a .p8 cart: expected a "pico-8 cartridge ..." header on the first line',
95
+ );
96
+ }
97
+ if (!/^pico-8 cartridge[^\n]*\r?\nversion\s+\d+/.test(text)) {
98
+ throw new CartParseError(
99
+ 'missing-version',
100
+ 'malformed .p8 cart: expected a "version N" line after the header',
101
+ );
102
+ }
103
+
104
+ const markers: {
105
+ name: string;
106
+ marker: string;
107
+ start: number;
108
+ bodyStart: number;
109
+ }[] = [];
110
+ SECTION_MARKER.lastIndex = 0;
111
+ for (
112
+ let m = SECTION_MARKER.exec(text);
113
+ m !== null;
114
+ m = SECTION_MARKER.exec(text)
115
+ ) {
116
+ markers.push({
117
+ name: m[1] as string,
118
+ marker: m[0],
119
+ start: m.index,
120
+ bodyStart: m.index + m[0].length,
121
+ });
122
+ }
123
+
124
+ const header = markers.length > 0 ? text.slice(0, markers[0]?.start) : text;
125
+
126
+ const sections: CartSection[] = [];
127
+ const seen = new Set<string>();
128
+ for (let i = 0; i < markers.length; i++) {
129
+ const cur = markers[i];
130
+ if (cur === undefined) continue;
131
+ if (!KNOWN_SECTIONS.has(cur.name)) {
132
+ throw new CartParseError(
133
+ 'unknown-section',
134
+ `unknown .p8 section "__${cur.name}__" (known: ${CART_SECTION_NAMES.map((n) => `__${n}__`).join(', ')})`,
135
+ );
136
+ }
137
+ if (seen.has(cur.name)) {
138
+ throw new CartParseError(
139
+ 'duplicate-section',
140
+ `duplicate .p8 section "__${cur.name}__"`,
141
+ );
142
+ }
143
+ seen.add(cur.name);
144
+ const bodyEnd = markers[i + 1]?.start ?? text.length;
145
+ sections.push({
146
+ name: cur.name as CartSectionName,
147
+ marker: cur.marker,
148
+ body: text.slice(cur.bodyStart, bodyEnd),
149
+ });
150
+ }
151
+
152
+ return new Cart(header, sections);
153
+ }
154
+
155
+ /** The names of the sections present in this cart, in file order. */
156
+ sectionNames(): CartSectionName[] {
157
+ return this.sections.map((s) => s.name);
158
+ }
159
+
160
+ /** True if the cart contains the named section. */
161
+ hasSection(name: CartSectionName): boolean {
162
+ return this.sections.some((s) => s.name === name);
163
+ }
164
+
165
+ /**
166
+ * Returns the raw body text of a section (everything after the marker line,
167
+ * verbatim including its own newlines), or `undefined` if the section is not
168
+ * present. The gfx/map region helpers build on this.
169
+ */
170
+ getSection(name: CartSectionName): string | undefined {
171
+ return this.sections.find((s) => s.name === name)?.body;
172
+ }
173
+
174
+ /**
175
+ * Replaces one section's body in place, leaving every other section and the
176
+ * header untouched. If the section is not yet present it is appended (with a
177
+ * `__name__\n` marker in the file's dominant newline style).
178
+ *
179
+ * The body is written verbatim; callers that want a trailing newline must
180
+ * include it. Region-level helpers (gfx/map) go through here.
181
+ */
182
+ setSection(name: CartSectionName, body: string): void {
183
+ const existing = this.sections.find((s) => s.name === name);
184
+ if (existing !== undefined) {
185
+ existing.body = body;
186
+ return;
187
+ }
188
+ this.sections.push({name, marker: `__${name}__${this.newline()}`, body});
189
+ }
190
+
191
+ /**
192
+ * Serializes the cart back to a `.p8` text blob. For an unmodified cart this
193
+ * is byte-identical to the text it was parsed from.
194
+ */
195
+ serialize(): string {
196
+ let out = this.header;
197
+ for (const s of this.sections) {
198
+ out += s.marker + s.body;
199
+ }
200
+ return out;
201
+ }
202
+
203
+ /**
204
+ * The dominant newline style of the file, inferred from the header, used only
205
+ * when APPENDING a brand-new section marker (existing markers keep their own
206
+ * terminator verbatim). Defaults to `\n`.
207
+ */
208
+ private newline(): string {
209
+ return /\r\n/.test(this.header) ? '\r\n' : '\n';
210
+ }
211
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Structured errors for the cart model.
3
+ *
4
+ * Parsing a malformed cart throws a `CartParseError` (never an unstructured
5
+ * crash), carrying a machine-readable `code` and a human-readable `message` so
6
+ * the command layer can turn it into incur's `error({ code, message, ... })`
7
+ * envelope rather than letting a raw exception escape. Region access out of
8
+ * bounds throws a `CartRangeError` for the same reason.
9
+ */
10
+
11
+ /** Machine-readable reason a `.p8` could not be parsed into the cart model. */
12
+ export type CartParseErrorCode =
13
+ /** The `pico-8 cartridge ...` header line is missing or unrecognised. */
14
+ | 'missing-header'
15
+ /** The `version N` line is missing or malformed. */
16
+ | 'missing-version'
17
+ /** A `__section__` marker is present but names a section we do not model. */
18
+ | 'unknown-section'
19
+ /** The same `__section__` marker appears more than once. */
20
+ | 'duplicate-section';
21
+
22
+ /** Thrown when a `.p8` text blob cannot be parsed into a {@link Cart}. */
23
+ export class CartParseError extends Error {
24
+ readonly code: CartParseErrorCode;
25
+
26
+ constructor(code: CartParseErrorCode, message: string) {
27
+ super(message);
28
+ this.name = 'CartParseError';
29
+ this.code = code;
30
+ }
31
+ }
32
+
33
+ /** Thrown when a gfx/map region access falls outside the addressable grid. */
34
+ export class CartRangeError extends Error {
35
+ constructor(message: string) {
36
+ super(message);
37
+ this.name = 'CartRangeError';
38
+ }
39
+ }
@@ -0,0 +1,6 @@
1
+ pico-8 cartridge // http://www.pico-8.com
2
+ version 42
3
+ __lua__
4
+ print("crlf")
5
+ __gfx__
6
+ 00000000