@raskell/wasm-ad 0.1.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
@@ -1,8 +1,9 @@
1
1
  # WASM-AD for AssemblyScript
2
2
 
3
3
  An AssemblyScript SDK for [WASM-AD](https://github.com/raskell-io/wasm-ad) carts: register
4
- addresses, typed builders for backgrounds, sprites and affine matrices, input, save RAM and
5
- `trace`, with no runtime allocation and no mutable globals.
4
+ addresses, typed builders for backgrounds (tiled and affine), sprites, affine matrices and
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
 
@@ -12,8 +13,10 @@ npm run build # build/starter.wasm
12
13
  node ../../tools/wasmad.mjs check build/starter.wasm
13
14
  ```
14
15
 
15
- [`examples/starter.ts`](examples/starter.ts) is the same cart as every other SDK's starter
16
- and renders bit-identical frames. Drop `build/starter.wasm` into the player.
16
+ [`examples/starter.ts`](examples/starter.ts) is the same cart as every other SDK's starter: a
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.
17
20
 
18
21
  ## A new cart
19
22
 
@@ -72,7 +75,59 @@ export function start(): void {
72
75
  ```
73
76
 
74
77
  `const` values are fine. Avoid `new`, string concatenation, arrays and anything else that
75
- allocates; use `memory.data` for static buffers.
78
+ allocates; use `memory.data` for static buffers and tables, e.g.
79
+ `const NOTES = memory.data<u32>([262, 330, 392])` read with `load<u32>(NOTES + (i << 2))`.
80
+
81
+ ## Sound
82
+
83
+ `sound.enable()` turns sound on with every channel on both sides. A channel plays when its
84
+ start bit is written; the host clears it after the frame (SPEC.md §14):
85
+
86
+ ```ts
87
+ write16(reg.SOUND2CNT_L, sound.DUTY_50 | sound.envelope(12, 2, false));
88
+ write16(reg.SOUND2CNT_H, sound.START | sound.rate(440));
89
+ ```
90
+
91
+ Sample channels play signed 8-bit samples you write to `sound.sampleRam()`:
92
+ `pcm.play(0, offset, length, 16000, 64)`.
93
+
94
+ ## Affine backgrounds
95
+
96
+ In modes 1 and 2, BG2 (and BG3 in mode 2) is drawn through a matrix. Its tiles are 8bpp
97
+ (`bgTile8`) and its map is one byte per tile (`affineMap`). `setBgAffine(2, m, tx, ty, sx, sy)`
98
+ puts texture pixel (tx, ty) at screen pixel (sx, sy) and rotates and scales around it.
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
+ ```
76
131
 
77
132
  ## API
78
133
 
@@ -83,16 +138,21 @@ All values are plain integers; builders take a value and return a new one.
83
138
  | `read16`, `write16`, `read32`, `write32` | Raw register access, e.g. `write16(reg.DISPCNT, dispcnt.MODE0 \| dispcnt.BG0)` |
84
139
  | `reg.*`, `reg.BGCNT(i)`, `reg.BGHOFS(i)`, `reg.BGVOFS(i)` | Register addresses by their SPEC.md names |
85
140
  | `dispcnt`, `layer`, `blend`, `key` | Bit constants; `blend.cnt(effect, first, second)`, `blend.alpha(eva, evb)` |
86
- | `bgcnt.priority/charBlock/screenBlock/size/bpp8/mosaic` | `BGxCNT` builders |
141
+ | `bgcnt.priority/charBlock/screenBlock/size/bpp8/mosaic/affineSize/wrap` | `BGxCNT` builders |
87
142
  | `rgb(r, g, b)` | 8-bit channels to RGB555 |
88
143
  | `setBgColor(i, c)`, `setObjColor(i, c)` | Palette RAM |
89
144
  | `bgTile(block, i)`, `objTile(i)`, `fillTile(addr, byte)` | Addresses of 32-byte 4bpp tiles |
90
145
  | `screenBlock(b)`, `setScreenEntry(b, i, e)`, `screenEntry.make/hflip/vflip` | Background maps |
146
+ | `bgTile8(block, i)`, `affineMap(b)`, `setBgAffine(bg, m, tx, ty, sx, sy)` | Affine backgrounds (modes 1 and 2) |
91
147
  | `sprite.make(x, y, SpriteSize.S16x16, tile)` then `.bank/.priority/.hflip/.vflip/.semiTransparent/.window/.mosaic/.bpp8/.affine` | Sprites, packed in a `u64` |
92
148
  | `oam.set(i, s)`, `oam.hide(i)`, `oam.hideAll()`, `oam.setAffine(slot, m)` | OAM |
93
149
  | `affine.rotateScaleFixed(angle, sx, sy)`, `affine.IDENTITY`, `affine.make` | Affine matrices; angle in 1/256 turns, scale in 8.8 |
94
150
  | `fixed.sin(a)`, `fixed.cos(a)` | Q14 trigonometry, identical in every SDK |
95
- | `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) |
96
153
  | `frame()`, `seed()` | Time and the only entropy source |
