@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 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 mutable globals.
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 and a little tune on every sound channel, with
17
- bit-identical frames and sound. Drop `build/starter.wasm` into the player.
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 = 0x0002;
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
- const STATE: usize = memory.data(4); // held: u16, prev: u16
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
- export function update(): void {
682
- store<u16>(STATE + 2, load<u16>(STATE));
683
- store<u16>(STATE, keys());
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>(STATE) & k) != 0;
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
- return (load<u16>(STATE) & ~load<u16>(STATE + 2) & k) != 0;
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.2';
29
- const SPEC_VERSION = 0x0002;
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
- /** Returns `bytes` with its `wasm-ad` sections replaced by one holding `meta`. */
277
- export function setMeta(bytes, meta) {
278
- const text = Object.entries(meta).map(([k, v]) => `${k}=${v}\n`).join('');
279
- const name = new TextEncoder().encode('wasm-ad');
280
- const payload = [...leb(name.length), ...name, ...new TextEncoder().encode(text)];
281
- const section = Uint8Array.from([0, ...leb(payload.length), ...payload]);
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() === 'wasm-ad') continue;
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(section);
288
- const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
289
- let at = 0;
290
- for (const p of parts) { out.set(p, at); at += p.length; }
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
- /** Parses "0-29:right,30-59:down+b,60:a" into a frame -> KEYS function. */
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
- let mask = 0;
538
+ const masks = [0, 0, 0, 0];
302
539
  for (const b of (buttons ?? '').split('+').filter(Boolean)) {
303
- if (!(b in KEY)) throw new Error(`unknown button "${b}"; use ${Object.keys(KEY).join(', ')}`);
304
- mask |= KEY[b];
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, mask };
547
+ return { from, to, masks };
307
548
  });
308
- return (frame) => spans.reduce((k, s) => (frame >= s.from && frame <= s.to ? k | s.mask : k), 0);
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
- /** Boots a cart and returns a host that runs frames (SPEC.md §3). */
312
- export async function boot(bytes, { core: corePath, seed = 1, onTrace = () => {} } = {}) {
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
- /** Runs one frame with `keys` held and returns the rendered RGBA8 pixels. */
334
- frame(keys = 0) {
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('--')) opts[args[i].slice(2)] = args[++i];
396
- else opts._.push(args[i]);
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('usage: wasmad check <cart.wasm>\n wasmad meta <cart.wasm> [-o out.wasm] key=value...\n wasmad run <cart.wasm> [--frames N] [--keys SCRIPT] [--seed N] [--hash F,F] [--ppm FILE] [--wav FILE]');
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 host = await boot(bytes, { core, seed: Number(opts.seed ?? 1), onTrace: (m) => console.log(`trace: ${m}`) });
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 frames = Number(opts.frames ?? 1);
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 = 0; f < frames; f++) {
445
- pixels = host.frame(keys(f));
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));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@raskell/wasm-ad",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "AssemblyScript SDK for writing WASM-AD carts",
5
5
  "license": "MIT",
6
6
  "repository": {