picopilot 0.0.0 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +202 -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 +217 -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 +460 -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 +238 -0
- package/src/commands/run.ts +259 -0
- package/src/commands/serve.test.ts +218 -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 +236 -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,181 @@
|
|
|
1
|
+
import {describe, expect, it} from 'vitest';
|
|
2
|
+
import {Cart} from '../cart/index.js';
|
|
3
|
+
import {mmlToSfxRow} from '../audio/sfx.js';
|
|
4
|
+
import {mergeSfxRow} from '../audio/section.js';
|
|
5
|
+
import {
|
|
6
|
+
buildRecordHarness,
|
|
7
|
+
buildRenderHarness,
|
|
8
|
+
DEFAULT_RECORD_SECONDS,
|
|
9
|
+
decodeSfxTiming,
|
|
10
|
+
HarnessError,
|
|
11
|
+
recordSeconds,
|
|
12
|
+
} from './harness.js';
|
|
13
|
+
import {DONE_SENTINEL} from './adapter.js';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The `audio render`/`audio record` harness builder is PURE (cart-text in,
|
|
17
|
+
* cart-text + record window out), so every path is unit-tested WITHOUT the paid
|
|
18
|
+
* PICO-8 binary. Live capture is the manual/opt-in tier (ADR-0009).
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const HEADER = 'pico-8 cartridge // http://www.pico-8.com\nversion 42\n';
|
|
22
|
+
|
|
23
|
+
/** A cart with an authored SFX in slot `slot` (`mml`), and an existing __lua__. */
|
|
24
|
+
function cartWithSfx(slot: number, mml: string): string {
|
|
25
|
+
const cart = Cart.parse(`${HEADER}__lua__\nfunction _update() end\n`);
|
|
26
|
+
mergeSfxRow(cart, slot, mmlToSfxRow(mml).row);
|
|
27
|
+
return cart.serialize();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe('decodeSfxTiming', () => {
|
|
31
|
+
it('reads speed + audible row count from a __sfx__ row', () => {
|
|
32
|
+
// `s8` speed 8, four audible notes = 4 rows.
|
|
33
|
+
const {row} = mmlToSfxRow('s8 @1 v6 c d e f');
|
|
34
|
+
const timing = decodeSfxTiming(row);
|
|
35
|
+
expect(timing.speed).toBe(8);
|
|
36
|
+
expect(timing.rows).toBe(4);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('does not count a trailing silent tail as audible rows', () => {
|
|
40
|
+
const {row} = mmlToSfxRow('s16 @0 v5 c r r'); // one note then two rests
|
|
41
|
+
expect(decodeSfxTiming(row).rows).toBe(1);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe('recordSeconds', () => {
|
|
46
|
+
it('derives the window from an SFX target speed + rows (+ tail)', () => {
|
|
47
|
+
// speed 16 x 4 rows = 64 ticks / 120 = 0.533s, + 0.5 tail ~= 1.03s.
|
|
48
|
+
const cart = Cart.parse(cartWithSfx(3, 's16 @1 v6 c d e f'));
|
|
49
|
+
const secs = recordSeconds(cart, {kind: 'sfx', index: 3});
|
|
50
|
+
expect(secs).toBeCloseTo(64 / 120 + 0.5, 2);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('uses the default window for a pattern/song target (length not derivable)', () => {
|
|
54
|
+
const cart = Cart.parse(cartWithSfx(0, 's16 c'));
|
|
55
|
+
expect(recordSeconds(cart, {kind: 'song'})).toBe(DEFAULT_RECORD_SECONDS);
|
|
56
|
+
expect(recordSeconds(cart, {kind: 'pattern', index: 0})).toBe(
|
|
57
|
+
DEFAULT_RECORD_SECONDS,
|
|
58
|
+
);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('an explicit override always wins', () => {
|
|
62
|
+
const cart = Cart.parse(cartWithSfx(3, 's16 c d e f'));
|
|
63
|
+
expect(recordSeconds(cart, {kind: 'sfx', index: 3}, 12)).toBe(12);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe('buildRenderHarness', () => {
|
|
68
|
+
it('injects an audio_rec/target-play/audio_end/sentinel harness for an SFX target', () => {
|
|
69
|
+
const h = buildRenderHarness(cartWithSfx(5, 's8 @3 v6 c e g'), {
|
|
70
|
+
kind: 'sfx',
|
|
71
|
+
index: 5,
|
|
72
|
+
});
|
|
73
|
+
expect(h.cartText).toContain('extcmd("audio_rec")');
|
|
74
|
+
expect(h.cartText).toContain('sfx(5)'); // plays the chosen target
|
|
75
|
+
expect(h.cartText).toContain('extcmd("audio_end",1)'); // save to current folder
|
|
76
|
+
expect(h.cartText).toContain(`printh("${DONE_SENTINEL}")`);
|
|
77
|
+
expect(h.description).toBe('sfx 5');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('plays music(P) for a pattern target and music(0) for the whole song', () => {
|
|
81
|
+
const cart = cartWithSfx(0, 's16 c');
|
|
82
|
+
expect(
|
|
83
|
+
buildRenderHarness(cart, {kind: 'pattern', index: 2}).cartText,
|
|
84
|
+
).toContain('music(2)');
|
|
85
|
+
expect(buildRenderHarness(cart, {kind: 'song'}).cartText).toContain(
|
|
86
|
+
'music(0)',
|
|
87
|
+
);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('preserves the authored __sfx__ byte-for-byte (only __lua__ changes)', () => {
|
|
91
|
+
const cartText = cartWithSfx(5, 's8 @3 v6 c e g');
|
|
92
|
+
const original = Cart.parse(cartText);
|
|
93
|
+
const rendered = Cart.parse(
|
|
94
|
+
buildRenderHarness(cartText, {kind: 'sfx', index: 5}).cartText,
|
|
95
|
+
);
|
|
96
|
+
expect(rendered.getSection('sfx')).toBe(original.getSection('sfx'));
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('records for at least the target play window (frames = seconds * 30fps)', () => {
|
|
100
|
+
const h = buildRenderHarness(cartWithSfx(3, 's16 @1 v6 c d e f'), {
|
|
101
|
+
kind: 'sfx',
|
|
102
|
+
index: 3,
|
|
103
|
+
});
|
|
104
|
+
expect(h.frames).toBe(Math.round(h.seconds * 30));
|
|
105
|
+
expect(h.frames).toBeGreaterThan(0);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('refuses an out-of-range SFX target (structured, not silent)', () => {
|
|
109
|
+
expect(() =>
|
|
110
|
+
buildRenderHarness(cartWithSfx(0, 's16 c'), {kind: 'sfx', index: 99}),
|
|
111
|
+
).toThrowError(HarnessError);
|
|
112
|
+
try {
|
|
113
|
+
buildRenderHarness(cartWithSfx(0, 's16 c'), {kind: 'sfx', index: 99});
|
|
114
|
+
} catch (e) {
|
|
115
|
+
expect((e as HarnessError).code).toBe('audio-render-target-out-of-range');
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('refuses an empty SFX slot (nothing to render)', () => {
|
|
120
|
+
try {
|
|
121
|
+
buildRenderHarness(cartWithSfx(0, 's16 c'), {kind: 'sfx', index: 7});
|
|
122
|
+
throw new Error('expected a refusal');
|
|
123
|
+
} catch (e) {
|
|
124
|
+
expect(e).toBeInstanceOf(HarnessError);
|
|
125
|
+
expect((e as HarnessError).code).toBe('audio-render-target-empty');
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('refuses an unparseable cart (not a .p8)', () => {
|
|
130
|
+
try {
|
|
131
|
+
buildRenderHarness('not a cart', {kind: 'song'});
|
|
132
|
+
throw new Error('expected a refusal');
|
|
133
|
+
} catch (e) {
|
|
134
|
+
expect((e as HarnessError).code).toBe('audio-render-cart-parse-error');
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('honours a custom sentinel + basename', () => {
|
|
139
|
+
const h = buildRenderHarness(
|
|
140
|
+
cartWithSfx(0, 's16 c'),
|
|
141
|
+
{kind: 'song'},
|
|
142
|
+
{
|
|
143
|
+
sentinel: '__DONE__',
|
|
144
|
+
wavBasename: 'out',
|
|
145
|
+
},
|
|
146
|
+
);
|
|
147
|
+
expect(h.cartText).toContain('printh("__DONE__")');
|
|
148
|
+
expect(h.cartText).toContain('extcmd("set_filename","out")');
|
|
149
|
+
expect(h.wavBasename).toBe('out');
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
describe('buildRecordHarness', () => {
|
|
154
|
+
it('keeps the cart own code + appends a cooperative recorder tab', () => {
|
|
155
|
+
const cartText = `${HEADER}__lua__\nfunction _update() foo() end\n`;
|
|
156
|
+
const h = buildRecordHarness(cartText);
|
|
157
|
+
// The cart's own code survives (record captures its OWN playback).
|
|
158
|
+
expect(h.cartText).toContain('function _update() foo() end');
|
|
159
|
+
// The recorder is appended as a separate tab that wraps _update.
|
|
160
|
+
expect(h.cartText).toContain('-->8');
|
|
161
|
+
expect(h.cartText).toContain('__pp_rec_update=_update');
|
|
162
|
+
expect(h.cartText).toContain('extcmd("audio_rec")');
|
|
163
|
+
expect(h.cartText).toContain('extcmd("audio_end",1)');
|
|
164
|
+
expect(h.cartText).toContain(`printh("${DONE_SENTINEL}")`);
|
|
165
|
+
expect(h.description).toBe('the running cart');
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('uses the default record window when none is given', () => {
|
|
169
|
+
const h = buildRecordHarness(`${HEADER}__lua__\n`);
|
|
170
|
+
expect(h.seconds).toBe(DEFAULT_RECORD_SECONDS);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('refuses an unparseable cart', () => {
|
|
174
|
+
try {
|
|
175
|
+
buildRecordHarness('garbage');
|
|
176
|
+
throw new Error('expected a refusal');
|
|
177
|
+
} catch (e) {
|
|
178
|
+
expect((e as HarnessError).code).toBe('audio-render-cart-parse-error');
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
});
|
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `audio render` harness builder (ADR-0009 Part 3): the PURE, CI-testable
|
|
3
|
+
* core that turns a user's cart + a chosen render TARGET into a THROWAWAY cart
|
|
4
|
+
* whose `_update` plays the target, records it via `audio_rec`/`audio_end(1)`,
|
|
5
|
+
* and prints the done-sentinel. This is the audio analogue of the `run`
|
|
6
|
+
* screenshot harness (the `picopilot-debug` recipe): the author writes NO
|
|
7
|
+
* playback code and still gets "render sfx 5 -> a WAV".
|
|
8
|
+
*
|
|
9
|
+
* Kept pure (cart-text in, cart-text + a record window out) so the harness
|
|
10
|
+
* construction + the record-duration heuristic are unit-testable WITHOUT the
|
|
11
|
+
* paid PICO-8 binary; only the actual capture needs a live A/V session (the
|
|
12
|
+
* manual/opt-in tier, ADR-0009).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import {Cart} from '../cart/index.js';
|
|
16
|
+
import {
|
|
17
|
+
DONE_SENTINEL,
|
|
18
|
+
PICO8_FRAMES_PER_SECOND,
|
|
19
|
+
PICO8_TICKS_PER_SECOND,
|
|
20
|
+
RECORD_WAV_BASENAME,
|
|
21
|
+
} from './adapter.js';
|
|
22
|
+
import {SFX_ROW_LENGTH} from '../audio/sfx.js';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* A render target: a single SFX slot, a single music PATTERN, or the whole song
|
|
26
|
+
* (pattern 0 onward). This mirrors the audio authoring vocabulary (`sfx from-mml`
|
|
27
|
+
* writes a slot; `music from-patterns` writes an ordered pattern list), so an
|
|
28
|
+
* author renders exactly what they composed.
|
|
29
|
+
*/
|
|
30
|
+
export type RenderTarget =
|
|
31
|
+
| {readonly kind: 'sfx'; readonly index: number}
|
|
32
|
+
| {readonly kind: 'pattern'; readonly index: number}
|
|
33
|
+
| {readonly kind: 'song'};
|
|
34
|
+
|
|
35
|
+
/** The lowest/highest SFX slot a render target may name (finding: 64 slots). */
|
|
36
|
+
export const SFX_TARGET_MIN = 0;
|
|
37
|
+
export const SFX_TARGET_MAX = 63;
|
|
38
|
+
|
|
39
|
+
/** The lowest/highest music PATTERN a render target may name (finding: 64 patterns). */
|
|
40
|
+
export const PATTERN_TARGET_MIN = 0;
|
|
41
|
+
export const PATTERN_TARGET_MAX = 63;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The default record window (seconds) for a target whose exact play length
|
|
45
|
+
* cannot be derived cheaply (a music pattern / whole song: PICO-8 does not store
|
|
46
|
+
* a per-pattern length, and following loops/flags statically is out of scope for
|
|
47
|
+
* a render harness). A generous window that captures a typical loop; the author
|
|
48
|
+
* overrides it with an explicit duration (ADR-0009: render is a real-time
|
|
49
|
+
* recording, so a fixed window is honest, not a promise of exact length).
|
|
50
|
+
*/
|
|
51
|
+
export const DEFAULT_RECORD_SECONDS = 8;
|
|
52
|
+
|
|
53
|
+
/** A small tail margin (seconds) appended so the target's final note is not clipped. */
|
|
54
|
+
export const RECORD_TAIL_SECONDS = 0.5;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* A structured reason a render harness could not be built. Surfaced by the
|
|
58
|
+
* command as incur's `error({code,...})` envelope; never a silent guess.
|
|
59
|
+
*/
|
|
60
|
+
export type HarnessErrorCode =
|
|
61
|
+
/** The target named an SFX/pattern outside the valid 0..63 range. */
|
|
62
|
+
| 'audio-render-target-out-of-range'
|
|
63
|
+
/** The target SFX slot is empty (all-zero) in the cart: nothing to render. */
|
|
64
|
+
| 'audio-render-target-empty'
|
|
65
|
+
/** The cart could not be parsed (not a valid .p8). */
|
|
66
|
+
| 'audio-render-cart-parse-error';
|
|
67
|
+
|
|
68
|
+
/** A structured harness-build failure (a target refusal, not a crash). */
|
|
69
|
+
export class HarnessError extends Error {
|
|
70
|
+
readonly code: HarnessErrorCode;
|
|
71
|
+
constructor(code: HarnessErrorCode, message: string) {
|
|
72
|
+
super(message);
|
|
73
|
+
this.name = 'HarnessError';
|
|
74
|
+
this.code = code;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** An all-zero `__sfx__` row: the canonical "empty slot" (mirrors section.ts). */
|
|
79
|
+
const EMPTY_SFX_ROW = '0'.repeat(SFX_ROW_LENGTH);
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The decoded timing of one authored `__sfx__` row: its `speed` (ticks/row) and
|
|
83
|
+
* the number of NON-EMPTY leading rows (its play length in rows). Both come from
|
|
84
|
+
* the friendly text row (finding Part A): header bytes 2..3 = speed, then 32
|
|
85
|
+
* five-nibble notes; a note is silent when its volume nibble is 0.
|
|
86
|
+
*/
|
|
87
|
+
interface SfxTiming {
|
|
88
|
+
readonly speed: number;
|
|
89
|
+
readonly rows: number;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Reads slot `index`'s row from a `__sfx__` body, or undefined if absent/empty. */
|
|
93
|
+
function sfxRow(
|
|
94
|
+
sfxBody: string | undefined,
|
|
95
|
+
index: number,
|
|
96
|
+
): string | undefined {
|
|
97
|
+
if (sfxBody === undefined) return undefined;
|
|
98
|
+
const rows = sfxBody.split(/\r?\n/).filter((l) => l.length > 0);
|
|
99
|
+
const row = rows[index];
|
|
100
|
+
if (
|
|
101
|
+
row === undefined ||
|
|
102
|
+
row.length !== SFX_ROW_LENGTH ||
|
|
103
|
+
row === EMPTY_SFX_ROW
|
|
104
|
+
) {
|
|
105
|
+
return undefined;
|
|
106
|
+
}
|
|
107
|
+
return row;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Decodes a `__sfx__` text row into its {@link SfxTiming}. `speed` is header
|
|
112
|
+
* byte [2:4]; `rows` is the count of tracker rows up to and including the last
|
|
113
|
+
* one with a non-zero volume nibble (a trailing silent tail does not extend the
|
|
114
|
+
* audible length). Returns `rows = 0` for an all-silent row.
|
|
115
|
+
*/
|
|
116
|
+
export function decodeSfxTiming(row: string): SfxTiming {
|
|
117
|
+
const speed = Number.parseInt(row.slice(2, 4), 16) || 1;
|
|
118
|
+
let lastAudible = 0;
|
|
119
|
+
for (let r = 0; r < 32; r++) {
|
|
120
|
+
const noteStart = 8 + r * 5;
|
|
121
|
+
// Note layout: PP (pitch, 2) W (waveform, 1) V (volume, 1) E (effect, 1).
|
|
122
|
+
const vol = Number.parseInt(row.charAt(noteStart + 3), 16);
|
|
123
|
+
if (!Number.isNaN(vol) && vol > 0) lastAudible = r + 1;
|
|
124
|
+
}
|
|
125
|
+
return {speed, rows: lastAudible};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The record window (seconds) for a render target, given the cart. For an SFX
|
|
130
|
+
* target the window is derived from that slot's speed + audible row count
|
|
131
|
+
* (`rows * speed` ticks / {@link PICO8_TICKS_PER_SECOND}) plus a tail margin, so
|
|
132
|
+
* the recording covers exactly the target's play length without guessing. For a
|
|
133
|
+
* pattern / whole-song target the length is not cheaply derivable, so the
|
|
134
|
+
* {@link DEFAULT_RECORD_SECONDS} window is used (overridable by the caller).
|
|
135
|
+
*
|
|
136
|
+
* An explicit `override` (the command's `--seconds`) always wins.
|
|
137
|
+
*/
|
|
138
|
+
export function recordSeconds(
|
|
139
|
+
cart: Cart,
|
|
140
|
+
target: RenderTarget,
|
|
141
|
+
override?: number,
|
|
142
|
+
): number {
|
|
143
|
+
if (override !== undefined) return override;
|
|
144
|
+
if (target.kind === 'sfx') {
|
|
145
|
+
const row = sfxRow(cart.getSection('sfx'), target.index);
|
|
146
|
+
if (row !== undefined) {
|
|
147
|
+
const {speed, rows} = decodeSfxTiming(row);
|
|
148
|
+
const ticks = rows * speed;
|
|
149
|
+
const seconds = ticks / PICO8_TICKS_PER_SECOND + RECORD_TAIL_SECONDS;
|
|
150
|
+
// A degenerate (0-row) slot still records a short window rather than 0s.
|
|
151
|
+
return Math.max(seconds, RECORD_TAIL_SECONDS + 0.5);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return DEFAULT_RECORD_SECONDS;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** The Lua expression that PLAYS the target once, at harness start. */
|
|
158
|
+
function playCall(target: RenderTarget): string {
|
|
159
|
+
switch (target.kind) {
|
|
160
|
+
case 'sfx':
|
|
161
|
+
return `sfx(${target.index})`;
|
|
162
|
+
case 'pattern':
|
|
163
|
+
return `music(${target.index})`;
|
|
164
|
+
case 'song':
|
|
165
|
+
return 'music(0)';
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** A one-line human description of the target (for the render result + help). */
|
|
170
|
+
export function describeTarget(target: RenderTarget): string {
|
|
171
|
+
switch (target.kind) {
|
|
172
|
+
case 'sfx':
|
|
173
|
+
return `sfx ${target.index}`;
|
|
174
|
+
case 'pattern':
|
|
175
|
+
return `music pattern ${target.index}`;
|
|
176
|
+
case 'song':
|
|
177
|
+
return 'the whole song (music 0)';
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** The result of building a render harness: the throwaway cart + its record window. */
|
|
182
|
+
export interface RenderHarness {
|
|
183
|
+
/** The throwaway `.p8` cart text (same `__sfx__`/`__music__`, injected `__lua__`). */
|
|
184
|
+
readonly cartText: string;
|
|
185
|
+
/** The record window in seconds the adapter should let the capture run for. */
|
|
186
|
+
readonly seconds: number;
|
|
187
|
+
/** The number of `_update` frames the harness records for (= seconds * 30fps). */
|
|
188
|
+
readonly frames: number;
|
|
189
|
+
/** The base filename the harness passes to `extcmd("set_filename")`. */
|
|
190
|
+
readonly wavBasename: string;
|
|
191
|
+
/** A human description of what is being rendered (for the result envelope). */
|
|
192
|
+
readonly description: string;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Validates a render target against the cart's authored content, refusing an
|
|
197
|
+
* out-of-range index or an empty SFX slot (nothing to render) with a structured
|
|
198
|
+
* {@link HarnessError} rather than recording silence.
|
|
199
|
+
*/
|
|
200
|
+
function validateTarget(cart: Cart, target: RenderTarget): void {
|
|
201
|
+
if (target.kind === 'sfx') {
|
|
202
|
+
if (target.index < SFX_TARGET_MIN || target.index > SFX_TARGET_MAX) {
|
|
203
|
+
throw new HarnessError(
|
|
204
|
+
'audio-render-target-out-of-range',
|
|
205
|
+
`SFX target ${target.index} is out of range ${SFX_TARGET_MIN}..${SFX_TARGET_MAX}.`,
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
if (sfxRow(cart.getSection('sfx'), target.index) === undefined) {
|
|
209
|
+
throw new HarnessError(
|
|
210
|
+
'audio-render-target-empty',
|
|
211
|
+
`SFX slot ${target.index} is empty in this cart, there is nothing to render. Author it first with \`sfx from-mml ${target.index} "<mml>"\`.`,
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
} else if (target.kind === 'pattern') {
|
|
215
|
+
if (
|
|
216
|
+
target.index < PATTERN_TARGET_MIN ||
|
|
217
|
+
target.index > PATTERN_TARGET_MAX
|
|
218
|
+
) {
|
|
219
|
+
throw new HarnessError(
|
|
220
|
+
'audio-render-target-out-of-range',
|
|
221
|
+
`music pattern target ${target.index} is out of range ${PATTERN_TARGET_MIN}..${PATTERN_TARGET_MAX}.`,
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Builds the throwaway render harness (ADR-0009 Part 3). Takes the user's cart
|
|
229
|
+
* text + a render TARGET (+ an optional record-window override) and produces a
|
|
230
|
+
* new cart with the SAME authored `__sfx__`/`__music__` but a REPLACED `__lua__`
|
|
231
|
+
* whose `_update`:
|
|
232
|
+
* - at frame 1: `extcmd("set_filename", <base>)`, `extcmd("audio_rec")`, plays
|
|
233
|
+
* the target (`sfx(N)` / `music(P)` / `music(0)`);
|
|
234
|
+
* - at frame `frames`: `extcmd("audio_end", 1)` (save to the current folder,
|
|
235
|
+
* which the adapter points at an isolated temp dir);
|
|
236
|
+
* - at frame `frames + 1`: `printh` the done-sentinel so the launcher kills
|
|
237
|
+
* PICO-8 promptly (a cart cannot self-quit, ADR-0006).
|
|
238
|
+
*
|
|
239
|
+
* @throws {HarnessError} an out-of-range/empty target or an unparseable cart.
|
|
240
|
+
*/
|
|
241
|
+
export function buildRenderHarness(
|
|
242
|
+
cartText: string,
|
|
243
|
+
target: RenderTarget,
|
|
244
|
+
options: {seconds?: number; sentinel?: string; wavBasename?: string} = {},
|
|
245
|
+
): RenderHarness {
|
|
246
|
+
let cart: Cart;
|
|
247
|
+
try {
|
|
248
|
+
cart = Cart.parse(cartText);
|
|
249
|
+
} catch (e) {
|
|
250
|
+
throw new HarnessError(
|
|
251
|
+
'audio-render-cart-parse-error',
|
|
252
|
+
`cart does not parse: ${e instanceof Error ? e.message : String(e)}`,
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
validateTarget(cart, target);
|
|
257
|
+
|
|
258
|
+
const seconds = recordSeconds(cart, target, options.seconds);
|
|
259
|
+
const frames = Math.max(1, Math.round(seconds * PICO8_FRAMES_PER_SECOND));
|
|
260
|
+
const sentinel = options.sentinel ?? DONE_SENTINEL;
|
|
261
|
+
const wavBasename = options.wavBasename ?? RECORD_WAV_BASENAME;
|
|
262
|
+
const play = playCall(target);
|
|
263
|
+
|
|
264
|
+
// Replace __lua__ with the record harness; keep every OTHER section (crucially
|
|
265
|
+
// __sfx__/__music__) byte-identical so the target plays exactly as authored.
|
|
266
|
+
const lua = [
|
|
267
|
+
'-- picopilot audio render harness (throwaway; ADR-0009).',
|
|
268
|
+
'-- Real-time recording of playback, NOT an offline export.',
|
|
269
|
+
't=0',
|
|
270
|
+
'function _update()',
|
|
271
|
+
' t+=1',
|
|
272
|
+
` if t==1 then extcmd("set_filename","${wavBasename}") extcmd("audio_rec") ${play} end`,
|
|
273
|
+
` if t==${frames} then extcmd("audio_end",1) end`,
|
|
274
|
+
` if t==${frames + 1} then printh("${sentinel}") end`,
|
|
275
|
+
'end',
|
|
276
|
+
'function _draw() cls() print("rendering "..t,0,0,7) end',
|
|
277
|
+
'',
|
|
278
|
+
].join('\n');
|
|
279
|
+
|
|
280
|
+
cart.setSection('lua', lua);
|
|
281
|
+
|
|
282
|
+
return {
|
|
283
|
+
cartText: cart.serialize(),
|
|
284
|
+
seconds,
|
|
285
|
+
frames,
|
|
286
|
+
wavBasename,
|
|
287
|
+
description: describeTarget(target),
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Builds the `audio record <cart>` harness: unlike `render`, this captures the
|
|
293
|
+
* cart's OWN playback, so it keeps the cart's `__lua__` and only APPENDS a
|
|
294
|
+
* cooperative recorder tab that starts recording immediately and stops after the
|
|
295
|
+
* window. The recorder runs as an extra code tab so it does not disturb the
|
|
296
|
+
* cart's own `_update`/`_draw` (PICO-8 runs all tabs; the appended top-level code
|
|
297
|
+
* arms the recorder and a hooked `_update` advances the timer).
|
|
298
|
+
*
|
|
299
|
+
* Because a general cart may define its own `_update`, the recorder wraps it: it
|
|
300
|
+
* saves the cart's `_update` and installs one that ticks the record timer then
|
|
301
|
+
* calls the original. This is the record analogue of the debug skill's "add
|
|
302
|
+
* capture lines, run, revert" recipe, done on a THROWAWAY copy.
|
|
303
|
+
*
|
|
304
|
+
* @throws {HarnessError} an unparseable cart.
|
|
305
|
+
*/
|
|
306
|
+
export function buildRecordHarness(
|
|
307
|
+
cartText: string,
|
|
308
|
+
options: {seconds?: number; sentinel?: string; wavBasename?: string} = {},
|
|
309
|
+
): RenderHarness {
|
|
310
|
+
let cart: Cart;
|
|
311
|
+
try {
|
|
312
|
+
cart = Cart.parse(cartText);
|
|
313
|
+
} catch (e) {
|
|
314
|
+
throw new HarnessError(
|
|
315
|
+
'audio-render-cart-parse-error',
|
|
316
|
+
`cart does not parse: ${e instanceof Error ? e.message : String(e)}`,
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const seconds = options.seconds ?? DEFAULT_RECORD_SECONDS;
|
|
321
|
+
const frames = Math.max(1, Math.round(seconds * PICO8_FRAMES_PER_SECOND));
|
|
322
|
+
const sentinel = options.sentinel ?? DONE_SENTINEL;
|
|
323
|
+
const wavBasename = options.wavBasename ?? RECORD_WAV_BASENAME;
|
|
324
|
+
|
|
325
|
+
const existing = cart.getSection('lua') ?? '';
|
|
326
|
+
// Append the recorder AFTER the cart's own code. It wraps _update so it works
|
|
327
|
+
// whether or not the cart defines one, and never edits the cart's own logic.
|
|
328
|
+
const recorder = [
|
|
329
|
+
'',
|
|
330
|
+
'-->8',
|
|
331
|
+
'-- picopilot audio record harness (throwaway; ADR-0009).',
|
|
332
|
+
"-- Records the cart's own audio; real-time capture, NOT an offline export.",
|
|
333
|
+
'__pp_rec_update=_update',
|
|
334
|
+
'__pp_rec_t=0',
|
|
335
|
+
'function _update()',
|
|
336
|
+
' __pp_rec_t+=1',
|
|
337
|
+
` if __pp_rec_t==1 then extcmd("set_filename","${wavBasename}") extcmd("audio_rec") end`,
|
|
338
|
+
` if __pp_rec_t==${frames} then extcmd("audio_end",1) end`,
|
|
339
|
+
` if __pp_rec_t==${frames + 1} then printh("${sentinel}") end`,
|
|
340
|
+
' if __pp_rec_update then __pp_rec_update() end',
|
|
341
|
+
'end',
|
|
342
|
+
'',
|
|
343
|
+
].join('\n');
|
|
344
|
+
|
|
345
|
+
cart.setSection('lua', existing + recorder);
|
|
346
|
+
|
|
347
|
+
return {
|
|
348
|
+
cartText: cart.serialize(),
|
|
349
|
+
seconds,
|
|
350
|
+
frames,
|
|
351
|
+
wavBasename,
|
|
352
|
+
description: 'the running cart',
|
|
353
|
+
};
|
|
354
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The PICO-8 engine seam: launch + capture over the user's PICO-8 binary
|
|
3
|
+
* (ADR-0006, native `pico8 -x` path). Barrel re-export so the command layer and
|
|
4
|
+
* later PICO-8-gated features (`audio render`, `export`) import from one place.
|
|
5
|
+
*
|
|
6
|
+
* - `adapter`: the `Pico8Adapter` interface, `Pico8Result`, the structured
|
|
7
|
+
* `pico8-not-found` value, and the run-report shapes.
|
|
8
|
+
* - `sentinel`: the pure sentinel-watch core (CI-testable without the binary).
|
|
9
|
+
* - `shell`: the v1 adapter shelling out to native `pico8 -x`, with an
|
|
10
|
+
* injectable spawn seam for isolation-testing.
|
|
11
|
+
*/
|
|
12
|
+
export * from './adapter.js';
|
|
13
|
+
export * from './drive.js';
|
|
14
|
+
export * from './harness.js';
|
|
15
|
+
export * from './sentinel.js';
|
|
16
|
+
export * from './session.js';
|
|
17
|
+
export * from './session-daemon.js';
|
|
18
|
+
export * from './session-daemon-main.js';
|
|
19
|
+
export * from './shell.js';
|
|
20
|
+
export * from './supervisor.js';
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import {describe, expect, it} from 'vitest';
|
|
2
|
+
import {DONE_SENTINEL} from './adapter.js';
|
|
3
|
+
import {findSentinel, SentinelWatcher, splitLines} from './sentinel.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The sentinel-watch core is the load-bearing run logic AND the one piece
|
|
7
|
+
* testable without the paid PICO-8 binary (ADR-0006), so it carries the run's
|
|
8
|
+
* termination guarantees: a cart that prints the sentinel ends the run promptly,
|
|
9
|
+
* and a cart that never signals leaves it to the backstop. These tests drive it
|
|
10
|
+
* with SYNTHETIC stdout, exactly as the shell adapter drives it with real stdout.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
describe('splitLines: whole-line splitting with a carried remainder', () => {
|
|
14
|
+
it('splits complete lines and returns the unterminated remainder', () => {
|
|
15
|
+
const {lines, rest} = splitLines('a\nb\nhalf');
|
|
16
|
+
expect(lines).toEqual(['a', 'b']);
|
|
17
|
+
expect(rest).toBe('half');
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('strips \\r so a sentinel matches regardless of line endings', () => {
|
|
21
|
+
const {lines} = splitLines('done\r\n');
|
|
22
|
+
expect(lines).toEqual(['done']);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('an empty chunk yields no lines and an empty remainder', () => {
|
|
26
|
+
expect(splitLines('')).toEqual({lines: [], rest: ''});
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe('findSentinel: exact whole-line match', () => {
|
|
31
|
+
it('matches the sentinel on its own line', () => {
|
|
32
|
+
const {matched, index} = findSentinel(['a', DONE_SENTINEL, 'b']);
|
|
33
|
+
expect(matched).toBe(true);
|
|
34
|
+
expect(index).toBe(1);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('does NOT match an incidental substring in other output', () => {
|
|
38
|
+
// A line that merely CONTAINS the sentinel text must not trip it.
|
|
39
|
+
const {matched} = findSentinel([`printing ${DONE_SENTINEL} soon`]);
|
|
40
|
+
expect(matched).toBe(false);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('reports no match when absent', () => {
|
|
44
|
+
expect(findSentinel(['a', 'b'])).toEqual({matched: false, index: -1});
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe('SentinelWatcher: streaming accumulation + prompt match', () => {
|
|
49
|
+
it('matches the sentinel the moment it arrives (kill-on-match)', () => {
|
|
50
|
+
const w = new SentinelWatcher();
|
|
51
|
+
expect(w.push('RUNNING:\n')).toBe(false); // not yet
|
|
52
|
+
expect(w.push(`${DONE_SENTINEL}\n`)).toBe(true); // signal -> kill now
|
|
53
|
+
expect(w.sentinelSeen).toBe(true);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('matches a sentinel split ACROSS two chunks (partial-line buffering)', () => {
|
|
57
|
+
const w = new SentinelWatcher();
|
|
58
|
+
expect(w.push('__PICOPILOT')).toBe(false); // half a line, no newline yet
|
|
59
|
+
expect(w.push('_DONE__\n')).toBe(true); // completes the line -> match
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('never matches when the cart does not signal (backstop territory)', () => {
|
|
63
|
+
const w = new SentinelWatcher();
|
|
64
|
+
w.push('RUNNING:\nx=1\nx=2\n');
|
|
65
|
+
expect(w.sentinelSeen).toBe(false); // caller falls through to the backstop
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('captures the full stdout text (printh), sentinel line included', () => {
|
|
69
|
+
const w = new SentinelWatcher();
|
|
70
|
+
w.push('score=10\n');
|
|
71
|
+
w.push(`${DONE_SENTINEL}\n`);
|
|
72
|
+
expect(w.text).toContain('score=10');
|
|
73
|
+
expect(w.text).toContain(DONE_SENTINEL);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('honours a custom sentinel string', () => {
|
|
77
|
+
const w = new SentinelWatcher('END!');
|
|
78
|
+
expect(w.push('END!\n')).toBe(true);
|
|
79
|
+
});
|
|
80
|
+
});
|