97
154
  | `saveRam()`, `saveRestored()`, `requestSave()` | 32 KB of save RAM at 0x19000 |
155
+ | `sound.enable/disable`, `sound.rate(hz)`, `sound.sweep/envelope/length/noise`, `sound.START/LENGTH/DUTY_*/WAVE_*` | Sound channels 1–4, through `reg.SOUND*` |
156
+ | `sound.waveRam()`, `sound.sampleRam()`, `sound.status()` | Wave RAM, sample RAM, which channels are playing |
157
+ | `pcm.play(ch, start, len, rate, vol)`, `pcm.playLooped`, `pcm.stop`, `pcm.position`, `pcm.playing` | Sample channels A (0) and B (1) |
98
158
  | `trace(msg)` | UTF-8 to the host's debug log (at most 1 KB) |
package/assembly/index.ts CHANGED
@@ -27,13 +27,18 @@ export const VRAM_SIZE: usize = 0x18000;
27
27
  export const OBJ_VRAM: usize = VRAM + 0x10000;
28
28
  export const SAVE: usize = 0x19000;
29
29
  export const SAVE_SIZE: usize = 0x8000;
30
+ /** Sample RAM: signed 8-bit samples for the sample channels (SPEC.md §14.6). */
31
+ export const SAMPLE_RAM: usize = 0x21000;
32
+ export const SAMPLE_RAM_SIZE: usize = 0x7000;
33
+ /** Wave RAM: two banks of 16 bytes (SPEC.md §14.5). */
34
+ export const WAVE_RAM: usize = 0x490;
30
35
  /** First address the cart may use for its own data, stack and heap. */
31
36
  export const CART: usize = 0x28000;
32
37
  /** OAM attribute 0 that hides a sprite. */
33
38
  export const OBJ_HIDDEN: u16 = 0x0200;
34
- export const SPEC_VERSION: u16 = 0x0001;
39
+ export const SPEC_VERSION: u16 = 0x0003;
35
40
 
36
- // ---- Registers (SPEC.md §5, §6) ---------------------------------------------
41
+ // ---- Registers (SPEC.md §5, §6, §14) ----------------------------------------
37
42
 
38
43
  /** Every register address, by its SPEC.md name. Use with `read16`/`write16`. */
