@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 +67 -7
- package/assembly/index.ts +263 -11
- package/bin/wasmad.mjs +387 -45
- package/package.json +1 -1
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
|
|
5
|
-
`trace`, with no runtime allocation and no
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
462
|
-
|
|
463
|
-
|
|
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>(
|
|
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
|
-
|
|
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
|
-
// `
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
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
|
-
// --
|
|
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.
|
|
27
|
-
const SPEC_VERSION =
|
|
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
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
const
|
|
275
|
-
|
|
276
|
-
|
|
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() ===
|
|
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(
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
538
|
+
const masks = [0, 0, 0, 0];
|
|
297
539
|
for (const b of (buttons ?? '').split('+').filter(Boolean)) {
|
|
298
|
-
|
|
299
|
-
|
|
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,
|
|
547
|
+
return { from, to, masks };
|
|
302
548
|
});
|
|
303
|
-
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
|
+
};
|
|
304
554
|
}
|
|
305
555
|
|
|
306
|
-
/**
|
|
307
|
-
|
|
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
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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
|
-
/**
|
|
326
|
-
|
|
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.
|
|
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(
|
|
349
|
-
|
|
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('--'))
|
|
367
|
-
|
|
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(
|
|
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
|
|
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
|
|
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
|
-
|
|
415
|
-
|
|
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
|
|