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,694 @@
1
+ /**
2
+ * `engine/audio` — the shrinko-free picopilot-MML to `__sfx__` codec.
3
+ *
4
+ * This module OWNS the picopilot-MML dialect and its transpilation into ONE
5
+ * `__sfx__` text row (the friendly text surface PICO-8 serializes, NOT the
6
+ * scattered RAM bit-packing). It is the greenfield/algorithmic seam of the audio
7
+ * path; the `sfx from-mml` command is thin wiring over {@link mmlToSfxRow}.
8
+ *
9
+ * The mapping is the CLOSED spec from the ADR-0005 spike, verified byte-for-byte
10
+ * against PICO-8 v0.2.7:
11
+ * `work/notes/findings/pico8-audio-sfx-music-layout-and-mml-subset.md` (Part A =
12
+ * the `__sfx__` text layout; Part B = the picopilot-MML grammar + mapping).
13
+ *
14
+ * The KEY reframe (finding B.4 / B.7 #1): PICO-8 has NO per-note duration or
15
+ * tempo. Every note occupies exactly one tracker ROW; all rows share ONE
16
+ * SFX-level `speed`. A picopilot-MML length is therefore a ROW COUNT, not a
17
+ * musical fraction, and there is no tempo/BPM token. Holding a note for M rows
18
+ * re-emits the same note in the following (M-1) rows (PICO-8 has no cross-row
19
+ * sustain bit).
20
+ *
21
+ * Out-of-range pitch and >32 rows are STRUCTURED ERRORS ({@link AudioMmlError}),
22
+ * never silent clamps: a silent clamp produces wrong-sounding music the author
23
+ * cannot see (finding B.2 / B.4, matching picopilot's smart-refuse philosophy).
24
+ *
25
+ * SFX-level FILTER directives (finding A.7): `!noiz !buzz !detune1/!detune2
26
+ * !reverb[1|2] !dampen[1|2]` set PICO-8's 5 per-SFX filters (the first header
27
+ * byte), reaching the class of DESIGNED sounds (explosion = DAMPEN body + REVERB
28
+ * tail, engine hum, pad) that no per-note pitch/waveform/volume MML can
29
+ * synthesize. `!`-prefixed keywords never collide with the note grammar (unlike
30
+ * the rejected bare-letter effect mnemonics, ADR-0008). A bad DAMPEN/REVERB level
31
+ * is a structured refusal, never a clamp.
32
+ */
33
+
34
+ /** A `__sfx__` row is exactly this many hex chars: 8 header + 32 notes x 5. */
35
+ export const SFX_ROW_LENGTH = 168;
36
+
37
+ /** The number of tracker rows (notes) in one SFX. A hard PICO-8 limit. */
38
+ export const SFX_MAX_ROWS = 32;
39
+
40
+ /** The lowest representable pitch: C0 = 0 (finding A.2 / B.2). */
41
+ export const PITCH_MIN = 0;
42
+
43
+ /** The highest representable pitch: D#5 = 63 (finding A.2 / B.2). */
44
+ export const PITCH_MAX = 63;
45
+
46
+ /** The default octave when none is set (`o2`, pitch 24 = C2; finding B.2). */
47
+ export const DEFAULT_OCTAVE = 2;
48
+
49
+ /** The default SFX speed (ticks/row) when `s<N>` is omitted (finding B.5). */
50
+ export const DEFAULT_SPEED = 16;
51
+
52
+ /** The default note length in tracker rows (one row; finding B.4). */
53
+ export const DEFAULT_LENGTH = 1;
54
+
55
+ /**
56
+ * The per-SFX FILTER switches (finding A.7). They live in the FIRST header byte
57
+ * of the `__sfx__` row (the `[0:2]` hex pair, the byte A.1 called "editor mode"):
58
+ * an 8-bit bitfield whose LOW bit is the cosmetic pitch/tracker mode flag and
59
+ * whose upper 7 bits are the filters. picopilot emits mode 0 (pitch mode), so the
60
+ * byte is exactly the OR of the selected filter values below.
61
+ *
62
+ * Decoded byte-for-byte on PICO-8 v0.2.7 by poke-and-readback + single-bit audio
63
+ * diff (finding A.7). NOIZ applies only to instrument 6 (`@6`, manual);
64
+ * DAMPEN/REVERB have 2 levels each (`!dampen`/`!dampen2`, `!reverb`/`!reverb2`).
65
+ */
66
+ export const FILTER_NOIZ = 0x02;
67
+ export const FILTER_BUZZ = 0x04;
68
+ export const FILTER_DETUNE_1 = 0x08;
69
+ export const FILTER_DETUNE_2 = 0x10;
70
+ /** REVERB (echo) switch (`0x20`); see the level note below (finding A.7). */
71
+ export const FILTER_REVERB = 0x20;
72
+ /**
73
+ * DAMPEN (low-pass) level 1 (`0x80`) and level 2 (`0xc0`); 0 = off (finding
74
+ * A.7). Verified progressive: `0xc0` low-passes strictly more than `0x80`.
75
+ */
76
+ export const FILTER_DAMPEN_LEVEL = [0x00, 0x80, 0xc0] as const;
77
+
78
+ /** The highest level DAMPEN (`!dampen<N>`) accepts (finding A.7). */
79
+ export const FILTER_MAX_LEVEL = 2;
80
+
81
+ /**
82
+ * Semitone offset of each note letter from C, within an octave (finding B.2:
83
+ * `c=0,d=2,e=4,f=5,g=7,a=9,b=11`).
84
+ */
85
+ const SEMITONE: Readonly<Record<string, number>> = {
86
+ c: 0,
87
+ d: 2,
88
+ e: 4,
89
+ f: 5,
90
+ g: 7,
91
+ a: 9,
92
+ b: 11,
93
+ };
94
+
95
+ // The finding (B.3) lists OPTIONAL two-letter effect mnemonics
96
+ // (`sl vb dr fi fo a4 a8`) as sugar ("SHOULD accept"), sitting atop the
97
+ // REQUIRED canonical `e0..e7`. picopilot-MML ships ONLY `e0..e7` (ADR-0008
98
+ // decision): every mnemonic collides with a note-letter sequence in this
99
+ // tracker-row grammar (`a4`/`a8` = note A + length; `dr` = note D + rest;
100
+ // `sl` shares its head with the `s` speed directive), so shipping them would
101
+ // SILENTLY re-mean genuine note sequences, exactly the coherence trap to avoid.
102
+ // The canonical numeric form is unambiguous and covers all 8 effects.
103
+
104
+ /** Machine-readable reason a picopilot-MML string could not be transpiled. */
105
+ export type AudioMmlErrorCode =
106
+ /** A pitch fell outside C0..D#5 (0..63) (finding B.2). */
107
+ | 'audio-mml-pitch-out-of-range'
108
+ /** The accumulated tracker rows exceeded the 32-row SFX cap (finding B.4). */
109
+ | 'audio-mml-sfx-overflow'
110
+ /** A `!dampen<N>`/`!reverb<N>` level fell outside 1..2 (finding A.7). */
111
+ | 'audio-mml-filter-level-out-of-range'
112
+ /** The MML text is syntactically malformed (a token we cannot parse). */
113
+ | 'audio-mml-parse-error';
114
+
115
+ /**
116
+ * A structured picopilot-MML transpile failure. Carries a machine-readable
117
+ * `code` (surfaced by the command as incur's `error({code,...})` envelope) plus
118
+ * a human-readable, actionable `message`. Never thrown for a case the spec
119
+ * defines a clamp/round for; only the genuine refusals + parse errors.
120
+ */
121
+ export class AudioMmlError extends Error {
122
+ readonly code: AudioMmlErrorCode;
123
+
124
+ constructor(code: AudioMmlErrorCode, message: string) {
125
+ super(message);
126
+ this.name = 'AudioMmlError';
127
+ this.code = code;
128
+ }
129
+ }
130
+
131
+ /** One decoded tracker note (row): the 5 `__sfx__` nibbles `PP W V E`. */
132
+ interface Note {
133
+ /** Chromatic pitch index 0..63 (C0..D#5). Ignored when `volume === 0`. */
134
+ pitch: number;
135
+ /** Waveform / instrument nibble 0..15 (0..7 built-in, 8..15 custom SFX-instr). */
136
+ waveform: number;
137
+ /** Volume 0..7; 0 = silent (a rest). */
138
+ volume: number;
139
+ /** Effect 0..7 (finding A.4 table). */
140
+ effect: number;
141
+ }
142
+
143
+ /** A rounding note surfaced to the caller (a dotted length that was not integral). */
144
+ export interface RowRoundingWarning {
145
+ /** The picopilot-MML token whose row count was rounded, e.g. `c4.`. */
146
+ token: string;
147
+ /** The exact (fractional) row count the length + dots computed. */
148
+ exactRows: number;
149
+ /** The integer row count actually emitted (nearest, min 1). */
150
+ roundedRows: number;
151
+ }
152
+
153
+ /** The successful result of transpiling picopilot-MML to one `__sfx__` row. */
154
+ export interface SfxTranspileResult {
155
+ /** The `__sfx__` text row: exactly {@link SFX_ROW_LENGTH} hex chars. */
156
+ row: string;
157
+ /** The number of tracker rows the MML produced (0..32). */
158
+ rows: number;
159
+ /** The SFX speed (ticks/row) written into the header (1..255). */
160
+ speed: number;
161
+ /** The header loop-start value (row index, or a LEN; finding A.1 / B.5). */
162
+ loopStart: number;
163
+ /** The header loop-end value (0 when off or in the LEN special case). */
164
+ loopEnd: number;
165
+ /**
166
+ * The per-SFX FILTER byte (the first header byte, finding A.7): the OR of the
167
+ * selected filter values, with the low mode bit 0. 0 = no filters.
168
+ */
169
+ filters: number;
170
+ /** Any dotted-length rows that had to be rounded to a whole row. */
171
+ roundingWarnings: RowRoundingWarning[];
172
+ }
173
+
174
+ /** Modal parser state (finding B.1: `@`/`v`/`e`/`o`/`l` are sticky). */
175
+ interface State {
176
+ octave: number;
177
+ waveform: number;
178
+ volume: number;
179
+ effect: number;
180
+ length: number;
181
+ }
182
+
183
+ /**
184
+ * Transpiles a picopilot-MML string into ONE `__sfx__` text row (finding Part
185
+ * A/B). Emits the friendly text nibbles directly.
186
+ *
187
+ * Modal state model (finding B.1): `@<hex>` waveform, `v<0-7>` volume,
188
+ * `e<0-7>`/mnemonic effect, `o<0-5>` octave, `l<N>` default length, `>`/`<`
189
+ * octave shift, all sticky. A bare note emits one row using the current state.
190
+ *
191
+ * SFX-level directives (finding B.5): `s<N>` speed (default 16), `{`/`}` loop
192
+ * markers (single `{` = the LEN special case).
193
+ *
194
+ * Durations are TRACKER ROWS (finding B.4): a length `N` = N rows, emitted as
195
+ * the note then `(N-1)` tie rows re-striking it; `.` multiplies by 1.5 (rounded
196
+ * to the nearest whole row, warned); `r` = silent rows; `^` extends the previous
197
+ * note.
198
+ *
199
+ * @throws {AudioMmlError} `audio-mml-pitch-out-of-range` (a note below C0 / above
200
+ * D#5), `audio-mml-sfx-overflow` (>32 rows), or `audio-mml-parse-error` (a
201
+ * token that cannot be parsed, incl. a tempo-style input).
202
+ */
203
+ export function mmlToSfxRow(mml: string): SfxTranspileResult {
204
+ const state: State = {
205
+ octave: DEFAULT_OCTAVE,
206
+ waveform: 0,
207
+ volume: 7,
208
+ effect: 0,
209
+ length: DEFAULT_LENGTH,
210
+ };
211
+
212
+ const notes: Note[] = [];
213
+ const warnings: RowRoundingWarning[] = [];
214
+ let speed = DEFAULT_SPEED;
215
+ let filters = 0;
216
+ let loopStartRow: number | undefined;
217
+ let loopEndRow: number | undefined;
218
+ let lastNote: Note | undefined;
219
+
220
+ const src = mml;
221
+ let i = 0;
222
+ const len = src.length;
223
+
224
+ /** The overflow check, applied every time rows are appended (finding B.4). */
225
+ const pushNote = (note: Note): void => {
226
+ if (notes.length + 1 > SFX_MAX_ROWS) {
227
+ throw new AudioMmlError(
228
+ 'audio-mml-sfx-overflow',
229
+ `picopilot-MML produced more than ${SFX_MAX_ROWS} tracker rows (an SFX holds at most ${SFX_MAX_ROWS} rows). Split the melody across multiple SFX + a music pattern, or shorten it. (Remember: a length is a ROW COUNT, not a musical note value.)`,
230
+ );
231
+ }
232
+ notes.push(note);
233
+ };
234
+
235
+ while (i < len) {
236
+ const ch = src[i] as string;
237
+
238
+ // Whitespace + `|` bar separators are cosmetic and ignored (readability).
239
+ if (/\s/.test(ch) || ch === '|') {
240
+ i++;
241
+ continue;
242
+ }
243
+
244
+ const lower = ch.toLowerCase();
245
+
246
+ // --- SFX-level directives -------------------------------------------------
247
+ if (lower === 's') {
248
+ const {value, next} = readInt(src, i + 1);
249
+ if (value === undefined) {
250
+ throw parseError(src, i, "expected a number after 's' (SFX speed)");
251
+ }
252
+ if (value < 1 || value > 255) {
253
+ throw parseError(
254
+ src,
255
+ i,
256
+ `SFX speed 's${value}' out of range 1..255 (ticks per row)`,
257
+ );
258
+ }
259
+ speed = value;
260
+ i = next;
261
+ continue;
262
+ }
263
+ // `!<name>[<level>]`: an SFX-level FILTER directive (finding A.7). A `!` head
264
+ // never collides with the note grammar (unlike the rejected `sl vb dr`
265
+ // mnemonics, which WERE bare note-letter sequences, ADR-0008). They set the
266
+ // first header byte; DAMPEN/REVERB take an optional 1..2 level (default 1).
267
+ if (ch === '!') {
268
+ const parsed = readFilter(src, i);
269
+ filters |= parsed.bits;
270
+ i = parsed.next;
271
+ continue;
272
+ }
273
+ if (ch === '{') {
274
+ loopStartRow = notes.length;
275
+ i++;
276
+ continue;
277
+ }
278
+ if (ch === '}') {
279
+ loopEndRow = notes.length;
280
+ i++;
281
+ continue;
282
+ }
283
+
284
+ // --- Modal setters --------------------------------------------------------
285
+ if (lower === '@') {
286
+ // `@<hexdigit>`: waveform 0..7 built-in, 8..15 custom SFX-instrument.
287
+ const hd = src[i + 1];
288
+ const wv = hd === undefined ? Number.NaN : Number.parseInt(hd, 16);
289
+ if (Number.isNaN(wv)) {
290
+ throw parseError(
291
+ src,
292
+ i,
293
+ "expected a hex digit 0-f after '@' (waveform 0-7, 8-f = custom SFX-instrument)",
294
+ );
295
+ }
296
+ state.waveform = wv;
297
+ i += 2;
298
+ continue;
299
+ }
300
+ if (lower === 'v') {
301
+ const {value, next} = readInt(src, i + 1);
302
+ if (value === undefined || value > 7) {
303
+ throw parseError(src, i, "expected a digit 0-7 after 'v' (volume)");
304
+ }
305
+ state.volume = value;
306
+ i = next;
307
+ continue;
308
+ }
309
+ if (lower === 'o') {
310
+ const {value, next} = readInt(src, i + 1);
311
+ if (value === undefined || value > 5) {
312
+ throw parseError(src, i, "expected a digit 0-5 after 'o' (octave)");
313
+ }
314
+ state.octave = value;
315
+ i = next;
316
+ continue;
317
+ }
318
+ if (lower === 'l') {
319
+ // `l<N>[.]`: default note length in ROWS (may be dotted).
320
+ const {value, next} = readInt(src, i + 1);
321
+ if (value === undefined || value < 1) {
322
+ throw parseError(
323
+ src,
324
+ i,
325
+ "expected a positive number after 'l' (default note length in rows)",
326
+ );
327
+ }
328
+ const dots = countDots(src, next);
329
+ const token = src.slice(i, dots.next);
330
+ const rows = resolveRows(value, dots.count, token, warnings);
331
+ state.length = rows;
332
+ i = dots.next;
333
+ continue;
334
+ }
335
+ if (ch === '>') {
336
+ state.octave += 1;
337
+ i++;
338
+ continue;
339
+ }
340
+ if (ch === '<') {
341
+ state.octave -= 1;
342
+ i++;
343
+ continue;
344
+ }
345
+
346
+ // --- Effect ---------------------------------------------------------------
347
+ // `e` is BOTH a note letter and the effect prefix (finding B.1). The
348
+ // committed disambiguation (finding B.3: `e0..e7` is "the canonical,
349
+ // unambiguous form"): `e` FOLLOWED BY a digit 0-7 is the effect token; a
350
+ // bare `e` (or `e` + accidental/dot) falls through to the note-E handler
351
+ // below. So note E takes the default length (`l4 e`) rather than `e4`
352
+ // (which is effect 4). Documented in the command help (ADR-0008).
353
+ if (lower === 'e') {
354
+ const d = src[i + 1];
355
+ if (d !== undefined && d >= '0' && d <= '7') {
356
+ state.effect = Number.parseInt(d, 10);
357
+ i += 2;
358
+ continue;
359
+ }
360
+ if (d !== undefined && d >= '8' && d <= '9') {
361
+ throw parseError(
362
+ src,
363
+ i,
364
+ "effect 'e8'/'e9' out of range; effects are e0..e7",
365
+ );
366
+ }
367
+ // else: fall through, `e` is the note E.
368
+ }
369
+ // --- Rest -----------------------------------------------------------------
370
+ if (lower === 'r') {
371
+ const dur = readDuration(src, i, i + 1, state.length);
372
+ for (let k = 0; k < dur.rows; k++) {
373
+ pushNote({pitch: 0, waveform: state.waveform, volume: 0, effect: 0});
374
+ }
375
+ collectWarning(dur, warnings);
376
+ lastNote = undefined;
377
+ i = dur.next;
378
+ continue;
379
+ }
380
+
381
+ // --- Tie: extend the previous note ---------------------------------------
382
+ if (ch === '^') {
383
+ if (lastNote === undefined) {
384
+ throw parseError(src, i, "'^' (tie) with no preceding note to extend");
385
+ }
386
+ const dur = readDuration(src, i, i + 1, state.length);
387
+ for (let k = 0; k < dur.rows; k++) pushNote({...lastNote});
388
+ collectWarning(dur, warnings);
389
+ i = dur.next;
390
+ continue;
391
+ }
392
+
393
+ // --- Note -----------------------------------------------------------------
394
+ if (lower in SEMITONE) {
395
+ let semitone = SEMITONE[lower] as number;
396
+ let j = i + 1;
397
+ // Accidental: `+`/`#` sharp, `-` flat.
398
+ if (src[j] === '+' || src[j] === '#') {
399
+ semitone += 1;
400
+ j++;
401
+ } else if (src[j] === '-') {
402
+ semitone -= 1;
403
+ j++;
404
+ }
405
+ const pitch = 12 * state.octave + semitone;
406
+ const noteToken = src.slice(i, j);
407
+ if (pitch < PITCH_MIN || pitch > PITCH_MAX) {
408
+ throw new AudioMmlError(
409
+ 'audio-mml-pitch-out-of-range',
410
+ `note '${noteToken}' at octave o${state.octave} maps to pitch ${pitch}, outside PICO-8's range C0..D#5 (0..${PITCH_MAX}). PICO-8 has no notes above D#5 or below C0; pick an octave in range rather than transposing off the keyboard.`,
411
+ );
412
+ }
413
+ const dur = readDuration(src, i, j, state.length);
414
+ const note: Note = {
415
+ pitch,
416
+ waveform: state.waveform,
417
+ volume: state.volume,
418
+ effect: state.effect,
419
+ };
420
+ for (let k = 0; k < dur.rows; k++) pushNote({...note});
421
+ collectWarning(dur, warnings);
422
+ lastNote = note;
423
+ i = dur.next;
424
+ continue;
425
+ }
426
+
427
+ throw parseError(src, i, `unexpected token '${ch}'`);
428
+ }
429
+
430
+ const {loopStart, loopEnd} = resolveLoop(
431
+ loopStartRow,
432
+ loopEndRow,
433
+ notes.length,
434
+ );
435
+
436
+ return {
437
+ row: emitRow({filters, speed, loopStart, loopEnd, notes}),
438
+ rows: notes.length,
439
+ speed,
440
+ loopStart,
441
+ loopEnd,
442
+ filters,
443
+ roundingWarnings: warnings,
444
+ };
445
+ }
446
+
447
+ /** A parsed duration: how many rows, whether it rounded, and where parsing resumed. */
448
+ interface Duration {
449
+ rows: number;
450
+ next: number;
451
+ warning?: RowRoundingWarning;
452
+ }
453
+
454
+ /**
455
+ * Reads an optional `[length][dots]` after a note/rest/tie head at `from`. If no
456
+ * explicit length digit is present the current default `length` (already in
457
+ * rows) is used, and any dots still multiply it.
458
+ */
459
+ function readDuration(
460
+ src: string,
461
+ head: number,
462
+ from: number,
463
+ defaultRows: number,
464
+ ): Duration {
465
+ const {value, next} = readInt(src, from);
466
+ const dots = countDots(src, value === undefined ? from : next);
467
+ if (value === undefined && dots.count === 0) {
468
+ return {rows: defaultRows, next: dots.next};
469
+ }
470
+ const base = value ?? defaultRows;
471
+ const warnings: RowRoundingWarning[] = [];
472
+ const token = src.slice(head, dots.next);
473
+ const rows = resolveRows(base, dots.count, token, warnings);
474
+ return {rows, next: dots.next, warning: warnings[0]};
475
+ }
476
+
477
+ /**
478
+ * Resolves a base row count + dot count into an integer row count. Each dot
479
+ * multiplies by an added 1/2, 1/4, ... (dotted-note convention): `n` dots =>
480
+ * factor `2 - (1/2)^n`. Fractional results round to the nearest whole row (min
481
+ * 1), and a rounding is recorded so the command can warn (finding B.4).
482
+ */
483
+ function resolveRows(
484
+ base: number,
485
+ dots: number,
486
+ token: string,
487
+ warnings: RowRoundingWarning[],
488
+ ): number {
489
+ if (dots === 0) return base;
490
+ const factor = 2 - 0.5 ** dots;
491
+ const exact = base * factor;
492
+ const rounded = Math.max(1, Math.round(exact));
493
+ if (rounded !== exact) {
494
+ warnings.push({token, exactRows: exact, roundedRows: rounded});
495
+ }
496
+ return rounded;
497
+ }
498
+
499
+ /** Pushes a duration's rounding warning (if any) into the accumulator. */
500
+ function collectWarning(dur: Duration, warnings: RowRoundingWarning[]): void {
501
+ if (dur.warning) warnings.push(dur.warning);
502
+ }
503
+
504
+ /** Counts consecutive `.` dots from `at`, returning the count + resume index. */
505
+ function countDots(src: string, at: number): {count: number; next: number} {
506
+ let n = at;
507
+ while (src[n] === '.') n++;
508
+ return {count: n - at, next: n};
509
+ }
510
+
511
+ /** Reads a non-negative integer at `at`, or `undefined` if none; returns resume index. */
512
+ function readInt(
513
+ src: string,
514
+ at: number,
515
+ ): {value: number | undefined; next: number} {
516
+ let j = at;
517
+ while (j < src.length && src[j]! >= '0' && src[j]! <= '9') j++;
518
+ if (j === at) return {value: undefined, next: at};
519
+ return {value: Number.parseInt(src.slice(at, j), 10), next: j};
520
+ }
521
+
522
+ /** Builds an `audio-mml-parse-error` naming the offending column (1-based). */
523
+ function parseError(src: string, at: number, why: string): AudioMmlError {
524
+ const col = at + 1;
525
+ const near = src.slice(at, at + 8);
526
+ return new AudioMmlError(
527
+ 'audio-mml-parse-error',
528
+ `picopilot-MML parse error at column ${col} (near "${near}"): ${why}. Note: durations are TRACKER ROWS (l4 = 4 rows), not tempo/BPM; there is no tempo token.`,
529
+ );
530
+ }
531
+
532
+ /**
533
+ * Parses one `!<name>[<level>]` FILTER directive at `at` (the `!`), returning the
534
+ * filter bits to OR into the SFX header byte + the resume index (finding A.7).
535
+ *
536
+ * The 5 switches: `!noiz` (instrument-6-only), `!buzz`, `!detune1`, `!detune2`,
537
+ * `!reverb[1|2]`, `!dampen[1|2]`. DAMPEN/REVERB take an optional level 1..2
538
+ * (default 1); `!dampen`/`!dampen1` = level 1, `!dampen2` = level 2. A level
539
+ * OUTSIDE 1..2 is a STRUCTURED refusal ({@link AudioMmlError}
540
+ * `audio-mml-filter-level-out-of-range`), never a silent clamp (matching the
541
+ * pitch/overflow refusals, ADR-0008). `!detune` requires an explicit 1/2 (the
542
+ * two detunes are distinct sub-modes, not levels of one).
543
+ *
544
+ * @throws {AudioMmlError} `audio-mml-filter-level-out-of-range` (bad DAMPEN/
545
+ * REVERB/DETUNE level) or `audio-mml-parse-error` (an unknown `!<name>`).
546
+ */
547
+ function readFilter(src: string, at: number): {bits: number; next: number} {
548
+ // Read the lowercase name letters after `!`.
549
+ let j = at + 1;
550
+ while (j < src.length && /[a-z]/i.test(src[j]!)) j++;
551
+ const name = src.slice(at + 1, j).toLowerCase();
552
+ // Read an optional trailing level digit (e.g. `!dampen2`, `!detune1`).
553
+ const {value: level, next} = readInt(src, j);
554
+ const token = src.slice(at, next);
555
+
556
+ const leveled = (
557
+ table: readonly number[],
558
+ label: string,
559
+ defaultLevel: number,
560
+ ): number => {
561
+ const lvl = level ?? defaultLevel;
562
+ if (lvl < 1 || lvl > FILTER_MAX_LEVEL) {
563
+ throw new AudioMmlError(
564
+ 'audio-mml-filter-level-out-of-range',
565
+ `filter '${token}' has ${label} level ${lvl}, outside 1..${FILTER_MAX_LEVEL}. PICO-8 gives ${label} exactly ${FILTER_MAX_LEVEL} levels; use !${name}1 or !${name}2.`,
566
+ );
567
+ }
568
+ return table[lvl]!;
569
+ };
570
+
571
+ switch (name) {
572
+ case 'noiz':
573
+ rejectLevel(src, at, token, name, level);
574
+ return {bits: FILTER_NOIZ, next};
575
+ case 'buzz':
576
+ rejectLevel(src, at, token, name, level);
577
+ return {bits: FILTER_BUZZ, next};
578
+ case 'reverb':
579
+ rejectLevel(src, at, token, name, level);
580
+ return {bits: FILTER_REVERB, next};
581
+ case 'detune': {
582
+ // `!detune` needs an explicit 1 or 2 (they are distinct sub-modes).
583
+ if (level === undefined) {
584
+ throw new AudioMmlError(
585
+ 'audio-mml-filter-level-out-of-range',
586
+ `filter '${token}' needs a mode: !detune1 (flange) or !detune2 (octave). PICO-8 has two distinct detunes, not a level.`,
587
+ );
588
+ }
589
+ if (level === 1) return {bits: FILTER_DETUNE_1, next};
590
+ if (level === 2) return {bits: FILTER_DETUNE_2, next};
591
+ throw new AudioMmlError(
592
+ 'audio-mml-filter-level-out-of-range',
593
+ `filter '${token}' mode ${level} is not 1 (flange) or 2 (octave).`,
594
+ );
595
+ }
596
+ case 'detune1':
597
+ return {bits: FILTER_DETUNE_1, next};
598
+ case 'detune2':
599
+ return {bits: FILTER_DETUNE_2, next};
600
+ case 'dampen':
601
+ return {bits: leveled(FILTER_DAMPEN_LEVEL, 'DAMPEN', 1), next};
602
+ default:
603
+ throw parseError(
604
+ src,
605
+ at,
606
+ `unknown filter '${token}'. The 5 SFX filters are !noiz !buzz !detune1/!detune2 !reverb !dampen[1|2]`,
607
+ );
608
+ }
609
+ }
610
+
611
+ /** Refuses a level on a filter that has none (`!noiz2`, `!buzz1`), no silent drop. */
612
+ function rejectLevel(
613
+ src: string,
614
+ at: number,
615
+ token: string,
616
+ name: string,
617
+ level: number | undefined,
618
+ ): void {
619
+ if (level !== undefined) {
620
+ throw new AudioMmlError(
621
+ 'audio-mml-filter-level-out-of-range',
622
+ `filter '${token}' takes no level; !${name} is a simple on/off switch (only !dampen and !reverb have levels).`,
623
+ );
624
+ }
625
+ }
626
+
627
+ /**
628
+ * Resolves the raw `{`/`}` marker rows into header `loopStart`/`loopEnd` values
629
+ * (finding A.1 / B.5):
630
+ * - neither marker: loop off (`0 0`).
631
+ * - both: `loopStart` = row at `{`, `loopEnd` = row at `}`.
632
+ * - only `{`: the LEN special case (`loopEnd == 0`, `loopStart` reinterpreted as
633
+ * the pattern length): emit `loopStart = rowCount`, `loopEnd = 0`.
634
+ */
635
+ function resolveLoop(
636
+ loopStartRow: number | undefined,
637
+ loopEndRow: number | undefined,
638
+ rowCount: number,
639
+ ): {loopStart: number; loopEnd: number} {
640
+ if (loopStartRow === undefined && loopEndRow === undefined) {
641
+ return {loopStart: 0, loopEnd: 0};
642
+ }
643
+ if (loopStartRow !== undefined && loopEndRow === undefined) {
644
+ // Single `{` = LEN special case: this SFX is `rowCount` rows long.
645
+ return {loopStart: rowCount, loopEnd: 0};
646
+ }
647
+ // `}` present (with or without `{`): a real loop. A bare `}` loops back to 0.
648
+ return {loopStart: loopStartRow ?? 0, loopEnd: loopEndRow ?? 0};
649
+ }
650
+
651
+ /** Serializes the header + notes into the 168-char `__sfx__` text row (finding A). */
652
+ function emitRow(sfx: {
653
+ filters: number;
654
+ speed: number;
655
+ loopStart: number;
656
+ loopEnd: number;
657
+ notes: Note[];
658
+ }): string {
659
+ // Header: filter-byte(2) speed(2) loopstart(2) loopend(2). The first byte is the
660
+ // per-SFX FILTER bitfield (finding A.7); its low bit is the cosmetic pitch/
661
+ // tracker mode flag, which we leave 0 (pitch mode), so the byte is exactly the
662
+ // OR of the selected filter values.
663
+ const header =
664
+ byte(sfx.filters) +
665
+ byte(sfx.speed) +
666
+ byte(sfx.loopStart) +
667
+ byte(sfx.loopEnd);
668
+
669
+ let body = '';
670
+ for (let r = 0; r < SFX_MAX_ROWS; r++) {
671
+ const note = sfx.notes[r];
672
+ if (note === undefined) {
673
+ // Unfilled rows are all-zero (pitch 0, wave 0, vol 0 = silent, eff 0).
674
+ body += '00000';
675
+ continue;
676
+ }
677
+ body +=
678
+ byte(note.pitch) +
679
+ nib(note.waveform) +
680
+ nib(note.volume) +
681
+ nib(note.effect);
682
+ }
683
+ return header + body;
684
+ }
685
+
686
+ /** A byte as two lowercase hex chars. */
687
+ function byte(n: number): string {
688
+ return n.toString(16).padStart(2, '0');
689
+ }
690
+
691
+ /** A nibble as one lowercase hex char. */
692
+ function nib(n: number): string {
693
+ return n.toString(16);
694
+ }