39
44
  export namespace reg {
@@ -55,6 +60,51 @@ export namespace reg {
55
60
  export const SAVECNT: usize = 0x70c;
56
61
  export const SAVESTAT: usize = 0x70e;
57
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;
68
+
69
+ export const BG2PA: usize = 0x420;
70
+ export const BG2PB: usize = 0x422;
71
+ export const BG2PC: usize = 0x424;
72
+ export const BG2PD: usize = 0x426;
73
+ export const BG2X: usize = 0x428; // u32
74
+ export const BG2Y: usize = 0x42c; // u32
75
+ export const BG3PA: usize = 0x430;
76
+ export const BG3PB: usize = 0x432;
77
+ export const BG3PC: usize = 0x434;
78
+ export const BG3PD: usize = 0x436;
79
+ export const BG3X: usize = 0x438; // u32
80
+ export const BG3Y: usize = 0x43c; // u32
81
+
82
+ export const SOUND1CNT_L: usize = 0x460;
83
+ export const SOUND1CNT_H: usize = 0x462;
84
+ export const SOUND1CNT_X: usize = 0x464;
85
+ export const SOUND2CNT_L: usize = 0x468;
86
+ export const SOUND2CNT_H: usize = 0x46c;
87
+ export const SOUND3CNT_L: usize = 0x470;
88
+ export const SOUND3CNT_H: usize = 0x472;
89
+ export const SOUND3CNT_X: usize = 0x474;
90
+ export const SOUND4CNT_L: usize = 0x478;
91
+ export const SOUND4CNT_H: usize = 0x47c;
92
+ export const SOUNDCNT_L: usize = 0x480;
93
+ export const SOUNDCNT_H: usize = 0x482;
94
+ export const SOUNDCNT_X: usize = 0x484;
95
+
96
+ export const PCMA_SAD: usize = 0x4b0;
97
+ export const PCMA_LEN: usize = 0x4b2;
98
+ export const PCMA_LOOP: usize = 0x4b4;
99
+ export const PCMA_RATE: usize = 0x4b6;
100
+ export const PCMA_CNT: usize = 0x4b8;
101
+ export const PCMA_POS: usize = 0x4ba;
102
+ export const PCMB_SAD: usize = 0x4c0;
103
+ export const PCMB_LEN: usize = 0x4c2;
104
+ export const PCMB_LOOP: usize = 0x4c4;
105
+ export const PCMB_RATE: usize = 0x4c6;
106
+ export const PCMB_CNT: usize = 0x4c8;
107
+ export const PCMB_POS: usize = 0x4ca;
58
108
 
59
109
  /** `BGxCNT` for background 0–3. */
60
110
  // @ts-ignore: decorator
@@ -93,6 +143,10 @@ export namespace reg {
93
143
  /** `DISPCNT` bits (SPEC.md §6.1). */
94
144
  export namespace dispcnt {
95
145
  export const MODE0: u16 = 0;
146
+ /** BG0 and BG1 tiled, BG2 affine. */
147
+ export const MODE1: u16 = 1;
148
+ /** BG2 and BG3 affine. */
149
+ export const MODE2: u16 = 2;
96
150
  export const MODE3: u16 = 3;
97
151
  export const MODE4: u16 = 4;
98
152
  export const MODE5: u16 = 5;
@@ -165,6 +219,14 @@ export enum MapSize {
165
219
  S64x64 = 3,
166
220
  }
167
221
 
222
+ /** Affine background map sizes, in tiles (SPEC.md §8.4). */
223
+ export enum AffineSize {
224
+ S16x16 = 0,
225
+ S32x32 = 1,
226
+ S64x64 = 2,
227
+ S128x128 = 3,
228
+ }
229
+
168
230
  /** Builders for a `BGxCNT` value (SPEC.md §6.2), e.g. `bgcnt.priority(bgcnt.screenBlock(0, 31), 1)`. */
169
231
  export namespace bgcnt {
170
232
  // @ts-ignore: decorator
@@ -191,6 +253,16 @@ export namespace bgcnt {
191
253
  @inline export function mosaic(c: u16): u16 {
192
254
  return c | 0x40;
193
255
  }
256
+ /** Map size of an affine background. */
257
+ // @ts-ignore: decorator
258
+ @inline export function affineSize(c: u16, s: AffineSize): u16 {
259
+ return (c & 0x3fff) | (<u16>s << 14);
260
+ }
261
+ /** An affine background repeats instead of being transparent outside its map. */
262
+ // @ts-ignore: decorator
263
+ @inline export function wrap(c: u16): u16 {
264
+ return c | 0x2000;
265
+ }
194
266
  }
195
267
 
196
268
  /** Packs 8-bit channels into RGB555. */
@@ -215,6 +287,19 @@ export namespace bgcnt {
215
287
  @inline export function bgTile(block: i32, index: i32): usize {
216
288
  return VRAM + <usize>block * 0x4000 + (<usize>index << 5);
217
289
  }
290
+ /** Address of 8bpp background tile `index` counted from character block `block` (0–3). Tiles are 64 bytes;
291
+ * affine backgrounds always use these. */
292
+ // @ts-ignore: decorator
293
+ @inline export function bgTile8(block: i32, index: i32): usize {
294
+ return VRAM + <usize>block * 0x4000 + (<usize>index << 6);
295
+ }
296
+ /** Address of an affine background's map at screen block `block` (0–31): one tile index per byte,
297
+ * row-major (SPEC.md §8.4). */
298
+ // @ts-ignore: decorator
299
+ @inline export function affineMap(block: i32): usize {
300
+ return VRAM + <usize>block * 0x800;
301
+ }
302
+
218
303
  /** Address of 4bpp sprite tile `index` (0–1023). */
219
304
  // @ts-ignore: decorator
220
305
  @inline export function objTile(index: i32): usize {
@@ -412,6 +497,146 @@ export namespace oam {
412
497
  }
413
498
  }
414
499
 
500
+ /** Sets affine background `bg` (2 or 3) to matrix `m`, placed so that texture
501
+ * pixel (texX, texY) appears at screen pixel (scrX, scrY). Rotation and
502
+ * scaling then happen around that point. */
503
+ export function setBgAffine(bg: i32, m: AffineMatrix, texX: i32, texY: i32, scrX: i32, scrY: i32): void {
504
+ const at = reg.BG2PA + <usize>(bg - 2) * 0x10;
505
+ const pa = <i32>affine.pa(m), pb = <i32>affine.pb(m), pc = <i32>affine.pc(m), pd = <i32>affine.pd(m);
506
+ store<u16>(at, <u16>pa);
507
+ store<u16>(at + 2, <u16>pb);
508
+ store<u16>(at + 4, <u16>pc);
509
+ store<u16>(at + 6, <u16>pd);
510
+ store<u32>(at + 8, <u32>((texX << 8) - pa * scrX - pb * scrY));
511
+ store<u32>(at + 12, <u32>((texY << 8) - pc * scrX - pd * scrY));
512
+ }
513
+
514
+ // ---- Sound (SPEC.md §14) -------------------------------------------------------
515
+
516
+ /** Sound: four GBA-style channels written through `reg.SOUND*`, with
517
+ * builders for their fields, and setup:
518
+ *
519
+ * sound.enable();
520
+ * write16(reg.SOUND2CNT_L, sound.DUTY_50 | sound.envelope(12, 2, false));
521
+ * write16(reg.SOUND2CNT_H, sound.START | sound.rate(440));
522
+ */
523
+ export namespace sound {
524
+ /** Starts (or restarts) a channel; the host clears it. */
525
+ export const START: u16 = 1 << 15;
526
+ /** Stops the channel when its length runs out. */
527
+ export const LENGTH: u16 = 1 << 14;
528
+
529
+ export const DUTY_12: u16 = 0 << 6;
530
+ export const DUTY_25: u16 = 1 << 6;
531
+ export const DUTY_50: u16 = 2 << 6;
532
+ export const DUTY_75: u16 = 3 << 6;
533
+
534
+ /** `SOUND3CNT_L`: the wave channel is on. */
535
+ export const WAVE_ON: u16 = 1 << 7;
536
+ /** `SOUND3CNT_L`: play bank 1 (or start with it, with `WAVE_64`). */
537
+ export const WAVE_BANK1: u16 = 1 << 6;
538
+ /** `SOUND3CNT_L`: play both banks as one 64-sample wave. */
539
+ export const WAVE_64: u16 = 1 << 5;
540
+ /** `SOUND3CNT_H` volumes. */
541
+ export const WAVE_100: u16 = 1 << 13;
542
+ export const WAVE_50: u16 = 2 << 13;
543
+ export const WAVE_25: u16 = 3 << 13;
544
+ export const WAVE_75: u16 = 1 << 15;
545
+
546
+ /** Turns sound on with every channel on both sides at full volume. */
547
+ export function enable(): void {
548
+ store<u16>(reg.SOUNDCNT_X, 0x80);
549
+ store<u16>(reg.SOUNDCNT_L, 0xff77);
550
+ store<u16>(reg.SOUNDCNT_H, 0x330e);
551
+ }
552
+ /** Turns sound off; every channel stops. */
553
+ export function disable(): void {
554
+ store<u16>(reg.SOUNDCNT_X, 0);
555
+ }
556
+
557
+ /** The rate field for a square channel playing `hz` (64 Hz and up); the
558
+ * wave channel plays an octave lower at the same rate. */
559
+ // @ts-ignore: decorator
560
+ @inline export function rate(hz: u32): u16 {
561
+ return hz < 64 ? 0 : <u16>(2048 - 131072 / hz);
562
+ }
563
+ /** `SOUND1CNT_L`: every `time` 128ths of a second (1–7, 0 = off), the
564
+ * rate changes by rate >> `shift` (1–7), down if `decrease`. */
565
+ // @ts-ignore: decorator
566
+ @inline export function sweep(time: u16, shift: u16, decrease: bool): u16 {
567
+ return ((time & 7) << 4) | (shift & 7) | (decrease ? 8 : 0);
568
+ }
569
+ /** The envelope fields: start at `volume` (0–15), stepping by one every
570
+ * `step` 64ths of a second (0 = constant), up if `increase`. */
571
+ // @ts-ignore: decorator
572
+ @inline export function envelope(volume: u16, step: u16, increase: bool): u16 {
573
+ return ((volume & 15) << 12) | ((step & 7) << 8) | (increase ? 1 << 11 : 0);
574
+ }
575
+ /** The length field of channels 1, 2 and 4 for `n` 256ths of a second
576
+ * (1–64); it only counts with `LENGTH` set. Channel 3 takes 256 − n. */
577
+ // @ts-ignore: decorator
578
+ @inline export function length(n: u16): u16 {
579
+ return (64 - n) & 63;
580
+ }
581
+ /** `SOUND4CNT_H`: noise from a divider (0–7) and shift (0–13), with the
582
+ * 7-bit register if `short` (more tonal). */
583
+ // @ts-ignore: decorator
584
+ @inline export function noise(divider: u16, shift: u16, short: bool): u16 {
585
+ return (divider & 7) | ((shift & 15) << 4) | (short ? 8 : 0);
586
+ }
587
+
588
+ /** Address of wave RAM: two banks of 16 bytes, each 32 4-bit samples, high nibble first. */
589
+ // @ts-ignore: decorator
590
+ @inline export function waveRam(): usize {
591
+ return WAVE_RAM;
592
+ }
593
+ /** Address of the 28 KB of sample RAM (signed 8-bit) that the sample channels play from. */
594
+ // @ts-ignore: decorator
595
+ @inline export function sampleRam(): usize {
596
+ return SAMPLE_RAM;
597
+ }
598
+ /** Which channels are playing: bits 0–3 channels 1–4, bits 4 and 5 sample
599
+ * channels A and B, as of the end of the last frame. */
600
+ // @ts-ignore: decorator
601
+ @inline export function status(): u16 {
602
+ return load<u16>(reg.SOUNDCNT_X) & 0x3f;
603
+ }
604
+ }
605
+
606
+ /** The two sample channels, A (0) and B (1), playing signed 8-bit samples
607
+ * from sample RAM (SPEC.md §14.6). */
608
+ export namespace pcm {
609
+ // @ts-ignore: decorator
610
+ @inline function base(ch: i32): usize {
611
+ return ch == 0 ? reg.PCMA_SAD : reg.PCMB_SAD;
612
+ }
613
+
614
+ /** Plays `len` samples from sample RAM offset `start` at `rate` Hz and `volume` 0–64, once. */
615
+ export function play(ch: i32, start: u16, len: u16, rate: u16, volume: u16): void {
616
+ const b = base(ch);
617
+ store<u16>(b, start);
618
+ store<u16>(b + 2, len);
619
+ store<u16>(b + 6, rate);
620
+ store<u16>(b + 8, sound.START | (volume < 64 ? volume : 64));
621
+ }
622
+ /** Like `play`, then repeats from sample `loopAt` until stopped. */
623
+ export function playLooped(ch: i32, start: u16, len: u16, loopAt: u16, rate: u16, volume: u16): void {
624
+ store<u16>(base(ch) + 4, loopAt);
625
+ play(ch, start, len, rate, volume);
626
+ store<u16>(base(ch) + 8, load<u16>(base(ch) + 8) | (1 << 13));
627
+ }
628
+ export function stop(ch: i32): void {
629
+ store<u16>(base(ch) + 8, 1 << 14);
630
+ }
631
+ /** The playback position, in samples from the start, as of the end of the last frame. */
632
+ export function position(ch: i32): u16 {
633
+ return load<u16>(base(ch) + 10);
634
+ }
635
+ export function playing(ch: i32): bool {
636
+ return (sound.status() & (1 << (4 + ch))) != 0;
637
+ }
638
+ }
639
+
415
640
  // ---- Fixed-point trigonometry --------------------------------------------------
416
641
 
417
642
  /** Fixed-point trigonometry shared by all WASM-AD SDKs, so carts in any
@@ -447,27 +672,54 @@ export namespace fixed {
447
672
 
448
673
  // ---- Input, time, save RAM -------------------------------------------------------
449
674
 
450
- /** Buttons held this frame. */
675
+ /** Buttons held this frame by player 1. */
451
676
  // @ts-ignore: decorator
452
677
  @inline export function keys(): u16 {
453
678
  return load<u16>(reg.KEYS);
454
679
  }
455
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
+
456
699
  /** Edge detection over `KEYS`: call `buttons.update()` at the start of every
457
700
  * frame. Its two words of state live in linear memory. */
458
701
  export namespace buttons {
459
- 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
+ }
460
709
 
461
- export function update(): void {
462
- store<u16>(STATE + 2, load<u16>(STATE));
463
- 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));
464
715
  }
465
- export function held(k: u16): bool {
466
- return (load<u16>(STATE) & k) != 0;
716
+ export function held(k: u16, player: i32 = 1): bool {
717
+ return (load<u16>(at(player)) & k) != 0;
467
718
  }
468
719
  /** True only on the frame the button went down. */
469
- export function pressed(k: u16): bool {
470
- 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;
471
723
  }
472
724
  }
473
725
 
package/bin/wasmad.mjs CHANGED
@@ -4,32 +4,51 @@
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
  //
13
- // `run` boots the cart like a host does (SPEC.md §3) and renders with the
14
- // reference renderer, 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"
15
+ // `icon` takes a 32×32 PNG with at most 15 colors plus transparency.
16
+ //
17
+ // `run` boots the cart like a host does (SPEC.md §3) and renders and makes
18
+ // sound with the reference core, player/web/core.wasm (`make player`). Options:
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)
18
- // --hash F,F,... print a hash of the frame rendered at each listed frame
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
29
+ // --hash F,F,... print a hash of the frame rendered at each listed frame,
30
+ // and one of all the sound
19
31
  // --ppm FILE write the last rendered frame as a PPM image
32
+ // --wav FILE write the sound of every frame as a 48 kHz stereo WAV
20
33
  // --core FILE a different core.wasm
21
34
 
22
35
  import { readFileSync, realpathSync, writeFileSync } from 'node:fs';
23
36
  import { createHash } from 'node:crypto';
24
37
  import { fileURLToPath } from 'node:url';
38
+ import { deflateRawSync, deflateSync, inflateRawSync, inflateSync } from 'node:zlib';
25
39
 
26
- const SPEC = '0.1';
27
- const SPEC_VERSION = 0x0001;
40
+ const SPEC = '0.3';
41
+ const SPEC_VERSION = 0x0003;
28
42
  const W = 240, H = 160;
29
43
  const A = {
30
44
  IO: 0x400, PAL_END: 0xC00, OAM: 0xC00, VIDEO_END: 0x19000, SAVE: 0x19000, SAVE_SIZE: 0x8000, CART: 0x28000,
31
45
  VCOUNT: 0x406, KEYS: 0x700, FRAME: 0x704, SEED: 0x708, SAVECNT: 0x70C, SAVESTAT: 0x70E, SPEC: 0x710,
46
+ KEYS2: 0x712, KEYS3: 0x714, KEYS4: 0x716, PADS: 0x718,
47
+ BG2PA: 0x420, BG2PD: 0x426, BG3PA: 0x430, BG3PD: 0x436,
48
+ SOUND_REGS: 0x460, SOUND_REGS_END: 0x4D0, SOUND_STATE: 0x780, SOUND_STATE_END: 0x800,
32
49
  };
50
+ const SOUND_SAMPLES = 800;
51
+ const ICON_SIZE = 544;
33
52
  const KEY = { a: 1, b: 2, select: 4, start: 8, right: 16, left: 32, up: 64, down: 128, r: 256, l: 512 };
34
53
  const MAX_CART = 32 * 1024 * 1024;
35
54
 
@@ -245,6 +264,17 @@ export function check(bytes) {
245
264
  else if (!/^\d+\.\d+$/.test(meta.spec)) errors.push(`metadata spec "${meta.spec}" is not major.minor (§2.3)`);
246
265
  if (!meta.title) errors.push('metadata has no title (§2.3)');
247
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
+ }
248
278
  return { errors, warnings, meta, info };
249
279
  }
250
280
 
@@ -268,43 +298,267 @@ function leb(n) {
268
298
  return out;
269
299
  }
270
300
 
271
- /** Returns `bytes` with its `wasm-ad` sections replaced by one holding `meta`. */
272
- export function setMeta(bytes, meta) {
273
- const text = Object.entries(meta).map(([k, v]) => `${k}=${v}\n`).join('');
274
- const name = new TextEncoder().encode('wasm-ad');
275
- const payload = [...leb(name.length), ...name, ...new TextEncoder().encode(text)];
276
- 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]);
277
312
  const parts = [bytes.subarray(0, 8)];
278
313
  for (const s of sections(bytes)) {
279
- 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;
280
315
  parts.push(bytes.subarray(s.start, s.end));
281
316
  }
282
- parts.push(section);
283
- const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
284
- let at = 0;
285
- 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));
286
419
  return out;
