picopilot 0.2.0 → 0.3.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.
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +129 -36
- package/dist/cli.js.map +1 -1
- package/dist/commands/audio.d.ts +23 -2
- package/dist/commands/audio.d.ts.map +1 -1
- package/dist/commands/audio.js +239 -26
- package/dist/commands/audio.js.map +1 -1
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +8 -3
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/gfx.d.ts.map +1 -1
- package/dist/commands/gfx.js +11 -5
- package/dist/commands/gfx.js.map +1 -1
- package/dist/commands/music.d.ts +2 -1
- package/dist/commands/music.d.ts.map +1 -1
- package/dist/commands/music.js +209 -2
- package/dist/commands/music.js.map +1 -1
- package/dist/commands/pico8-env.d.ts +39 -3
- package/dist/commands/pico8-env.d.ts.map +1 -1
- package/dist/commands/pico8-env.js +51 -4
- package/dist/commands/pico8-env.js.map +1 -1
- package/dist/commands/run.d.ts.map +1 -1
- package/dist/commands/run.js +8 -2
- package/dist/commands/run.js.map +1 -1
- package/dist/commands/sfx.d.ts.map +1 -1
- package/dist/commands/sfx.js +99 -11
- package/dist/commands/sfx.js.map +1 -1
- package/dist/engine/audio/analyze.d.ts +164 -0
- package/dist/engine/audio/analyze.d.ts.map +1 -0
- package/dist/engine/audio/analyze.js +512 -0
- package/dist/engine/audio/analyze.js.map +1 -0
- package/dist/engine/audio/index.d.ts +4 -2
- package/dist/engine/audio/index.d.ts.map +1 -1
- package/dist/engine/audio/index.js +4 -2
- package/dist/engine/audio/index.js.map +1 -1
- package/dist/engine/audio/score.d.ts +137 -0
- package/dist/engine/audio/score.d.ts.map +1 -0
- package/dist/engine/audio/score.js +420 -0
- package/dist/engine/audio/score.js.map +1 -0
- package/dist/engine/audio/section.d.ts +7 -0
- package/dist/engine/audio/section.d.ts.map +1 -1
- package/dist/engine/audio/section.js +17 -0
- package/dist/engine/audio/section.js.map +1 -1
- package/dist/engine/audio/sfx.d.ts +114 -12
- package/dist/engine/audio/sfx.d.ts.map +1 -1
- package/dist/engine/audio/sfx.js +236 -33
- package/dist/engine/audio/sfx.js.map +1 -1
- package/dist/engine/pico8/harness.d.ts +11 -5
- package/dist/engine/pico8/harness.d.ts.map +1 -1
- package/dist/engine/pico8/harness.js +24 -10
- package/dist/engine/pico8/harness.js.map +1 -1
- package/dist/engine/scaffold/scaffold.d.ts.map +1 -1
- package/dist/engine/scaffold/scaffold.js +5 -1
- package/dist/engine/scaffold/scaffold.js.map +1 -1
- package/package.json +2 -2
- package/src/cli.test.ts +42 -1
- package/src/cli.ts +143 -37
- package/src/commands/audio.test.ts +170 -1
- package/src/commands/audio.ts +294 -30
- package/src/commands/doctor.test.ts +1 -0
- package/src/commands/doctor.ts +8 -3
- package/src/commands/gfx.ts +12 -9
- package/src/commands/music.test.ts +119 -0
- package/src/commands/music.ts +251 -1
- package/src/commands/pico8-env.ts +71 -3
- package/src/commands/run.ts +8 -2
- package/src/commands/sfx.test.ts +55 -0
- package/src/commands/sfx.ts +118 -11
- package/src/engine/audio/analyze.test.ts +239 -0
- package/src/engine/audio/analyze.ts +664 -0
- package/src/engine/audio/index.ts +46 -1
- package/src/engine/audio/score.test.ts +402 -0
- package/src/engine/audio/score.ts +630 -0
- package/src/engine/audio/section.ts +19 -0
- package/src/engine/audio/sfx.test.ts +219 -4
- package/src/engine/audio/sfx.ts +326 -40
- package/src/engine/pico8/harness.test.ts +19 -0
- package/src/engine/pico8/harness.ts +29 -11
- package/src/engine/scaffold/scaffold.ts +5 -1
- package/src/mcp.process.test.ts +219 -0
- package/src/skills/picopilot-audio/SKILL.md +74 -184
- package/src/skills/picopilot-overview/SKILL.md +1 -1
package/src/commands/gfx.ts
CHANGED
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
createShellShrinkoFactory,
|
|
26
26
|
type ShrinkoAdapterFactory,
|
|
27
27
|
} from './shrinko-env.js';
|
|
28
|
+
import {mcpConfigOption} from './pico8-env.js';
|
|
28
29
|
|
|
29
30
|
/** Injects the shrinko adapter `gfx import`/`gfx export` use (the spritesheet round-trip). */
|
|
30
31
|
export type {ShrinkoAdapterFactory} from './shrinko-env.js';
|
|
@@ -199,11 +200,13 @@ function buildGfxGroup(shrinkoFactory: ShrinkoAdapterFactory): Cli.Cli {
|
|
|
199
200
|
),
|
|
200
201
|
}),
|
|
201
202
|
options: z.object({
|
|
203
|
+
// No zod default: an absent value must stay distinguishable, so that under
|
|
204
|
+
// `--mcp` (where incur loads no config) picopilot.json still applies.
|
|
202
205
|
allowMapOverlap: z
|
|
203
206
|
.boolean()
|
|
204
|
-
.
|
|
207
|
+
.optional()
|
|
205
208
|
.describe(
|
|
206
|
-
'Authorise overwriting existing shared-region data when writing sprites 128-255 (ADR-0004). Also settable as allowMapOverlap in picopilot.json.',
|
|
209
|
+
'Authorise overwriting existing shared-region data when writing sprites 128-255 (ADR-0004). Also settable as allowMapOverlap in picopilot.json. Default false.',
|
|
207
210
|
),
|
|
208
211
|
}),
|
|
209
212
|
output: z.object({
|
|
@@ -237,7 +240,7 @@ function buildGfxGroup(shrinkoFactory: ShrinkoAdapterFactory): Cli.Cli {
|
|
|
237
240
|
},
|
|
238
241
|
},
|
|
239
242
|
],
|
|
240
|
-
run({args, options, error, ok}) {
|
|
243
|
+
run({args, options, error, ok, var: vars}) {
|
|
241
244
|
const cartPath = resolveCart(args.cart);
|
|
242
245
|
|
|
243
246
|
// Decode the incoming grid FIRST: a malformed grid is a caller error and
|
|
@@ -267,12 +270,12 @@ function buildGfxGroup(shrinkoFactory: ShrinkoAdapterFactory): Cli.Cli {
|
|
|
267
270
|
// (the shared-bank bytes at risk), NOT __map__ (ADR-0004). The flag and
|
|
268
271
|
// the picopilot.json config both feed `options.allowMapOverlap` (incur
|
|
269
272
|
// resolves argv > config > default), so both authorisation paths converge
|
|
270
|
-
// here.
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
);
|
|
273
|
+
// here. Under `--mcp` incur loads no config, so the picopilot.json value
|
|
274
|
+
// comes from the `mcpConfig` var instead.
|
|
275
|
+
const allowMapOverlap =
|
|
276
|
+
options.allowMapOverlap ??
|
|
277
|
+
mcpConfigOption(vars, ['gfx', 'set'], 'allowMapOverlap') === true;
|
|
278
|
+
const decision = decideOverlap(sheet, args.sprite, allowMapOverlap);
|
|
276
279
|
|
|
277
280
|
if (decision.kind === 'refused') {
|
|
278
281
|
// The silent-corruption guard: REFUSE and DO NOT write. The cart file is
|
|
@@ -270,3 +270,122 @@ describe('picopilot music from-patterns: input surface (arg vs --file)', () => {
|
|
|
270
270
|
expect(JSON.parse(stdout).code).toBe('cart-not-found');
|
|
271
271
|
});
|
|
272
272
|
});
|
|
273
|
+
|
|
274
|
+
/** The __sfx__ body lines of a cart file. */
|
|
275
|
+
function sfxLines(text: string): string[] {
|
|
276
|
+
const m = text.match(/__sfx__\n([\s\S]*?)(?:\n__|$)/);
|
|
277
|
+
return (m?.[1] ?? '').split('\n').filter((l) => l.length > 0);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
describe('picopilot music from-score (ADR-0020)', () => {
|
|
281
|
+
const gameSfx = mmlToSfxRow('s4 @3 v6 c e g').row;
|
|
282
|
+
|
|
283
|
+
it('writes two songs, reports start patterns, and leaves other content alone', async () => {
|
|
284
|
+
writeFileSync(cartPath, cartText({sfx: `${gameSfx}\n`}));
|
|
285
|
+
const score = {
|
|
286
|
+
songs: [
|
|
287
|
+
{name: 'title', speed: 16, channels: ['l32 c d', 'o1 l64 c', null]},
|
|
288
|
+
{name: 'game', speed: 20, channels: [null, 'o1 l16 g d g d']},
|
|
289
|
+
],
|
|
290
|
+
};
|
|
291
|
+
const scoreFile = join(dir, 'score.json');
|
|
292
|
+
writeFileSync(scoreFile, JSON.stringify(score));
|
|
293
|
+
const before = readFileSync(cartPath, 'utf8');
|
|
294
|
+
const {stdout, exitCode} = await runMusic([
|
|
295
|
+
'music',
|
|
296
|
+
'from-score',
|
|
297
|
+
'--file',
|
|
298
|
+
scoreFile,
|
|
299
|
+
'--cart',
|
|
300
|
+
cartPath,
|
|
301
|
+
'--json',
|
|
302
|
+
]);
|
|
303
|
+
expect(exitCode).toBe(0);
|
|
304
|
+
const out = JSON.parse(stdout);
|
|
305
|
+
expect(
|
|
306
|
+
out.songs.map((s: {name: string; startPattern: number}) => [
|
|
307
|
+
s.name,
|
|
308
|
+
s.startPattern,
|
|
309
|
+
]),
|
|
310
|
+
).toEqual([
|
|
311
|
+
['title', 0],
|
|
312
|
+
['game', 2],
|
|
313
|
+
]);
|
|
314
|
+
expect(out.patterns).toBe(4);
|
|
315
|
+
const after = readFileSync(cartPath, 'utf8');
|
|
316
|
+
// Slot 0 (the game SFX), __lua__ and __gfx__ are byte-identical.
|
|
317
|
+
expect(sfxLines(after)[0]).toBe(gameSfx);
|
|
318
|
+
expect(after.split('__sfx__')[0]).toBe(before.split('__sfx__')[0]);
|
|
319
|
+
// Free slots are used from 63 down; slot 0 is never touched.
|
|
320
|
+
expect(out.slots.every((s: {slot: number}) => s.slot > 0)).toBe(true);
|
|
321
|
+
expect(out.slots.every((s: {overwrote: boolean}) => !s.overwrote)).toBe(
|
|
322
|
+
true,
|
|
323
|
+
);
|
|
324
|
+
// The game song's first channel is off in every pattern.
|
|
325
|
+
const music = musicLines(after);
|
|
326
|
+
expect(music).toHaveLength(4);
|
|
327
|
+
for (const line of music.slice(2)) expect(line.slice(3, 5)).toBe('40');
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it('--slots owns a range: re-running is idempotent (byte-identical cart)', async () => {
|
|
331
|
+
writeFileSync(cartPath, cartText({sfx: `${gameSfx}\n`}));
|
|
332
|
+
const score = JSON.stringify({channels: ['l32 c d', 'o1 l64 c']});
|
|
333
|
+
const argv = [
|
|
334
|
+
'music',
|
|
335
|
+
'from-score',
|
|
336
|
+
score,
|
|
337
|
+
'--cart',
|
|
338
|
+
cartPath,
|
|
339
|
+
'--slots',
|
|
340
|
+
'40-50',
|
|
341
|
+
'--json',
|
|
342
|
+
];
|
|
343
|
+
const first = await runMusic(argv);
|
|
344
|
+
expect(first.exitCode).toBe(0);
|
|
345
|
+
const once = readFileSync(cartPath, 'utf8');
|
|
346
|
+
await runMusic(argv);
|
|
347
|
+
expect(readFileSync(cartPath, 'utf8')).toBe(once);
|
|
348
|
+
const slots = JSON.parse(first.stdout).slots.map(
|
|
349
|
+
(s: {slot: number}) => s.slot,
|
|
350
|
+
);
|
|
351
|
+
expect(slots.every((s: number) => s >= 40 && s <= 50)).toBe(true);
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
it('a bad score never touches the cart', async () => {
|
|
355
|
+
writeFileSync(cartPath, cartText({sfx: `${gameSfx}\n`}));
|
|
356
|
+
const before = readFileSync(cartPath, 'utf8');
|
|
357
|
+
for (const score of [
|
|
358
|
+
'{',
|
|
359
|
+
'{"channels":["o5 g"]}',
|
|
360
|
+
'{"channels":["{c}"]}',
|
|
361
|
+
]) {
|
|
362
|
+
const {stdout, exitCode} = await runMusic([
|
|
363
|
+
'music',
|
|
364
|
+
'from-score',
|
|
365
|
+
score,
|
|
366
|
+
'--cart',
|
|
367
|
+
cartPath,
|
|
368
|
+
'--json',
|
|
369
|
+
]);
|
|
370
|
+
expect(exitCode, score).not.toBe(0);
|
|
371
|
+
expect(stdout).toMatch(/audio-(score|mml)-/);
|
|
372
|
+
}
|
|
373
|
+
expect(readFileSync(cartPath, 'utf8')).toBe(before);
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
it('refuses a non-contiguous --slots', async () => {
|
|
377
|
+
writeFileSync(cartPath, cartText());
|
|
378
|
+
const {stdout, exitCode} = await runMusic([
|
|
379
|
+
'music',
|
|
380
|
+
'from-score',
|
|
381
|
+
'{"channels":["c"]}',
|
|
382
|
+
'--cart',
|
|
383
|
+
cartPath,
|
|
384
|
+
'--slots',
|
|
385
|
+
'3,9',
|
|
386
|
+
'--json',
|
|
387
|
+
]);
|
|
388
|
+
expect(exitCode).not.toBe(0);
|
|
389
|
+
expect(stdout).toContain('audio-score-bad-slot-range');
|
|
390
|
+
});
|
|
391
|
+
});
|
package/src/commands/music.ts
CHANGED
|
@@ -3,9 +3,19 @@ import {isAbsolute, resolve} from 'node:path';
|
|
|
3
3
|
import {Cli, z} from 'incur';
|
|
4
4
|
|
|
5
5
|
import {
|
|
6
|
+
AudioMmlError,
|
|
7
|
+
clearSfxSlot,
|
|
8
|
+
formatSlots,
|
|
9
|
+
mergeSfxRow,
|
|
6
10
|
MusicError,
|
|
11
|
+
musicSfxRefs,
|
|
12
|
+
parseSlotList,
|
|
7
13
|
patternsToMusic,
|
|
8
14
|
type Pattern,
|
|
15
|
+
planScore,
|
|
16
|
+
readScore,
|
|
17
|
+
readSfxRows,
|
|
18
|
+
ScoreError,
|
|
9
19
|
SFX_INDEX_MAX,
|
|
10
20
|
setMusicSection,
|
|
11
21
|
} from '../engine/audio/index.js';
|
|
@@ -270,11 +280,251 @@ function buildMusicGroup(): Cli.Cli {
|
|
|
270
280
|
},
|
|
271
281
|
});
|
|
272
282
|
|
|
283
|
+
music.command('from-score', {
|
|
284
|
+
description:
|
|
285
|
+
"Write whole SONGS from a score: up to 4 channels per song, each a picopilot-MML string of ANY length (ADR-0020). picopilot cuts every channel into 32-row SFX aligned across channels, makes a channel that is silent for a whole pattern OFF (null), de-duplicates identical SFX, allocates slots, and writes __sfx__ + __music__ (only the slots it writes or clears and __music__ change). Several songs (e.g. title + in-game) are laid out back to back; the report gives each song's start pattern for `music(n)`. Slots: by default only FREE (note-less) slots, filled from 63 down, never touching other content; --slots <from-to> gives the score a range it OWNS (it overwrites and clears that range; recommended when iterating, so re-runs are idempotent).",
|
|
286
|
+
args: z.object({
|
|
287
|
+
score: z
|
|
288
|
+
.string()
|
|
289
|
+
.optional()
|
|
290
|
+
.describe(
|
|
291
|
+
'The score as JSON: {"songs":[{"name":"title","speed":16,"loop":true,"channels":["<mml ch0>", {"mml":"<mml ch1>","filters":"!dampen"}, null, null]}]}. Per song: speed (ticks/row, default the channels\' shared s<N> or 16), patternRows (1-32, default 32), loop (default true; false = stop at the end), loopFrom (pattern within the song the loop returns to, default 0). One song may be given directly as {"channels":[...]}. Omit and pass --file instead.',
|
|
292
|
+
),
|
|
293
|
+
}),
|
|
294
|
+
options: z.object({
|
|
295
|
+
cart: z
|
|
296
|
+
.string()
|
|
297
|
+
.default('main.p8')
|
|
298
|
+
.describe(
|
|
299
|
+
'The .p8 cart to write. Defaults to main.p8 in the current folder.',
|
|
300
|
+
),
|
|
301
|
+
file: z
|
|
302
|
+
.string()
|
|
303
|
+
.optional()
|
|
304
|
+
.describe(
|
|
305
|
+
'Read the JSON score from this file instead of the `score` argument.',
|
|
306
|
+
),
|
|
307
|
+
slots: z
|
|
308
|
+
.string()
|
|
309
|
+
.optional()
|
|
310
|
+
.describe(
|
|
311
|
+
'A slot range the score OWNS, e.g. "16-63": any slot in it may be overwritten, and the ones the score does not use are cleared. Without it, only free (note-less) slots are used.',
|
|
312
|
+
),
|
|
313
|
+
}),
|
|
314
|
+
output: z.object({
|
|
315
|
+
cart: z
|
|
316
|
+
.string()
|
|
317
|
+
.describe('The absolute path of the cart that was written.'),
|
|
318
|
+
songs: z
|
|
319
|
+
.array(
|
|
320
|
+
z.object({
|
|
321
|
+
name: z.string(),
|
|
322
|
+
startPattern: z
|
|
323
|
+
.number()
|
|
324
|
+
.describe('Play this song with music(startPattern).'),
|
|
325
|
+
endPattern: z.number(),
|
|
326
|
+
patterns: z.number(),
|
|
327
|
+
speed: z.number(),
|
|
328
|
+
patternRows: z.number(),
|
|
329
|
+
loop: z.boolean(),
|
|
330
|
+
loopStartPattern: z.number().optional(),
|
|
331
|
+
seconds: z.number().describe('Length of one pass, in seconds.'),
|
|
332
|
+
}),
|
|
333
|
+
)
|
|
334
|
+
.describe('Each song as laid out in __music__.'),
|
|
335
|
+
patterns: z.number().describe('Total patterns written to __music__.'),
|
|
336
|
+
slots: z
|
|
337
|
+
.array(
|
|
338
|
+
z.object({
|
|
339
|
+
slot: z.number(),
|
|
340
|
+
uses: z.array(z.string()),
|
|
341
|
+
overwrote: z
|
|
342
|
+
.boolean()
|
|
343
|
+
.describe('The slot held different, non-empty content before.'),
|
|
344
|
+
}),
|
|
345
|
+
)
|
|
346
|
+
.describe('Every SFX slot the score wrote, and where it plays.'),
|
|
347
|
+
slotRange: z
|
|
348
|
+
.string()
|
|
349
|
+
.describe('The slots used, compactly (e.g. "40-63").'),
|
|
350
|
+
cleared: z
|
|
351
|
+
.array(z.number())
|
|
352
|
+
.describe(
|
|
353
|
+
'Owned slots (--slots) the score does not use, cleared to empty.',
|
|
354
|
+
),
|
|
355
|
+
warnings: z.array(z.string()).describe('Advisory warnings.'),
|
|
356
|
+
}),
|
|
357
|
+
examples: [
|
|
358
|
+
{
|
|
359
|
+
description:
|
|
360
|
+
'Two songs (title + in-game) from a file, owning slots 16-63',
|
|
361
|
+
args: {},
|
|
362
|
+
options: {file: 'score.json', slots: '16-63'},
|
|
363
|
+
},
|
|
364
|
+
{
|
|
365
|
+
description: 'One short looping song inline',
|
|
366
|
+
args: {
|
|
367
|
+
score:
|
|
368
|
+
'{"speed":16,"channels":["!dampen @0 v[3 2 2 1/0] o2 l8 a d+ f e","!dampen @0 v[2 2 1] o1 l16 d a-"]}',
|
|
369
|
+
},
|
|
370
|
+
},
|
|
371
|
+
],
|
|
372
|
+
run({args, options, error, ok}) {
|
|
373
|
+
let text: string;
|
|
374
|
+
if (options.file !== undefined) {
|
|
375
|
+
if (args.score !== undefined) {
|
|
376
|
+
return error({
|
|
377
|
+
code: 'ambiguous-input',
|
|
378
|
+
message:
|
|
379
|
+
'pass EITHER the score argument OR --file, not both (they are two ways to give the same source).',
|
|
380
|
+
exitCode: 1,
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
const filePath = resolvePath(options.file);
|
|
384
|
+
if (!existsSync(filePath)) {
|
|
385
|
+
return error({
|
|
386
|
+
code: 'score-file-not-found',
|
|
387
|
+
message: `no score file at ${filePath}`,
|
|
388
|
+
exitCode: 1,
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
text = readFileSync(filePath, 'utf8');
|
|
392
|
+
} else if (args.score !== undefined) {
|
|
393
|
+
text = args.score;
|
|
394
|
+
} else {
|
|
395
|
+
return error({
|
|
396
|
+
code: 'no-score',
|
|
397
|
+
message:
|
|
398
|
+
'no score: pass it as the `score` argument (JSON) or via --file.',
|
|
399
|
+
exitCode: 1,
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
let range: {from: number; to: number} | undefined;
|
|
404
|
+
if (options.slots !== undefined) {
|
|
405
|
+
const list = parseSlotList(options.slots);
|
|
406
|
+
const ok =
|
|
407
|
+
list !== undefined &&
|
|
408
|
+
list.length > 0 &&
|
|
409
|
+
list[list.length - 1]! - list[0]! + 1 === list.length;
|
|
410
|
+
if (!ok) {
|
|
411
|
+
return error({
|
|
412
|
+
code: 'audio-score-bad-slot-range',
|
|
413
|
+
message: `--slots "${options.slots}" is not one contiguous range within 0-${SFX_INDEX_MAX}, e.g. "16-63".`,
|
|
414
|
+
exitCode: 1,
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
range = {from: list![0]!, to: list![list!.length - 1]!};
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
const cartPath = resolvePath(options.cart);
|
|
421
|
+
const loaded = loadCart(cartPath);
|
|
422
|
+
if (!loaded.ok) {
|
|
423
|
+
return error({code: loaded.code, message: loaded.message, exitCode: 1});
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// Plan FIRST (pure): a bad score never touches the cart.
|
|
427
|
+
let plan: ReturnType<typeof planScore>;
|
|
428
|
+
try {
|
|
429
|
+
let raw: unknown;
|
|
430
|
+
try {
|
|
431
|
+
raw = JSON.parse(text);
|
|
432
|
+
} catch (e) {
|
|
433
|
+
throw new ScoreError(
|
|
434
|
+
'audio-score-parse-error',
|
|
435
|
+
`score is not valid JSON: ${e instanceof Error ? e.message : String(e)}`,
|
|
436
|
+
);
|
|
437
|
+
}
|
|
438
|
+
plan = planScore(
|
|
439
|
+
readScore(raw),
|
|
440
|
+
{
|
|
441
|
+
sfxRows: readSfxRows(loaded.cart),
|
|
442
|
+
musicRefs: musicSfxRefs(loaded.cart.getSection('music')),
|
|
443
|
+
},
|
|
444
|
+
range !== undefined ? {slots: range} : {},
|
|
445
|
+
);
|
|
446
|
+
} catch (e) {
|
|
447
|
+
if (e instanceof ScoreError || e instanceof AudioMmlError) {
|
|
448
|
+
return error({
|
|
449
|
+
code: e.code,
|
|
450
|
+
message: e.message,
|
|
451
|
+
exitCode: 1,
|
|
452
|
+
...(e.code === 'audio-score-no-free-slots'
|
|
453
|
+
? {
|
|
454
|
+
cta: {
|
|
455
|
+
description: 'Give the music a slot range it owns:',
|
|
456
|
+
commands: [
|
|
457
|
+
{
|
|
458
|
+
command:
|
|
459
|
+
'music from-score --file <score.json> --slots 16-63',
|
|
460
|
+
description:
|
|
461
|
+
'The score overwrites and clears that range (and nothing else).',
|
|
462
|
+
},
|
|
463
|
+
],
|
|
464
|
+
},
|
|
465
|
+
}
|
|
466
|
+
: {}),
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
throw e;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
for (const s of plan.slots) mergeSfxRow(loaded.cart, s.slot, s.row);
|
|
473
|
+
for (const slot of plan.cleared) clearSfxSlot(loaded.cart, slot);
|
|
474
|
+
setMusicSection(loaded.cart, plan.musicSection);
|
|
475
|
+
writeFileSync(cartPath, loaded.cart.serialize());
|
|
476
|
+
|
|
477
|
+
const lua = plan.songs
|
|
478
|
+
.map((s) => `${s.name}: music(${s.startPattern})`)
|
|
479
|
+
.join(', ');
|
|
480
|
+
return ok(
|
|
481
|
+
{
|
|
482
|
+
cart: cartPath,
|
|
483
|
+
songs: plan.songs.map((s) => ({
|
|
484
|
+
name: s.name,
|
|
485
|
+
startPattern: s.startPattern,
|
|
486
|
+
endPattern: s.endPattern,
|
|
487
|
+
patterns: s.patterns,
|
|
488
|
+
speed: s.speed,
|
|
489
|
+
patternRows: s.patternRows,
|
|
490
|
+
loop: s.loop,
|
|
491
|
+
...(s.loopStartPattern !== undefined
|
|
492
|
+
? {loopStartPattern: s.loopStartPattern}
|
|
493
|
+
: {}),
|
|
494
|
+
seconds: s.seconds,
|
|
495
|
+
})),
|
|
496
|
+
patterns: plan.patterns,
|
|
497
|
+
slots: plan.slots.map((s) => ({
|
|
498
|
+
slot: s.slot,
|
|
499
|
+
uses: s.uses,
|
|
500
|
+
overwrote: s.overwrote,
|
|
501
|
+
})),
|
|
502
|
+
slotRange: formatSlots(plan.slots.map((s) => s.slot)),
|
|
503
|
+
cleared: plan.cleared,
|
|
504
|
+
warnings: plan.warnings,
|
|
505
|
+
},
|
|
506
|
+
{
|
|
507
|
+
cta: {
|
|
508
|
+
description: `Written. Play from Lua with ${lua} (add a channel mask, e.g. music(n,0,7), to leave channel 3 for SFX). Then hear and measure it:`,
|
|
509
|
+
commands: [
|
|
510
|
+
{
|
|
511
|
+
command: `audio render --pattern ${plan.songs[0]!.startPattern} --seconds ${Math.ceil(2 * plan.songs[0]!.seconds + 2)} --analyze`,
|
|
512
|
+
description:
|
|
513
|
+
'Record the first song twice over (the loop length needs two passes) and get loudness/brightness/repetition numbers.',
|
|
514
|
+
},
|
|
515
|
+
],
|
|
516
|
+
},
|
|
517
|
+
},
|
|
518
|
+
);
|
|
519
|
+
},
|
|
520
|
+
});
|
|
521
|
+
|
|
273
522
|
return music;
|
|
274
523
|
}
|
|
275
524
|
|
|
276
525
|
/**
|
|
277
|
-
* Registers the `music` command group (`music from-patterns`
|
|
526
|
+
* Registers the `music` command group (`music from-patterns`, `music
|
|
527
|
+
* from-score`) on the root CLI.
|
|
278
528
|
*
|
|
279
529
|
* `music` is a shrinko-FREE and PICO-8-FREE command group: it assembles a
|
|
280
530
|
* structural pattern list into `__music__` and merges it through the cart model
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import {z} from 'incur';
|
|
2
2
|
import {
|
|
3
3
|
DEFAULT_DISPLAY_MODE,
|
|
4
|
+
DISPLAY_MODES,
|
|
4
5
|
type DisplayMode,
|
|
5
6
|
isDisplayMode,
|
|
6
7
|
type Pico8Adapter,
|
|
@@ -72,9 +73,23 @@ export function createShellAdapterFactory(
|
|
|
72
73
|
}
|
|
73
74
|
|
|
74
75
|
/**
|
|
75
|
-
* The display
|
|
76
|
-
*
|
|
77
|
-
*
|
|
76
|
+
* The `display` option of the commands that use the display mode (`run`,
|
|
77
|
+
* `audio record`, `audio render`, `doctor`). A per-command option, not an incur
|
|
78
|
+
* global, because incur's MCP transport passes no globals: as an option it is
|
|
79
|
+
* also an argument of the MCP tool. Unset = the invocation-level value.
|
|
80
|
+
*/
|
|
81
|
+
export const DISPLAY_OPTION = z
|
|
82
|
+
.enum(DISPLAY_MODES)
|
|
83
|
+
.optional()
|
|
84
|
+
.describe(
|
|
85
|
+
'How a PICO-8 -run session (audio recording) gets a display: auto (default: a private virtual display only when there is none), xvfb (always virtual), native (as-is). Else PICOPILOT_DISPLAY, else picopilot.json "display".',
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The display mode the root middleware in `createCli` resolved for this
|
|
90
|
+
* invocation (top-level `--display` > PICOPILOT_DISPLAY > picopilot.json >
|
|
91
|
+
* auto). A command mounted on a bare CLI (some tests) has no var and gets the
|
|
92
|
+
* default.
|
|
78
93
|
*/
|
|
79
94
|
export function displayModeOf(vars: unknown): DisplayMode {
|
|
80
95
|
const value =
|
|
@@ -84,6 +99,59 @@ export function displayModeOf(vars: unknown): DisplayMode {
|
|
|
84
99
|
return isDisplayMode(value) ? value : DEFAULT_DISPLAY_MODE;
|
|
85
100
|
}
|
|
86
101
|
|
|
102
|
+
/**
|
|
103
|
+
* The display mode a command uses: its own `display` option (a per-call MCP
|
|
104
|
+
* argument) wins, else the invocation-level value. An invalid invocation-level
|
|
105
|
+
* value (a typo in PICOPILOT_DISPLAY, say) is an `invalid-display-mode` error
|
|
106
|
+
* here, so only the commands that use the mode fail on it.
|
|
107
|
+
*/
|
|
108
|
+
export function resolveCommandDisplay(
|
|
109
|
+
option: DisplayMode | undefined,
|
|
110
|
+
vars: unknown,
|
|
111
|
+
):
|
|
112
|
+
| {ok: true; mode: DisplayMode}
|
|
113
|
+
| {
|
|
114
|
+
ok: false;
|
|
115
|
+
error: {code: 'invalid-display-mode'; message: string; exitCode: number};
|
|
116
|
+
} {
|
|
117
|
+
if (option !== undefined) return {ok: true, mode: option};
|
|
118
|
+
const problem =
|
|
119
|
+
vars !== null && typeof vars === 'object'
|
|
120
|
+
? (vars as {displayError?: unknown}).displayError
|
|
121
|
+
: undefined;
|
|
122
|
+
if (typeof problem === 'string') {
|
|
123
|
+
return {
|
|
124
|
+
ok: false,
|
|
125
|
+
error: {code: 'invalid-display-mode', message: problem, exitCode: 1},
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
return {ok: true, mode: displayModeOf(vars)};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Under `--mcp`, incur loads no config file, so picopilot loads picopilot.json
|
|
133
|
+
* itself and hands the tree to commands as the `mcpConfig` var. A command whose
|
|
134
|
+
* option can come from the config reads its fallback here (CLI runs get it from
|
|
135
|
+
* incur as the option's default, so this is undefined there).
|
|
136
|
+
*/
|
|
137
|
+
export function mcpConfigOption(
|
|
138
|
+
vars: unknown,
|
|
139
|
+
commandPath: readonly string[],
|
|
140
|
+
key: string,
|
|
141
|
+
): unknown {
|
|
142
|
+
let node: unknown =
|
|
143
|
+
vars !== null && typeof vars === 'object'
|
|
144
|
+
? (vars as {mcpConfig?: unknown}).mcpConfig
|
|
145
|
+
: undefined;
|
|
146
|
+
for (const part of commandPath) {
|
|
147
|
+
node = (node as {commands?: Record<string, unknown>} | undefined)
|
|
148
|
+
?.commands?.[part];
|
|
149
|
+
}
|
|
150
|
+
return (node as {options?: Record<string, unknown>} | undefined)?.options?.[
|
|
151
|
+
key
|
|
152
|
+
];
|
|
153
|
+
}
|
|
154
|
+
|
|
87
155
|
/**
|
|
88
156
|
* The `error(...)` payload for a `display-unavailable` result (ADR-0017): the
|
|
89
157
|
* session needs a display, there is none, and no Xvfb is installed to provide a
|
package/src/commands/run.ts
CHANGED
|
@@ -16,10 +16,11 @@ import {
|
|
|
16
16
|
import {makeReadInclude} from './cart-includes.js';
|
|
17
17
|
import {
|
|
18
18
|
createShellAdapterFactory,
|
|
19
|
-
|
|
19
|
+
DISPLAY_OPTION,
|
|
20
20
|
displayUnavailableError,
|
|
21
21
|
PICO8_ENV,
|
|
22
22
|
pico8NotFoundError,
|
|
23
|
+
resolveCommandDisplay,
|
|
23
24
|
type Pico8AdapterFactory,
|
|
24
25
|
} from './pico8-env.js';
|
|
25
26
|
|
|
@@ -96,6 +97,7 @@ export function registerRun(
|
|
|
96
97
|
.describe(
|
|
97
98
|
'One-shot scripted input passed as `-p` (the cart reads it via stat(6)) for automated playtests.',
|
|
98
99
|
),
|
|
100
|
+
display: DISPLAY_OPTION,
|
|
99
101
|
recordAudio: z
|
|
100
102
|
.boolean()
|
|
101
103
|
.default(false)
|
|
@@ -183,8 +185,12 @@ export function registerRun(
|
|
|
183
185
|
: mkdtempSync(join(tmpdir(), 'picopilot-run-'));
|
|
184
186
|
if (!existsSync(shotDir)) mkdirSync(shotDir, {recursive: true});
|
|
185
187
|
|
|
188
|
+
// The display mode only matters to the --record-audio (-run) pass, so a
|
|
189
|
+
// bad PICOPILOT_DISPLAY only fails a run that records.
|
|
190
|
+
const display = resolveCommandDisplay(options.display, vars);
|
|
191
|
+
if (options.recordAudio && !display.ok) return error(display.error);
|
|
186
192
|
const adapter = adapterFactory(env as NodeJS.ProcessEnv, {
|
|
187
|
-
display:
|
|
193
|
+
display: display.ok ? display.mode : 'auto',
|
|
188
194
|
});
|
|
189
195
|
const result = await adapter.run({
|
|
190
196
|
cartPath,
|
package/src/commands/sfx.test.ts
CHANGED
|
@@ -7,6 +7,9 @@ import {afterEach, beforeEach, describe, expect, it} from 'vitest';
|
|
|
7
7
|
import {mmlToSfxRow, SFX_ROW_LENGTH} from '../engine/audio/index.js';
|
|
8
8
|
import {createCli} from '../cli.js';
|
|
9
9
|
|
|
10
|
+
/** The .p8 header lines. */
|
|
11
|
+
const HEADER = 'pico-8 cartridge // http://www.pico-8.com\nversion 42\n';
|
|
12
|
+
|
|
10
13
|
/** A minimal valid cart with a lua + gfx section (to prove they stay untouched). */
|
|
11
14
|
function cartText(sfxBody?: string): string {
|
|
12
15
|
let text =
|
|
@@ -347,3 +350,55 @@ describe('picopilot sfx from-mml: dotted-length rounding is reported', () => {
|
|
|
347
350
|
expect(out.warnings[0]).toContain('rounded');
|
|
348
351
|
});
|
|
349
352
|
});
|
|
353
|
+
|
|
354
|
+
describe('picopilot sfx clear', () => {
|
|
355
|
+
it('clears the named slots and nothing else', async () => {
|
|
356
|
+
const a = mmlToSfxRow('s4 c').row;
|
|
357
|
+
const b = mmlToSfxRow('s5 d').row;
|
|
358
|
+
const c = mmlToSfxRow('s6 e0 c').row;
|
|
359
|
+
writeFileSync(
|
|
360
|
+
cartPath,
|
|
361
|
+
`${HEADER}__lua__\nprint(1)\n__sfx__\n${a}\n${b}\n${c}\n`,
|
|
362
|
+
);
|
|
363
|
+
const {stdout, exitCode} = await runSfx([
|
|
364
|
+
'sfx',
|
|
365
|
+
'clear',
|
|
366
|
+
'1,5',
|
|
367
|
+
'--cart',
|
|
368
|
+
cartPath,
|
|
369
|
+
'--json',
|
|
370
|
+
]);
|
|
371
|
+
expect(exitCode).toBe(0);
|
|
372
|
+
const out = JSON.parse(stdout);
|
|
373
|
+
expect(out.cleared).toEqual([1]);
|
|
374
|
+
expect(out.alreadyEmpty).toEqual([5]);
|
|
375
|
+
const text = readFileSync(cartPath, 'utf8');
|
|
376
|
+
expect(text).toContain(`${a}\n${'0'.repeat(168)}\n${c}\n`);
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
it('clearing the last slots trims them (what PICO-8 writes)', async () => {
|
|
380
|
+
const a = mmlToSfxRow('s4 c').row;
|
|
381
|
+
writeFileSync(
|
|
382
|
+
cartPath,
|
|
383
|
+
`${HEADER}__lua__\nprint(1)\n__sfx__\n${a}\n${a}\n`,
|
|
384
|
+
);
|
|
385
|
+
await runSfx(['sfx', 'clear', '1-63', '--cart', cartPath, '--json']);
|
|
386
|
+
expect(readFileSync(cartPath, 'utf8')).toBe(
|
|
387
|
+
`${HEADER}__lua__\nprint(1)\n__sfx__\n${a}\n`,
|
|
388
|
+
);
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
it('refuses a bad slot list', async () => {
|
|
392
|
+
writeFileSync(cartPath, `${HEADER}__lua__\nprint(1)\n`);
|
|
393
|
+
const {stdout, exitCode} = await runSfx([
|
|
394
|
+
'sfx',
|
|
395
|
+
'clear',
|
|
396
|
+
'70',
|
|
397
|
+
'--cart',
|
|
398
|
+
cartPath,
|
|
399
|
+
'--json',
|
|
400
|
+
]);
|
|
401
|
+
expect(exitCode).not.toBe(0);
|
|
402
|
+
expect(stdout).toContain('bad-slot-list');
|
|
403
|
+
});
|
|
404
|
+
});
|