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,729 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `playtest` DRIVE-transform (ADR-0011): the PURE, CI-testable core that
|
|
3
|
+
* turns an ARBITRARY cart's Lua + a driven-run SPEC into a THROWAWAY driven cart
|
|
4
|
+
* whose input and frame loop the harness OWNS. This is the load-bearing seam of
|
|
5
|
+
* `picopilot playtest`, kept pure (cart text + spec in -> cart text + encoded
|
|
6
|
+
* command blocks out) so its correctness (the `btn`/`btnp` shim, the
|
|
7
|
+
* `_update`/`_draw` wrap, btnp-edge reconstruction, the opcode/block codec, the
|
|
8
|
+
* opt-in `srand`) is unit-tested WITHOUT the paid PICO-8 binary. Only the live
|
|
9
|
+
* drive-and-capture needs a real session (the manual/opt-in tier, ADR-0011).
|
|
10
|
+
*
|
|
11
|
+
* The recipe is DECIDED + spike-verified (PICO-8 v0.2.7). This module IMPLEMENTS
|
|
12
|
+
* it; it is not open design. The three load-bearing verified facts:
|
|
13
|
+
* 1. Redefine the global `btn`/`btnp` to read a per-frame held-buttons byte
|
|
14
|
+
* from `serial(0x804)`, RECONSTRUCTING `btnp` EDGES from the held level (a
|
|
15
|
+
* single scripted press = exactly one `btnp` true frame, not held-repeat).
|
|
16
|
+
* 2. The host<->cart transport is FIXED-SIZE command blocks (small unpadded
|
|
17
|
+
* writes to a live `-x` stdin coalesce/drop in the OS pipe buffer), drained
|
|
18
|
+
* ONE block per frame gated on the step-budget, with a `printh` ACK per
|
|
19
|
+
* completed command (the resumable session waits on the ACK; the one-shot
|
|
20
|
+
* ignores it).
|
|
21
|
+
* 3. The harness OWNS the frame loop: it wraps `_update`/`_update60`/`_draw` so
|
|
22
|
+
* a STEP advances exactly N frames and a PAUSE (budget 0) FREEZES the game
|
|
23
|
+
* (its callbacks skipped -> all state held, incl. logic in `_draw`).
|
|
24
|
+
*
|
|
25
|
+
* The one-shot command sends the whole script up front and exits; the cart-side
|
|
26
|
+
* machine here is identical to what the resumable session reuses (ADR-0011).
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import {Cart} from '../cart/index.js';
|
|
30
|
+
import {CartParseError} from '../cart/errors.js';
|
|
31
|
+
import {DONE_SENTINEL} from './adapter.js';
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* A structured reason a drive harness could not be built (a target refusal, not
|
|
35
|
+
* a crash). Surfaced by the command as incur's `error({code,...})` envelope.
|
|
36
|
+
*/
|
|
37
|
+
export type DriveErrorCode =
|
|
38
|
+
/** The cart could not be parsed (not a valid .p8). */
|
|
39
|
+
| 'playtest-cart-parse-error'
|
|
40
|
+
/** The input script named an out-of-range button bit or frame. */
|
|
41
|
+
| 'playtest-input-invalid';
|
|
42
|
+
|
|
43
|
+
/** A structured drive-harness-build failure. */
|
|
44
|
+
export class DriveError extends Error {
|
|
45
|
+
readonly code: DriveErrorCode;
|
|
46
|
+
constructor(code: DriveErrorCode, message: string) {
|
|
47
|
+
super(message);
|
|
48
|
+
this.name = 'DriveError';
|
|
49
|
+
this.code = code;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The PICO-8 button bit order (the `btn(i)`/`btnp(i)` index), used by the
|
|
55
|
+
* held-buttons byte the harness pipes over `serial(0x804)`: bit `i` set = button
|
|
56
|
+
* `i` held this frame. Matches PICO-8's own `btn` indices (verified).
|
|
57
|
+
*/
|
|
58
|
+
export const BUTTON_BITS = {
|
|
59
|
+
left: 0,
|
|
60
|
+
right: 1,
|
|
61
|
+
up: 2,
|
|
62
|
+
down: 3,
|
|
63
|
+
o: 4,
|
|
64
|
+
x: 5,
|
|
65
|
+
} as const;
|
|
66
|
+
|
|
67
|
+
/** The valid button-bit range [0,5] a held-buttons byte can set. */
|
|
68
|
+
export const BUTTON_BIT_MIN = 0;
|
|
69
|
+
export const BUTTON_BIT_MAX = 5;
|
|
70
|
+
|
|
71
|
+
/** The accepted button NAMES (and their bits) the live `input` verb parses. */
|
|
72
|
+
export const BUTTON_NAMES: Readonly<Record<string, number>> = {
|
|
73
|
+
left: BUTTON_BITS.left,
|
|
74
|
+
right: BUTTON_BITS.right,
|
|
75
|
+
up: BUTTON_BITS.up,
|
|
76
|
+
down: BUTTON_BITS.down,
|
|
77
|
+
o: BUTTON_BITS.o,
|
|
78
|
+
x: BUTTON_BITS.x,
|
|
79
|
+
// Common single-letter directional aliases.
|
|
80
|
+
l: BUTTON_BITS.left,
|
|
81
|
+
r: BUTTON_BITS.right,
|
|
82
|
+
u: BUTTON_BITS.up,
|
|
83
|
+
d: BUTTON_BITS.down,
|
|
84
|
+
// PICO-8 KEY convention: z = the O button, x = the X button. Agents type
|
|
85
|
+
// these (they think in keys), so accept them. (`x` already = the X button.)
|
|
86
|
+
z: BUTTON_BITS.o,
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Parses a live-session `input` button spec into a single HELD-buttons byte: a
|
|
91
|
+
* comma/space-separated list of button NAMES (`left right up down o x`, plus the
|
|
92
|
+
* single-letter aliases `l r u d`), e.g. `"right o"` = hold Right + O this turn.
|
|
93
|
+
* Case-insensitive; an empty spec is the byte 0 (release all). An unknown name is
|
|
94
|
+
* a structured {@link DriveError} (`playtest-input-invalid`), never a silent skip.
|
|
95
|
+
* This is the SESSION-facing input surface (per-turn held level); the one-shot's
|
|
96
|
+
* {@link parseInputScript} is the frame-scripted surface. Both land on the same
|
|
97
|
+
* held byte -> {@link btnpEdges} machine.
|
|
98
|
+
*/
|
|
99
|
+
export function parseButtons(spec: string): number {
|
|
100
|
+
let held = 0;
|
|
101
|
+
for (const raw of spec.split(/[\s,]+/)) {
|
|
102
|
+
if (raw.trim().length === 0) continue;
|
|
103
|
+
// Shares the button vocabulary with `--input` (names or bit digits 0..5).
|
|
104
|
+
held |= 1 << parseButtonToken(raw);
|
|
105
|
+
}
|
|
106
|
+
return held;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The FIXED command-block size in bytes (ADR-0011). Load-bearing: small unpadded
|
|
111
|
+
* writes to a live `pico8 -x` stdin coalesce/drop in the OS pipe buffer, so every
|
|
112
|
+
* command is sent as a block of exactly this many bytes (opcode + arg + pad). The
|
|
113
|
+
* cart reads exactly this many bytes per frame and buffers a partial block across
|
|
114
|
+
* frames. 4 bytes comfortably clears the 1-2 byte coalesce window the spike hit.
|
|
115
|
+
*/
|
|
116
|
+
export const BLOCK_SIZE = 4;
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The tagged opcodes carried in a command block's first byte (ADR-0011). One
|
|
120
|
+
* command per block; the cart drains one block per frame against the step budget.
|
|
121
|
+
* - STEP<n>: advance exactly `n` frames (the arg byte), then pause.
|
|
122
|
+
* - INPUT<b>: set the held-buttons byte to `b` (the arg byte) for the next STEP.
|
|
123
|
+
* - SHOT: screenshot the current (frozen) frame.
|
|
124
|
+
* - PAUSE: set the step budget to 0 (freeze; callbacks skipped).
|
|
125
|
+
* - QUIT: print the done-sentinel so the launcher kills PICO-8.
|
|
126
|
+
*/
|
|
127
|
+
export const OPCODE = {
|
|
128
|
+
step: 1,
|
|
129
|
+
input: 2,
|
|
130
|
+
shot: 3,
|
|
131
|
+
pause: 4,
|
|
132
|
+
quit: 5,
|
|
133
|
+
} as const;
|
|
134
|
+
|
|
135
|
+
export type OpcodeName = keyof typeof OPCODE;
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* The cart-side ACK lines the drive shim `printh`s when a command COMPLETES
|
|
139
|
+
* (ADR-0011: the resumable session WAITS for the ACK before sending the next
|
|
140
|
+
* command; the one-shot ignores them and waits only for the QUIT sentinel). One
|
|
141
|
+
* source of truth shared by the Lua shim (which prints them) and the host-side
|
|
142
|
+
* {@link import('./session.js').DriveSession} (which waits on them), so the
|
|
143
|
+
* handshake can never drift between the two ends.
|
|
144
|
+
*
|
|
145
|
+
* The load-bearing one is {@link ACK.stepDone}: a STEP's ACK fires when the frame
|
|
146
|
+
* budget REACHES ZERO (the N frames have actually advanced), NOT when the STEP
|
|
147
|
+
* block is merely read. That is what makes stepping deterministic, the host acts
|
|
148
|
+
* on a SETTLED, known frame, never mid-step. INPUT/SHOT/PAUSE complete the frame
|
|
149
|
+
* they are read (instantaneous), so they ACK at read time.
|
|
150
|
+
*/
|
|
151
|
+
export const ACK = {
|
|
152
|
+
/** A STEP's frame budget has fully drained (the N frames advanced). */
|
|
153
|
+
stepDone: '__PP_ACK_STEP_DONE__',
|
|
154
|
+
/** The held-buttons byte was set (INPUT). */
|
|
155
|
+
input: '__PP_ACK_INPUT__',
|
|
156
|
+
/** A screenshot was queued for the current frame (SHOT). */
|
|
157
|
+
shot: '__PP_ACK_SHOT__',
|
|
158
|
+
/** The frame budget was forced to 0 (PAUSE). */
|
|
159
|
+
pause: '__PP_ACK_PAUSE__',
|
|
160
|
+
} as const;
|
|
161
|
+
|
|
162
|
+
/** One command in a driven-run script (the pre-codec, human-facing form). */
|
|
163
|
+
export type DriveCommand =
|
|
164
|
+
| {readonly op: 'step'; readonly frames: number}
|
|
165
|
+
| {readonly op: 'input'; readonly held: number}
|
|
166
|
+
| {readonly op: 'shot'}
|
|
167
|
+
| {readonly op: 'pause'}
|
|
168
|
+
| {readonly op: 'quit'};
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* A single scripted button press/hold: press button `bit` on every frame in
|
|
172
|
+
* `[from, to]` inclusive (a single-frame press is `from === to`). The parsed
|
|
173
|
+
* form of the `--input "frame:bit,..."` spec, before it is expanded into the
|
|
174
|
+
* per-frame held-buttons bytes and then INPUT/STEP command blocks.
|
|
175
|
+
*/
|
|
176
|
+
export interface ScriptedPress {
|
|
177
|
+
readonly from: number;
|
|
178
|
+
readonly to: number;
|
|
179
|
+
readonly bit: number;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Encodes one {@link DriveCommand} into a FIXED-SIZE ({@link BLOCK_SIZE}) block:
|
|
184
|
+
* `[opcode, arg, 0, 0]`. `arg` carries the STEP frame count or the INPUT held
|
|
185
|
+
* byte; SHOT/PAUSE/QUIT ignore it (0). Fixed size is the point (ADR-0011): the
|
|
186
|
+
* cart always consumes exactly {@link BLOCK_SIZE} bytes per read.
|
|
187
|
+
*/
|
|
188
|
+
export function encodeCommand(cmd: DriveCommand): Uint8Array {
|
|
189
|
+
const block = new Uint8Array(BLOCK_SIZE); // zero-padded by construction
|
|
190
|
+
switch (cmd.op) {
|
|
191
|
+
case 'step':
|
|
192
|
+
block[0] = OPCODE.step;
|
|
193
|
+
block[1] = cmd.frames & 0xff;
|
|
194
|
+
break;
|
|
195
|
+
case 'input':
|
|
196
|
+
block[0] = OPCODE.input;
|
|
197
|
+
block[1] = cmd.held & 0xff;
|
|
198
|
+
break;
|
|
199
|
+
case 'shot':
|
|
200
|
+
block[0] = OPCODE.shot;
|
|
201
|
+
break;
|
|
202
|
+
case 'pause':
|
|
203
|
+
block[0] = OPCODE.pause;
|
|
204
|
+
break;
|
|
205
|
+
case 'quit':
|
|
206
|
+
block[0] = OPCODE.quit;
|
|
207
|
+
break;
|
|
208
|
+
}
|
|
209
|
+
return block;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Encodes a whole command script into a single concatenated block stream. */
|
|
213
|
+
export function encodeScript(script: readonly DriveCommand[]): Uint8Array {
|
|
214
|
+
const out = new Uint8Array(script.length * BLOCK_SIZE);
|
|
215
|
+
script.forEach((cmd, i) => out.set(encodeCommand(cmd), i * BLOCK_SIZE));
|
|
216
|
+
return out;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* A stateful block DECODER mirroring the cart-side shim: feed it raw byte chunks
|
|
221
|
+
* (a live `-x` stdin delivers arbitrary-sized reads), it buffers partial blocks
|
|
222
|
+
* across chunks and yields exactly one {@link DriveCommand} per completed
|
|
223
|
+
* {@link BLOCK_SIZE}-byte block. This is the TS twin of the Lua decoder, so the
|
|
224
|
+
* codec (including "a block split across two reads still decodes once") is
|
|
225
|
+
* asserted in CI without the binary.
|
|
226
|
+
*/
|
|
227
|
+
export class BlockDecoder {
|
|
228
|
+
private buffer: number[] = [];
|
|
229
|
+
|
|
230
|
+
/** Feeds a chunk of bytes; returns the commands newly completed by it. */
|
|
231
|
+
push(chunk: Uint8Array | readonly number[]): DriveCommand[] {
|
|
232
|
+
for (const b of chunk) this.buffer.push(b & 0xff);
|
|
233
|
+
const out: DriveCommand[] = [];
|
|
234
|
+
while (this.buffer.length >= BLOCK_SIZE) {
|
|
235
|
+
const block = this.buffer.splice(0, BLOCK_SIZE);
|
|
236
|
+
out.push(decodeBlock(block));
|
|
237
|
+
}
|
|
238
|
+
return out;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Decodes one {@link BLOCK_SIZE}-byte block into a {@link DriveCommand}. */
|
|
243
|
+
export function decodeBlock(block: readonly number[]): DriveCommand {
|
|
244
|
+
const [op = 0, arg = 0] = block;
|
|
245
|
+
switch (op) {
|
|
246
|
+
case OPCODE.step:
|
|
247
|
+
return {op: 'step', frames: arg};
|
|
248
|
+
case OPCODE.input:
|
|
249
|
+
return {op: 'input', held: arg};
|
|
250
|
+
case OPCODE.shot:
|
|
251
|
+
return {op: 'shot'};
|
|
252
|
+
case OPCODE.pause:
|
|
253
|
+
return {op: 'pause'};
|
|
254
|
+
case OPCODE.quit:
|
|
255
|
+
return {op: 'quit'};
|
|
256
|
+
default:
|
|
257
|
+
// An unknown opcode is a no-op step-0 (never a crash); the codec is total.
|
|
258
|
+
return {op: 'step', frames: 0};
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Reconstructs `btnp` EDGES from a per-frame HELD-buttons sequence (ADR-0011,
|
|
264
|
+
* the load-bearing verified fact). Given the held byte for each frame, returns
|
|
265
|
+
* the `btnp` byte for each frame: bit `i` is set only on the frame the button
|
|
266
|
+
* TRANSITIONS 0 -> 1 (a rising edge), so a single scripted press triggers exactly
|
|
267
|
+
* one `btnp` (menus/start/single-actions behave), a hold triggers one edge then
|
|
268
|
+
* stays held (no repeat), and a release+press triggers two edges. This is a pure
|
|
269
|
+
* mirror of the cart-side shim (`btnp = held & ~prev`), tested independently.
|
|
270
|
+
*/
|
|
271
|
+
export function btnpEdges(held: readonly number[]): number[] {
|
|
272
|
+
let prev = 0;
|
|
273
|
+
return held.map((cur) => {
|
|
274
|
+
const edge = cur & ~prev & 0xff;
|
|
275
|
+
prev = cur;
|
|
276
|
+
return edge;
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Expands scripted presses into a per-frame HELD-buttons byte array of length
|
|
282
|
+
* `frames`: frame `f`'s byte has bit `p.bit` set for every press `p` whose
|
|
283
|
+
* `[from,to]` window covers `f`. This is the level signal the harness pipes; the
|
|
284
|
+
* cart reconstructs `btnp` edges from it (see {@link btnpEdges}).
|
|
285
|
+
*/
|
|
286
|
+
export function pressesToHeld(
|
|
287
|
+
presses: readonly ScriptedPress[],
|
|
288
|
+
frames: number,
|
|
289
|
+
): number[] {
|
|
290
|
+
const held = new Array<number>(frames).fill(0);
|
|
291
|
+
for (const p of presses) {
|
|
292
|
+
for (let f = Math.max(0, p.from); f <= p.to && f < frames; f++) {
|
|
293
|
+
held[f] = (held[f] ?? 0) | (1 << p.bit);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
return held;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Resolves a single BUTTON token to its bit. Accepts a NAME (`left right up down
|
|
301
|
+
* o x`, plus aliases `l r u d`, case-insensitive) OR a bare bit digit `0..5`, so
|
|
302
|
+
* BOTH `playtest` input surfaces share one button vocabulary (the `--input`
|
|
303
|
+
* timeline and the session `input` verb). An unknown token is a structured
|
|
304
|
+
* {@link DriveError}.
|
|
305
|
+
*/
|
|
306
|
+
export function parseButtonToken(token: string): number {
|
|
307
|
+
const t = token.trim().toLowerCase();
|
|
308
|
+
const named = BUTTON_NAMES[t];
|
|
309
|
+
if (named !== undefined) return named;
|
|
310
|
+
if (/^\d+$/.test(t)) {
|
|
311
|
+
const bit = Number(t);
|
|
312
|
+
if (bit >= BUTTON_BIT_MIN && bit <= BUTTON_BIT_MAX) return bit;
|
|
313
|
+
}
|
|
314
|
+
throw new DriveError(
|
|
315
|
+
'playtest-input-invalid',
|
|
316
|
+
`unknown button "${token}": use a name (left/right/up/down/o/x, or l/r/u/d) or a bit 0..5 (0=L 1=R 2=U 3=D 4=O 5=X)`,
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Parses an `--input` spec into scripted presses. Each comma-separated token is
|
|
322
|
+
* either a TIMELINE entry `frame:button` / `from-to:button` (a press at that
|
|
323
|
+
* frame, or a hold across the inclusive window) OR a BARE button (a name/key with
|
|
324
|
+
* no `frame:`), which is a press at frame 0, so a name-only spec like `"z"` or
|
|
325
|
+
* `"o right"` (the natural "just press this" form, matching the session `input`
|
|
326
|
+
* verb) Just Works. `button` is a NAME (`o x left right up down`, aliases
|
|
327
|
+
* `l r u d`, PICO-8 keys `z`/`x`) or a bit digit `0..5`, so both `playtest` input
|
|
328
|
+
* surfaces share ONE vocabulary. E.g. `"3:o, 18-22:o, 20:right"` = press O at
|
|
329
|
+
* frame 3, hold O frames 18..22, press right at frame 20; `"z"` = press O at
|
|
330
|
+
* frame 0. Whitespace around tokens is ignored (a bare list may be space- or
|
|
331
|
+
* comma-separated). An unknown button is a structured {@link DriveError}.
|
|
332
|
+
*/
|
|
333
|
+
export function parseInputScript(spec: string): ScriptedPress[] {
|
|
334
|
+
const presses: ScriptedPress[] = [];
|
|
335
|
+
// A bare button list (no `frame:` colon anywhere) is the "just press these"
|
|
336
|
+
// form: each is a press at frame 0. Allow space- OR comma-separation for it.
|
|
337
|
+
const hasTimeline = spec.includes(':');
|
|
338
|
+
const tokens = hasTimeline ? spec.split(',') : spec.split(/[\s,]+/);
|
|
339
|
+
for (const raw of tokens) {
|
|
340
|
+
const tok = raw.trim();
|
|
341
|
+
if (tok.length === 0) continue;
|
|
342
|
+
const m = /^(\d+)(?:-(\d+))?:(.+)$/.exec(tok);
|
|
343
|
+
if (m === null) {
|
|
344
|
+
if (tok.includes(':')) {
|
|
345
|
+
throw new DriveError(
|
|
346
|
+
'playtest-input-invalid',
|
|
347
|
+
`bad input token "${tok}": expected "frame:button" or "from-to:button" (button = a name like o/right or a bit 0..5)`,
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
// A bare button = a press at frame 0.
|
|
351
|
+
presses.push({from: 0, to: 0, bit: parseButtonToken(tok)});
|
|
352
|
+
continue;
|
|
353
|
+
}
|
|
354
|
+
const from = Number(m[1]);
|
|
355
|
+
const to = m[2] !== undefined ? Number(m[2]) : from;
|
|
356
|
+
const bit = parseButtonToken(m[3] as string);
|
|
357
|
+
if (to < from) {
|
|
358
|
+
throw new DriveError(
|
|
359
|
+
'playtest-input-invalid',
|
|
360
|
+
`hold window end ${to} precedes start ${from} in "${tok}"`,
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
presses.push({from, to, bit});
|
|
364
|
+
}
|
|
365
|
+
return presses;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* The GENERIC input default (ADR-0011, US #9): applied when `--input` is omitted.
|
|
370
|
+
* Tuned to REACH play and stay there briefly for common one-button / runner /
|
|
371
|
+
* flappy shapes: press O once to START, a few well-spaced single-frame O presses
|
|
372
|
+
* (clean `btnp` edges) so a one-button game acts without dying instantly, a short
|
|
373
|
+
* O HOLD for hold-to-thrust games, and a right-nudge for directional runners. It
|
|
374
|
+
* is a best-effort default, NOT a universal driver; an unusual control scheme
|
|
375
|
+
* should pass an explicit per-cart `--input` (documented in the command help).
|
|
376
|
+
*/
|
|
377
|
+
export const GENERIC_INPUT: readonly ScriptedPress[] = [
|
|
378
|
+
{from: 3, to: 3, bit: BUTTON_BITS.o}, // press O to start
|
|
379
|
+
{from: 16, to: 16, bit: BUTTON_BITS.o}, // a gentle action
|
|
380
|
+
{from: 18, to: 22, bit: BUTTON_BITS.o}, // a short hold (thrust games)
|
|
381
|
+
{from: 20, to: 20, bit: BUTTON_BITS.right}, // a right-nudge (runners)
|
|
382
|
+
{from: 24, to: 24, bit: BUTTON_BITS.o},
|
|
383
|
+
{from: 30, to: 30, bit: BUTTON_BITS.o},
|
|
384
|
+
];
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* The default number of frames the generic/one-shot driver advances the cart, and
|
|
388
|
+
* the shot points within it. Chosen (per the spike) to catch LIVE play soon after
|
|
389
|
+
* the start press (before any death/retry churn) then a couple more spread out.
|
|
390
|
+
*/
|
|
391
|
+
export const DEFAULT_DRIVE_FRAMES = 40;
|
|
392
|
+
export const DEFAULT_SHOT_FRAMES: readonly number[] = [12, 22, 34];
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* The driven-run SPEC the transform expands: either an explicit per-frame press
|
|
396
|
+
* script (`presses`) or the {@link GENERIC_INPUT} default, plus the frames to
|
|
397
|
+
* advance, the frames to screenshot at, and the opt-in `srand` seed.
|
|
398
|
+
*/
|
|
399
|
+
export interface DriveSpec {
|
|
400
|
+
/** The scripted presses; defaults to {@link GENERIC_INPUT} when omitted. */
|
|
401
|
+
readonly presses?: readonly ScriptedPress[];
|
|
402
|
+
/** Total frames to advance the driven cart (defaults to {@link DEFAULT_DRIVE_FRAMES}). */
|
|
403
|
+
readonly frames?: number;
|
|
404
|
+
/** The frames to screenshot at (defaults to {@link DEFAULT_SHOT_FRAMES}). */
|
|
405
|
+
readonly shotFrames?: readonly number[];
|
|
406
|
+
/**
|
|
407
|
+
* The opt-in determinism seed (US #4, ADR-0011): iff given, the transform
|
|
408
|
+
* injects `srand(seed)` at cart start so an otherwise-random cart replays
|
|
409
|
+
* identically. NEVER injected silently (it changes game behaviour).
|
|
410
|
+
*/
|
|
411
|
+
readonly seed?: number;
|
|
412
|
+
/** The done-sentinel the cart prints on QUIT (defaults to {@link DONE_SENTINEL}). */
|
|
413
|
+
readonly sentinel?: string;
|
|
414
|
+
/** The base filename the SHOT command names each screenshot (defaults to `play`). */
|
|
415
|
+
readonly shotBasename?: string;
|
|
416
|
+
/**
|
|
417
|
+
* Resolver for `#include <file>` directives in the cart's Lua (picopilot's
|
|
418
|
+
* standard scaffold is `__lua__` = a single `#include main.lua`). The driven
|
|
419
|
+
* cart runs from a THROWAWAY temp dir where the included files do not exist, so
|
|
420
|
+
* the transform must INLINE them to be self-contained. Given an include path
|
|
421
|
+
* (relative to the source cart), return the file's text, or `undefined` if it
|
|
422
|
+
* cannot be read (the directive is then left verbatim). Injected (not `fs`
|
|
423
|
+
* imported) so the transform stays pure + unit-testable. Omitted => includes
|
|
424
|
+
* are left as-is (fine for an already-inlined cart).
|
|
425
|
+
*/
|
|
426
|
+
readonly readInclude?: (includePath: string) => string | undefined;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Inlines `#include <file>` directives in a cart's Lua so the driven cart is
|
|
431
|
+
* self-contained (it runs from a throwaway temp dir where the included files are
|
|
432
|
+
* absent, and `definedCallbacks` must SEE the real `_update`/`_draw` that live in
|
|
433
|
+
* the included file). Matches PICO-8's own `#include`: a line whose first
|
|
434
|
+
* non-space token is `#include`, the rest of the line is the path. Resolves
|
|
435
|
+
* recursively (an included file may itself `#include`), guarding against cycles.
|
|
436
|
+
* A path the resolver cannot read is left verbatim (best-effort, like the rest of
|
|
437
|
+
* the transform). With no resolver, the Lua is returned unchanged.
|
|
438
|
+
*/
|
|
439
|
+
export function resolveIncludes(
|
|
440
|
+
lua: string,
|
|
441
|
+
readInclude: ((includePath: string) => string | undefined) | undefined,
|
|
442
|
+
seen: ReadonlySet<string> = new Set(),
|
|
443
|
+
): string {
|
|
444
|
+
if (readInclude === undefined) return lua;
|
|
445
|
+
return lua
|
|
446
|
+
.split('\n')
|
|
447
|
+
.map((line) => {
|
|
448
|
+
const m = /^\s*#include\s+(.+?)\s*$/.exec(line);
|
|
449
|
+
if (m === null) return line;
|
|
450
|
+
const path = m[1] as string;
|
|
451
|
+
if (seen.has(path)) return line; // cycle guard: leave it
|
|
452
|
+
const content = readInclude(path);
|
|
453
|
+
if (content === undefined) return line; // unreadable: leave verbatim
|
|
454
|
+
return resolveIncludes(content, readInclude, new Set([...seen, path]));
|
|
455
|
+
})
|
|
456
|
+
.join('\n');
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/** The default base filename SHOT names each screenshot (`play0.png`, ...). */
|
|
460
|
+
export const SHOT_BASENAME = 'play' as const;
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* Builds the ONE-SHOT command SCRIPT from a spec: the full sequence of INPUT /
|
|
464
|
+
* STEP / SHOT / QUIT blocks that drives the cart frame-by-frame. Each frame is an
|
|
465
|
+
* INPUT (the held byte for that frame) followed by a single-frame STEP; SHOT
|
|
466
|
+
* blocks are interleaved at the shot frames; a trailing QUIT ends the run. The
|
|
467
|
+
* cart drains one block per frame, so the whole script is sent up front and the
|
|
468
|
+
* cart replays it deterministically (ADR-0011: the one-shot sends everything and
|
|
469
|
+
* exits; the block+ACK transport lets it queue without loss).
|
|
470
|
+
*/
|
|
471
|
+
export function buildDriveScript(spec: DriveSpec): DriveCommand[] {
|
|
472
|
+
const presses = spec.presses ?? GENERIC_INPUT;
|
|
473
|
+
const frames = spec.frames ?? DEFAULT_DRIVE_FRAMES;
|
|
474
|
+
const shotFrames = new Set(spec.shotFrames ?? DEFAULT_SHOT_FRAMES);
|
|
475
|
+
const held = pressesToHeld(presses, frames);
|
|
476
|
+
|
|
477
|
+
const script: DriveCommand[] = [];
|
|
478
|
+
let prevHeld = -1;
|
|
479
|
+
for (let f = 0; f < frames; f++) {
|
|
480
|
+
const h = held[f] ?? 0;
|
|
481
|
+
// Only re-send INPUT when the held level CHANGES (the cart holds the last
|
|
482
|
+
// value); this keeps the block stream compact without losing any edge.
|
|
483
|
+
if (h !== prevHeld) {
|
|
484
|
+
script.push({op: 'input', held: h});
|
|
485
|
+
prevHeld = h;
|
|
486
|
+
}
|
|
487
|
+
script.push({op: 'step', frames: 1});
|
|
488
|
+
if (shotFrames.has(f)) script.push({op: 'shot'});
|
|
489
|
+
}
|
|
490
|
+
script.push({op: 'quit'});
|
|
491
|
+
return script;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/** The result of building a drive harness: the throwaway cart + its command blocks. */
|
|
495
|
+
export interface DriveHarness {
|
|
496
|
+
/** The throwaway `.p8` cart text (the entry's cart is UNTOUCHED). */
|
|
497
|
+
readonly cartText: string;
|
|
498
|
+
/** The command script (pre-codec) the harness drives the cart with. */
|
|
499
|
+
readonly script: readonly DriveCommand[];
|
|
500
|
+
/** The encoded FIXED-SIZE command blocks to pipe to the cart's stdin. */
|
|
501
|
+
readonly blocks: Uint8Array;
|
|
502
|
+
/** The base filename SHOT names each screenshot (for the result envelope). */
|
|
503
|
+
readonly shotBasename: string;
|
|
504
|
+
/** The number of SHOT commands in the script (= expected screenshots). */
|
|
505
|
+
readonly shotCount: number;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* Which frame callbacks the cart defines. `_update60` (60fps) takes precedence
|
|
510
|
+
* over `_update` (30fps) in PICO-8; a cart may define only `_draw` (a draw-only
|
|
511
|
+
* cart). The harness wraps whichever exist and is no-op-safe when one is missing.
|
|
512
|
+
*/
|
|
513
|
+
function definedCallbacks(lua: string): {
|
|
514
|
+
update: boolean;
|
|
515
|
+
update60: boolean;
|
|
516
|
+
draw: boolean;
|
|
517
|
+
} {
|
|
518
|
+
return {
|
|
519
|
+
update: /function\s+_update\s*\(/.test(lua),
|
|
520
|
+
update60: /function\s+_update60\s*\(/.test(lua),
|
|
521
|
+
draw: /function\s+_draw\s*\(/.test(lua),
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* The DRIVE shim, prepended to a throwaway copy of the cart (ADR-0011). It:
|
|
527
|
+
* - redefines the global `btn`/`btnp` to read a per-frame held-buttons byte from
|
|
528
|
+
* a harness-owned variable, reconstructing `btnp` edges (`held & ~prev`);
|
|
529
|
+
* - decodes FIXED-SIZE ({@link BLOCK_SIZE}) command blocks from `serial(0x804)`,
|
|
530
|
+
* buffering partial blocks across frames, one block drained per frame;
|
|
531
|
+
* - owns the frame loop: a STEP advances exactly N frames (the budget); budget 0
|
|
532
|
+
* FREEZES the game (both `_update*` AND `_draw` skipped between shots, so
|
|
533
|
+
* logic-in-draw also freezes -> a stable framebuffer). A SHOT renders the
|
|
534
|
+
* current state once (via `_draw`) THEN screenshots it, so the capture always
|
|
535
|
+
* reflects the frame just drawn (never a stale/pre-draw framebuffer);
|
|
536
|
+
* - `printh`s an ACK when a command COMPLETES (INPUT/SHOT/PAUSE at read time; a
|
|
537
|
+
* STEP when its frame budget drains to 0, so a step ACK means the N frames
|
|
538
|
+
* actually ran, see {@link ACK}), which the resumable session waits on; the
|
|
539
|
+
* one-shot ignores the ACKs and waits only for the done-sentinel on QUIT.
|
|
540
|
+
*
|
|
541
|
+
* The shim wraps whichever of `_update`/`_update60`/`_draw` the cart defines by
|
|
542
|
+
* capturing them AFTER the cart's code runs (in `_init`-time via a deferred
|
|
543
|
+
* hook), so it is no-op-safe when a callback is missing.
|
|
544
|
+
*/
|
|
545
|
+
function driveShim(
|
|
546
|
+
callbacks: {update: boolean; update60: boolean; draw: boolean},
|
|
547
|
+
sentinel: string,
|
|
548
|
+
shotBasename: string,
|
|
549
|
+
): string {
|
|
550
|
+
// PICO-8 sets _update60 XOR _update; capture whichever exists as the ticked
|
|
551
|
+
// callback. _draw is separate. We freeze BOTH when the budget is 0.
|
|
552
|
+
return [
|
|
553
|
+
'-- picopilot playtest drive shim (auto-injected; throwaway; ADR-0011).',
|
|
554
|
+
'-- Redefines btn/btnp to a harness-piped held-buttons byte (serial 0x804),',
|
|
555
|
+
'-- reconstructs btnp edges, and owns the frame loop (step/pause/shot/quit)',
|
|
556
|
+
'-- over fixed-size command blocks. The entry cart is UNTOUCHED (copy).',
|
|
557
|
+
'__drv_held=0 __drv_prev=0',
|
|
558
|
+
'__drv_budget=0 __drv_buf="" __drv_shot=0 __drv_pshot=nil',
|
|
559
|
+
`__drv_bs=${BLOCK_SIZE}`,
|
|
560
|
+
`function btn(i,p) if i==nil then return __drv_held end return (__drv_held & (1<<i))!=0 end`,
|
|
561
|
+
'function btnp(i,p) return (__drv_held & (1<<i))!=0 and (__drv_prev & (1<<i))==0 end',
|
|
562
|
+
'-- Read exactly one fixed-size block per frame from stdin; buffer partials.',
|
|
563
|
+
'function __drv_read_block()',
|
|
564
|
+
' local n=serial(0x804,0x4300,__drv_bs)',
|
|
565
|
+
' for j=0,n-1 do __drv_buf=__drv_buf..chr(peek(0x4300+j)) end',
|
|
566
|
+
' if #__drv_buf<__drv_bs then return nil end',
|
|
567
|
+
' local op=ord(__drv_buf,1) local arg=ord(__drv_buf,2)',
|
|
568
|
+
' __drv_buf=sub(__drv_buf,__drv_bs+1)',
|
|
569
|
+
' return op,arg',
|
|
570
|
+
'end',
|
|
571
|
+
'-- Drain one command block per frame, gated on the step budget.',
|
|
572
|
+
'function __drv_poll()',
|
|
573
|
+
' if __drv_budget>0 then return end',
|
|
574
|
+
' local op,arg=__drv_read_block()',
|
|
575
|
+
' if op==nil then return end',
|
|
576
|
+
` if op==${OPCODE.input} then __drv_held=arg printh("${ACK.input}")`,
|
|
577
|
+
// STEP does NOT ACK at read time; its ACK (`__drv_step_done`) fires when the
|
|
578
|
+
// budget drains to 0 (below), so the host learns the N frames ACTUALLY ran.
|
|
579
|
+
` elseif op==${OPCODE.step} then __drv_budget=arg`,
|
|
580
|
+
` elseif op==${OPCODE.shot} then __drv_pshot="${shotBasename}"..__drv_shot __drv_shot+=1 printh("${ACK.shot}")`,
|
|
581
|
+
` elseif op==${OPCODE.pause} then __drv_budget=0 printh("${ACK.pause}")`,
|
|
582
|
+
` elseif op==${OPCODE.quit} then printh("${sentinel}")`,
|
|
583
|
+
' end',
|
|
584
|
+
'end',
|
|
585
|
+
'-- Frame ownership: advance the ticked callback only while budget>0, and',
|
|
586
|
+
'-- freeze _draw too so logic-in-draw holds. __drv_tick is set below.',
|
|
587
|
+
'-- btnp edges are computed relative to the PREVIOUS TICK (not the previous',
|
|
588
|
+
'-- host frame): INPUT and STEP arrive on separate frames, so __drv_prev is',
|
|
589
|
+
'-- updated to the held level only AFTER a tick runs. A single scripted press',
|
|
590
|
+
'-- (INPUT held then INPUT 0, each followed by a STEP) is thus one btnp edge.',
|
|
591
|
+
'function __drv_step()',
|
|
592
|
+
' __drv_poll()',
|
|
593
|
+
' if __drv_budget>0 then',
|
|
594
|
+
' __drv_budget-=1',
|
|
595
|
+
' if __drv_tick then __drv_tick() end',
|
|
596
|
+
' __drv_prev=__drv_held',
|
|
597
|
+
// A STEP completes (its ACK) the frame its budget reaches 0: the requested N
|
|
598
|
+
// frames have now advanced, so the host acts on a settled frame (ADR-0011).
|
|
599
|
+
` if __drv_budget==0 then printh("${ACK.stepDone}") end`,
|
|
600
|
+
' return true',
|
|
601
|
+
' end',
|
|
602
|
+
' return false',
|
|
603
|
+
'end',
|
|
604
|
+
'',
|
|
605
|
+
].join('\n');
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/**
|
|
609
|
+
* The wrapper tab, appended AFTER the cart's code so the cart's callbacks are
|
|
610
|
+
* already defined when we capture them. It saves the cart's own
|
|
611
|
+
* `_update60`/`_update`/`_draw`, then installs harness-owned ones: the ticked
|
|
612
|
+
* callback runs the drain/step (advancing the cart's real update only while the
|
|
613
|
+
* budget allows), and `_draw` runs the cart's draw when a step advanced this
|
|
614
|
+
* frame OR a SHOT is pending (so a paused game shows a STABLE frame yet a SHOT
|
|
615
|
+
* always captures a freshly-drawn frame). A pending SHOT screenshots AFTER the
|
|
616
|
+
* cart's draw, so the capture reflects the frame just rendered, never a stale
|
|
617
|
+
* pre-draw framebuffer. No-op-safe when a callback is missing (draw-only carts).
|
|
618
|
+
*/
|
|
619
|
+
function wrapperTab(callbacks: {
|
|
620
|
+
update: boolean;
|
|
621
|
+
update60: boolean;
|
|
622
|
+
draw: boolean;
|
|
623
|
+
}): string {
|
|
624
|
+
const ticked = callbacks.update60
|
|
625
|
+
? '_update60'
|
|
626
|
+
: callbacks.update
|
|
627
|
+
? '_update'
|
|
628
|
+
: undefined;
|
|
629
|
+
const lines: string[] = [
|
|
630
|
+
'',
|
|
631
|
+
'-->8',
|
|
632
|
+
'-- picopilot playtest wrapper (auto-injected; throwaway; ADR-0011).',
|
|
633
|
+
'-- Captures the cart callbacks AFTER its code ran, then OWNS the frame loop.',
|
|
634
|
+
];
|
|
635
|
+
// __drv_tick is the cart's real ticked callback (nil for a draw-only cart);
|
|
636
|
+
// __drv_step advances it under the budget.
|
|
637
|
+
if (ticked !== undefined) {
|
|
638
|
+
lines.push(`__drv_tick=${ticked}`);
|
|
639
|
+
}
|
|
640
|
+
lines.push('__drv_draw=_draw', '__drv_advanced=false');
|
|
641
|
+
// The harness-owned ticked callback: always defined so the drain runs even for
|
|
642
|
+
// a draw-only cart. It advances the cart's real callback via __drv_step.
|
|
643
|
+
lines.push(
|
|
644
|
+
'function _update() __drv_advanced=__drv_step() end',
|
|
645
|
+
// A cart that defined _update60 gets a 60fps loop; alias it.
|
|
646
|
+
callbacks.update60
|
|
647
|
+
? 'function _update60() __drv_advanced=__drv_step() end'
|
|
648
|
+
: '',
|
|
649
|
+
// _draw renders the cart's draw when a step advanced (live) OR a SHOT is
|
|
650
|
+
// pending (render the frozen state for the capture). Between shots while
|
|
651
|
+
// paused it is skipped, so logic-in-draw freezes and the last frame persists.
|
|
652
|
+
// A pending SHOT screenshots AFTER the draw, so the PNG is the frame just
|
|
653
|
+
// drawn, never a stale pre-draw framebuffer.
|
|
654
|
+
'function _draw()',
|
|
655
|
+
' if __drv_draw and (__drv_advanced or __drv_pshot!=nil) then __drv_draw() end',
|
|
656
|
+
' if __drv_pshot!=nil then',
|
|
657
|
+
' extcmd("set_filename",__drv_pshot) extcmd("screen") __drv_pshot=nil',
|
|
658
|
+
' end',
|
|
659
|
+
'end',
|
|
660
|
+
'',
|
|
661
|
+
);
|
|
662
|
+
return lines.filter((l) => l !== '').join('\n') + '\n';
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
/**
|
|
666
|
+
* Builds the throwaway DRIVE harness (ADR-0011). Takes the user's cart text + a
|
|
667
|
+
* {@link DriveSpec} and produces a new cart with the SAME `__gfx__`/`__sfx__`/...
|
|
668
|
+
* and a `__lua__` that is: the drive shim, then the cart's own Lua (optionally
|
|
669
|
+
* prefixed with `srand(seed)` iff `--seed` given), then the wrapper tab. The
|
|
670
|
+
* entry's own cart bytes are NEVER mutated (we transform a parsed COPY).
|
|
671
|
+
*
|
|
672
|
+
* Returns the driven cart text plus the encoded command blocks the command pipes
|
|
673
|
+
* to stdin. Kept pure so every path is unit-tested without the paid binary.
|
|
674
|
+
*
|
|
675
|
+
* @throws {DriveError} an unparseable cart.
|
|
676
|
+
*/
|
|
677
|
+
export function buildDriveHarness(
|
|
678
|
+
cartText: string,
|
|
679
|
+
spec: DriveSpec = {},
|
|
680
|
+
): DriveHarness {
|
|
681
|
+
let cart: Cart;
|
|
682
|
+
try {
|
|
683
|
+
cart = Cart.parse(cartText);
|
|
684
|
+
} catch (e) {
|
|
685
|
+
if (e instanceof CartParseError) {
|
|
686
|
+
throw new DriveError(
|
|
687
|
+
'playtest-cart-parse-error',
|
|
688
|
+
`cart does not parse: ${e.message}`,
|
|
689
|
+
);
|
|
690
|
+
}
|
|
691
|
+
throw e;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
const sentinel = spec.sentinel ?? DONE_SENTINEL;
|
|
695
|
+
const shotBasename = spec.shotBasename ?? SHOT_BASENAME;
|
|
696
|
+
// Inline #include directives so the driven cart is self-contained in its temp
|
|
697
|
+
// dir AND so definedCallbacks sees the real _update/_draw (which live in the
|
|
698
|
+
// included file for picopilot's standard `#include main.lua` scaffold).
|
|
699
|
+
const cartLua = resolveIncludes(
|
|
700
|
+
cart.getSection('lua') ?? '',
|
|
701
|
+
spec.readInclude,
|
|
702
|
+
);
|
|
703
|
+
const callbacks = definedCallbacks(cartLua);
|
|
704
|
+
|
|
705
|
+
// The opt-in srand: injected at the TOP of the cart's own code iff a seed is
|
|
706
|
+
// given, never silently (it changes game behaviour). It runs before the cart's
|
|
707
|
+
// _init/first frame, so rnd() is deterministic from the start.
|
|
708
|
+
const seedLine = spec.seed !== undefined ? `srand(${spec.seed})\n` : '';
|
|
709
|
+
|
|
710
|
+
const lua = [
|
|
711
|
+
driveShim(callbacks, sentinel, shotBasename),
|
|
712
|
+
seedLine + cartLua,
|
|
713
|
+
wrapperTab(callbacks),
|
|
714
|
+
].join('\n');
|
|
715
|
+
|
|
716
|
+
cart.setSection('lua', lua);
|
|
717
|
+
|
|
718
|
+
const script = buildDriveScript(spec);
|
|
719
|
+
const blocks = encodeScript(script);
|
|
720
|
+
const shotCount = script.filter((c) => c.op === 'shot').length;
|
|
721
|
+
|
|
722
|
+
return {
|
|
723
|
+
cartText: cart.serialize(),
|
|
724
|
+
script,
|
|
725
|
+
blocks,
|
|
726
|
+
shotBasename,
|
|
727
|
+
shotCount,
|
|
728
|
+
};
|
|
729
|
+
}
|