287
420
  }
288
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);
453
+ return out;
454
+ }
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
+
289
526
  // ---- Headless host ----------------------------------------------------------
290
527
 
291
- /** 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
+ */
292
533
  export function parseKeys(script) {
534
+ let pads = 1;
293
535
  const spans = (script ?? '').split(',').filter(Boolean).map((part) => {
294
536
  const [range, buttons] = part.split(':');
295
537
  const [from, to = from] = range.split('-').map(Number);
296
- let mask = 0;
538
+ const masks = [0, 0, 0, 0];
297
539
  for (const b of (buttons ?? '').split('+').filter(Boolean)) {
298
- if (!(b in KEY)) throw new Error(`unknown button "${b}"; use ${Object.keys(KEY).join(', ')}`);
299
- 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;
300
546
  }
301
- return { from, to, mask };
547
+ return { from, to, masks };
302
548
  });
303
- 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
+ };
304
554
  }
305
555
 
306
- /** Boots a cart and returns a host that runs frames (SPEC.md §3). */
307
- 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 = () => {} } = {}) {
308
562
  const core = (await WebAssembly.instantiate(readFileSync(corePath))).instance.exports;
309
563
  let instance;
310
564
  const decoder = new TextDecoder();
@@ -313,23 +567,55 @@ export async function boot(bytes, { core: corePath, seed = 1, onTrace = () => {}
313
567
  const ex = instance.exports;
314
568
  const mem = () => new Uint8Array(ex.memory.buffer);
315
569
  const dv = () => new DataView(ex.memory.buffer);
316
- mem().fill(0, 0, A.CART);
317
- for (let i = 0; i < 128; i++) dv().setUint16(A.OAM + i * 8, 0x0200, true);
318
- dv().setUint32(A.SEED, seed >>> 0, true);
319
- dv().setUint16(A.SPEC, SPEC_VERSION, true);
320
- ex.start?.();
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
+ }
321
589
 
322
- let frame = 0;
323
590
  return {
324
591
  exports: ex,
325
- /** Runs one frame with `keys` held and returns the rendered RGBA8 pixels. */
326
- frame(keys = 0) {
592
+ /** The last frame's sound: 800 stereo pairs of i16, left first. */
593
+ get sound() { return sound; },
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;
327
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);
328
609
  dv().setUint32(A.FRAME, frame >>> 0, true);
329
610
  dv().setUint16(A.VCOUNT, 160, true);
330
611
  ex.update();
331
612
  const coreMem = new Uint8Array(core.memory.buffer, core.ad_mem(), core.ad_mem_len());
332
- coreMem.set(mem().subarray(0, A.VIDEO_END));
613
+ coreMem.set(mem().subarray(0, A.CART));
614
+ core.ad_sound_frame();
615
+ for (const [from, to] of [[A.SOUND_REGS, A.SOUND_REGS_END], [A.SOUND_STATE, A.SOUND_STATE_END]]) {
616
+ mem().set(coreMem.subarray(from, to), from);
617
+ }
618
+ sound = new Int16Array(core.memory.buffer, core.ad_sound_buffer(), SOUND_SAMPLES * 2).slice();
333
619
  for (let y = 0; y < H; y++) {
334
620
  if (ex.scanline) {
335
621
  dv().setUint16(A.VCOUNT, y, true);
@@ -345,8 +631,24 @@ export async function boot(bytes, { core: corePath, seed = 1, onTrace = () => {}
345
631
  };
346
632
  }
347
633
 
348
- export function hash(pixels) {
349
- return createHash('sha256').update(pixels).digest('hex').slice(0, 16);
634
+ export function hash(...chunks) {
635
+ const h = createHash('sha256');
636
+ for (const c of chunks) h.update(new Uint8Array(c.buffer, c.byteOffset, c.byteLength));
637
+ return h.digest('hex').slice(0, 16);
638
+ }
639
+
640
+ /** A 48 kHz 16-bit stereo WAV file of `frames` (Int16Arrays from `sound`). */
641
+ export function wav(frames) {
642
+ const bytes = frames.reduce((n, f) => n + f.byteLength, 0);
643
+ const out = new DataView(new ArrayBuffer(44 + bytes));
644
+ const tag = (at, s) => [...s].forEach((c, i) => out.setUint8(at + i, c.charCodeAt(0)));
645
+ tag(0, 'RIFF'); out.setUint32(4, 36 + bytes, true); tag(8, 'WAVEfmt ');
646
+ out.setUint32(16, 16, true); out.setUint16(20, 1, true); out.setUint16(22, 2, true);
647
+ out.setUint32(24, 48000, true); out.setUint32(28, 48000 * 4, true); out.setUint16(32, 4, true);
648
+ out.setUint16(34, 16, true); tag(36, 'data'); out.setUint32(40, bytes, true);
649
+ let at = 44;
650
+ for (const f of frames) for (const s of f) { out.setInt16(at, s, true); at += 2; }
651
+ return new Uint8Array(out.buffer);
350
652
  }
351
653
 
352
654
  function ppm(pixels) {
@@ -359,12 +661,16 @@ function ppm(pixels) {
359
661
 
360
662
  // ---- CLI ----------------------------------------------------------------------
361
663
 
664
+ const FLAGS = new Set(['force', 'unbound']);
665
+
362
666
  function options(args) {
363
667
  const opts = { _: [] };
364
668
  for (let i = 0; i < args.length; i++) {
365
669
  if (args[i] === '-o') opts.o = args[++i];
366
- else if (args[i].startsWith('--')) opts[args[i].slice(2)] = args[++i];
367
- 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]);
368
674
  }
369
675
  return opts;
370
676
  }
@@ -372,8 +678,16 @@ function options(args) {
372
678
  async function main([cmd, ...args]) {
373
679
  const opts = options(args);
374
680
  const [file, ...rest] = opts._;
375
- if (!cmd || !file || !['check', 'meta', 'run'].includes(cmd)) {
376
- 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]');
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'));
377
691
  return 2;
378
692
  }
379
693
  const bytes = new Uint8Array(readFileSync(file));
@@ -390,6 +704,18 @@ async function main([cmd, ...args]) {
390
704
  return 0;
391
705
  }
392
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
+
393
719
  if (cmd === 'check') {
394
720
  const { errors, warnings, meta, info } = check(bytes);
395
721
  if (meta) console.log(`${meta.title}${meta.author ? ` by ${meta.author}` : ''} (spec ${meta.spec}), ${bytes.length} bytes`);
@@ -406,16 +732,32 @@ async function main([cmd, ...args]) {
406
732
  return 1;
407
733
  }
408
734
  const core = opts.core ?? fileURLToPath(new URL('../player/web/core.wasm', import.meta.url));
409
- 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}`) });
410
744
  const keys = parseKeys(opts.keys);
411
- 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));
412
747
  const marks = new Set((opts.hash ?? '').split(',').filter(Boolean).map(Number));
413
748
  let pixels;
414
- for (let f = 0; f < frames; f++) {
415
- 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));
753
+ sound.push(host.sound);
416
754
  if (marks.has(f)) console.log(`frame ${f}: ${hash(pixels)}`);
417
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() }));
758
+ if (opts.hash) console.log(`sound: ${hash(...sound)}`);
418
759
  if (opts.ppm) writeFileSync(opts.ppm, ppm(pixels));
760
+ if (opts.wav) writeFileSync(opts.wav, wav(sound));
419
761
  return 0;
420
762
  }
421
763
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@raskell/wasm-ad",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "AssemblyScript SDK for writing WASM-AD carts",
5
5
  "license": "MIT",
6
6
  "repository": {