@raskell/wasm-ad 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +39 -4
- package/assembly/index.ts +42 -10
- package/bin/wasmad.mjs +350 -41
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
An AssemblyScript SDK for [WASM-AD](https://github.com/raskell-io/wasm-ad) carts: register
|
|
4
4
|
addresses, typed builders for backgrounds (tiled and affine), sprites, affine matrices and
|
|
5
|
-
sound, input, save RAM and `trace`, with no runtime allocation and no
|
|
5
|
+
sound, input for up to four players, save RAM and `trace`, with no runtime allocation and no
|
|
6
|
+
mutable globals.
|
|
6
7
|
|
|
7
8
|
## The starter cart
|
|
8
9
|
|
|
@@ -13,8 +14,9 @@ node ../../tools/wasmad.mjs check build/starter.wasm
|
|
|
13
14
|
```
|
|
14
15
|
|
|
15
16
|
[`examples/starter.ts`](examples/starter.ts) is the same cart as every other SDK's starter: a
|
|
16
|
-
rotating affine checkerboard, a spinning star
|
|
17
|
-
|
|
17
|
+
rotating affine checkerboard, a spinning star, a second star for player 2 when a second
|
|
18
|
+
controller connects, and a little tune on every sound channel, with bit-identical frames and
|
|
19
|
+
sound. Drop `build/starter.wasm` into the player.
|
|
18
20
|
|
|
19
21
|
## A new cart
|
|
20
22
|
|
|
@@ -95,6 +97,38 @@ In modes 1 and 2, BG2 (and BG3 in mode 2) is drawn through a matrix. Its tiles a
|
|
|
95
97
|
(`bgTile8`) and its map is one byte per tile (`affineMap`). `setBgAffine(2, m, tx, ty, sx, sy)`
|
|
96
98
|
puts texture pixel (tx, ty) at screen pixel (sx, sy) and rotates and scales around it.
|
|
97
99
|
|
|
100
|
+
## Players
|
|
101
|
+
|
|
102
|
+
`KEYS` is player 1; players 2–4 read `KEYS2`–`KEYS4`, and `PADS` says which players have a
|
|
103
|
+
controller (SPEC.md §5). `buttons` keeps edge detection for each player:
|
|
104
|
+
|
|
105
|
+
```ts
|
|
106
|
+
if (connected(2)) {
|
|
107
|
+
buttons.update(2);
|
|
108
|
+
if (buttons.pressed(key.A, 2)) jump(2);
|
|
109
|
+
}
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## Icons
|
|
113
|
+
|
|
114
|
+
A cart can carry a 32×32 icon that hosts show next to its title (SPEC.md §2.5). Draw a PNG
|
|
115
|
+
with at most 15 colors plus transparency and add it after building:
|
|
116
|
+
|
|
117
|
+
```sh
|
|
118
|
+
npx wasmad icon cart.wasm icon.png
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
## Replays and save states
|
|
122
|
+
|
|
123
|
+
A cart is deterministic, so `wasmad` can record a run and play it back, or save and resume it
|
|
124
|
+
(FORMATS.md in the repository):
|
|
125
|
+
|
|
126
|
+
```sh
|
|
127
|
+
npx wasmad run cart.wasm --frames 600 --keys 0-59:right --record run.wadreplay
|
|
128
|
+
npx wasmad run cart.wasm --replay run.wadreplay --save-state end.wadstate
|
|
129
|
+
npx wasmad run cart.wasm --state end.wadstate --frames 60 --ppm frame.ppm
|
|
130
|
+
```
|
|
131
|
+
|
|
98
132
|
## API
|
|
99
133
|
|
|
100
134
|
All values are plain integers; builders take a value and return a new one.
|
|
@@ -114,7 +148,8 @@ All values are plain integers; builders take a value and return a new one.
|
|
|
114
148
|
| `oam.set(i, s)`, `oam.hide(i)`, `oam.hideAll()`, `oam.setAffine(slot, m)` | OAM |
|
|
115
149
|
| `affine.rotateScaleFixed(angle, sx, sy)`, `affine.IDENTITY`, `affine.make` | Affine matrices; angle in 1/256 turns, scale in 8.8 |
|
|
116
150
|
| `fixed.sin(a)`, `fixed.cos(a)` | Q14 trigonometry, identical in every SDK |
|
|
117
|
-
| `buttons.update()`, `buttons.held(k)`, `buttons.pressed(k)`, `keys()` | Input |
|
|
151
|
+
| `buttons.update(player?)`, `buttons.held(k, player?)`, `buttons.pressed(k, player?)`, `keys()` | Input; `player` 1–4, default 1 |
|
|
152
|
+
| `keysOf(player)`, `pads()`, `connected(player)` | Players 1–4 (SPEC.md §5) |
|
|
118
153
|
| `frame()`, `seed()` | Time and the only entropy source |
|
|
119
154
|
| `saveRam()`, `saveRestored()`, `requestSave()` | 32 KB of save RAM at 0x19000 |
|
|
120
155
|
| `sound.enable/disable`, `sound.rate(hz)`, `sound.sweep/envelope/length/noise`, `sound.START/LENGTH/DUTY_*/WAVE_*` | Sound channels 1–4, through `reg.SOUND*` |
|
package/assembly/index.ts
CHANGED
|
@@ -36,7 +36,7 @@ export const WAVE_RAM: usize = 0x490;
|
|
|
36
36
|
export const CART: usize = 0x28000;
|
|
37
37
|
/** OAM attribute 0 that hides a sprite. */
|
|
38
38
|
export const OBJ_HIDDEN: u16 = 0x0200;
|
|
39
|
-
export const SPEC_VERSION: u16 =
|
|
39
|
+
export const SPEC_VERSION: u16 = 0x0003;
|
|
40
40
|
|
|
41
41
|
// ---- Registers (SPEC.md §5, §6, §14) ----------------------------------------
|
|
42
42
|
|
|
@@ -60,6 +60,11 @@ export namespace reg {
|
|
|
60
60
|
export const SAVECNT: usize = 0x70c;
|
|
61
61
|
export const SAVESTAT: usize = 0x70e;
|
|
62
62
|
export const SPEC: usize = 0x710;
|
|
63
|
+
export const KEYS2: usize = 0x712;
|
|
64
|
+
export const KEYS3: usize = 0x714;
|
|
65
|
+
export const KEYS4: usize = 0x716;
|
|
66
|
+
/** Bits 0–3: players 1–4 have a controller. */
|
|
67
|
+
export const PADS: usize = 0x718;
|
|
63
68
|
|
|
64
69
|
export const BG2PA: usize = 0x420;
|
|
65
70
|
export const BG2PB: usize = 0x422;
|
|
@@ -667,27 +672,54 @@ export namespace fixed {
|
|
|
667
672
|
|
|
668
673
|
// ---- Input, time, save RAM -------------------------------------------------------
|
|
669
674
|
|
|
670
|
-
/** Buttons held this frame. */
|
|
675
|
+
/** Buttons held this frame by player 1. */
|
|
671
676
|
// @ts-ignore: decorator
|
|
672
677
|
@inline export function keys(): u16 {
|
|
673
678
|
return load<u16>(reg.KEYS);
|
|
674
679
|
}
|
|
675
680
|
|
|
681
|
+
/** Buttons held this frame by `player`, 1–4 (SPEC.md §5). */
|
|
682
|
+
export function keysOf(player: i32): u16 {
|
|
683
|
+
assert(player >= 1 && player <= 4);
|
|
684
|
+
return player == 1 ? load<u16>(reg.KEYS) : load<u16>(reg.KEYS2 + <usize>(player - 2) * 2);
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
/** Bits 0–3: which of players 1–4 have a controller. */
|
|
688
|
+
// @ts-ignore: decorator
|
|
689
|
+
@inline export function pads(): u16 {
|
|
690
|
+
return load<u16>(reg.PADS);
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
/** Whether `player` (1–4) has a controller. Player 1 always does. */
|
|
694
|
+
export function connected(player: i32): bool {
|
|
695
|
+
assert(player >= 1 && player <= 4);
|
|
696
|
+
return (<i32>pads() & (1 << (player - 1))) != 0;
|
|
697
|
+
}
|
|
698
|
+
|
|
676
699
|
/** Edge detection over `KEYS`: call `buttons.update()` at the start of every
|
|
677
700
|
* frame. Its two words of state live in linear memory. */
|
|
678
701
|
export namespace buttons {
|
|
679
|
-
|
|
702
|
+
// Per player 1–4: held: u16, prev: u16.
|
|
703
|
+
const STATE: usize = memory.data(16);
|
|
704
|
+
|
|
705
|
+
// @ts-ignore: decorator
|
|
706
|
+
@inline function at(player: i32): usize {
|
|
707
|
+
return STATE + <usize>(player - 1) * 4;
|
|
708
|
+
}
|
|
680
709
|
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
710
|
+
/** Reads `player`'s buttons (default player 1) for this frame. */
|
|
711
|
+
export function update(player: i32 = 1): void {
|
|
712
|
+
const s = at(player);
|
|
713
|
+
store<u16>(s + 2, load<u16>(s));
|
|
714
|
+
store<u16>(s, keysOf(player));
|
|
684
715
|
}
|
|
685
|
-
export function held(k: u16): bool {
|
|
686
|
-
return (load<u16>(
|
|
716
|
+
export function held(k: u16, player: i32 = 1): bool {
|
|
717
|
+
return (load<u16>(at(player)) & k) != 0;
|
|
687
718
|
}
|
|
688
719
|
/** True only on the frame the button went down. */
|
|
689
|
-
export function pressed(k: u16): bool {
|
|
690
|
-
|
|
720
|
+
export function pressed(k: u16, player: i32 = 1): bool {
|
|
721
|
+
const s = at(player);
|
|
722
|
+
return (load<u16>(s) & ~load<u16>(s + 2) & k) != 0;
|
|
691
723
|
}
|
|
692
724
|
}
|
|
693
725
|
|
package/bin/wasmad.mjs
CHANGED
|
@@ -4,17 +4,28 @@
|
|
|
4
4
|
//
|
|
5
5
|
// wasmad check <cart.wasm> validate a cart against SPEC.md
|
|
6
6
|
// wasmad meta <cart.wasm> key=value... set the `wasm-ad` metadata section
|
|
7
|
+
// wasmad icon <cart.wasm> <icon.png> set the icon (SPEC.md §2.5)
|
|
8
|
+
// wasmad icon <cart.wasm> --png <out.png> export the icon as a PNG
|
|
7
9
|
// wasmad run <cart.wasm> [options] run frames headlessly
|
|
8
10
|
//
|
|
9
11
|
// `meta` replaces the metadata section in place (or writes `-o <file>`); `spec`
|
|
10
12
|
// defaults to the version this tool implements. It is for toolchains that
|
|
11
13
|
// cannot emit custom sections themselves (AssemblyScript, TinyGo).
|
|
12
14
|
//
|
|
15
|
+
// `icon` takes a 32×32 PNG with at most 15 colors plus transparency.
|
|
16
|
+
//
|
|
13
17
|
// `run` boots the cart like a host does (SPEC.md §3) and renders and makes
|
|
14
18
|
// sound with the reference core, player/web/core.wasm (`make player`). Options:
|
|
15
|
-
// --frames N frames to run (default 1)
|
|
16
|
-
// --keys SCRIPT held buttons per frame, e.g. "0-29:right,30-59:down+b,60:a"
|
|
19
|
+
// --frames N frames to run (default 1, or the replay's length)
|
|
20
|
+
// --keys SCRIPT held buttons per frame, e.g. "0-29:right,30-59:down+b,60:a";
|
|
21
|
+
// "a@2" is player 2's A button
|
|
17
22
|
// --seed N value for SEED (default 1)
|
|
23
|
+
// --save FILE boot with this 32 KB save RAM
|
|
24
|
+
// --replay FILE play a .wadreplay (FORMATS.md) instead of --keys
|
|
25
|
+
// --record FILE write the run as a .wadreplay (--unbound: not tied to the cart)
|
|
26
|
+
// --state FILE start from a .wadstate instead of booting
|
|
27
|
+
// --save-state FILE write a .wadstate after the last frame
|
|
28
|
+
// --force use a replay or state made for a different cart
|
|
18
29
|
// --hash F,F,... print a hash of the frame rendered at each listed frame,
|
|
19
30
|
// and one of all the sound
|
|
20
31
|
// --ppm FILE write the last rendered frame as a PPM image
|
|
@@ -24,17 +35,20 @@
|
|
|
24
35
|
import { readFileSync, realpathSync, writeFileSync } from 'node:fs';
|
|
25
36
|
import { createHash } from 'node:crypto';
|
|
26
37
|
import { fileURLToPath } from 'node:url';
|
|
38
|
+
import { deflateRawSync, deflateSync, inflateRawSync, inflateSync } from 'node:zlib';
|
|
27
39
|
|
|
28
|
-
const SPEC = '0.
|
|
29
|
-
const SPEC_VERSION =
|
|
40
|
+
const SPEC = '0.3';
|
|
41
|
+
const SPEC_VERSION = 0x0003;
|
|
30
42
|
const W = 240, H = 160;
|
|
31
43
|
const A = {
|
|
32
44
|
IO: 0x400, PAL_END: 0xC00, OAM: 0xC00, VIDEO_END: 0x19000, SAVE: 0x19000, SAVE_SIZE: 0x8000, CART: 0x28000,
|
|
33
45
|
VCOUNT: 0x406, KEYS: 0x700, FRAME: 0x704, SEED: 0x708, SAVECNT: 0x70C, SAVESTAT: 0x70E, SPEC: 0x710,
|
|
46
|
+
KEYS2: 0x712, KEYS3: 0x714, KEYS4: 0x716, PADS: 0x718,
|
|
34
47
|
BG2PA: 0x420, BG2PD: 0x426, BG3PA: 0x430, BG3PD: 0x436,
|
|
35
48
|
SOUND_REGS: 0x460, SOUND_REGS_END: 0x4D0, SOUND_STATE: 0x780, SOUND_STATE_END: 0x800,
|
|
36
49
|
};
|
|
37
50
|
const SOUND_SAMPLES = 800;
|
|
51
|
+
const ICON_SIZE = 544;
|
|
38
52
|
const KEY = { a: 1, b: 2, select: 4, start: 8, right: 16, left: 32, up: 64, down: 128, r: 256, l: 512 };
|
|
39
53
|
const MAX_CART = 32 * 1024 * 1024;
|
|
40
54
|
|
|
@@ -250,6 +264,17 @@ export function check(bytes) {
|
|
|
250
264
|
else if (!/^\d+\.\d+$/.test(meta.spec)) errors.push(`metadata spec "${meta.spec}" is not major.minor (§2.3)`);
|
|
251
265
|
if (!meta.title) errors.push('metadata has no title (§2.3)');
|
|
252
266
|
}
|
|
267
|
+
// §2.5 icon
|
|
268
|
+
const icons = m.custom.filter((c) => c.name === 'wasm-ad-icon');
|
|
269
|
+
if (icons.length) {
|
|
270
|
+
if (icons[0].data.length !== ICON_SIZE) errors.push(`the icon section is ${icons[0].data.length} bytes; it must be ${ICON_SIZE} (§2.5)`);
|
|
271
|
+
else {
|
|
272
|
+
const used = new Set();
|
|
273
|
+
for (const b of icons[0].data.subarray(32)) used.add(b & 15).add(b >> 4);
|
|
274
|
+
used.delete(0);
|
|
275
|
+
info.push(`icon: 32×32, ${used.size} colors`);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
253
278
|
return { errors, warnings, meta, info };
|
|
254
279
|
}
|
|
255
280
|
|
|
@@ -273,43 +298,267 @@ function leb(n) {
|
|
|
273
298
|
return out;
|
|
274
299
|
}
|
|
275
300
|
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
const
|
|
280
|
-
|
|
281
|
-
|
|
301
|
+
function concat(parts) {
|
|
302
|
+
const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
|
|
303
|
+
let at = 0;
|
|
304
|
+
for (const p of parts) { out.set(p, at); at += p.length; }
|
|
305
|
+
return out;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** Returns `bytes` with its custom sections called `name` replaced by one holding `data`. */
|
|
309
|
+
export function setSection(bytes, name, data) {
|
|
310
|
+
const n = new TextEncoder().encode(name);
|
|
311
|
+
const payload = concat([Uint8Array.from([...leb(n.length), ...n]), data]);
|
|
282
312
|
const parts = [bytes.subarray(0, 8)];
|
|
283
313
|
for (const s of sections(bytes)) {
|
|
284
|
-
if (s.id === 0 && new Reader(bytes, s.body, s.end).name() ===
|
|
314
|
+
if (s.id === 0 && new Reader(bytes, s.body, s.end).name() === name) continue;
|
|
285
315
|
parts.push(bytes.subarray(s.start, s.end));
|
|
286
316
|
}
|
|
287
|
-
parts.push(
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
317
|
+
parts.push(Uint8Array.from([0, ...leb(payload.length)]), payload);
|
|
318
|
+
return concat(parts);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** Returns `bytes` with its `wasm-ad` sections replaced by one holding `meta`. */
|
|
322
|
+
export function setMeta(bytes, meta) {
|
|
323
|
+
const text = Object.entries(meta).map(([k, v]) => `${k}=${v}\n`).join('');
|
|
324
|
+
return setSection(bytes, 'wasm-ad', new TextEncoder().encode(text));
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// ---- Icons (SPEC.md §2.5) --------------------------------------------------------
|
|
328
|
+
|
|
329
|
+
/** Decodes a non-interlaced 8-bit-or-less PNG into { width, height, rgba }. */
|
|
330
|
+
export function decodePng(bytes) {
|
|
331
|
+
const sig = [137, 80, 78, 71, 13, 10, 26, 10];
|
|
332
|
+
if (sig.some((b, i) => bytes[i] !== b)) throw new Error('not a PNG file');
|
|
333
|
+
const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
334
|
+
let width, height, depth, type, palette = [], alpha = [], idat = [];
|
|
335
|
+
for (let at = 8; at < bytes.length;) {
|
|
336
|
+
const len = dv.getUint32(at);
|
|
337
|
+
const kind = String.fromCharCode(...bytes.subarray(at + 4, at + 8));
|
|
338
|
+
const data = bytes.subarray(at + 8, at + 8 + len);
|
|
339
|
+
if (kind === 'IHDR') {
|
|
340
|
+
const h = new DataView(data.buffer, data.byteOffset, len);
|
|
341
|
+
[width, height, depth, type] = [h.getUint32(0), h.getUint32(4), data[8], data[9]];
|
|
342
|
+
if (data[12]) throw new Error('interlaced PNGs are not supported');
|
|
343
|
+
if (depth > 8) throw new Error('16-bit PNGs are not supported');
|
|
344
|
+
} else if (kind === 'PLTE') for (let i = 0; i < len; i += 3) palette.push([data[i], data[i + 1], data[i + 2]]);
|
|
345
|
+
else if (kind === 'tRNS') alpha = [...data];
|
|
346
|
+
else if (kind === 'IDAT') idat.push(data);
|
|
347
|
+
at += 12 + len;
|
|
348
|
+
}
|
|
349
|
+
const channels = { 0: 1, 2: 3, 3: 1, 4: 2, 6: 4 }[type];
|
|
350
|
+
if (!channels) throw new Error(`unsupported PNG color type ${type}`);
|
|
351
|
+
const raw = inflateSync(concat(idat));
|
|
352
|
+
const stride = Math.ceil((width * channels * depth) / 8), bpp = Math.max(1, (channels * depth) / 8);
|
|
353
|
+
const rows = [];
|
|
354
|
+
let prev = new Uint8Array(stride);
|
|
355
|
+
for (let y = 0; y < height; y++) {
|
|
356
|
+
const f = raw[y * (stride + 1)], line = raw.slice(y * (stride + 1) + 1, (y + 1) * (stride + 1));
|
|
357
|
+
for (let i = 0; i < stride; i++) {
|
|
358
|
+
const a = i >= bpp ? line[i - bpp] : 0, b = prev[i], c = i >= bpp ? prev[i - bpp] : 0;
|
|
359
|
+
const p = a + b - c, pa = Math.abs(p - a), pb = Math.abs(p - b), pc = Math.abs(p - c);
|
|
360
|
+
line[i] += [0, a, b, (a + b) >> 1, pa <= pb && pa <= pc ? a : pb <= pc ? b : c][f];
|
|
361
|
+
}
|
|
362
|
+
rows.push(prev = line);
|
|
363
|
+
}
|
|
364
|
+
const rgba = new Uint8Array(width * height * 4);
|
|
365
|
+
const sample = (row, i) => (depth === 8 ? row[i] : (row[(i * depth) >> 3] >> (8 - depth - ((i * depth) & 7))) & ((1 << depth) - 1));
|
|
366
|
+
for (let y = 0; y < height; y++) {
|
|
367
|
+
for (let x = 0; x < width; x++) {
|
|
368
|
+
const o = (y * width + x) * 4, r = rows[y];
|
|
369
|
+
let px;
|
|
370
|
+
if (type === 3) { const i = sample(r, x); px = [...(palette[i] ?? [0, 0, 0]), alpha[i] ?? 255]; }
|
|
371
|
+
else if (type === 0 || type === 4) {
|
|
372
|
+
const g = sample(r, x * channels) * (255 / ((1 << depth) - 1));
|
|
373
|
+
px = [g, g, g, type === 4 ? r[x * 2 + 1] : 255];
|
|
374
|
+
} else px = [r[x * channels], r[x * channels + 1], r[x * channels + 2], type === 6 ? r[x * 4 + 3] : 255];
|
|
375
|
+
rgba.set(px, o);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
return { width, height, rgba };
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** Encodes RGBA8 pixels as a PNG. */
|
|
382
|
+
export function encodePng(width, height, rgba) {
|
|
383
|
+
const crcTable = Array.from({ length: 256 }, (_, n) => {
|
|
384
|
+
for (let k = 0; k < 8; k++) n = n & 1 ? 0xEDB88320 ^ (n >>> 1) : n >>> 1;
|
|
385
|
+
return n >>> 0;
|
|
386
|
+
});
|
|
387
|
+
const crc = (b) => { let c = 0xFFFFFFFF; for (const x of b) c = crcTable[(c ^ x) & 255] ^ (c >>> 8); return (c ^ 0xFFFFFFFF) >>> 0; };
|
|
388
|
+
const chunk = (kind, data) => {
|
|
389
|
+
const out = new Uint8Array(12 + data.length), dv = new DataView(out.buffer);
|
|
390
|
+
dv.setUint32(0, data.length);
|
|
391
|
+
out.set(new TextEncoder().encode(kind), 4);
|
|
392
|
+
out.set(data, 8);
|
|
393
|
+
dv.setUint32(8 + data.length, crc(out.subarray(4, 8 + data.length)));
|
|
394
|
+
return out;
|
|
395
|
+
};
|
|
396
|
+
const ihdr = new Uint8Array(13), h = new DataView(ihdr.buffer);
|
|
397
|
+
h.setUint32(0, width); h.setUint32(4, height); ihdr.set([8, 6, 0, 0, 0], 8);
|
|
398
|
+
const raw = new Uint8Array(height * (width * 4 + 1));
|
|
399
|
+
for (let y = 0; y < height; y++) raw.set(rgba.subarray(y * width * 4, (y + 1) * width * 4), y * (width * 4 + 1) + 1);
|
|
400
|
+
return concat([Uint8Array.from([137, 80, 78, 71, 13, 10, 26, 10]), chunk('IHDR', ihdr), chunk('IDAT', deflateSync(raw)), chunk('IEND', new Uint8Array())]);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/** Converts a 32×32 PNG with at most 15 colors plus transparency to an icon section's 544 bytes. */
|
|
404
|
+
export function iconFromPng(png) {
|
|
405
|
+
const { width, height, rgba } = decodePng(png);
|
|
406
|
+
if (width !== 32 || height !== 32) throw new Error(`the icon is ${width}×${height}; it must be 32×32`);
|
|
407
|
+
const colors = [0], out = new Uint8Array(ICON_SIZE), dv = new DataView(out.buffer);
|
|
408
|
+
for (let i = 0; i < 1024; i++) {
|
|
409
|
+
let index = 0;
|
|
410
|
+
if (rgba[i * 4 + 3] >= 128) {
|
|
411
|
+
const c = (rgba[i * 4] >> 3) | ((rgba[i * 4 + 1] >> 3) << 5) | ((rgba[i * 4 + 2] >> 3) << 10);
|
|
412
|
+
index = colors.indexOf(c, 1);
|
|
413
|
+
if (index < 0) index = colors.push(c) - 1;
|
|
414
|
+
}
|
|
415
|
+
out[32 + (i >> 1)] |= index << ((i & 1) * 4);
|
|
416
|
+
}
|
|
417
|
+
if (colors.length > 16) throw new Error(`the icon has ${colors.length - 1} colors; at most 15 plus transparency fit (§2.5)`);
|
|
418
|
+
colors.forEach((c, i) => dv.setUint16(i * 2, c, true));
|
|
419
|
+
return out;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/** The cart's icon as 32×32 RGBA8 pixels, or null. */
|
|
423
|
+
export function readIcon(bytes) {
|
|
424
|
+
const icon = inspect(bytes).custom.find((c) => c.name === 'wasm-ad-icon');
|
|
425
|
+
if (!icon || icon.data.length !== ICON_SIZE) return null;
|
|
426
|
+
const d = icon.data, rgba = new Uint8Array(1024 * 4);
|
|
427
|
+
for (let i = 0; i < 1024; i++) {
|
|
428
|
+
const index = (d[32 + (i >> 1)] >> ((i & 1) * 4)) & 15;
|
|
429
|
+
if (!index) continue;
|
|
430
|
+
const c = d[index * 2] | (d[index * 2 + 1] << 8);
|
|
431
|
+
const x = (v) => (v << 3) | (v >> 2);
|
|
432
|
+
rgba.set([x(c & 31), x((c >> 5) & 31), x((c >> 10) & 31), 255], i * 4);
|
|
433
|
+
}
|
|
434
|
+
return rgba;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// ---- Replays and save states (FORMATS.md) -------------------------------------
|
|
438
|
+
|
|
439
|
+
const FORMAT_VERSION = 1;
|
|
440
|
+
|
|
441
|
+
export function sha256(bytes) {
|
|
442
|
+
return new Uint8Array(createHash('sha256').update(bytes).digest());
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function header(magic, cartHash, frames, seed) {
|
|
446
|
+
const out = new Uint8Array(48), dv = new DataView(out.buffer);
|
|
447
|
+
out.set(new TextEncoder().encode(magic));
|
|
448
|
+
dv.setUint16(4, FORMAT_VERSION, true);
|
|
449
|
+
dv.setUint16(6, SPEC_VERSION, true);
|
|
450
|
+
if (cartHash) out.set(cartHash, 8);
|
|
451
|
+
dv.setUint32(40, frames >>> 0, true);
|
|
452
|
+
dv.setUint32(44, seed >>> 0, true);
|
|
291
453
|
return out;
|
|
292
454
|
}
|
|
293
455
|
|
|
456
|
+
function readHeader(bytes, magic, what) {
|
|
457
|
+
if (bytes.length < 48 || new TextDecoder().decode(bytes.subarray(0, 4)) !== magic) throw new Error(`not a ${what} file`);
|
|
458
|
+
const dv = new DataView(bytes.buffer, bytes.byteOffset, 48);
|
|
459
|
+
const version = dv.getUint16(4, true);
|
|
460
|
+
if (version > FORMAT_VERSION) throw new Error(`${what} format ${version} is newer than this tool's (${FORMAT_VERSION})`);
|
|
461
|
+
const cart = bytes.slice(8, 40);
|
|
462
|
+
return {
|
|
463
|
+
version, spec: dv.getUint16(6, true), cart: cart.some((b) => b) ? cart : null,
|
|
464
|
+
frames: dv.getUint32(40, true), seed: dv.getUint32(44, true), body: inflateRawSync(bytes.subarray(48)),
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/** Throws unless a replay or state made for `hash` (null: any cart) fits `cart`. */
|
|
469
|
+
export function checkCart(hash, cart, force = false) {
|
|
470
|
+
if (!hash || force) return;
|
|
471
|
+
if (!sha256(cart).every((b, i) => b === hash[i])) throw new Error('this file was made for a different cart (--force to use it anyway)');
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* A .wadreplay: `inputs` holds [KEYS, KEYS2, KEYS3, KEYS4, PADS] per frame;
|
|
476
|
+
* `cart` null makes it an input script for any cart.
|
|
477
|
+
*/
|
|
478
|
+
export function encodeReplay({ cart = null, seed, save = null, inputs }) {
|
|
479
|
+
const runs = [];
|
|
480
|
+
for (const input of inputs) {
|
|
481
|
+
const last = runs.at(-1);
|
|
482
|
+
if (last && last.input.every((v, i) => v === input[i])) last.n++;
|
|
483
|
+
else runs.push({ n: 1, input });
|
|
484
|
+
}
|
|
485
|
+
const body = new Uint8Array(2 + (save ? A.SAVE_SIZE : 0) + runs.length * 14), dv = new DataView(body.buffer);
|
|
486
|
+
let at = 2;
|
|
487
|
+
if (save) { dv.setUint16(0, 1, true); body.set(save, 2); at += A.SAVE_SIZE; }
|
|
488
|
+
for (const { n, input } of runs) {
|
|
489
|
+
dv.setUint32(at, n, true);
|
|
490
|
+
for (let i = 0; i < 5; i++) dv.setUint16(at + 4 + i * 2, input[i] ?? 0, true);
|
|
491
|
+
at += 14;
|
|
492
|
+
}
|
|
493
|
+
return concat([header('WADR', cart && sha256(cart), inputs.length, seed), deflateRawSync(body)]);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
export function decodeReplay(bytes) {
|
|
497
|
+
const { body, ...h } = readHeader(bytes, 'WADR', 'replay');
|
|
498
|
+
const dv = new DataView(body.buffer, body.byteOffset, body.byteLength);
|
|
499
|
+
let at = 2, save = null;
|
|
500
|
+
if (dv.getUint16(0, true) & 1) { save = body.slice(2, 2 + A.SAVE_SIZE); at += A.SAVE_SIZE; }
|
|
501
|
+
const inputs = [];
|
|
502
|
+
for (; at + 14 <= body.length; at += 14) {
|
|
503
|
+
const input = [0, 1, 2, 3, 4].map((i) => dv.getUint16(at + 4 + i * 2, true));
|
|
504
|
+
for (let n = dv.getUint32(at, true); n--;) inputs.push(input);
|
|
505
|
+
}
|
|
506
|
+
if (inputs.length !== h.frames) throw new Error('the replay is damaged: its runs do not add up to its frame count');
|
|
507
|
+
return { ...h, save, inputs };
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/** A .wadstate of `memory` after `frame` frames. */
|
|
511
|
+
export function encodeState({ cart, frame, seed, memory }) {
|
|
512
|
+
const body = new Uint8Array(4 + memory.length);
|
|
513
|
+
new DataView(body.buffer).setUint32(0, memory.length, true);
|
|
514
|
+
body.set(memory, 4);
|
|
515
|
+
return concat([header('WADS', sha256(cart), frame, seed), deflateRawSync(body)]);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
export function decodeState(bytes) {
|
|
519
|
+
const { body, ...h } = readHeader(bytes, 'WADS', 'save state');
|
|
520
|
+
const length = new DataView(body.buffer, body.byteOffset, 4).getUint32(0, true);
|
|
521
|
+
if (length % 65536 || body.length !== 4 + length) throw new Error('the save state is damaged');
|
|
522
|
+
if (!h.cart) throw new Error('the save state names no cart');
|
|
523
|
+
return { ...h, memory: body.subarray(4) };
|
|
524
|
+
}
|
|
525
|
+
|
|
294
526
|
// ---- Headless host ----------------------------------------------------------
|
|
295
527
|
|
|
296
|
-
/**
|
|
528
|
+
/**
|
|
529
|
+
* Parses "0-29:right,30-59:down+b+a@2" into a function from a frame number to
|
|
530
|
+
* [KEYS, KEYS2, KEYS3, KEYS4, PADS]. "b@2" is player 2's B; every player the
|
|
531
|
+
* script mentions has a controller for the whole run.
|
|
532
|
+
*/
|
|
297
533
|
export function parseKeys(script) {
|
|
534
|
+
let pads = 1;
|
|
298
535
|
const spans = (script ?? '').split(',').filter(Boolean).map((part) => {
|
|
299
536
|
const [range, buttons] = part.split(':');
|
|
300
537
|
const [from, to = from] = range.split('-').map(Number);
|
|
301
|
-
|
|
538
|
+
const masks = [0, 0, 0, 0];
|
|
302
539
|
for (const b of (buttons ?? '').split('+').filter(Boolean)) {
|
|
303
|
-
|
|
304
|
-
|
|
540
|
+
const [name, player = '1'] = b.split('@');
|
|
541
|
+
const p = Number(player) - 1;
|
|
542
|
+
if (!(name in KEY)) throw new Error(`unknown button "${name}"; use ${Object.keys(KEY).join(', ')}`);
|
|
543
|
+
if (!(p >= 0 && p < 4)) throw new Error(`unknown player "${player}"; use 1 to 4`);
|
|
544
|
+
masks[p] |= KEY[name];
|
|
545
|
+
pads |= 1 << p;
|
|
305
546
|
}
|
|
306
|
-
return { from, to,
|
|
547
|
+
return { from, to, masks };
|
|
307
548
|
});
|
|
308
|
-
return (frame) =>
|
|
549
|
+
return (frame) => {
|
|
550
|
+
const out = [0, 0, 0, 0, pads];
|
|
551
|
+
for (const s of spans) if (frame >= s.from && frame <= s.to) s.masks.forEach((m, i) => { out[i] |= m; });
|
|
552
|
+
return out;
|
|
553
|
+
};
|
|
309
554
|
}
|
|
310
555
|
|
|
311
|
-
/**
|
|
312
|
-
|
|
556
|
+
/**
|
|
557
|
+
* Boots a cart and returns a host that runs frames (SPEC.md §3). `save` is
|
|
558
|
+
* the save RAM to restore at boot; `state` a decoded save state to start
|
|
559
|
+
* from instead of booting (FORMATS.md §3).
|
|
560
|
+
*/
|
|
561
|
+
export async function boot(bytes, { core: corePath, seed = 1, save = null, state = null, onTrace = () => {} } = {}) {
|
|
313
562
|
const core = (await WebAssembly.instantiate(readFileSync(corePath))).instance.exports;
|
|
314
563
|
let instance;
|
|
315
564
|
const decoder = new TextDecoder();
|
|
@@ -318,21 +567,45 @@ export async function boot(bytes, { core: corePath, seed = 1, onTrace = () => {}
|
|
|
318
567
|
const ex = instance.exports;
|
|
319
568
|
const mem = () => new Uint8Array(ex.memory.buffer);
|
|
320
569
|
const dv = () => new DataView(ex.memory.buffer);
|
|
321
|
-
mem().fill(0, 0, A.CART);
|
|
322
|
-
for (let i = 0; i < 128; i++) dv().setUint16(A.OAM + i * 8, 0x0200, true);
|
|
323
|
-
for (const a of [A.BG2PA, A.BG2PD, A.BG3PA, A.BG3PD]) dv().setUint16(a, 0x100, true);
|
|
324
|
-
dv().setUint32(A.SEED, seed >>> 0, true);
|
|
325
|
-
dv().setUint16(A.SPEC, SPEC_VERSION, true);
|
|
326
|
-
ex.start?.();
|
|
327
|
-
|
|
328
570
|
let frame = 0, sound = new Int16Array(SOUND_SAMPLES * 2);
|
|
571
|
+
if (state) {
|
|
572
|
+
const grow = (state.memory.length - ex.memory.buffer.byteLength) / 65536;
|
|
573
|
+
if (grow < 0) throw new Error('the save state has less memory than the cart starts with');
|
|
574
|
+
if (grow) ex.memory.grow(grow);
|
|
575
|
+
mem().set(state.memory);
|
|
576
|
+
frame = state.frames;
|
|
577
|
+
} else {
|
|
578
|
+
mem().fill(0, 0, A.CART);
|
|
579
|
+
for (let i = 0; i < 128; i++) dv().setUint16(A.OAM + i * 8, 0x0200, true);
|
|
580
|
+
for (const a of [A.BG2PA, A.BG2PD, A.BG3PA, A.BG3PD]) dv().setUint16(a, 0x100, true);
|
|
581
|
+
dv().setUint32(A.SEED, seed >>> 0, true);
|
|
582
|
+
dv().setUint16(A.SPEC, SPEC_VERSION, true);
|
|
583
|
+
if (save) {
|
|
584
|
+
mem().set(save, A.SAVE);
|
|
585
|
+
dv().setUint16(A.SAVESTAT, 1, true);
|
|
586
|
+
}
|
|
587
|
+
ex.start?.();
|
|
588
|
+
}
|
|
589
|
+
|
|
329
590
|
return {
|
|
330
591
|
exports: ex,
|
|
331
592
|
/** The last frame's sound: 800 stereo pairs of i16, left first. */
|
|
332
593
|
get sound() { return sound; },
|
|
333
|
-
/**
|
|
334
|
-
|
|
594
|
+
/** Frames run since boot. */
|
|
595
|
+
get frames() { return frame; },
|
|
596
|
+
/** A copy of the cart's whole linear memory, between frames. */
|
|
597
|
+
memory() { return mem().slice(); },
|
|
598
|
+
/**
|
|
599
|
+
* Runs one frame and returns the rendered RGBA8 pixels. `input` is KEYS,
|
|
600
|
+
* or [KEYS, KEYS2, KEYS3, KEYS4, PADS].
|
|
601
|
+
*/
|
|
602
|
+
frame(input = 0) {
|
|
603
|
+
const [keys, k2 = 0, k3 = 0, k4 = 0, pads = 1] = typeof input === 'number' ? [input] : input;
|
|
335
604
|
dv().setUint16(A.KEYS, keys & 0x3FF, true);
|
|
605
|
+
dv().setUint16(A.KEYS2, k2 & 0x3FF, true);
|
|
606
|
+
dv().setUint16(A.KEYS3, k3 & 0x3FF, true);
|
|
607
|
+
dv().setUint16(A.KEYS4, k4 & 0x3FF, true);
|
|
608
|
+
dv().setUint16(A.PADS, (pads & 0xF) | 1, true);
|
|
336
609
|
dv().setUint32(A.FRAME, frame >>> 0, true);
|
|
337
610
|
dv().setUint16(A.VCOUNT, 160, true);
|
|
338
611
|
ex.update();
|
|
@@ -388,12 +661,16 @@ function ppm(pixels) {
|
|
|
388
661
|
|
|
389
662
|
// ---- CLI ----------------------------------------------------------------------
|
|
390
663
|
|
|
664
|
+
const FLAGS = new Set(['force', 'unbound']);
|
|
665
|
+
|
|
391
666
|
function options(args) {
|
|
392
667
|
const opts = { _: [] };
|
|
393
668
|
for (let i = 0; i < args.length; i++) {
|
|
394
669
|
if (args[i] === '-o') opts.o = args[++i];
|
|
395
|
-
else if (args[i].startsWith('--'))
|
|
396
|
-
|
|
670
|
+
else if (args[i].startsWith('--')) {
|
|
671
|
+
const name = args[i].slice(2);
|
|
672
|
+
opts[name] = FLAGS.has(name) ? true : args[++i];
|
|
673
|
+
} else opts._.push(args[i]);
|
|
397
674
|
}
|
|
398
675
|
return opts;
|
|
399
676
|
}
|
|
@@ -401,8 +678,16 @@ function options(args) {
|
|
|
401
678
|
async function main([cmd, ...args]) {
|
|
402
679
|
const opts = options(args);
|
|
403
680
|
const [file, ...rest] = opts._;
|
|
404
|
-
if (!cmd || !file || !['check', 'meta', 'run'].includes(cmd)) {
|
|
405
|
-
console.error(
|
|
681
|
+
if (!cmd || !file || !['check', 'meta', 'icon', 'run'].includes(cmd)) {
|
|
682
|
+
console.error([
|
|
683
|
+
'usage: wasmad check <cart.wasm>',
|
|
684
|
+
' wasmad meta <cart.wasm> [-o out.wasm] key=value...',
|
|
685
|
+
' wasmad icon <cart.wasm> <icon.png> [-o out.wasm]',
|
|
686
|
+
' wasmad icon <cart.wasm> --png <out.png>',
|
|
687
|
+
' wasmad run <cart.wasm> [--frames N] [--keys SCRIPT | --replay FILE] [--seed N] [--save FILE]',
|
|
688
|
+
' [--state FILE] [--hash F,F] [--ppm FILE] [--wav FILE] [--record FILE [--unbound]]',
|
|
689
|
+
' [--save-state FILE] [--force]',
|
|
690
|
+
].join('\n'));
|
|
406
691
|
return 2;
|
|
407
692
|
}
|
|
408
693
|
const bytes = new Uint8Array(readFileSync(file));
|
|
@@ -419,6 +704,18 @@ async function main([cmd, ...args]) {
|
|
|
419
704
|
return 0;
|
|
420
705
|
}
|
|
421
706
|
|
|
707
|
+
if (cmd === 'icon') {
|
|
708
|
+
if (opts.png) {
|
|
709
|
+
const rgba = readIcon(bytes);
|
|
710
|
+
if (!rgba) throw new Error('the cart has no icon');
|
|
711
|
+
writeFileSync(opts.png, encodePng(32, 32, rgba));
|
|
712
|
+
} else {
|
|
713
|
+
if (!rest[0]) throw new Error('give a 32×32 PNG');
|
|
714
|
+
writeFileSync(opts.o ?? file, setSection(bytes, 'wasm-ad-icon', iconFromPng(new Uint8Array(readFileSync(rest[0])))));
|
|
715
|
+
}
|
|
716
|
+
return 0;
|
|
717
|
+
}
|
|
718
|
+
|
|
422
719
|
if (cmd === 'check') {
|
|
423
720
|
const { errors, warnings, meta, info } = check(bytes);
|
|
424
721
|
if (meta) console.log(`${meta.title}${meta.author ? ` by ${meta.author}` : ''} (spec ${meta.spec}), ${bytes.length} bytes`);
|
|
@@ -435,17 +732,29 @@ async function main([cmd, ...args]) {
|
|
|
435
732
|
return 1;
|
|
436
733
|
}
|
|
437
734
|
const core = opts.core ?? fileURLToPath(new URL('../player/web/core.wasm', import.meta.url));
|
|
438
|
-
const
|
|
735
|
+
const replay = opts.replay ? decodeReplay(new Uint8Array(readFileSync(opts.replay))) : null;
|
|
736
|
+
const state = opts.state ? decodeState(new Uint8Array(readFileSync(opts.state))) : null;
|
|
737
|
+
if (replay) checkCart(replay.cart, bytes, opts.force);
|
|
738
|
+
if (state) checkCart(state.cart, bytes, opts.force);
|
|
739
|
+
if (state && opts.record) throw new Error('a replay starts at boot, so it cannot be recorded from a save state');
|
|
740
|
+
let save = replay ? replay.save : opts.save ? new Uint8Array(readFileSync(opts.save)) : null;
|
|
741
|
+
if (save && save.length !== A.SAVE_SIZE) throw new Error('a save file must be exactly 32 KB');
|
|
742
|
+
const seed = replay?.seed ?? Number(opts.seed ?? 1);
|
|
743
|
+
const host = await boot(bytes, { core, seed, save, state, onTrace: (m) => console.log(`trace: ${m}`) });
|
|
439
744
|
const keys = parseKeys(opts.keys);
|
|
440
|
-
const
|
|
745
|
+
const input = (f) => (replay ? replay.inputs[f] ?? [0, 0, 0, 0, 1] : keys(f));
|
|
746
|
+
const first = host.frames, frames = Number(opts.frames ?? (replay ? replay.frames - first : 1));
|
|
441
747
|
const marks = new Set((opts.hash ?? '').split(',').filter(Boolean).map(Number));
|
|
442
748
|
let pixels;
|
|
443
|
-
const sound = [];
|
|
444
|
-
for (let f =
|
|
445
|
-
|
|
749
|
+
const sound = [], recorded = [];
|
|
750
|
+
for (let f = first; f < first + frames; f++) {
|
|
751
|
+
recorded.push(input(f));
|
|
752
|
+
pixels = host.frame(input(f));
|
|
446
753
|
sound.push(host.sound);
|
|
447
754
|
if (marks.has(f)) console.log(`frame ${f}: ${hash(pixels)}`);
|
|
448
755
|
}
|
|
756
|
+
if (opts.record) writeFileSync(opts.record, encodeReplay({ cart: opts.unbound ? null : bytes, seed, save, inputs: recorded }));
|
|
757
|
+
if (opts['save-state']) writeFileSync(opts['save-state'], encodeState({ cart: bytes, frame: host.frames, seed: state?.seed ?? seed, memory: host.memory() }));
|
|
449
758
|
if (opts.hash) console.log(`sound: ${hash(...sound)}`);
|
|
450
759
|
if (opts.ppm) writeFileSync(opts.ppm, ppm(pixels));
|
|
451
760
|
if (opts.wav) writeFileSync(opts.wav, wav(sound));
|