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,349 @@
|
|
|
1
|
+
import {execFileSync} from 'node:child_process';
|
|
2
|
+
import {mkdtempSync, readFileSync, writeFileSync} from 'node:fs';
|
|
3
|
+
import {tmpdir} from 'node:os';
|
|
4
|
+
import {join} from 'node:path';
|
|
5
|
+
import {afterEach, beforeEach, describe, expect, it} from 'vitest';
|
|
6
|
+
|
|
7
|
+
import {mmlToSfxRow, SFX_ROW_LENGTH} from '../engine/audio/index.js';
|
|
8
|
+
import {createCli} from '../cli.js';
|
|
9
|
+
|
|
10
|
+
/** A minimal valid cart with a lua + gfx section (to prove they stay untouched). */
|
|
11
|
+
function cartText(sfxBody?: string): string {
|
|
12
|
+
let text =
|
|
13
|
+
'pico-8 cartridge // http://www.pico-8.com\nversion 42\n__lua__\nprint("hi")\n__gfx__\n0011223344556677\n';
|
|
14
|
+
if (sfxBody !== undefined) text += `__sfx__\n${sfxBody}`;
|
|
15
|
+
return text;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
let dir: string;
|
|
19
|
+
let cartPath: string;
|
|
20
|
+
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
dir = mkdtempSync(join(tmpdir(), 'picopilot-sfx-'));
|
|
23
|
+
cartPath = join(dir, 'main.p8');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
afterEach(() => {
|
|
27
|
+
try {
|
|
28
|
+
execFileSync('rm', ['-rf', dir]);
|
|
29
|
+
} catch {
|
|
30
|
+
// ignore
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
/** Drives a `picopilot sfx ...` invocation through incur's serve DI. */
|
|
35
|
+
async function runSfx(argv: string[]) {
|
|
36
|
+
let stdout = '';
|
|
37
|
+
let exitCode = 0;
|
|
38
|
+
await createCli().serve(argv, {
|
|
39
|
+
stdout(s) {
|
|
40
|
+
stdout += s;
|
|
41
|
+
},
|
|
42
|
+
exit(code) {
|
|
43
|
+
exitCode = code;
|
|
44
|
+
},
|
|
45
|
+
env: {},
|
|
46
|
+
});
|
|
47
|
+
return {stdout, exitCode};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Extracts the __sfx__ body lines from a cart file. */
|
|
51
|
+
function sfxLines(text: string): string[] {
|
|
52
|
+
const m = text.match(/__sfx__\n([\s\S]*?)(?:\n__|$)/);
|
|
53
|
+
return (m?.[1] ?? '').split('\n').filter((l) => l.length > 0);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
describe('picopilot sfx from-mml: transpile + merge', () => {
|
|
57
|
+
it('merges a transpiled __sfx__ row into the target slot and reports it', async () => {
|
|
58
|
+
writeFileSync(cartPath, cartText());
|
|
59
|
+
const {stdout, exitCode} = await runSfx([
|
|
60
|
+
'sfx',
|
|
61
|
+
'from-mml',
|
|
62
|
+
'0',
|
|
63
|
+
's8 @1 v6 c d e',
|
|
64
|
+
'--cart',
|
|
65
|
+
cartPath,
|
|
66
|
+
'--json',
|
|
67
|
+
]);
|
|
68
|
+
expect(exitCode).toBe(0);
|
|
69
|
+
const out = JSON.parse(stdout);
|
|
70
|
+
expect(out.slot).toBe(0);
|
|
71
|
+
expect(out.rows).toBe(3);
|
|
72
|
+
expect(out.speed).toBe(8);
|
|
73
|
+
expect(out.row).toBe(mmlToSfxRow('s8 @1 v6 c d e').row);
|
|
74
|
+
expect(out.row).toHaveLength(SFX_ROW_LENGTH);
|
|
75
|
+
// Persisted: slot 0 holds the row.
|
|
76
|
+
const lines = sfxLines(readFileSync(cartPath, 'utf8'));
|
|
77
|
+
expect(lines[0]).toBe(out.row);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('writes into a non-zero slot, padding intermediate slots', async () => {
|
|
81
|
+
writeFileSync(cartPath, cartText());
|
|
82
|
+
const {stdout, exitCode} = await runSfx([
|
|
83
|
+
'sfx',
|
|
84
|
+
'from-mml',
|
|
85
|
+
'3',
|
|
86
|
+
'@2 c',
|
|
87
|
+
'--cart',
|
|
88
|
+
cartPath,
|
|
89
|
+
'--json',
|
|
90
|
+
]);
|
|
91
|
+
expect(exitCode).toBe(0);
|
|
92
|
+
expect(JSON.parse(stdout).slot).toBe(3);
|
|
93
|
+
const lines = sfxLines(readFileSync(cartPath, 'utf8'));
|
|
94
|
+
expect(lines).toHaveLength(4);
|
|
95
|
+
expect(lines[3]).toBe(mmlToSfxRow('@2 c').row);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('CTAs toward verify + audio render', async () => {
|
|
99
|
+
writeFileSync(cartPath, cartText());
|
|
100
|
+
const {stdout} = await runSfx([
|
|
101
|
+
'sfx',
|
|
102
|
+
'from-mml',
|
|
103
|
+
'0',
|
|
104
|
+
'c',
|
|
105
|
+
'--cart',
|
|
106
|
+
cartPath,
|
|
107
|
+
'--json',
|
|
108
|
+
]);
|
|
109
|
+
const cta = JSON.stringify(JSON.parse(stdout).cta);
|
|
110
|
+
expect(cta).toContain('verify');
|
|
111
|
+
expect(cta).toContain('audio render');
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe('picopilot sfx from-mml: leaves every OTHER section byte-identical', () => {
|
|
116
|
+
it('only __sfx__ changes; lua + gfx + header preserved', async () => {
|
|
117
|
+
writeFileSync(cartPath, cartText());
|
|
118
|
+
const before = readFileSync(cartPath, 'utf8');
|
|
119
|
+
await runSfx([
|
|
120
|
+
'sfx',
|
|
121
|
+
'from-mml',
|
|
122
|
+
'0',
|
|
123
|
+
'@1 c d e',
|
|
124
|
+
'--cart',
|
|
125
|
+
cartPath,
|
|
126
|
+
'--json',
|
|
127
|
+
]);
|
|
128
|
+
const after = readFileSync(cartPath, 'utf8');
|
|
129
|
+
// The whole prefix up to __sfx__ is byte-identical.
|
|
130
|
+
const cut = before.length; // before has no __sfx__ yet
|
|
131
|
+
expect(after.slice(0, cut)).toBe(before);
|
|
132
|
+
expect(after).toContain('print("hi")');
|
|
133
|
+
expect(after).toContain('__gfx__\n0011223344556677\n');
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('overwrites only the target slot, preserving other authored slots', async () => {
|
|
137
|
+
// Pre-author slots 0 and 2.
|
|
138
|
+
const row0 = mmlToSfxRow('@2 c').row;
|
|
139
|
+
const row2 = mmlToSfxRow('@5 g').row;
|
|
140
|
+
const empty = '0'.repeat(SFX_ROW_LENGTH);
|
|
141
|
+
writeFileSync(cartPath, cartText(`${row0}\n${empty}\n${row2}\n`));
|
|
142
|
+
|
|
143
|
+
await runSfx([
|
|
144
|
+
'sfx',
|
|
145
|
+
'from-mml',
|
|
146
|
+
'1',
|
|
147
|
+
'@7 e',
|
|
148
|
+
'--cart',
|
|
149
|
+
cartPath,
|
|
150
|
+
'--json',
|
|
151
|
+
]);
|
|
152
|
+
const lines = sfxLines(readFileSync(cartPath, 'utf8'));
|
|
153
|
+
expect(lines[0]).toBe(row0); // untouched
|
|
154
|
+
expect(lines[1]).toBe(mmlToSfxRow('@7 e').row); // new
|
|
155
|
+
expect(lines[2]).toBe(row2); // untouched
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
describe('picopilot sfx from-mml: SFX filters (finding A.7)', () => {
|
|
160
|
+
it('merges the filter bits into the header, reporting the filter byte', async () => {
|
|
161
|
+
writeFileSync(cartPath, cartText());
|
|
162
|
+
const {stdout, exitCode} = await runSfx([
|
|
163
|
+
'sfx',
|
|
164
|
+
'from-mml',
|
|
165
|
+
'3',
|
|
166
|
+
'!dampen2 !reverb @6 v7 o3 e c o1 g c', // a designed explosion
|
|
167
|
+
'--cart',
|
|
168
|
+
cartPath,
|
|
169
|
+
'--json',
|
|
170
|
+
]);
|
|
171
|
+
expect(exitCode).toBe(0);
|
|
172
|
+
const out = JSON.parse(stdout);
|
|
173
|
+
// !dampen2 (0xc0) | !reverb (0x20) = 0xe0 = 224.
|
|
174
|
+
expect(out.filters).toBe(0xe0);
|
|
175
|
+
expect(out.row.slice(0, 2)).toBe('e0'); // first header byte
|
|
176
|
+
expect(out.row).toBe(
|
|
177
|
+
mmlToSfxRow('!dampen2 !reverb @6 v7 o3 e c o1 g c').row,
|
|
178
|
+
);
|
|
179
|
+
expect(out.row).toHaveLength(SFX_ROW_LENGTH);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('leaves the note rows + other sections byte-identical (filters are header-only)', async () => {
|
|
183
|
+
writeFileSync(cartPath, cartText());
|
|
184
|
+
const before = readFileSync(cartPath, 'utf8');
|
|
185
|
+
await runSfx([
|
|
186
|
+
'sfx',
|
|
187
|
+
'from-mml',
|
|
188
|
+
'0',
|
|
189
|
+
'!dampen @6 v7 c d e',
|
|
190
|
+
'--cart',
|
|
191
|
+
cartPath,
|
|
192
|
+
'--json',
|
|
193
|
+
]);
|
|
194
|
+
const after = readFileSync(cartPath, 'utf8');
|
|
195
|
+
// Prefix (lua + gfx, before __sfx__) untouched.
|
|
196
|
+
expect(after.slice(0, before.length)).toBe(before);
|
|
197
|
+
expect(after).toContain('__gfx__\n0011223344556677\n');
|
|
198
|
+
// The written row's NOTE body matches the same MML without the filter.
|
|
199
|
+
const lines = sfxLines(after);
|
|
200
|
+
expect(lines[0]!.slice(8)).toBe(mmlToSfxRow('@6 v7 c d e').row.slice(8));
|
|
201
|
+
expect(lines[0]!.slice(0, 2)).toBe('80'); // dampen header byte
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it('an out-of-range filter level -> audio-mml-filter-level-out-of-range, no write', async () => {
|
|
205
|
+
writeFileSync(cartPath, cartText());
|
|
206
|
+
const before = readFileSync(cartPath, 'utf8');
|
|
207
|
+
const {stdout, exitCode} = await runSfx([
|
|
208
|
+
'sfx',
|
|
209
|
+
'from-mml',
|
|
210
|
+
'0',
|
|
211
|
+
'!dampen3 c', // dampen has only 2 levels
|
|
212
|
+
'--cart',
|
|
213
|
+
cartPath,
|
|
214
|
+
'--json',
|
|
215
|
+
]);
|
|
216
|
+
expect(exitCode).not.toBe(0);
|
|
217
|
+
const out = JSON.parse(stdout);
|
|
218
|
+
expect(out.code).toBe('audio-mml-filter-level-out-of-range');
|
|
219
|
+
expect(out.message).toContain('DAMPEN');
|
|
220
|
+
// The cart is byte-untouched (refusal never writes).
|
|
221
|
+
expect(readFileSync(cartPath, 'utf8')).toBe(before);
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
describe('picopilot sfx from-mml: structured refusals (NO silent clamp)', () => {
|
|
226
|
+
it('out-of-range pitch -> audio-mml-pitch-out-of-range + nonzero exit, no write', async () => {
|
|
227
|
+
writeFileSync(cartPath, cartText());
|
|
228
|
+
const before = readFileSync(cartPath, 'utf8');
|
|
229
|
+
const {stdout, exitCode} = await runSfx([
|
|
230
|
+
'sfx',
|
|
231
|
+
'from-mml',
|
|
232
|
+
'0',
|
|
233
|
+
'o5 g', // pitch 67 > 63
|
|
234
|
+
'--cart',
|
|
235
|
+
cartPath,
|
|
236
|
+
'--json',
|
|
237
|
+
]);
|
|
238
|
+
expect(exitCode).not.toBe(0);
|
|
239
|
+
const out = JSON.parse(stdout);
|
|
240
|
+
expect(out.code).toBe('audio-mml-pitch-out-of-range');
|
|
241
|
+
expect(out.message).toContain('C0..D#5');
|
|
242
|
+
// The cart is byte-untouched (refusal never writes).
|
|
243
|
+
expect(readFileSync(cartPath, 'utf8')).toBe(before);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it('>32 rows -> audio-mml-sfx-overflow + nonzero exit, no write', async () => {
|
|
247
|
+
writeFileSync(cartPath, cartText());
|
|
248
|
+
const before = readFileSync(cartPath, 'utf8');
|
|
249
|
+
const {stdout, exitCode} = await runSfx([
|
|
250
|
+
'sfx',
|
|
251
|
+
'from-mml',
|
|
252
|
+
'0',
|
|
253
|
+
'c33',
|
|
254
|
+
'--cart',
|
|
255
|
+
cartPath,
|
|
256
|
+
'--json',
|
|
257
|
+
]);
|
|
258
|
+
expect(exitCode).not.toBe(0);
|
|
259
|
+
const out = JSON.parse(stdout);
|
|
260
|
+
expect(out.code).toBe('audio-mml-sfx-overflow');
|
|
261
|
+
expect(out.message).toContain('32');
|
|
262
|
+
expect(readFileSync(cartPath, 'utf8')).toBe(before);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it('a tempo-style token -> audio-mml-parse-error naming tracker rows', async () => {
|
|
266
|
+
writeFileSync(cartPath, cartText());
|
|
267
|
+
const {stdout, exitCode} = await runSfx([
|
|
268
|
+
'sfx',
|
|
269
|
+
'from-mml',
|
|
270
|
+
'0',
|
|
271
|
+
't120 c',
|
|
272
|
+
'--cart',
|
|
273
|
+
cartPath,
|
|
274
|
+
'--json',
|
|
275
|
+
]);
|
|
276
|
+
expect(exitCode).not.toBe(0);
|
|
277
|
+
const out = JSON.parse(stdout);
|
|
278
|
+
expect(out.code).toBe('audio-mml-parse-error');
|
|
279
|
+
expect(out.message).toContain('TRACKER ROWS');
|
|
280
|
+
});
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
describe('picopilot sfx from-mml: input surface (arg vs --file)', () => {
|
|
284
|
+
it('reads the MML from --file', async () => {
|
|
285
|
+
writeFileSync(cartPath, cartText());
|
|
286
|
+
const mmlPath = join(dir, 'lead.mml');
|
|
287
|
+
writeFileSync(mmlPath, 's4 @3 v7 c e g');
|
|
288
|
+
const {stdout, exitCode} = await runSfx([
|
|
289
|
+
'sfx',
|
|
290
|
+
'from-mml',
|
|
291
|
+
'0',
|
|
292
|
+
'--cart',
|
|
293
|
+
cartPath,
|
|
294
|
+
'--file',
|
|
295
|
+
mmlPath,
|
|
296
|
+
'--json',
|
|
297
|
+
]);
|
|
298
|
+
expect(exitCode).toBe(0);
|
|
299
|
+
const out = JSON.parse(stdout);
|
|
300
|
+
expect(out.row).toBe(mmlToSfxRow('s4 @3 v7 c e g').row);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it('errors when neither the mml arg nor --file is given', async () => {
|
|
304
|
+
writeFileSync(cartPath, cartText());
|
|
305
|
+
const {stdout, exitCode} = await runSfx([
|
|
306
|
+
'sfx',
|
|
307
|
+
'from-mml',
|
|
308
|
+
'0',
|
|
309
|
+
'--cart',
|
|
310
|
+
cartPath,
|
|
311
|
+
'--json',
|
|
312
|
+
]);
|
|
313
|
+
expect(exitCode).not.toBe(0);
|
|
314
|
+
expect(JSON.parse(stdout).code).toBe('no-mml');
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
it('a missing cart is a distinct cart-not-found error', async () => {
|
|
318
|
+
const {stdout, exitCode} = await runSfx([
|
|
319
|
+
'sfx',
|
|
320
|
+
'from-mml',
|
|
321
|
+
'0',
|
|
322
|
+
'c',
|
|
323
|
+
'--cart',
|
|
324
|
+
join(dir, 'nope.p8'),
|
|
325
|
+
'--json',
|
|
326
|
+
]);
|
|
327
|
+
expect(exitCode).not.toBe(0);
|
|
328
|
+
expect(JSON.parse(stdout).code).toBe('cart-not-found');
|
|
329
|
+
});
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
describe('picopilot sfx from-mml: dotted-length rounding is reported', () => {
|
|
333
|
+
it('warns when a dotted length rounds to a whole row', async () => {
|
|
334
|
+
writeFileSync(cartPath, cartText());
|
|
335
|
+
const {stdout, exitCode} = await runSfx([
|
|
336
|
+
'sfx',
|
|
337
|
+
'from-mml',
|
|
338
|
+
'0',
|
|
339
|
+
'c3.', // 4.5 rows -> rounds
|
|
340
|
+
'--cart',
|
|
341
|
+
cartPath,
|
|
342
|
+
'--json',
|
|
343
|
+
]);
|
|
344
|
+
expect(exitCode).toBe(0);
|
|
345
|
+
const out = JSON.parse(stdout);
|
|
346
|
+
expect(out.warnings.length).toBeGreaterThan(0);
|
|
347
|
+
expect(out.warnings[0]).toContain('rounded');
|
|
348
|
+
});
|
|
349
|
+
});
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
import {existsSync, readFileSync, writeFileSync} from 'node:fs';
|
|
2
|
+
import {isAbsolute, resolve} from 'node:path';
|
|
3
|
+
import {Cli, z} from 'incur';
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
AudioMmlError,
|
|
7
|
+
mergeSfxRow,
|
|
8
|
+
mmlToSfxRow,
|
|
9
|
+
PITCH_MAX,
|
|
10
|
+
SFX_MAX_ROWS,
|
|
11
|
+
SFX_SLOT_COUNT,
|
|
12
|
+
} from '../engine/audio/index.js';
|
|
13
|
+
import {Cart, CartParseError} from '../engine/cart/index.js';
|
|
14
|
+
|
|
15
|
+
/** Resolves a path argument to an absolute path (relative to cwd). */
|
|
16
|
+
function resolvePath(p: string): string {
|
|
17
|
+
return isAbsolute(p) ? p : resolve(process.cwd(), p);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Loads + parses a cart, returning either the parsed cart or a structured reason
|
|
22
|
+
* the caller turns into an `error(...)`. Mirrors the gfx command's loader.
|
|
23
|
+
*/
|
|
24
|
+
function loadCart(
|
|
25
|
+
cartPath: string,
|
|
26
|
+
): {ok: true; cart: Cart} | {ok: false; code: string; message: string} {
|
|
27
|
+
if (!existsSync(cartPath)) {
|
|
28
|
+
return {
|
|
29
|
+
ok: false,
|
|
30
|
+
code: 'cart-not-found',
|
|
31
|
+
message: `no cart at ${cartPath}`,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
const text = readFileSync(cartPath, 'utf8');
|
|
35
|
+
try {
|
|
36
|
+
return {ok: true, cart: Cart.parse(text)};
|
|
37
|
+
} catch (e) {
|
|
38
|
+
if (e instanceof CartParseError) {
|
|
39
|
+
return {
|
|
40
|
+
ok: false,
|
|
41
|
+
code: 'cart-parse-failed',
|
|
42
|
+
message: `cart at ${cartPath} does not parse (${e.code}): ${e.message}`,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
throw e;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Builds the `sfx` command group. Currently just `sfx from-mml` (the
|
|
51
|
+
* picopilot-MML to `__sfx__` transpiler + cart merge).
|
|
52
|
+
*/
|
|
53
|
+
function buildSfxGroup(): Cli.Cli {
|
|
54
|
+
const sfx = Cli.create('sfx', {
|
|
55
|
+
description:
|
|
56
|
+
'Compose SFX as text: transpile picopilot-MML into a cart __sfx__ slot. Durations are TRACKER ROWS at one SFX speed, NOT tempo/BPM (PICO-8 has no per-note duration).',
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
sfx.command('from-mml', {
|
|
60
|
+
description:
|
|
61
|
+
'Transpile picopilot-MML text into one __sfx__ slot (0-63) and merge it into the cart (only that slot changes). picopilot-MML is TRACKER-ROW based: a length is a ROW COUNT (l4 = 4 rows), NOT a musical note value, and there is NO tempo/BPM (PICO-8 measures in ticks/row via `s<N>` speed). Notes carry per-note waveform (@0-7, @8-f = custom SFX-instrument), volume (v0-7), and effect (e0-7) that a score notation cannot express. SFX-level FILTER directives reach DESIGNED sounds (explosion/engine/pad): !dampen[2] (low-pass body), !reverb (echo tail), !noiz (@6 white noise), !buzz, !detune1/!detune2. Out-of-range pitch (below C0 / above D#5), >32 rows, and a bad filter level are refused loudly, never silently clamped.',
|
|
62
|
+
args: z.object({
|
|
63
|
+
slot: z.coerce
|
|
64
|
+
.number()
|
|
65
|
+
.int()
|
|
66
|
+
.min(0)
|
|
67
|
+
.max(SFX_SLOT_COUNT - 1)
|
|
68
|
+
.describe(`The target SFX slot 0-${SFX_SLOT_COUNT - 1} to write.`),
|
|
69
|
+
mml: z
|
|
70
|
+
.string()
|
|
71
|
+
.optional()
|
|
72
|
+
.describe(
|
|
73
|
+
'The picopilot-MML source (e.g. "s16 @1 v6 c d e f"). Omit and pass --file to read from a file instead. Modal state: @ waveform, v volume, e effect, o octave, l default length (all sticky). Durations are ROWS: c4 = 4 rows (held via tie rows). Loop with {..}; a lone { marks the pattern LEN. SFX filters (per-SFX, place anywhere): !dampen/!dampen2, !reverb, !noiz, !buzz, !detune1/!detune2 (e.g. "!dampen !reverb @6 v7 o3 e c o1 g" = an explosion).',
|
|
74
|
+
),
|
|
75
|
+
}),
|
|
76
|
+
options: z.object({
|
|
77
|
+
cart: z
|
|
78
|
+
.string()
|
|
79
|
+
.default('main.p8')
|
|
80
|
+
.describe(
|
|
81
|
+
'The .p8 cart to merge into. Defaults to main.p8 in the current folder. (An option, not a positional, so it never collides with an omitted `mml` when using --file.)',
|
|
82
|
+
),
|
|
83
|
+
file: z
|
|
84
|
+
.string()
|
|
85
|
+
.optional()
|
|
86
|
+
.describe(
|
|
87
|
+
'Read the picopilot-MML from this file instead of the `mml` argument (for longer sources).',
|
|
88
|
+
),
|
|
89
|
+
}),
|
|
90
|
+
output: z.object({
|
|
91
|
+
slot: z.number().describe('The SFX slot that was written.'),
|
|
92
|
+
cart: z
|
|
93
|
+
.string()
|
|
94
|
+
.describe('The absolute path of the cart that was written.'),
|
|
95
|
+
rows: z
|
|
96
|
+
.number()
|
|
97
|
+
.describe('The number of tracker rows the MML produced (0-32).'),
|
|
98
|
+
speed: z
|
|
99
|
+
.number()
|
|
100
|
+
.describe('The SFX speed (ticks/row) written into the header.'),
|
|
101
|
+
loopStart: z
|
|
102
|
+
.number()
|
|
103
|
+
.describe('The header loop-start value (row index, or a LEN).'),
|
|
104
|
+
loopEnd: z
|
|
105
|
+
.number()
|
|
106
|
+
.describe(
|
|
107
|
+
'The header loop-end value (0 = off or the LEN special case).',
|
|
108
|
+
),
|
|
109
|
+
filters: z
|
|
110
|
+
.number()
|
|
111
|
+
.describe(
|
|
112
|
+
'The per-SFX FILTER byte (first header byte): the OR of the selected filters, 0 = none.',
|
|
113
|
+
),
|
|
114
|
+
row: z
|
|
115
|
+
.string()
|
|
116
|
+
.describe('The 168-char __sfx__ text row that was merged.'),
|
|
117
|
+
warnings: z
|
|
118
|
+
.array(z.string())
|
|
119
|
+
.describe(
|
|
120
|
+
'Advisory warnings (e.g. a dotted length whose row count was rounded).',
|
|
121
|
+
),
|
|
122
|
+
}),
|
|
123
|
+
examples: [
|
|
124
|
+
{
|
|
125
|
+
description: 'A simple square-wave arpeggio into slot 0',
|
|
126
|
+
args: {slot: 0, mml: 's8 @3 v6 c e g > c'},
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
description: 'A held note (4 rows) with vibrato into slot 5 of a cart',
|
|
130
|
+
args: {slot: 5, mml: 's16 @1 v5 e2 c4'},
|
|
131
|
+
options: {cart: 'game.p8'},
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
description:
|
|
135
|
+
'A DESIGNED explosion: DAMPEN body + REVERB tail on @6 noise',
|
|
136
|
+
args: {slot: 3, mml: 's7 !dampen2 !reverb @6 v7 o3 e c o1 g c'},
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
description: 'Read a longer source from a file',
|
|
140
|
+
args: {slot: 1},
|
|
141
|
+
options: {file: 'lead.mml'},
|
|
142
|
+
},
|
|
143
|
+
],
|
|
144
|
+
run({args, options, error, ok}) {
|
|
145
|
+
// Resolve the MML source: the `mml` arg OR `--file` (exactly one). This is
|
|
146
|
+
// the input surface; both are pure text, shrinko-free + PICO-8-free.
|
|
147
|
+
let mml: string;
|
|
148
|
+
if (options.file !== undefined) {
|
|
149
|
+
if (args.mml !== undefined) {
|
|
150
|
+
return error({
|
|
151
|
+
code: 'ambiguous-input',
|
|
152
|
+
message:
|
|
153
|
+
'pass EITHER the mml argument OR --file, not both (they are two ways to give the same source).',
|
|
154
|
+
exitCode: 1,
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
const filePath = resolvePath(options.file);
|
|
158
|
+
if (!existsSync(filePath)) {
|
|
159
|
+
return error({
|
|
160
|
+
code: 'mml-file-not-found',
|
|
161
|
+
message: `no picopilot-MML file at ${filePath}`,
|
|
162
|
+
exitCode: 1,
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
mml = readFileSync(filePath, 'utf8');
|
|
166
|
+
} else if (args.mml !== undefined) {
|
|
167
|
+
mml = args.mml;
|
|
168
|
+
} else {
|
|
169
|
+
return error({
|
|
170
|
+
code: 'no-mml',
|
|
171
|
+
message:
|
|
172
|
+
'no picopilot-MML source: pass it as the `mml` argument or via --file.',
|
|
173
|
+
exitCode: 1,
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Transpile FIRST: a bad MML is a caller error and must never touch the
|
|
178
|
+
// cart (no bytes change on a refusal), mirroring `gfx set`'s grid-first
|
|
179
|
+
// discipline.
|
|
180
|
+
let result: ReturnType<typeof mmlToSfxRow>;
|
|
181
|
+
try {
|
|
182
|
+
result = mmlToSfxRow(mml);
|
|
183
|
+
} catch (e) {
|
|
184
|
+
if (e instanceof AudioMmlError) {
|
|
185
|
+
// The structured refusals + parse errors surface as incur's error
|
|
186
|
+
// envelope with a nonzero exit (never a silent clamp). The CTA points
|
|
187
|
+
// at the range/cap/level the author must respect.
|
|
188
|
+
const cta =
|
|
189
|
+
e.code === 'audio-mml-pitch-out-of-range'
|
|
190
|
+
? {
|
|
191
|
+
description:
|
|
192
|
+
'PICO-8 pitch is a hard range; author within it:',
|
|
193
|
+
commands: [
|
|
194
|
+
{
|
|
195
|
+
command: 'sfx from-mml <slot> "<mml>"',
|
|
196
|
+
description: `Use octaves that keep every note within C0..D#5 (0..${PITCH_MAX}).`,
|
|
197
|
+
},
|
|
198
|
+
],
|
|
199
|
+
}
|
|
200
|
+
: e.code === 'audio-mml-sfx-overflow'
|
|
201
|
+
? {
|
|
202
|
+
description: `One SFX holds at most ${SFX_MAX_ROWS} rows; split longer melodies:`,
|
|
203
|
+
commands: [
|
|
204
|
+
{
|
|
205
|
+
command: 'sfx from-mml <other-slot> "<rest>"',
|
|
206
|
+
description:
|
|
207
|
+
'Author the remainder as another SFX, then arrange both with `music from-patterns`.',
|
|
208
|
+
},
|
|
209
|
+
],
|
|
210
|
+
}
|
|
211
|
+
: e.code === 'audio-mml-filter-level-out-of-range'
|
|
212
|
+
? {
|
|
213
|
+
description: 'The SFX filters + their valid levels:',
|
|
214
|
+
commands: [
|
|
215
|
+
{
|
|
216
|
+
command:
|
|
217
|
+
'sfx from-mml <slot> "!dampen[2] !reverb !noiz !buzz !detune1/!detune2 ..."',
|
|
218
|
+
description:
|
|
219
|
+
'!dampen has 2 levels (!dampen/!dampen2); !reverb/!noiz/!buzz are on/off; !detune needs 1 or 2.',
|
|
220
|
+
},
|
|
221
|
+
],
|
|
222
|
+
}
|
|
223
|
+
: undefined;
|
|
224
|
+
return error({
|
|
225
|
+
code: e.code,
|
|
226
|
+
message: e.message,
|
|
227
|
+
exitCode: 1,
|
|
228
|
+
...(cta ? {cta} : {}),
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
throw e;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const cartPath = resolvePath(options.cart);
|
|
235
|
+
const loaded = loadCart(cartPath);
|
|
236
|
+
if (!loaded.ok) {
|
|
237
|
+
return error({code: loaded.code, message: loaded.message, exitCode: 1});
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Merge into the target slot ONLY, through the cart model, so every other
|
|
241
|
+
// section (and every other SFX slot) stays byte-identical.
|
|
242
|
+
mergeSfxRow(loaded.cart, args.slot, result.row);
|
|
243
|
+
writeFileSync(cartPath, loaded.cart.serialize());
|
|
244
|
+
|
|
245
|
+
const warnings = result.roundingWarnings.map(
|
|
246
|
+
(w) =>
|
|
247
|
+
`length "${w.token}" = ${w.exactRows} rows rounded to ${w.roundedRows} (rows are whole; PICO-8 has no fractional-length notes).`,
|
|
248
|
+
);
|
|
249
|
+
|
|
250
|
+
// CTA toward the verify/hear loop: a merged SFX is next confirmed by
|
|
251
|
+
// verify (static integrity) and heard via `audio render`.
|
|
252
|
+
return ok(
|
|
253
|
+
{
|
|
254
|
+
slot: args.slot,
|
|
255
|
+
cart: cartPath,
|
|
256
|
+
rows: result.rows,
|
|
257
|
+
speed: result.speed,
|
|
258
|
+
loopStart: result.loopStart,
|
|
259
|
+
loopEnd: result.loopEnd,
|
|
260
|
+
filters: result.filters,
|
|
261
|
+
row: result.row,
|
|
262
|
+
warnings,
|
|
263
|
+
},
|
|
264
|
+
{
|
|
265
|
+
cta: {
|
|
266
|
+
description: 'The SFX is merged. Confirm it, then hear it:',
|
|
267
|
+
commands: [
|
|
268
|
+
{
|
|
269
|
+
command: 'verify',
|
|
270
|
+
description:
|
|
271
|
+
'Static gate: the cart still parses + is within budget.',
|
|
272
|
+
},
|
|
273
|
+
{
|
|
274
|
+
command: 'audio render',
|
|
275
|
+
description:
|
|
276
|
+
'Record a WAV to hear the result (real-time capture, requires PICO-8).',
|
|
277
|
+
},
|
|
278
|
+
],
|
|
279
|
+
},
|
|
280
|
+
},
|
|
281
|
+
);
|
|
282
|
+
},
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
return sfx;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Registers the `sfx` command group (`sfx from-mml`) on the root CLI.
|
|
290
|
+
*
|
|
291
|
+
* `sfx` is a shrinko-FREE and PICO-8-FREE command group: it transpiles
|
|
292
|
+
* picopilot-MML to `__sfx__` and merges it through the cart model entirely in
|
|
293
|
+
* TS, so it works with both absent and never spawns a child process. The
|
|
294
|
+
* picopilot-MML dialect (ADR-0005 + the audio spike finding) is TRACKER-ROW
|
|
295
|
+
* based: durations are row counts at one SFX speed, NOT tempo (ADR-0008), and
|
|
296
|
+
* out-of-range pitch / >32 rows are structured refusals, never silent clamps.
|
|
297
|
+
*/
|
|
298
|
+
export function registerSfx(cli: Cli.Cli): void {
|
|
299
|
+
cli.command(buildSfxGroup());
|
|
300
|
+
}
|