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,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `__label__` builder: turn a 128x128 image into the hex a `.p8` cart's
|
|
3
|
+
* label section holds, so `picopilot export --label <png>` can give a labelless
|
|
4
|
+
* cart a loading splash WITHOUT an interactive PICO-8 (F7) capture.
|
|
5
|
+
*
|
|
6
|
+
* A PICO-8 label is a 128x128 grid of palette indices (0..15), serialised as 128
|
|
7
|
+
* rows of 128 hex nibbles. This module decodes a PNG to RGB (via Node's stdlib
|
|
8
|
+
* `zlib` inflate, no npm dependency), maps each pixel to the NEAREST PICO-8
|
|
9
|
+
* palette colour, and emits that hex. It is the inverse direction of the
|
|
10
|
+
* palette-accurate PNG RENDER in `render.ts`/`png.ts` (which goes indices -> RGB).
|
|
11
|
+
*
|
|
12
|
+
* It intentionally supports only the PNG subset a caller realistically produces
|
|
13
|
+
* for a 128x128 label: 8-bit, non-interlaced, colour types 2 (RGB) and 6 (RGBA),
|
|
14
|
+
* and (via the palette map) 0/3 are rejected with a clear error rather than
|
|
15
|
+
* guessed at. Anything else fails loud with a structured {@link LabelError}.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import {inflateSync} from 'node:zlib';
|
|
19
|
+
import {PICO8_PALETTE} from './palette.js';
|
|
20
|
+
|
|
21
|
+
/** The fixed PICO-8 label dimensions: a 128x128 image. */
|
|
22
|
+
export const LABEL_SIZE = 128;
|
|
23
|
+
|
|
24
|
+
/** A structured label-build failure (bad PNG, wrong size, unsupported format). */
|
|
25
|
+
export class LabelError extends Error {
|
|
26
|
+
constructor(
|
|
27
|
+
readonly code:
|
|
28
|
+
'not-a-png' | 'unsupported-png' | 'wrong-size' | 'truncated-png',
|
|
29
|
+
message: string,
|
|
30
|
+
) {
|
|
31
|
+
super(message);
|
|
32
|
+
this.name = 'LabelError';
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
|
|
37
|
+
|
|
38
|
+
interface DecodedPng {
|
|
39
|
+
readonly width: number;
|
|
40
|
+
readonly height: number;
|
|
41
|
+
/** Row-major RGBA, 4 bytes per pixel. */
|
|
42
|
+
readonly rgba: Uint8Array;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Decodes an 8-bit, non-interlaced PNG (colour type 2 RGB or 6 RGBA) to RGBA.
|
|
47
|
+
* Uses `node:zlib` inflate for the IDAT stream and applies the five PNG scanline
|
|
48
|
+
* filters. Throws {@link LabelError} for anything outside this subset.
|
|
49
|
+
*/
|
|
50
|
+
function decodePng(bytes: Uint8Array): DecodedPng {
|
|
51
|
+
for (let i = 0; i < PNG_SIGNATURE.length; i++) {
|
|
52
|
+
if (bytes[i] !== PNG_SIGNATURE[i]) {
|
|
53
|
+
throw new LabelError('not-a-png', 'not a PNG file (bad signature)');
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
let off = 8;
|
|
58
|
+
let width = 0;
|
|
59
|
+
let height = 0;
|
|
60
|
+
let bitDepth = 0;
|
|
61
|
+
let colourType = 0;
|
|
62
|
+
let interlace = 0;
|
|
63
|
+
const idat: Uint8Array[] = [];
|
|
64
|
+
|
|
65
|
+
const readU32 = (p: number): number =>
|
|
66
|
+
((bytes[p]! << 24) |
|
|
67
|
+
(bytes[p + 1]! << 16) |
|
|
68
|
+
(bytes[p + 2]! << 8) |
|
|
69
|
+
bytes[p + 3]!) >>>
|
|
70
|
+
0;
|
|
71
|
+
|
|
72
|
+
while (off + 8 <= bytes.length) {
|
|
73
|
+
const len = readU32(off);
|
|
74
|
+
const type = String.fromCharCode(
|
|
75
|
+
bytes[off + 4]!,
|
|
76
|
+
bytes[off + 5]!,
|
|
77
|
+
bytes[off + 6]!,
|
|
78
|
+
bytes[off + 7]!,
|
|
79
|
+
);
|
|
80
|
+
const dataStart = off + 8;
|
|
81
|
+
const dataEnd = dataStart + len;
|
|
82
|
+
if (dataEnd + 4 > bytes.length) {
|
|
83
|
+
throw new LabelError('truncated-png', 'PNG chunk runs past end of file');
|
|
84
|
+
}
|
|
85
|
+
if (type === 'IHDR') {
|
|
86
|
+
width = readU32(dataStart);
|
|
87
|
+
height = readU32(dataStart + 4);
|
|
88
|
+
bitDepth = bytes[dataStart + 8]!;
|
|
89
|
+
colourType = bytes[dataStart + 9]!;
|
|
90
|
+
interlace = bytes[dataStart + 12]!;
|
|
91
|
+
} else if (type === 'IDAT') {
|
|
92
|
+
idat.push(bytes.subarray(dataStart, dataEnd));
|
|
93
|
+
} else if (type === 'IEND') {
|
|
94
|
+
break;
|
|
95
|
+
}
|
|
96
|
+
off = dataEnd + 4; // skip the 4-byte CRC
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (
|
|
100
|
+
bitDepth !== 8 ||
|
|
101
|
+
interlace !== 0 ||
|
|
102
|
+
(colourType !== 2 && colourType !== 6)
|
|
103
|
+
) {
|
|
104
|
+
throw new LabelError(
|
|
105
|
+
'unsupported-png',
|
|
106
|
+
`unsupported PNG: need 8-bit non-interlaced RGB or RGBA (got bitDepth=${bitDepth}, colourType=${colourType}, interlace=${interlace})`,
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const channels = colourType === 6 ? 4 : 3;
|
|
111
|
+
const compressed = Buffer.concat(idat.map((c) => Buffer.from(c)));
|
|
112
|
+
let raw: Buffer;
|
|
113
|
+
try {
|
|
114
|
+
raw = inflateSync(compressed);
|
|
115
|
+
} catch (e) {
|
|
116
|
+
throw new LabelError(
|
|
117
|
+
'truncated-png',
|
|
118
|
+
`could not inflate PNG image data: ${(e as Error).message}`,
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const stride = width * channels;
|
|
123
|
+
const rgba = new Uint8Array(width * height * 4);
|
|
124
|
+
const prev = new Uint8Array(stride);
|
|
125
|
+
let src = 0;
|
|
126
|
+
for (let y = 0; y < height; y++) {
|
|
127
|
+
const filter = raw[src++]!;
|
|
128
|
+
const line = new Uint8Array(stride);
|
|
129
|
+
for (let x = 0; x < stride; x++) {
|
|
130
|
+
const rawByte = raw[src++]!;
|
|
131
|
+
const a = x >= channels ? line[x - channels]! : 0; // left
|
|
132
|
+
const b = prev[x]!; // up
|
|
133
|
+
const c = x >= channels ? prev[x - channels]! : 0; // up-left
|
|
134
|
+
let val = rawByte;
|
|
135
|
+
switch (filter) {
|
|
136
|
+
case 0:
|
|
137
|
+
break; // None
|
|
138
|
+
case 1:
|
|
139
|
+
val = (rawByte + a) & 0xff;
|
|
140
|
+
break; // Sub
|
|
141
|
+
case 2:
|
|
142
|
+
val = (rawByte + b) & 0xff;
|
|
143
|
+
break; // Up
|
|
144
|
+
case 3:
|
|
145
|
+
val = (rawByte + ((a + b) >> 1)) & 0xff;
|
|
146
|
+
break; // Average
|
|
147
|
+
case 4:
|
|
148
|
+
val = (rawByte + paeth(a, b, c)) & 0xff;
|
|
149
|
+
break; // Paeth
|
|
150
|
+
default:
|
|
151
|
+
throw new LabelError(
|
|
152
|
+
'unsupported-png',
|
|
153
|
+
`unsupported PNG scanline filter ${filter}`,
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
line[x] = val;
|
|
157
|
+
}
|
|
158
|
+
// Expand this decoded scanline into RGBA.
|
|
159
|
+
for (let x = 0; x < width; x++) {
|
|
160
|
+
const s = x * channels;
|
|
161
|
+
const d = (y * width + x) * 4;
|
|
162
|
+
rgba[d] = line[s]!;
|
|
163
|
+
rgba[d + 1] = line[s + 1]!;
|
|
164
|
+
rgba[d + 2] = line[s + 2]!;
|
|
165
|
+
rgba[d + 3] = channels === 4 ? line[s + 3]! : 255;
|
|
166
|
+
}
|
|
167
|
+
prev.set(line);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return {width, height, rgba};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** The PNG Paeth predictor. */
|
|
174
|
+
function paeth(a: number, b: number, c: number): number {
|
|
175
|
+
const p = a + b - c;
|
|
176
|
+
const pa = Math.abs(p - a);
|
|
177
|
+
const pb = Math.abs(p - b);
|
|
178
|
+
const pc = Math.abs(p - c);
|
|
179
|
+
if (pa <= pb && pa <= pc) return a;
|
|
180
|
+
if (pb <= pc) return b;
|
|
181
|
+
return c;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** The nearest PICO-8 palette index (by squared RGB distance) for an RGB triple. */
|
|
185
|
+
export function nearestPaletteIndex(r: number, g: number, b: number): number {
|
|
186
|
+
let best = 0;
|
|
187
|
+
let bestDist = Infinity;
|
|
188
|
+
for (let i = 0; i < PICO8_PALETTE.length; i++) {
|
|
189
|
+
const c = PICO8_PALETTE[i]!;
|
|
190
|
+
const dr = r - c.r;
|
|
191
|
+
const dg = g - c.g;
|
|
192
|
+
const db = b - c.b;
|
|
193
|
+
const dist = dr * dr + dg * dg + db * db;
|
|
194
|
+
if (dist < bestDist) {
|
|
195
|
+
bestDist = dist;
|
|
196
|
+
best = i;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return best;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const HEX = '0123456789abcdef';
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Builds the `__label__` hex body (128 rows of 128 nibbles, newline-joined, with
|
|
206
|
+
* a trailing newline) from a 128x128 PNG. Each pixel is mapped to its nearest
|
|
207
|
+
* PICO-8 palette colour. The PNG MUST be exactly 128x128 ({@link LABEL_SIZE});
|
|
208
|
+
* anything else is a {@link LabelError} (`wrong-size`) so a mis-sized image fails
|
|
209
|
+
* loud instead of being silently cropped or stretched.
|
|
210
|
+
*/
|
|
211
|
+
export function labelHexFromPng(pngBytes: Uint8Array): string {
|
|
212
|
+
const {width, height, rgba} = decodePng(pngBytes);
|
|
213
|
+
if (width !== LABEL_SIZE || height !== LABEL_SIZE) {
|
|
214
|
+
throw new LabelError(
|
|
215
|
+
'wrong-size',
|
|
216
|
+
`label image must be ${LABEL_SIZE}x${LABEL_SIZE}, got ${width}x${height}`,
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
const rows: string[] = [];
|
|
220
|
+
for (let y = 0; y < LABEL_SIZE; y++) {
|
|
221
|
+
let row = '';
|
|
222
|
+
for (let x = 0; x < LABEL_SIZE; x++) {
|
|
223
|
+
const d = (y * LABEL_SIZE + x) * 4;
|
|
224
|
+
row += HEX[nearestPaletteIndex(rgba[d]!, rgba[d + 1]!, rgba[d + 2]!)];
|
|
225
|
+
}
|
|
226
|
+
rows.push(row);
|
|
227
|
+
}
|
|
228
|
+
return `${rows.join('\n')}\n`;
|
|
229
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import {describe, expect, it} from 'vitest';
|
|
2
|
+
|
|
3
|
+
import {GfxSheet} from '../cart/index.js';
|
|
4
|
+
import {decideOverlap, spriteHasData} from './overlap.js';
|
|
5
|
+
|
|
6
|
+
/** A GfxSheet whose sprite `n` is painted non-zero (data at risk in the shared bank). */
|
|
7
|
+
function sheetWithSpriteData(n: number): GfxSheet {
|
|
8
|
+
const sheet = GfxSheet.fromBody(undefined);
|
|
9
|
+
// A single non-zero pixel is enough to mean "the shared region holds data".
|
|
10
|
+
sheet.setPixel(...spritePixel(n), 8);
|
|
11
|
+
return sheet;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** A pixel coordinate inside sprite `n` (its top-left corner). */
|
|
15
|
+
function spritePixel(n: number): [number, number] {
|
|
16
|
+
const x0 = (n % 16) * 8;
|
|
17
|
+
const y0 = Math.floor(n / 16) * 8;
|
|
18
|
+
return [x0, y0];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe('gfx overlap: spriteHasData reads the target sprite own __gfx__ pixels', () => {
|
|
22
|
+
it('is false for an all-zero sprite', () => {
|
|
23
|
+
const sheet = GfxSheet.fromBody(undefined);
|
|
24
|
+
expect(spriteHasData(sheet, 200)).toBe(false);
|
|
25
|
+
});
|
|
26
|
+
it('is true once any pixel is non-zero', () => {
|
|
27
|
+
const sheet = sheetWithSpriteData(200);
|
|
28
|
+
expect(spriteHasData(sheet, 200)).toBe(true);
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe('gfx overlap: decideOverlap branches (ADR-0004 smart-refuse)', () => {
|
|
33
|
+
it('a base-bank sprite (0..127) is always allowed (no overlap possible)', () => {
|
|
34
|
+
const sheet = sheetWithSpriteData(10);
|
|
35
|
+
// Even with data present, a base-bank sprite aliases nothing.
|
|
36
|
+
expect(decideOverlap(sheet, 10, false)).toEqual({kind: 'allowed'});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('a shared-bank sprite with all-zero pixels is allowed-shared (empty)', () => {
|
|
40
|
+
const sheet = GfxSheet.fromBody(undefined);
|
|
41
|
+
expect(decideOverlap(sheet, 130, false)).toEqual({
|
|
42
|
+
kind: 'allowed-shared',
|
|
43
|
+
reason: 'empty',
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('a shared-bank sprite with data + authorised is allowed-shared (authorised)', () => {
|
|
48
|
+
const sheet = sheetWithSpriteData(130);
|
|
49
|
+
expect(decideOverlap(sheet, 130, true)).toEqual({
|
|
50
|
+
kind: 'allowed-shared',
|
|
51
|
+
reason: 'authorised',
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('a shared-bank sprite with data + NOT authorised is refused (the data-loss corner)', () => {
|
|
56
|
+
const sheet = sheetWithSpriteData(130);
|
|
57
|
+
const decision = decideOverlap(sheet, 130, false);
|
|
58
|
+
expect(decision.kind).toBe('refused');
|
|
59
|
+
if (decision.kind === 'refused') expect(decision.nonZeroPixels).toBe(1);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('sprite 128 (first shared) and 255 (last) both alias the shared bank', () => {
|
|
63
|
+
for (const n of [128, 255]) {
|
|
64
|
+
const sheet = sheetWithSpriteData(n);
|
|
65
|
+
expect(decideOverlap(sheet, n, false).kind).toBe('refused');
|
|
66
|
+
}
|
|
67
|
+
// 127 (last base) does NOT.
|
|
68
|
+
const base = sheetWithSpriteData(127);
|
|
69
|
+
expect(decideOverlap(base, 127, false).kind).toBe('allowed');
|
|
70
|
+
});
|
|
71
|
+
});
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import {type GfxSheet, spriteAliasesMap} from '../cart/index.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The gfx/map overlap smart-refuse decision (ADR-0004), computed purely from a
|
|
5
|
+
* cart's CURRENT `__gfx__` pixels plus the authorisation flag.
|
|
6
|
+
*
|
|
7
|
+
* Why `__gfx__`, not `__map__` (the corrected seam): sprites 128..255 alias the
|
|
8
|
+
* shared map region, but those shared bytes live in the `__gfx__` `0x1000` upper
|
|
9
|
+
* bank, NOT in the text-format `.p8` `__map__` section (which stores only map
|
|
10
|
+
* rows 0..31). So "the data at risk" when overwriting a shared-bank sprite IS
|
|
11
|
+
* the sprite's OWN current `__gfx__` pixels. This module inspects those pixels
|
|
12
|
+
* directly (via {@link GfxSheet}); it deliberately does NOT consult `__map__`.
|
|
13
|
+
*
|
|
14
|
+
* The single invariant: picopilot never SILENTLY overwrites existing
|
|
15
|
+
* shared-region data that nothing authorised.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** Whether ANY pixel of sprite `n` is non-zero, i.e. the sprite already holds data. */
|
|
19
|
+
export function spriteHasData(sheet: GfxSheet, n: number): boolean {
|
|
20
|
+
const grid = sheet.getSprite(n);
|
|
21
|
+
for (const row of grid) {
|
|
22
|
+
for (const px of row) if (px !== 0) return true;
|
|
23
|
+
}
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The verdict of {@link decideOverlap}. */
|
|
28
|
+
export type OverlapDecision =
|
|
29
|
+
| {kind: 'allowed'}
|
|
30
|
+
| {kind: 'allowed-shared'; reason: 'authorised' | 'empty'}
|
|
31
|
+
| {kind: 'refused'; nonZeroPixels: number};
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Decides whether a `gfx set` write to sprite `n` may proceed, given the cart's
|
|
35
|
+
* current `__gfx__` and whether overlap is authorised.
|
|
36
|
+
*
|
|
37
|
+
* - sprite 0..127 (base bank, aliases nothing) → `allowed` (no overlap possible).
|
|
38
|
+
* - sprite 128..255 whose current pixels are ALL-ZERO → `allowed-shared` (empty):
|
|
39
|
+
* the write proceeds; the caller notes the aliasing in its result.
|
|
40
|
+
* - sprite 128..255 with non-zero pixels AND `authorised` → `allowed-shared`
|
|
41
|
+
* (authorised): the write proceeds and overwrites the shared data on purpose.
|
|
42
|
+
* - sprite 128..255 with non-zero pixels AND NOT authorised → `refused`: the
|
|
43
|
+
* genuine data-loss corner. The caller REFUSES (structured + nonzero exit +
|
|
44
|
+
* bytes untouched).
|
|
45
|
+
*/
|
|
46
|
+
export function decideOverlap(
|
|
47
|
+
sheet: GfxSheet,
|
|
48
|
+
n: number,
|
|
49
|
+
authorised: boolean,
|
|
50
|
+
): OverlapDecision {
|
|
51
|
+
if (!spriteAliasesMap(n)) return {kind: 'allowed'};
|
|
52
|
+
|
|
53
|
+
if (!spriteHasData(sheet, n))
|
|
54
|
+
return {kind: 'allowed-shared', reason: 'empty'};
|
|
55
|
+
if (authorised) return {kind: 'allowed-shared', reason: 'authorised'};
|
|
56
|
+
|
|
57
|
+
return {kind: 'refused', nonZeroPixels: countNonZeroPixels(sheet, n)};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function countNonZeroPixels(sheet: GfxSheet, n: number): number {
|
|
61
|
+
let count = 0;
|
|
62
|
+
for (const row of sheet.getSprite(n)) {
|
|
63
|
+
for (const px of row) if (px !== 0) count++;
|
|
64
|
+
}
|
|
65
|
+
return count;
|
|
66
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import {describe, expect, it} from 'vitest';
|
|
2
|
+
|
|
3
|
+
import {PICO8_PALETTE, paletteColor} from './palette.js';
|
|
4
|
+
|
|
5
|
+
describe('PICO-8 palette (single source of truth)', () => {
|
|
6
|
+
it('has exactly 16 entries', () => {
|
|
7
|
+
expect(PICO8_PALETTE).toHaveLength(16);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it('matches the fixed PICO-8 constants from the findings table', () => {
|
|
11
|
+
// A representative spot-check against work/notes/findings/pico8-api-reference.md:
|
|
12
|
+
// do NOT re-invent these; they are the load-bearing render source of truth.
|
|
13
|
+
expect(paletteColor(0)).toEqual({r: 0, g: 0, b: 0}); // black
|
|
14
|
+
expect(paletteColor(7)).toEqual({r: 255, g: 241, b: 232}); // white
|
|
15
|
+
expect(paletteColor(8)).toEqual({r: 255, g: 0, b: 77}); // red
|
|
16
|
+
expect(paletteColor(11)).toEqual({r: 0, g: 228, b: 54}); // green
|
|
17
|
+
expect(paletteColor(12)).toEqual({r: 41, g: 173, b: 255}); // blue
|
|
18
|
+
expect(paletteColor(15)).toEqual({r: 255, g: 204, b: 170}); // peach
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('every entry is a valid 0..255 RGB triple', () => {
|
|
22
|
+
for (const c of PICO8_PALETTE) {
|
|
23
|
+
for (const v of [c.r, c.g, c.b]) {
|
|
24
|
+
expect(Number.isInteger(v)).toBe(true);
|
|
25
|
+
expect(v).toBeGreaterThanOrEqual(0);
|
|
26
|
+
expect(v).toBeLessThanOrEqual(255);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('throws for an out-of-range index', () => {
|
|
32
|
+
expect(() => paletteColor(-1)).toThrow();
|
|
33
|
+
expect(() => paletteColor(16)).toThrow();
|
|
34
|
+
});
|
|
35
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PICO-8's FIXED 16-colour palette (index → RGB), the single source of truth for
|
|
3
|
+
* the `gfx render` PNG encoder.
|
|
4
|
+
*
|
|
5
|
+
* These are the stable, widely-reproduced PICO-8 0..15 palette constants,
|
|
6
|
+
* transcribed verbatim from `work/notes/findings/pico8-api-reference.md` (the
|
|
7
|
+
* same table the scaffolded `AGENTS.md` reference cites). Do NOT re-invent these
|
|
8
|
+
* values: if a future PICO-8 version alters them, revise the finding, the
|
|
9
|
+
* scaffold reference, and THIS table together.
|
|
10
|
+
*
|
|
11
|
+
* Index 0 doubles as PICO-8's default transparent colour; `gfx render` renders it
|
|
12
|
+
* as its RGB (black) so the PNG shows the actual pixels a `spr()` with no
|
|
13
|
+
* transparency would draw. (Transparency is a draw-time concern, not a property
|
|
14
|
+
* of the spritesheet bytes, so the JUDGE surface renders every index opaquely.)
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** One palette entry: the colour index and its RGB triple. */
|
|
18
|
+
export interface PaletteColor {
|
|
19
|
+
readonly r: number;
|
|
20
|
+
readonly g: number;
|
|
21
|
+
readonly b: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The 16 PICO-8 palette RGB triples, indexed 0..15. Frozen so no caller can
|
|
26
|
+
* mutate the shared source of truth.
|
|
27
|
+
*/
|
|
28
|
+
export const PICO8_PALETTE: readonly PaletteColor[] = Object.freeze([
|
|
29
|
+
{r: 0, g: 0, b: 0}, // 0 black
|
|
30
|
+
{r: 29, g: 43, b: 83}, // 1 dark-blue
|
|
31
|
+
{r: 126, g: 37, b: 83}, // 2 dark-purple
|
|
32
|
+
{r: 0, g: 135, b: 81}, // 3 dark-green
|
|
33
|
+
{r: 171, g: 82, b: 54}, // 4 brown
|
|
34
|
+
{r: 95, g: 87, b: 79}, // 5 dark-grey
|
|
35
|
+
{r: 194, g: 195, b: 199}, // 6 light-grey
|
|
36
|
+
{r: 255, g: 241, b: 232}, // 7 white
|
|
37
|
+
{r: 255, g: 0, b: 77}, // 8 red
|
|
38
|
+
{r: 255, g: 163, b: 0}, // 9 orange
|
|
39
|
+
{r: 255, g: 236, b: 39}, // 10 yellow
|
|
40
|
+
{r: 0, g: 228, b: 54}, // 11 green
|
|
41
|
+
{r: 41, g: 173, b: 255}, // 12 blue
|
|
42
|
+
{r: 131, g: 118, b: 156}, // 13 lavender
|
|
43
|
+
{r: 255, g: 119, b: 168}, // 14 pink
|
|
44
|
+
{r: 255, g: 204, b: 170}, // 15 peach
|
|
45
|
+
] as const);
|
|
46
|
+
|
|
47
|
+
/** The RGB triple for colour index 0..15. Throws for an out-of-range index. */
|
|
48
|
+
export function paletteColor(index: number): PaletteColor {
|
|
49
|
+
const c = PICO8_PALETTE[index];
|
|
50
|
+
if (c === undefined) {
|
|
51
|
+
throw new RangeError(`palette index ${index} out of range 0..15`);
|
|
52
|
+
}
|
|
53
|
+
return c;
|
|
54
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import {inflateSync} from 'node:zlib';
|
|
2
|
+
import {describe, expect, it} from 'vitest';
|
|
3
|
+
|
|
4
|
+
import {encodePng, type RgbImage} from './png.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Decodes the PNG structure enough to recover the raw RGB pixels, so the tests
|
|
8
|
+
* assert against a REAL PNG round-trip (not the encoder's own internals). Uses
|
|
9
|
+
* node's `zlib.inflateSync` as an INDEPENDENT decompressor: it must accept the
|
|
10
|
+
* hand-rolled stored-block zlib stream, proving the file is standards-valid.
|
|
11
|
+
*/
|
|
12
|
+
function decodePng(bytes: Uint8Array): RgbImage {
|
|
13
|
+
const sig = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
|
|
14
|
+
for (let i = 0; i < sig.length; i++) {
|
|
15
|
+
expect(bytes[i]).toBe(sig[i]);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
let off = 8;
|
|
19
|
+
let width = 0;
|
|
20
|
+
let height = 0;
|
|
21
|
+
const idatParts: Uint8Array[] = [];
|
|
22
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
23
|
+
|
|
24
|
+
while (off < bytes.length) {
|
|
25
|
+
const len = view.getUint32(off);
|
|
26
|
+
const type = String.fromCharCode(
|
|
27
|
+
bytes[off + 4]!,
|
|
28
|
+
bytes[off + 5]!,
|
|
29
|
+
bytes[off + 6]!,
|
|
30
|
+
bytes[off + 7]!,
|
|
31
|
+
);
|
|
32
|
+
const data = bytes.subarray(off + 8, off + 8 + len);
|
|
33
|
+
if (type === 'IHDR') {
|
|
34
|
+
const dv = new DataView(data.buffer, data.byteOffset, data.byteLength);
|
|
35
|
+
width = dv.getUint32(0);
|
|
36
|
+
height = dv.getUint32(4);
|
|
37
|
+
expect(data[8]).toBe(8); // bit depth
|
|
38
|
+
expect(data[9]).toBe(2); // colour type RGB
|
|
39
|
+
} else if (type === 'IDAT') {
|
|
40
|
+
idatParts.push(data);
|
|
41
|
+
}
|
|
42
|
+
off += 12 + len; // length(4) + type(4) + data + crc(4)
|
|
43
|
+
if (type === 'IEND') break;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const idat = Buffer.concat(idatParts.map((p) => Buffer.from(p)));
|
|
47
|
+
const raw = new Uint8Array(inflateSync(idat));
|
|
48
|
+
|
|
49
|
+
// Strip the per-scanline filter byte (all filter type 0 = None).
|
|
50
|
+
const stride = width * 3;
|
|
51
|
+
const rgb = new Uint8Array(width * height * 3);
|
|
52
|
+
for (let y = 0; y < height; y++) {
|
|
53
|
+
expect(raw[y * (stride + 1)]).toBe(0); // filter None
|
|
54
|
+
rgb.set(
|
|
55
|
+
raw.subarray(y * (stride + 1) + 1, y * (stride + 1) + 1 + stride),
|
|
56
|
+
y * stride,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
return {width, height, rgb};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function pixelAt(
|
|
63
|
+
img: RgbImage,
|
|
64
|
+
x: number,
|
|
65
|
+
y: number,
|
|
66
|
+
): [number, number, number] {
|
|
67
|
+
const off = (y * img.width + x) * 3;
|
|
68
|
+
return [img.rgb[off]!, img.rgb[off + 1]!, img.rgb[off + 2]!];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
describe('png encoder', () => {
|
|
72
|
+
it('encodes a 2x2 RGB image that a standard inflate decoder reads back exactly', () => {
|
|
73
|
+
const rgb = Uint8Array.from([
|
|
74
|
+
255, 0, 0, /* red */ 0, 255, 0 /* green */, 0, 0, 255, /* blue */ 255,
|
|
75
|
+
255, 255 /* white */,
|
|
76
|
+
]);
|
|
77
|
+
const png = encodePng({width: 2, height: 2, rgb});
|
|
78
|
+
const decoded = decodePng(png);
|
|
79
|
+
expect(decoded.width).toBe(2);
|
|
80
|
+
expect(decoded.height).toBe(2);
|
|
81
|
+
expect(pixelAt(decoded, 0, 0)).toEqual([255, 0, 0]);
|
|
82
|
+
expect(pixelAt(decoded, 1, 0)).toEqual([0, 255, 0]);
|
|
83
|
+
expect(pixelAt(decoded, 0, 1)).toEqual([0, 0, 255]);
|
|
84
|
+
expect(pixelAt(decoded, 1, 1)).toEqual([255, 255, 255]);
|
|
85
|
+
expect(Array.from(decoded.rgb)).toEqual(Array.from(rgb));
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('is deterministic: the same input yields byte-identical output', () => {
|
|
89
|
+
const rgb = Uint8Array.from([1, 2, 3, 4, 5, 6]);
|
|
90
|
+
const a = encodePng({width: 2, height: 1, rgb});
|
|
91
|
+
const b = encodePng({width: 2, height: 1, rgb});
|
|
92
|
+
expect(Array.from(a)).toEqual(Array.from(b));
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('handles a buffer larger than one stored block (>65535 bytes)', () => {
|
|
96
|
+
// 200x200 RGB = 120000 bytes of pixels + filter bytes > 65535 → 2+ blocks.
|
|
97
|
+
const width = 200;
|
|
98
|
+
const height = 200;
|
|
99
|
+
const rgb = new Uint8Array(width * height * 3);
|
|
100
|
+
for (let i = 0; i < rgb.length; i++) rgb[i] = i % 256;
|
|
101
|
+
const png = encodePng({width, height, rgb});
|
|
102
|
+
const decoded = decodePng(png);
|
|
103
|
+
expect(Array.from(decoded.rgb)).toEqual(Array.from(rgb));
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('rejects a mismatched buffer length', () => {
|
|
107
|
+
expect(() =>
|
|
108
|
+
encodePng({width: 2, height: 2, rgb: new Uint8Array(3)}),
|
|
109
|
+
).toThrow();
|
|
110
|
+
});
|
|
111
|
+
});
|