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.
- package/LICENSE +661 -0
- package/dist/bin.d.ts +3 -0
- package/dist/bin.d.ts.map +1 -0
- package/dist/bin.js +4 -0
- package/dist/bin.js.map +1 -0
- package/dist/cli.d.ts +15 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +59 -0
- package/dist/cli.js.map +1 -0
- package/dist/commands/audio.d.ts +17 -0
- package/dist/commands/audio.d.ts.map +1 -0
- package/dist/commands/audio.js +315 -0
- package/dist/commands/audio.js.map +1 -0
- package/dist/commands/export.d.ts +36 -0
- package/dist/commands/export.d.ts.map +1 -0
- package/dist/commands/export.js +257 -0
- package/dist/commands/export.js.map +1 -0
- package/dist/commands/gfx.d.ts +28 -0
- package/dist/commands/gfx.d.ts.map +1 -0
- package/dist/commands/gfx.js +521 -0
- package/dist/commands/gfx.js.map +1 -0
- package/dist/commands/init.d.ts +34 -0
- package/dist/commands/init.d.ts.map +1 -0
- package/dist/commands/init.js +151 -0
- package/dist/commands/init.js.map +1 -0
- package/dist/commands/lint.d.ts +20 -0
- package/dist/commands/lint.d.ts.map +1 -0
- package/dist/commands/lint.js +90 -0
- package/dist/commands/lint.js.map +1 -0
- package/dist/commands/minify.d.ts +20 -0
- package/dist/commands/minify.d.ts.map +1 -0
- package/dist/commands/minify.js +129 -0
- package/dist/commands/minify.js.map +1 -0
- package/dist/commands/music.d.ts +13 -0
- package/dist/commands/music.d.ts.map +1 -0
- package/dist/commands/music.js +241 -0
- package/dist/commands/music.js.map +1 -0
- package/dist/commands/playtest.d.ts +29 -0
- package/dist/commands/playtest.d.ts.map +1 -0
- package/dist/commands/playtest.js +631 -0
- package/dist/commands/playtest.js.map +1 -0
- package/dist/commands/run.d.ts +30 -0
- package/dist/commands/run.d.ts.map +1 -0
- package/dist/commands/run.js +224 -0
- package/dist/commands/run.js.map +1 -0
- package/dist/commands/serve.d.ts +50 -0
- package/dist/commands/serve.d.ts.map +1 -0
- package/dist/commands/serve.js +213 -0
- package/dist/commands/serve.js.map +1 -0
- package/dist/commands/sfx.d.ts +13 -0
- package/dist/commands/sfx.d.ts.map +1 -0
- package/dist/commands/sfx.js +254 -0
- package/dist/commands/sfx.js.map +1 -0
- package/dist/commands/tokens.d.ts +36 -0
- package/dist/commands/tokens.d.ts.map +1 -0
- package/dist/commands/tokens.js +129 -0
- package/dist/commands/tokens.js.map +1 -0
- package/dist/commands/verify.d.ts +57 -0
- package/dist/commands/verify.d.ts.map +1 -0
- package/dist/commands/verify.js +213 -0
- package/dist/commands/verify.js.map +1 -0
- package/dist/commands/version.d.ts +12 -0
- package/dist/commands/version.d.ts.map +1 -0
- package/dist/commands/version.js +24 -0
- package/dist/commands/version.js.map +1 -0
- package/dist/engine/audio/index.d.ts +14 -0
- package/dist/engine/audio/index.d.ts.map +1 -0
- package/dist/engine/audio/index.js +14 -0
- package/dist/engine/audio/index.js.map +1 -0
- package/dist/engine/audio/music.d.ts +116 -0
- package/dist/engine/audio/music.d.ts.map +1 -0
- package/dist/engine/audio/music.js +138 -0
- package/dist/engine/audio/music.js.map +1 -0
- package/dist/engine/audio/section.d.ts +34 -0
- package/dist/engine/audio/section.d.ts.map +1 -0
- package/dist/engine/audio/section.js +67 -0
- package/dist/engine/audio/section.js.map +1 -0
- package/dist/engine/audio/sfx.d.ts +141 -0
- package/dist/engine/audio/sfx.d.ts.map +1 -0
- package/dist/engine/audio/sfx.js +506 -0
- package/dist/engine/audio/sfx.js.map +1 -0
- package/dist/engine/cart/cart.d.ts +75 -0
- package/dist/engine/cart/cart.d.ts.map +1 -0
- package/dist/engine/cart/cart.js +153 -0
- package/dist/engine/cart/cart.js.map +1 -0
- package/dist/engine/cart/errors.d.ts +29 -0
- package/dist/engine/cart/errors.d.ts.map +1 -0
- package/dist/engine/cart/errors.js +26 -0
- package/dist/engine/cart/errors.js.map +1 -0
- package/dist/engine/cart/gfx.d.ts +79 -0
- package/dist/engine/cart/gfx.d.ts.map +1 -0
- package/dist/engine/cart/gfx.js +171 -0
- package/dist/engine/cart/gfx.js.map +1 -0
- package/dist/engine/cart/index.d.ts +15 -0
- package/dist/engine/cart/index.d.ts.map +1 -0
- package/dist/engine/cart/index.js +15 -0
- package/dist/engine/cart/index.js.map +1 -0
- package/dist/engine/cart/map.d.ts +46 -0
- package/dist/engine/cart/map.d.ts.map +1 -0
- package/dist/engine/cart/map.js +118 -0
- package/dist/engine/cart/map.js.map +1 -0
- package/dist/engine/config/config.d.ts +74 -0
- package/dist/engine/config/config.d.ts.map +1 -0
- package/dist/engine/config/config.js +103 -0
- package/dist/engine/config/config.js.map +1 -0
- package/dist/engine/config/index.d.ts +7 -0
- package/dist/engine/config/index.d.ts.map +1 -0
- package/dist/engine/config/index.js +7 -0
- package/dist/engine/config/index.js.map +1 -0
- package/dist/engine/gfx/grid.d.ts +51 -0
- package/dist/engine/gfx/grid.d.ts.map +1 -0
- package/dist/engine/gfx/grid.js +116 -0
- package/dist/engine/gfx/grid.js.map +1 -0
- package/dist/engine/gfx/index.d.ts +17 -0
- package/dist/engine/gfx/index.d.ts.map +1 -0
- package/dist/engine/gfx/index.js +17 -0
- package/dist/engine/gfx/index.js.map +1 -0
- package/dist/engine/gfx/label.d.ts +34 -0
- package/dist/engine/gfx/label.d.ts.map +1 -0
- package/dist/engine/gfx/label.js +190 -0
- package/dist/engine/gfx/label.js.map +1 -0
- package/dist/engine/gfx/overlap.d.ts +42 -0
- package/dist/engine/gfx/overlap.d.ts.map +1 -0
- package/dist/engine/gfx/overlap.js +57 -0
- package/dist/engine/gfx/overlap.js.map +1 -0
- package/dist/engine/gfx/palette.d.ts +29 -0
- package/dist/engine/gfx/palette.d.ts.map +1 -0
- package/dist/engine/gfx/palette.js +46 -0
- package/dist/engine/gfx/palette.js.map +1 -0
- package/dist/engine/gfx/png.d.ts +31 -0
- package/dist/engine/gfx/png.d.ts.map +1 -0
- package/dist/engine/gfx/png.js +165 -0
- package/dist/engine/gfx/png.js.map +1 -0
- package/dist/engine/gfx/render.d.ts +42 -0
- package/dist/engine/gfx/render.d.ts.map +1 -0
- package/dist/engine/gfx/render.js +72 -0
- package/dist/engine/gfx/render.js.map +1 -0
- package/dist/engine/index.d.ts +22 -0
- package/dist/engine/index.d.ts.map +1 -0
- package/dist/engine/index.js +22 -0
- package/dist/engine/index.js.map +1 -0
- package/dist/engine/pico8/adapter.d.ts +267 -0
- package/dist/engine/pico8/adapter.d.ts.map +1 -0
- package/dist/engine/pico8/adapter.js +37 -0
- package/dist/engine/pico8/adapter.js.map +1 -0
- package/dist/engine/pico8/drive.d.ts +308 -0
- package/dist/engine/pico8/drive.d.ts.map +1 -0
- package/dist/engine/pico8/drive.js +581 -0
- package/dist/engine/pico8/drive.js.map +1 -0
- package/dist/engine/pico8/harness.d.ts +146 -0
- package/dist/engine/pico8/harness.d.ts.map +1 -0
- package/dist/engine/pico8/harness.js +251 -0
- package/dist/engine/pico8/harness.js.map +1 -0
- package/dist/engine/pico8/index.d.ts +21 -0
- package/dist/engine/pico8/index.d.ts.map +1 -0
- package/dist/engine/pico8/index.js +21 -0
- package/dist/engine/pico8/index.js.map +1 -0
- package/dist/engine/pico8/sentinel.d.ts +63 -0
- package/dist/engine/pico8/sentinel.d.ts.map +1 -0
- package/dist/engine/pico8/sentinel.js +86 -0
- package/dist/engine/pico8/sentinel.js.map +1 -0
- package/dist/engine/pico8/session-daemon-entry.d.ts +10 -0
- package/dist/engine/pico8/session-daemon-entry.d.ts.map +1 -0
- package/dist/engine/pico8/session-daemon-entry.js +11 -0
- package/dist/engine/pico8/session-daemon-entry.js.map +1 -0
- package/dist/engine/pico8/session-daemon-main.d.ts +44 -0
- package/dist/engine/pico8/session-daemon-main.d.ts.map +1 -0
- package/dist/engine/pico8/session-daemon-main.js +71 -0
- package/dist/engine/pico8/session-daemon-main.js.map +1 -0
- package/dist/engine/pico8/session-daemon.d.ts +68 -0
- package/dist/engine/pico8/session-daemon.d.ts.map +1 -0
- package/dist/engine/pico8/session-daemon.js +264 -0
- package/dist/engine/pico8/session-daemon.js.map +1 -0
- package/dist/engine/pico8/session.d.ts +140 -0
- package/dist/engine/pico8/session.d.ts.map +1 -0
- package/dist/engine/pico8/session.js +250 -0
- package/dist/engine/pico8/session.js.map +1 -0
- package/dist/engine/pico8/shell.d.ts +103 -0
- package/dist/engine/pico8/shell.d.ts.map +1 -0
- package/dist/engine/pico8/shell.js +324 -0
- package/dist/engine/pico8/shell.js.map +1 -0
- package/dist/engine/pico8/supervisor.d.ts +137 -0
- package/dist/engine/pico8/supervisor.d.ts.map +1 -0
- package/dist/engine/pico8/supervisor.js +140 -0
- package/dist/engine/pico8/supervisor.js.map +1 -0
- package/dist/engine/scaffold/index.d.ts +8 -0
- package/dist/engine/scaffold/index.d.ts.map +1 -0
- package/dist/engine/scaffold/index.js +8 -0
- package/dist/engine/scaffold/index.js.map +1 -0
- package/dist/engine/scaffold/scaffold.d.ts +49 -0
- package/dist/engine/scaffold/scaffold.d.ts.map +1 -0
- package/dist/engine/scaffold/scaffold.js +259 -0
- package/dist/engine/scaffold/scaffold.js.map +1 -0
- package/dist/engine/shrinko/adapter.d.ts +167 -0
- package/dist/engine/shrinko/adapter.d.ts.map +1 -0
- package/dist/engine/shrinko/adapter.js +107 -0
- package/dist/engine/shrinko/adapter.js.map +1 -0
- package/dist/engine/shrinko/index.d.ts +13 -0
- package/dist/engine/shrinko/index.d.ts.map +1 -0
- package/dist/engine/shrinko/index.js +13 -0
- package/dist/engine/shrinko/index.js.map +1 -0
- package/dist/engine/shrinko/shell.d.ts +69 -0
- package/dist/engine/shrinko/shell.d.ts.map +1 -0
- package/dist/engine/shrinko/shell.js +156 -0
- package/dist/engine/shrinko/shell.js.map +1 -0
- package/dist/engine/skills/index.d.ts +8 -0
- package/dist/engine/skills/index.d.ts.map +1 -0
- package/dist/engine/skills/index.js +8 -0
- package/dist/engine/skills/index.js.map +1 -0
- package/dist/engine/skills/skills.d.ts +104 -0
- package/dist/engine/skills/skills.d.ts.map +1 -0
- package/dist/engine/skills/skills.js +149 -0
- package/dist/engine/skills/skills.js.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +10 -0
- package/dist/index.js.map +1 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +19 -0
- package/dist/version.js.map +1 -0
- package/package.json +48 -2
- package/src/bin.ts +4 -0
- package/src/cli.ts +61 -0
- package/src/commands/audio.test.ts +280 -0
- package/src/commands/audio.ts +410 -0
- package/src/commands/export.test.ts +310 -0
- package/src/commands/export.ts +316 -0
- package/src/commands/gfx-io.test.ts +178 -0
- package/src/commands/gfx.test.ts +470 -0
- package/src/commands/gfx.ts +645 -0
- package/src/commands/init.test.ts +482 -0
- package/src/commands/init.ts +193 -0
- package/src/commands/lint.test.ts +137 -0
- package/src/commands/lint.ts +117 -0
- package/src/commands/minify.test.ts +166 -0
- package/src/commands/minify.ts +156 -0
- package/src/commands/music.test.ts +272 -0
- package/src/commands/music.ts +288 -0
- package/src/commands/playtest.test.ts +317 -0
- package/src/commands/playtest.ts +773 -0
- package/src/commands/run.test.ts +264 -0
- package/src/commands/run.ts +285 -0
- package/src/commands/serve.test.ts +227 -0
- package/src/commands/serve.ts +272 -0
- package/src/commands/sfx.test.ts +349 -0
- package/src/commands/sfx.ts +300 -0
- package/src/commands/tokens.test.ts +212 -0
- package/src/commands/tokens.ts +168 -0
- package/src/commands/verify.test.ts +238 -0
- package/src/commands/verify.ts +256 -0
- package/src/commands/version.test.ts +70 -0
- package/src/commands/version.ts +24 -0
- package/src/engine/audio/index.ts +47 -0
- package/src/engine/audio/music.test.ts +188 -0
- package/src/engine/audio/music.ts +218 -0
- package/src/engine/audio/section.test.ts +124 -0
- package/src/engine/audio/section.ts +79 -0
- package/src/engine/audio/sfx.test.ts +410 -0
- package/src/engine/audio/sfx.ts +694 -0
- package/src/engine/cart/cart.test.ts +304 -0
- package/src/engine/cart/cart.ts +211 -0
- package/src/engine/cart/errors.ts +39 -0
- package/src/engine/cart/fixtures/crlf.p8 +6 -0
- package/src/engine/cart/fixtures/full.p8 +174 -0
- package/src/engine/cart/fixtures/header-only.p8 +2 -0
- package/src/engine/cart/fixtures/malformed-no-header.p8 +2 -0
- package/src/engine/cart/fixtures/minimal.p8 +10 -0
- package/src/engine/cart/fixtures/no-trailing-newline.p8 +6 -0
- package/src/engine/cart/gfx.ts +195 -0
- package/src/engine/cart/index.ts +27 -0
- package/src/engine/cart/map.ts +134 -0
- package/src/engine/config/config.ts +115 -0
- package/src/engine/config/index.ts +12 -0
- package/src/engine/gfx/grid.test.ts +128 -0
- package/src/engine/gfx/grid.ts +136 -0
- package/src/engine/gfx/index.ts +34 -0
- package/src/engine/gfx/label.test.ts +105 -0
- package/src/engine/gfx/label.ts +229 -0
- package/src/engine/gfx/overlap.test.ts +71 -0
- package/src/engine/gfx/overlap.ts +66 -0
- package/src/engine/gfx/palette.test.ts +35 -0
- package/src/engine/gfx/palette.ts +54 -0
- package/src/engine/gfx/png.test.ts +111 -0
- package/src/engine/gfx/png.ts +196 -0
- package/src/engine/gfx/render.test.ts +180 -0
- package/src/engine/gfx/render.ts +106 -0
- package/src/engine/index.ts +21 -0
- package/src/engine/pico8/adapter.ts +303 -0
- package/src/engine/pico8/drive.test.ts +415 -0
- package/src/engine/pico8/drive.ts +729 -0
- package/src/engine/pico8/harness.test.ts +181 -0
- package/src/engine/pico8/harness.ts +354 -0
- package/src/engine/pico8/index.ts +20 -0
- package/src/engine/pico8/sentinel.test.ts +80 -0
- package/src/engine/pico8/sentinel.ts +93 -0
- package/src/engine/pico8/session-daemon-entry.ts +12 -0
- package/src/engine/pico8/session-daemon-main.ts +98 -0
- package/src/engine/pico8/session-daemon.test.ts +212 -0
- package/src/engine/pico8/session-daemon.ts +306 -0
- package/src/engine/pico8/session.test.ts +239 -0
- package/src/engine/pico8/session.ts +321 -0
- package/src/engine/pico8/shell.test.ts +583 -0
- package/src/engine/pico8/shell.ts +412 -0
- package/src/engine/pico8/supervisor.test.ts +105 -0
- package/src/engine/pico8/supervisor.ts +197 -0
- package/src/engine/scaffold/index.ts +15 -0
- package/src/engine/scaffold/scaffold.ts +278 -0
- package/src/engine/shrinko/adapter.ts +241 -0
- package/src/engine/shrinko/fixtures/count-no-compressed.txt +2 -0
- package/src/engine/shrinko/fixtures/count-over-budget.txt +3 -0
- package/src/engine/shrinko/fixtures/count-under-budget.txt +3 -0
- package/src/engine/shrinko/index.ts +33 -0
- package/src/engine/shrinko/shell.ts +224 -0
- package/src/engine/shrinko/shrinko.test.ts +182 -0
- package/src/engine/skills/index.ts +14 -0
- package/src/engine/skills/skills.test.ts +376 -0
- package/src/engine/skills/skills.ts +205 -0
- package/src/index.ts +13 -0
- package/src/skills/game-design-reference/SKILL.md +70 -0
- package/src/skills/game-jam/SKILL.md +45 -0
- package/src/skills/picopilot-art/SKILL.md +62 -0
- package/src/skills/picopilot-audio/SKILL.md +194 -0
- package/src/skills/picopilot-code/SKILL.md +97 -0
- package/src/skills/picopilot-code/reference/README.md +42 -0
- package/src/skills/picopilot-code/reference/mode7-racing.md +95 -0
- package/src/skills/picopilot-code/reference/platformer.md +119 -0
- package/src/skills/picopilot-code/reference/puzzle-grid.md +106 -0
- package/src/skills/picopilot-code/reference/rpg-menus-dialog.md +122 -0
- package/src/skills/picopilot-code/reference/top-down-adventure.md +117 -0
- package/src/skills/picopilot-code/reference/twin-stick-arcade.md +128 -0
- package/src/skills/picopilot-debug/SKILL.md +195 -0
- package/src/skills/picopilot-overview/SKILL.md +52 -0
- package/src/version.ts +22 -0
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `engine/audio`: the shrinko-free structural pattern-list to `__music__` codec.
|
|
3
|
+
*
|
|
4
|
+
* `__music__` carries NO melodic content (that lives entirely in the referenced
|
|
5
|
+
* SFX built by `sfx from-mml`). A song is authored STRUCTURALLY: an ordered list
|
|
6
|
+
* of patterns, each naming up to 4 SFX-channel references plus the 3 pattern-flow
|
|
7
|
+
* flags. This module OWNS that pattern-list -> `__music__` text emission; the
|
|
8
|
+
* `music from-patterns` command is thin wiring over {@link patternsToMusic}.
|
|
9
|
+
*
|
|
10
|
+
* The layout is the CLOSED spec from the ADR-0005 spike, verified byte-for-byte
|
|
11
|
+
* against PICO-8 v0.2.7:
|
|
12
|
+
* `work/notes/findings/pico8-audio-sfx-music-layout-and-mml-subset.md` Part A.5
|
|
13
|
+
* (the `__music__` text row) + Part B.6 (the structural authoring model). Each
|
|
14
|
+
* pattern serializes to `<flagbyte> <4 channel bytes>` = `FF CCCCCCCC` (a 2-hex
|
|
15
|
+
* flag byte + space + 8 hex = 4 channel bytes). Patterns are listed top to
|
|
16
|
+
* bottom = song order.
|
|
17
|
+
*
|
|
18
|
+
* The load-bearing subtlety (finding B.6 / B.7 #9): "channel off" is NOT "sfx 0".
|
|
19
|
+
* An off channel sets bit6 (`0x40`) on its channel byte and is silent this
|
|
20
|
+
* pattern; sfx 0 is a real, playing SFX. The model keeps them DISTINCT: a channel
|
|
21
|
+
* entry is an SFX index 0..63 OR `null` (off).
|
|
22
|
+
*
|
|
23
|
+
* Pattern LENGTH is not stored here: PICO-8 ends a pattern when the LEFT-MOST
|
|
24
|
+
* non-looping channel's SFX finishes (finding B.6), so timing is INHERITED from
|
|
25
|
+
* the referenced SFX (its speed + LEN, set via `sfx from-mml`). The music model
|
|
26
|
+
* carries no per-pattern length by design.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/** The flag bit set on the leading flag byte for a loop-start pattern (finding A.5). */
|
|
30
|
+
export const FLAG_LOOP_START = 0x01;
|
|
31
|
+
|
|
32
|
+
/** The flag bit for a loop-back pattern (jump back to loop-start at its end). */
|
|
33
|
+
export const FLAG_LOOP_BACK = 0x02;
|
|
34
|
+
|
|
35
|
+
/** The flag bit for a stop pattern (music stops after it). */
|
|
36
|
+
export const FLAG_STOP = 0x04;
|
|
37
|
+
|
|
38
|
+
/** The bit6 marker that makes a channel byte "off" (silent this pattern; finding A.5). */
|
|
39
|
+
export const CHANNEL_OFF_BIT = 0x40;
|
|
40
|
+
|
|
41
|
+
/** The highest valid SFX index a channel may reference (0..63; finding A.5). */
|
|
42
|
+
export const SFX_INDEX_MAX = 63;
|
|
43
|
+
|
|
44
|
+
/** The most patterns `__music__` holds (song positions 0..63; finding: 64 slots). */
|
|
45
|
+
export const MUSIC_PATTERN_MAX = 64;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* One channel reference in a pattern: an SFX index 0..63, or `null` = "off"
|
|
49
|
+
* (bit6 set; the channel is silent this pattern). `null` and `0` are DISTINCT
|
|
50
|
+
* (finding B.6): 0 is a real, playing SFX; `null` is silence.
|
|
51
|
+
*/
|
|
52
|
+
export type ChannelRef = number | null;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* One song pattern (finding B.6). `channels` is EXACTLY 4 entries (ch0..ch3);
|
|
56
|
+
* each is an SFX index 0..63 or `null` (off). The 3 flow flags are optional and
|
|
57
|
+
* default to false. Pattern order in the list = song order.
|
|
58
|
+
*/
|
|
59
|
+
export interface Pattern {
|
|
60
|
+
/** The 4 channel references (ch0..ch3): each an SFX index 0..63 or `null` (off). */
|
|
61
|
+
channels: [ChannelRef, ChannelRef, ChannelRef, ChannelRef];
|
|
62
|
+
/** Flag 0x01: a `music` loop-back searches back to the nearest of these (or pattern 0). */
|
|
63
|
+
loopStart?: boolean;
|
|
64
|
+
/** Flag 0x02: at the end of this pattern, jump back to the loop-start pattern. */
|
|
65
|
+
loopBack?: boolean;
|
|
66
|
+
/** Flag 0x04: music stops after this pattern. */
|
|
67
|
+
stop?: boolean;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Machine-readable reason a pattern list could not be transpiled. */
|
|
71
|
+
export type MusicErrorCode =
|
|
72
|
+
/** A channel referenced an SFX index outside 0..63 (finding A.5). */
|
|
73
|
+
| 'audio-music-sfx-out-of-range'
|
|
74
|
+
/** A pattern did not carry exactly 4 channel entries (finding A.5). */
|
|
75
|
+
| 'audio-music-bad-channel-count'
|
|
76
|
+
/** More than 64 patterns were supplied (the `__music__` slot count). */
|
|
77
|
+
| 'audio-music-too-many-patterns'
|
|
78
|
+
/** The pattern list was empty (nothing to emit). */
|
|
79
|
+
| 'audio-music-empty';
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* A structured pattern-list transpile failure. Mirrors {@link AudioMmlError}'s
|
|
83
|
+
* shape (a machine-readable `code` + an actionable `message`), surfaced by the
|
|
84
|
+
* `music from-patterns` command as incur's `error({code,...})` envelope with a
|
|
85
|
+
* nonzero exit. Never thrown for a case the spec allows (e.g. a loop-back with
|
|
86
|
+
* no loop-start, which WARNS, not errors).
|
|
87
|
+
*/
|
|
88
|
+
export class MusicError extends Error {
|
|
89
|
+
readonly code: MusicErrorCode;
|
|
90
|
+
|
|
91
|
+
constructor(code: MusicErrorCode, message: string) {
|
|
92
|
+
super(message);
|
|
93
|
+
this.name = 'MusicError';
|
|
94
|
+
this.code = code;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** A non-fatal advisory surfaced to the caller (finding B.6: loop-back w/o loop-start). */
|
|
99
|
+
export interface MusicWarning {
|
|
100
|
+
/** A machine handle for the advisory. */
|
|
101
|
+
code: 'loop-back-without-loop-start';
|
|
102
|
+
/** A human-readable, actionable explanation. */
|
|
103
|
+
message: string;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The successful result of transpiling a pattern list to `__music__`. */
|
|
107
|
+
export interface MusicTranspileResult {
|
|
108
|
+
/** The `__music__` section body: one `FF CCCCCCCC` row per pattern, `\n`-terminated. */
|
|
109
|
+
section: string;
|
|
110
|
+
/** The number of patterns emitted (= song length). */
|
|
111
|
+
patterns: number;
|
|
112
|
+
/** Any non-fatal advisories (e.g. a loop-back with no loop-start anywhere). */
|
|
113
|
+
warnings: MusicWarning[];
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** A byte as two lowercase hex chars. */
|
|
117
|
+
function byte(n: number): string {
|
|
118
|
+
return n.toString(16).padStart(2, '0');
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Encodes one pattern into its `FF CCCCCCCC` text row (finding A.5): the 3 flow
|
|
123
|
+
* flags are hoisted into the leading flag byte; each channel byte is the SFX
|
|
124
|
+
* index (0..63), OR the index with bit6 (`0x40`) set for an "off" channel. A
|
|
125
|
+
* `null` channel is off (bit6, index bits 0 -> byte `0x40`); the finding notes
|
|
126
|
+
* an off byte keeps its low 6 bits (`0x41` = off + sfx 1), so a `null` becomes a
|
|
127
|
+
* bare `0x40` (there is no underlying sfx index to preserve).
|
|
128
|
+
*
|
|
129
|
+
* @throws {MusicError} `audio-music-bad-channel-count` (not exactly 4 channels),
|
|
130
|
+
* `audio-music-sfx-out-of-range` (a channel index outside 0..63).
|
|
131
|
+
*/
|
|
132
|
+
function encodePattern(pattern: Pattern, index: number): string {
|
|
133
|
+
const {channels} = pattern;
|
|
134
|
+
if (!Array.isArray(channels) || channels.length !== 4) {
|
|
135
|
+
throw new MusicError(
|
|
136
|
+
'audio-music-bad-channel-count',
|
|
137
|
+
`pattern ${index} must have exactly 4 channels (ch0..ch3), got ${
|
|
138
|
+
Array.isArray(channels) ? channels.length : typeof channels
|
|
139
|
+
}. Each channel is an SFX index 0..${SFX_INDEX_MAX} or null (off).`,
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
let flags = 0;
|
|
144
|
+
if (pattern.loopStart) flags |= FLAG_LOOP_START;
|
|
145
|
+
if (pattern.loopBack) flags |= FLAG_LOOP_BACK;
|
|
146
|
+
if (pattern.stop) flags |= FLAG_STOP;
|
|
147
|
+
|
|
148
|
+
let channelBytes = '';
|
|
149
|
+
for (let c = 0; c < 4; c++) {
|
|
150
|
+
const ref = channels[c] as ChannelRef;
|
|
151
|
+
if (ref === null) {
|
|
152
|
+
// "Off" is bit6 with no sfx index: a bare 0x40 (finding A.5). Distinct
|
|
153
|
+
// from sfx 0 (byte 0x00), which is a real, playing SFX.
|
|
154
|
+
channelBytes += byte(CHANNEL_OFF_BIT);
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
if (!Number.isInteger(ref) || ref < 0 || ref > SFX_INDEX_MAX) {
|
|
158
|
+
throw new MusicError(
|
|
159
|
+
'audio-music-sfx-out-of-range',
|
|
160
|
+
`pattern ${index} channel ${c} references SFX ${ref}, outside 0..${SFX_INDEX_MAX}. A channel is an SFX index 0..${SFX_INDEX_MAX} or null (off); use null to silence a channel, not an out-of-range index.`,
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
channelBytes += byte(ref);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return `${byte(flags)} ${channelBytes}`;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Transpiles an ordered pattern list into a `__music__` section body (finding
|
|
171
|
+
* Part A.5 + B.6). Emits the friendly text rows directly (flow flags hoisted
|
|
172
|
+
* into the leading flag byte; bit6 set on off channels).
|
|
173
|
+
*
|
|
174
|
+
* Pattern order = song order. The result body is one `FF CCCCCCCC` row per
|
|
175
|
+
* pattern, `\n`-joined with a terminating newline (what PICO-8 writes).
|
|
176
|
+
*
|
|
177
|
+
* A `loopBack` flag anywhere with NO `loopStart` flag anywhere is ALLOWED (PICO-8
|
|
178
|
+
* falls back to pattern 0; finding B.6) but surfaces a {@link MusicWarning}, not
|
|
179
|
+
* an error. Pattern LENGTH is inherited from the referenced SFX and is not stored
|
|
180
|
+
* here (finding B.6).
|
|
181
|
+
*
|
|
182
|
+
* @throws {MusicError} `audio-music-empty` (no patterns),
|
|
183
|
+
* `audio-music-too-many-patterns` (>64), `audio-music-bad-channel-count` or
|
|
184
|
+
* `audio-music-sfx-out-of-range` (a malformed / out-of-range channel).
|
|
185
|
+
*/
|
|
186
|
+
export function patternsToMusic(patterns: Pattern[]): MusicTranspileResult {
|
|
187
|
+
if (!Array.isArray(patterns) || patterns.length === 0) {
|
|
188
|
+
throw new MusicError(
|
|
189
|
+
'audio-music-empty',
|
|
190
|
+
'no patterns to assemble: supply at least one pattern (each: 4 channel refs + optional loopStart/loopBack/stop flags).',
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
if (patterns.length > MUSIC_PATTERN_MAX) {
|
|
194
|
+
throw new MusicError(
|
|
195
|
+
'audio-music-too-many-patterns',
|
|
196
|
+
`${patterns.length} patterns exceeds the ${MUSIC_PATTERN_MAX}-pattern __music__ limit (song positions 0..${MUSIC_PATTERN_MAX - 1}).`,
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const rows = patterns.map((p, i) => encodePattern(p, i));
|
|
201
|
+
|
|
202
|
+
const warnings: MusicWarning[] = [];
|
|
203
|
+
const anyLoopStart = patterns.some((p) => p.loopStart === true);
|
|
204
|
+
const anyLoopBack = patterns.some((p) => p.loopBack === true);
|
|
205
|
+
if (anyLoopBack && !anyLoopStart) {
|
|
206
|
+
warnings.push({
|
|
207
|
+
code: 'loop-back-without-loop-start',
|
|
208
|
+
message:
|
|
209
|
+
'a pattern has loopBack but no pattern has loopStart: PICO-8 loops back to pattern 0. Add loopStart to the pattern you want the loop to return to, or accept the fall-back to pattern 0.',
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return {
|
|
214
|
+
section: rows.join('\n') + '\n',
|
|
215
|
+
patterns: rows.length,
|
|
216
|
+
warnings,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import {describe, expect, it} from 'vitest';
|
|
2
|
+
|
|
3
|
+
import {Cart} from '../cart/index.js';
|
|
4
|
+
import {mergeSfxRow, setMusicSection} from './section.js';
|
|
5
|
+
import {patternsToMusic} from './music.js';
|
|
6
|
+
import {mmlToSfxRow, SFX_ROW_LENGTH} from './sfx.js';
|
|
7
|
+
|
|
8
|
+
/** A minimal valid cart, optionally with an existing __sfx__ body + other sections. */
|
|
9
|
+
function cart(opts?: {sfx?: string; music?: string; withGfx?: boolean}): Cart {
|
|
10
|
+
let text =
|
|
11
|
+
'pico-8 cartridge // http://www.pico-8.com\nversion 42\n__lua__\nprint("hi")\n';
|
|
12
|
+
if (opts?.withGfx) text += '__gfx__\n0000\n';
|
|
13
|
+
if (opts?.sfx !== undefined) text += `__sfx__\n${opts.sfx}`;
|
|
14
|
+
if (opts?.music !== undefined) text += `__music__\n${opts.music}`;
|
|
15
|
+
return Cart.parse(text);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const ROW = mmlToSfxRow('s8 @1 v6 c d e').row;
|
|
19
|
+
const EMPTY = '0'.repeat(SFX_ROW_LENGTH);
|
|
20
|
+
|
|
21
|
+
describe('mergeSfxRow: writes the target slot', () => {
|
|
22
|
+
it('creates the __sfx__ section when absent and writes slot 0', () => {
|
|
23
|
+
const c = cart();
|
|
24
|
+
mergeSfxRow(c, 0, ROW);
|
|
25
|
+
const body = c.getSection('sfx');
|
|
26
|
+
expect(body).toBe(`${ROW}\n`);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('pads intermediate slots with empty rows to reach a higher slot', () => {
|
|
30
|
+
const c = cart();
|
|
31
|
+
mergeSfxRow(c, 2, ROW);
|
|
32
|
+
const lines = c.getSection('sfx')!.trimEnd().split('\n');
|
|
33
|
+
expect(lines).toHaveLength(3);
|
|
34
|
+
expect(lines[0]).toBe(EMPTY);
|
|
35
|
+
expect(lines[1]).toBe(EMPTY);
|
|
36
|
+
expect(lines[2]).toBe(ROW);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('trims trailing empty rows (PICO-8 canonical form)', () => {
|
|
40
|
+
// Author slot 0, then a slot-3 write, then clear slot 3 back to empty:
|
|
41
|
+
const c = cart();
|
|
42
|
+
mergeSfxRow(c, 0, ROW);
|
|
43
|
+
mergeSfxRow(c, 3, ROW);
|
|
44
|
+
expect(c.getSection('sfx')!.trimEnd().split('\n')).toHaveLength(4);
|
|
45
|
+
mergeSfxRow(c, 3, EMPTY);
|
|
46
|
+
// Slot 3 empty again -> trailing empties trimmed back to just slot 0.
|
|
47
|
+
expect(c.getSection('sfx')).toBe(`${ROW}\n`);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe('mergeSfxRow: leaves every OTHER slot byte-identical', () => {
|
|
52
|
+
it('replaces only the target slot, preserving siblings', () => {
|
|
53
|
+
const rowA = mmlToSfxRow('@2 c').row;
|
|
54
|
+
const rowB = mmlToSfxRow('@5 g').row;
|
|
55
|
+
const c = cart({sfx: `${rowA}\n${EMPTY}\n${rowB}\n`});
|
|
56
|
+
const newRow = mmlToSfxRow('@7 e').row;
|
|
57
|
+
mergeSfxRow(c, 1, newRow);
|
|
58
|
+
const lines = c.getSection('sfx')!.trimEnd().split('\n');
|
|
59
|
+
expect(lines[0]).toBe(rowA); // untouched
|
|
60
|
+
expect(lines[1]).toBe(newRow); // replaced
|
|
61
|
+
expect(lines[2]).toBe(rowB); // untouched
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe('mergeSfxRow: leaves every OTHER section byte-identical', () => {
|
|
66
|
+
it('only __sfx__ changes; header + lua + gfx are preserved', () => {
|
|
67
|
+
const c = cart({withGfx: true});
|
|
68
|
+
const before = c.serialize();
|
|
69
|
+
mergeSfxRow(c, 0, ROW);
|
|
70
|
+
const after = c.serialize();
|
|
71
|
+
// Everything up to __sfx__ is byte-identical.
|
|
72
|
+
const cut = before.indexOf('__gfx__');
|
|
73
|
+
expect(after.slice(0, before.length - (before.length - cut))).toBe(
|
|
74
|
+
before.slice(0, cut),
|
|
75
|
+
);
|
|
76
|
+
expect(after).toContain('print("hi")');
|
|
77
|
+
expect(after).toContain('__gfx__\n0000\n');
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe('setMusicSection: writes __music__ + leaves other sections identical', () => {
|
|
82
|
+
const BODY = patternsToMusic([
|
|
83
|
+
{channels: [0, 1, 2, 3], loopStart: true},
|
|
84
|
+
{channels: [4, null, 5, 6], loopBack: true, stop: true},
|
|
85
|
+
]).section;
|
|
86
|
+
|
|
87
|
+
it('creates the __music__ section when absent', () => {
|
|
88
|
+
const c = cart();
|
|
89
|
+
setMusicSection(c, BODY);
|
|
90
|
+
expect(c.getSection('music')).toBe(BODY);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('replaces an existing __music__ body wholesale', () => {
|
|
94
|
+
const c = cart({music: '00 00000000\n'});
|
|
95
|
+
setMusicSection(c, BODY);
|
|
96
|
+
expect(c.getSection('music')).toBe(BODY);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('leaves __sfx__ (and lua + gfx + header) BYTE-IDENTICAL', () => {
|
|
100
|
+
const sfxRow = mmlToSfxRow('s8 @1 v6 c d e').row;
|
|
101
|
+
const c = cart({withGfx: true, sfx: `${sfxRow}\n`});
|
|
102
|
+
const before = c.serialize();
|
|
103
|
+
const sfxBefore = c.getSection('sfx');
|
|
104
|
+
setMusicSection(c, BODY);
|
|
105
|
+
// __sfx__ is untouched.
|
|
106
|
+
expect(c.getSection('sfx')).toBe(sfxBefore);
|
|
107
|
+
// Everything up to where __music__ was appended is byte-identical.
|
|
108
|
+
expect(c.serialize().startsWith(before)).toBe(true);
|
|
109
|
+
expect(c.serialize()).toContain('print("hi")');
|
|
110
|
+
expect(c.serialize()).toContain('__gfx__\n0000\n');
|
|
111
|
+
expect(c.serialize()).toContain(`__sfx__\n${sfxRow}\n`);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe('mergeSfxRow: input validation', () => {
|
|
116
|
+
it('rejects an out-of-range slot', () => {
|
|
117
|
+
expect(() => mergeSfxRow(cart(), 64, ROW)).toThrow(RangeError);
|
|
118
|
+
expect(() => mergeSfxRow(cart(), -1, ROW)).toThrow(RangeError);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('rejects a malformed row', () => {
|
|
122
|
+
expect(() => mergeSfxRow(cart(), 0, 'abc')).toThrow(RangeError);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type {Cart} from '../cart/index.js';
|
|
2
|
+
import {SFX_ROW_LENGTH} from './sfx.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The number of SFX slots a cart holds (finding: 64 SFX, indices 0..63).
|
|
6
|
+
*/
|
|
7
|
+
export const SFX_SLOT_COUNT = 64;
|
|
8
|
+
|
|
9
|
+
/** An all-zero `__sfx__` row (168 chars): the canonical "empty slot". */
|
|
10
|
+
const EMPTY_ROW = '0'.repeat(SFX_ROW_LENGTH);
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Merges one transpiled `__sfx__` row into slot `slot` (0..63) of a cart's
|
|
14
|
+
* `__sfx__` section, leaving every other slot BYTE-IDENTICAL, and commits it
|
|
15
|
+
* back through the cart model (so all other sections stay untouched).
|
|
16
|
+
*
|
|
17
|
+
* PICO-8 trims trailing all-zero SFX rows on save, so a cart's `__sfx__` body
|
|
18
|
+
* may have FEWER than 64 lines (or be absent). This pads with empty rows up to
|
|
19
|
+
* the target slot as needed, replaces the target, and drops trailing empty rows
|
|
20
|
+
* again so the result matches what PICO-8 would write (a stable round-trip).
|
|
21
|
+
*
|
|
22
|
+
* The `__sfx__` body's newline style + trailing-newline are preserved from the
|
|
23
|
+
* existing section (defaulting to `\n` with a terminating newline, matching what
|
|
24
|
+
* PICO-8 writes).
|
|
25
|
+
*/
|
|
26
|
+
export function mergeSfxRow(cart: Cart, slot: number, row: string): void {
|
|
27
|
+
if (!Number.isInteger(slot) || slot < 0 || slot >= SFX_SLOT_COUNT) {
|
|
28
|
+
throw new RangeError(
|
|
29
|
+
`SFX slot ${slot} out of range 0..${SFX_SLOT_COUNT - 1}`,
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
if (row.length !== SFX_ROW_LENGTH) {
|
|
33
|
+
throw new RangeError(
|
|
34
|
+
`SFX row must be ${SFX_ROW_LENGTH} hex chars, got ${row.length}`,
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const body = cart.getSection('sfx');
|
|
39
|
+
const newline = body !== undefined && /\r\n/.test(body) ? '\r\n' : '\n';
|
|
40
|
+
|
|
41
|
+
// Parse existing rows (ignoring blank/trailing lines the split produces).
|
|
42
|
+
const existing =
|
|
43
|
+
body === undefined
|
|
44
|
+
? []
|
|
45
|
+
: body.split(/\r?\n/).filter((line) => line.length > 0);
|
|
46
|
+
|
|
47
|
+
// Grow to include the target slot, padding intermediate slots with empties.
|
|
48
|
+
const rows = existing.slice();
|
|
49
|
+
while (rows.length <= slot) rows.push(EMPTY_ROW);
|
|
50
|
+
rows[slot] = row;
|
|
51
|
+
|
|
52
|
+
// Trim trailing all-zero rows (what PICO-8 does on save) so the body is the
|
|
53
|
+
// canonical minimal form; a cart with only slot 0 authored stays one line.
|
|
54
|
+
while (rows.length > 0 && rows[rows.length - 1] === EMPTY_ROW) rows.pop();
|
|
55
|
+
|
|
56
|
+
const newBody = rows.length === 0 ? '' : rows.join(newline) + newline;
|
|
57
|
+
cart.setSection('sfx', newBody);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Replaces a cart's `__music__` section with a transpiled body, leaving every
|
|
62
|
+
* OTHER section (including `__sfx__`) BYTE-IDENTICAL, and commits it back through
|
|
63
|
+
* the cart model.
|
|
64
|
+
*
|
|
65
|
+
* Unlike `__sfx__` (where {@link mergeSfxRow} writes ONE slot), a `music
|
|
66
|
+
* from-patterns` call authors the WHOLE song at once (an ordered pattern list is
|
|
67
|
+
* the whole `__music__`), so this REPLACES the section wholesale rather than
|
|
68
|
+
* merging a single row. The body is the codec's output (one `FF CCCCCCCC` row per
|
|
69
|
+
* pattern); this only normalises its newline style to match the cart's existing
|
|
70
|
+
* `__music__`/dominant newline so the round-trip stays stable.
|
|
71
|
+
*/
|
|
72
|
+
export function setMusicSection(cart: Cart, body: string): void {
|
|
73
|
+
const existing = cart.getSection('music');
|
|
74
|
+
// Preserve the file's newline style: CRLF if the existing music section (or,
|
|
75
|
+
// absent, the codec body would already be \n) uses it. The codec emits \n.
|
|
76
|
+
const crlf = existing !== undefined && /\r\n/.test(existing);
|
|
77
|
+
const normalised = crlf ? body.replace(/\n/g, '\r\n') : body;
|
|
78
|
+
cart.setSection('music', normalised);
|
|
79
|
+
}
|