@raskell/wasm-ad 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 raskell.io
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,98 @@
1
- # Temporary Holding Version
1
+ # WASM-AD for AssemblyScript
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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.
6
+
7
+ ## The starter cart
8
+
9
+ ```sh
10
+ npm install
11
+ npm run build # build/starter.wasm
12
+ node ../../tools/wasmad.mjs check build/starter.wasm
13
+ ```
14
+
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.
17
+
18
+ ## A new cart
19
+
20
+ ```sh
21
+ npm install @raskell/wasm-ad assemblyscript@0.28
22
+ echo '{ "extends": "./node_modules/@raskell/wasm-ad/cart.asconfig.json" }' > asconfig.json
23
+ ```
24
+
25
+ ```ts
26
+ import { write16, reg, dispcnt, keys, key } from "@raskell/wasm-ad/assembly";
27
+ ```
28
+
29
+ ```sh
30
+ npx asc cart.ts --config asconfig.json -o cart.wasm
31
+ npx wasmad meta cart.wasm title="My Cart" author=you
32
+ npx wasmad check cart.wasm
33
+ ```
34
+
35
+ `cart.asconfig.json` is the configuration below with `abort` pointing into the
36
+ installed package; extend it rather than copying it. `wasmad` is the cart
37
+ checker from `tools/wasmad.mjs`, shipped with this package.
38
+
39
+ `asconfig.json` sets what the spec requires:
40
+
41
+ | Option | Why |
42
+ |---|---|
43
+ | `memoryBase: 163840` | Static data starts at 0x28000, above the hardware region (SPEC.md §4). |
44
+ | `initialMemory: 4` | At least 3 pages; 4 leaves room above the data. |
45
+ | `runtime: "stub"` | No garbage collector; carts do not allocate. |
46
+ | `use: abort=assembly/index/abort` | Traps instead of importing `env.abort`; a cart may only import `env.trace`. |
47
+
48
+ AssemblyScript cannot write custom sections, so `wasmad meta` adds the `wasm-ad` metadata
49
+ (`spec` is filled in). Run `wasmad check` before sharing a cart.
50
+
51
+ ## Rules
52
+
53
+ A cart exports `update()`, and optionally `start()` and `scanline(y: i32)`. Do the setup in
54
+ `start`, not at the top level of a module, which would run before the host clears the hardware.
55
+
56
+ **Keep state in linear memory.** AssemblyScript compiles a module-level `let` or `var` to a
57
+ mutable WebAssembly global, which SPEC.md §12 forbids (hosts snapshot linear memory for save
58
+ states and netplay), and `wasmad check` rejects it. Put the cart's state in an
59
+ `@unmanaged` class placed in a static block of memory:
60
+
61
+ ```ts
62
+ @unmanaged
63
+ class State {
64
+ x: i32;
65
+ y: i32;
66
+ }
67
+ const state = changetype<State>(memory.data(offsetof<State>()));
68
+
69
+ export function start(): void {
70
+ state.x = 112; // memory starts zeroed; set initial values here
71
+ }
72
+ ```
73
+
74
+ `const` values are fine. Avoid `new`, string concatenation, arrays and anything else that
75
+ allocates; use `memory.data` for static buffers.
76
+
77
+ ## API
78
+
79
+ All values are plain integers; builders take a value and return a new one.
80
+
81
+ | | |
82
+ |---|---|
83
+ | `read16`, `write16`, `read32`, `write32` | Raw register access, e.g. `write16(reg.DISPCNT, dispcnt.MODE0 \| dispcnt.BG0)` |
84
+ | `reg.*`, `reg.BGCNT(i)`, `reg.BGHOFS(i)`, `reg.BGVOFS(i)` | Register addresses by their SPEC.md names |
85
+ | `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 |
87
+ | `rgb(r, g, b)` | 8-bit channels to RGB555 |
88
+ | `setBgColor(i, c)`, `setObjColor(i, c)` | Palette RAM |
89
+ | `bgTile(block, i)`, `objTile(i)`, `fillTile(addr, byte)` | Addresses of 32-byte 4bpp tiles |
90
+ | `screenBlock(b)`, `setScreenEntry(b, i, e)`, `screenEntry.make/hflip/vflip` | Background maps |
91
+ | `sprite.make(x, y, SpriteSize.S16x16, tile)` then `.bank/.priority/.hflip/.vflip/.semiTransparent/.window/.mosaic/.bpp8/.affine` | Sprites, packed in a `u64` |
92
+ | `oam.set(i, s)`, `oam.hide(i)`, `oam.hideAll()`, `oam.setAffine(slot, m)` | OAM |
93
+ | `affine.rotateScaleFixed(angle, sx, sy)`, `affine.IDENTITY`, `affine.make` | Affine matrices; angle in 1/256 turns, scale in 8.8 |
94
+ | `fixed.sin(a)`, `fixed.cos(a)` | Q14 trigonometry, identical in every SDK |
95
+ | `buttons.update()`, `buttons.held(k)`, `buttons.pressed(k)`, `keys()` | Input |
96
+ | `frame()`, `seed()` | Time and the only entropy source |
97
+ | `saveRam()`, `saveRestored()`, `requestSave()` | 32 KB of save RAM at 0x19000 |
98
+ | `trace(msg)` | UTF-8 to the host's debug log (at most 1 KB) |
package/asconfig.json ADDED
@@ -0,0 +1,12 @@
1
+ {
2
+ "options": {
3
+ "runtime": "stub",
4
+ "memoryBase": 163840,
5
+ "initialMemory": 4,
6
+ "use": ["abort=assembly/index/abort"],
7
+ "optimizeLevel": 3,
8
+ "shrinkLevel": 1,
9
+ "noAssert": true,
10
+ "exportRuntime": false
11
+ }
12
+ }
@@ -0,0 +1,549 @@
1
+ // AssemblyScript SDK for WASM-AD carts.
2
+ //
3
+ // A cart exports `update` (and optionally `start` and `scanline`) and drives
4
+ // the console by writing its memory-mapped hardware, which this module wraps
5
+ // in typed helpers. See SPEC.md for the hardware.
6
+ //
7
+ // AssemblyScript keeps module-level `let`/`var` in WebAssembly globals, but a
8
+ // cart's state must live in linear memory (SPEC.md §12). So this SDK has no
9
+ // mutable globals, its values are plain integers (no allocation), and carts
10
+ // keep their own state in a `@unmanaged` class placed with `memory.data`:
11
+ //
12
+ // @unmanaged class State { x: i32; y: i32 }
13
+ // const state = changetype<State>(memory.data(offsetof<State>()));
14
+
15
+ // ---- Memory map (SPEC.md §4) ------------------------------------------------
16
+
17
+ export const SCREEN_W: i32 = 240;
18
+ export const SCREEN_H: i32 = 160;
19
+
20
+ export const IO: usize = 0x00400;
21
+ export const PAL_BG: usize = 0x00800;
22
+ export const PAL_OBJ: usize = 0x00a00;
23
+ export const OAM: usize = 0x00c00;
24
+ export const VRAM: usize = 0x01000;
25
+ export const VRAM_SIZE: usize = 0x18000;
26
+ /** Sprite tile 0. */
27
+ export const OBJ_VRAM: usize = VRAM + 0x10000;
28
+ export const SAVE: usize = 0x19000;
29
+ export const SAVE_SIZE: usize = 0x8000;
30
+ /** First address the cart may use for its own data, stack and heap. */
31
+ export const CART: usize = 0x28000;
32
+ /** OAM attribute 0 that hides a sprite. */
33
+ export const OBJ_HIDDEN: u16 = 0x0200;
34
+ export const SPEC_VERSION: u16 = 0x0001;
35
+
36
+ // ---- Registers (SPEC.md §5, §6) ---------------------------------------------
37
+
38
+ /** Every register address, by its SPEC.md name. Use with `read16`/`write16`. */
39
+ export namespace reg {
40
+ export const DISPCNT: usize = 0x400;
41
+ export const VCOUNT: usize = 0x406;
42
+ export const WIN0H: usize = 0x440;
43
+ export const WIN1H: usize = 0x442;
44
+ export const WIN0V: usize = 0x444;
45
+ export const WIN1V: usize = 0x446;
46
+ export const WININ: usize = 0x448;
47
+ export const WINOUT: usize = 0x44a;
48
+ export const MOSAIC: usize = 0x44c;
49
+ export const BLDCNT: usize = 0x450;
50
+ export const BLDALPHA: usize = 0x452;
51
+ export const BLDY: usize = 0x454;
52
+ export const KEYS: usize = 0x700;
53
+ export const FRAME: usize = 0x704; // u32
54
+ export const SEED: usize = 0x708; // u32
55
+ export const SAVECNT: usize = 0x70c;
56
+ export const SAVESTAT: usize = 0x70e;
57
+ export const SPEC: usize = 0x710;
58
+
59
+ /** `BGxCNT` for background 0–3. */
60
+ // @ts-ignore: decorator
61
+ @inline export function BGCNT(bg: i32): usize {
62
+ return 0x408 + (<usize>bg << 1);
63
+ }
64
+ /** `BGxHOFS` for background 0–3. */
65
+ // @ts-ignore: decorator
66
+ @inline export function BGHOFS(bg: i32): usize {
67
+ return 0x410 + (<usize>bg << 2);
68
+ }
69
+ /** `BGxVOFS` for background 0–3. */
70
+ // @ts-ignore: decorator
71
+ @inline export function BGVOFS(bg: i32): usize {
72
+ return 0x412 + (<usize>bg << 2);
73
+ }
74
+ }
75
+
76
+ // @ts-ignore: decorator
77
+ @inline export function read16(addr: usize): u16 {
78
+ return load<u16>(addr);
79
+ }
80
+ // @ts-ignore: decorator
81
+ @inline export function write16(addr: usize, v: u16): void {
82
+ store<u16>(addr, v);
83
+ }
84
+ // @ts-ignore: decorator
85
+ @inline export function read32(addr: usize): u32 {
86
+ return load<u32>(addr);
87
+ }
88
+ // @ts-ignore: decorator
89
+ @inline export function write32(addr: usize, v: u32): void {
90
+ store<u32>(addr, v);
91
+ }
92
+
93
+ /** `DISPCNT` bits (SPEC.md §6.1). */
94
+ export namespace dispcnt {
95
+ export const MODE0: u16 = 0;
96
+ export const MODE3: u16 = 3;
97
+ export const MODE4: u16 = 4;
98
+ export const MODE5: u16 = 5;
99
+ export const PAGE1: u16 = 1 << 4;
100
+ export const OBJ_1D: u16 = 1 << 6;
101
+ export const FORCED_BLANK: u16 = 1 << 7;
102
+ export const BG0: u16 = 1 << 8;
103
+ export const BG1: u16 = 1 << 9;
104
+ export const BG2: u16 = 1 << 10;
105
+ export const BG3: u16 = 1 << 11;
106
+ export const OBJ: u16 = 1 << 12;
107
+ export const WIN0: u16 = 1 << 13;
108
+ export const WIN1: u16 = 1 << 14;
109
+ export const OBJ_WIN: u16 = 1 << 15;
110
+ }
111
+
112
+ /** Layer bits for `BLDCNT` targets and window masks. */
113
+ export namespace layer {
114
+ export const BG0: u16 = 1 << 0;
115
+ export const BG1: u16 = 1 << 1;
116
+ export const BG2: u16 = 1 << 2;
117
+ export const BG3: u16 = 1 << 3;
118
+ export const OBJ: u16 = 1 << 4;
119
+ export const BACKDROP: u16 = 1 << 5;
120
+ /** In window masks: color effects enabled. */
121
+ export const EFFECTS: u16 = 1 << 5;
122
+ export const ALL: u16 = 0x3f;
123
+ }
124
+
125
+ /** `BLDCNT` effects (SPEC.md §6.5). */
126
+ export namespace blend {
127
+ export const NONE: u16 = 0;
128
+ export const ALPHA: u16 = 1 << 6;
129
+ export const BRIGHTEN: u16 = 2 << 6;
130
+ export const DARKEN: u16 = 3 << 6;
131
+
132
+ /** `BLDCNT` value: `effect` on `first` targets, blending onto `second`. */
133
+ // @ts-ignore: decorator
134
+ @inline export function cnt(effect: u16, first: u16, second: u16): u16 {
135
+ return effect | first | (second << 8);
136
+ }
137
+ /** `BLDALPHA` value; coefficients 0–16. */
138
+ // @ts-ignore: decorator
139
+ @inline export function alpha(eva: u16, evb: u16): u16 {
140
+ return eva | (evb << 8);
141
+ }
142
+ }
143
+
144
+ /** `KEYS` bits (SPEC.md §5). */
145
+ export namespace key {
146
+ export const A: u16 = 1 << 0;
147
+ export const B: u16 = 1 << 1;
148
+ export const SELECT: u16 = 1 << 2;
149
+ export const START: u16 = 1 << 3;
150
+ export const RIGHT: u16 = 1 << 4;
151
+ export const LEFT: u16 = 1 << 5;
152
+ export const UP: u16 = 1 << 6;
153
+ export const DOWN: u16 = 1 << 7;
154
+ export const R: u16 = 1 << 8;
155
+ export const L: u16 = 1 << 9;
156
+ }
157
+
158
+ // ---- Backgrounds (SPEC.md §6.2, §8) -----------------------------------------
159
+
160
+ /** Background map sizes, in tiles. */
161
+ export enum MapSize {
162
+ S32x32 = 0,
163
+ S64x32 = 1,
164
+ S32x64 = 2,
165
+ S64x64 = 3,
166
+ }
167
+
168
+ /** Builders for a `BGxCNT` value (SPEC.md §6.2), e.g. `bgcnt.priority(bgcnt.screenBlock(0, 31), 1)`. */
169
+ export namespace bgcnt {
170
+ // @ts-ignore: decorator
171
+ @inline export function priority(c: u16, p: u16): u16 {
172
+ return (c & ~3) | (p & 3);
173
+ }
174
+ // @ts-ignore: decorator
175
+ @inline export function charBlock(c: u16, b: u16): u16 {
176
+ return (c & ~0xc) | ((b & 3) << 2);
177
+ }
178
+ // @ts-ignore: decorator
179
+ @inline export function screenBlock(c: u16, b: u16): u16 {
180
+ return (c & ~0x1f00) | ((b & 31) << 8);
181
+ }
182
+ // @ts-ignore: decorator
183
+ @inline export function size(c: u16, s: MapSize): u16 {
184
+ return (c & 0x3fff) | (<u16>s << 14);
185
+ }
186
+ // @ts-ignore: decorator
187
+ @inline export function bpp8(c: u16): u16 {
188
+ return c | 0x80;
189
+ }
190
+ // @ts-ignore: decorator
191
+ @inline export function mosaic(c: u16): u16 {
192
+ return c | 0x40;
193
+ }
194
+ }
195
+
196
+ /** Packs 8-bit channels into RGB555. */
197
+ // @ts-ignore: decorator
198
+ @inline export function rgb(r: i32, g: i32, b: i32): u16 {
199
+ return <u16>(((r & 255) >> 3) | (((g & 255) >> 3) << 5) | (((b & 255) >> 3) << 10));
200
+ }
201
+
202
+ /** Sets background color `i` (0–255; 16 banks of 16 for 4bpp graphics). */
203
+ // @ts-ignore: decorator
204
+ @inline export function setBgColor(i: i32, c: u16): void {
205
+ store<u16>(PAL_BG + (<usize>i << 1), c);
206
+ }
207
+ /** Sets sprite color `i` (0–255). */
208
+ // @ts-ignore: decorator
209
+ @inline export function setObjColor(i: i32, c: u16): void {
210
+ store<u16>(PAL_OBJ + (<usize>i << 1), c);
211
+ }
212
+
213
+ /** Address of 4bpp background tile `index` counted from character block `block` (0–3). Tiles are 32 bytes. */
214
+ // @ts-ignore: decorator
215
+ @inline export function bgTile(block: i32, index: i32): usize {
216
+ return VRAM + <usize>block * 0x4000 + (<usize>index << 5);
217
+ }
218
+ /** Address of 4bpp sprite tile `index` (0–1023). */
219
+ // @ts-ignore: decorator
220
+ @inline export function objTile(index: i32): usize {
221
+ return OBJ_VRAM + (<usize>index << 5);
222
+ }
223
+ /** Fills a 32-byte 4bpp tile with one byte value, e.g. 0x11 for solid color 1. */
224
+ // @ts-ignore: decorator
225
+ @inline export function fillTile(tile: usize, byte: u8): void {
226
+ memory.fill(tile, byte, 32);
227
+ }
228
+
229
+ /** Address of screen block `block` (0–31): a 32×32 map of u16 entries, row-major. */
230
+ // @ts-ignore: decorator
231
+ @inline export function screenBlock(block: i32): usize {
232
+ return VRAM + <usize>block * 0x800;
233
+ }
234
+ /** Sets entry `i` (0–1023) of screen block `block`. */
235
+ // @ts-ignore: decorator
236
+ @inline export function setScreenEntry(block: i32, i: i32, e: u16): void {
237
+ store<u16>(screenBlock(block) + (<usize>i << 1), e);
238
+ }
239
+
240
+ /** Background map entries (SPEC.md §8.2). */
241
+ export namespace screenEntry {
242
+ // @ts-ignore: decorator
243
+ @inline export function make(tile: u16, bank: u16): u16 {
244
+ return (tile & 0x3ff) | ((bank & 0xf) << 12);
245
+ }
246
+ // @ts-ignore: decorator
247
+ @inline export function hflip(e: u16): u16 {
248
+ return e | (1 << 10);
249
+ }
250
+ // @ts-ignore: decorator
251
+ @inline export function vflip(e: u16): u16 {
252
+ return e | (1 << 11);
253
+ }
254
+ }
255
+
256
+ // ---- Sprites (SPEC.md §9) ------------------------------------------------------
257
+
258
+ /** Sprite sizes as shape × 4 + size (SPEC.md §9.2). */
259
+ export enum SpriteSize {
260
+ S8x8 = 0,
261
+ S16x16 = 1,
262
+ S32x32 = 2,
263
+ S64x64 = 3,
264
+ W16x8 = 4,
265
+ W32x8 = 5,
266
+ W32x16 = 6,
267
+ W64x32 = 7,
268
+ T8x16 = 8,
269
+ T8x32 = 9,
270
+ T16x32 = 10,
271
+ T32x64 = 11,
272
+ }
273
+
274
+ /** Width in pixels of a sprite size. */
275
+ export function spriteWidth(s: SpriteSize): i32 {
276
+ const shape = <i32>s >> 2, size = <i32>s & 3;
277
+ if (shape == 0) return 8 << size;
278
+ if (shape == 1) return size == 0 ? 16 : size == 3 ? 64 : 32;
279
+ return size == 3 ? 32 : size == 2 ? 16 : 8;
280
+ }
281
+ /** Height in pixels of a sprite size. */
282
+ export function spriteHeight(s: SpriteSize): i32 {
283
+ const shape = <i32>s >> 2, size = <i32>s & 3;
284
+ if (shape == 0) return 8 << size;
285
+ if (shape == 1) return size == 3 ? 32 : size == 2 ? 16 : 8;
286
+ return size == 0 ? 16 : size == 3 ? 64 : 32;
287
+ }
288
+
289
+ /** One OAM entry's three attributes, packed as attr0 | attr1 << 16 | attr2 << 32. */
290
+ export type Sprite = u64;
291
+
292
+ /** Builders for sprites (SPEC.md §9.1), e.g. `sprite.affine(sprite.make(x, y, SpriteSize.S16x16, 0), 0, true)`. */
293
+ export namespace sprite {
294
+ export const HIDDEN: Sprite = <u64>OBJ_HIDDEN;
295
+
296
+ // @ts-ignore: decorator
297
+ @inline export function attr0(s: Sprite): u16 {
298
+ return <u16>s;
299
+ }
300
+ // @ts-ignore: decorator
301
+ @inline export function attr1(s: Sprite): u16 {
302
+ return <u16>(s >> 16);
303
+ }
304
+ // @ts-ignore: decorator
305
+ @inline export function attr2(s: Sprite): u16 {
306
+ return <u16>(s >> 32);
307
+ }
308
+ // @ts-ignore: decorator
309
+ @inline export function pack(a0: u16, a1: u16, a2: u16): Sprite {
310
+ return <u64>a0 | (<u64>a1 << 16) | (<u64>a2 << 32);
311
+ }
312
+
313
+ /** A sprite with its top-left at screen (x, y), using `tile` and bank 0. */
314
+ export function make(x: i32, y: i32, size: SpriteSize, tile: u16): Sprite {
315
+ const shape = <u16>(<i32>size >> 2), sz = <u16>(<i32>size & 3);
316
+ return pack(<u16>(y & 0xff) | (shape << 14), <u16>(x & 0x1ff) | (sz << 14), tile & 0x3ff);
317
+ }
318
+ export function bank(s: Sprite, b: u16): Sprite {
319
+ return pack(attr0(s), attr1(s), (attr2(s) & 0x0fff) | ((b & 0xf) << 12));
320
+ }
321
+ export function priority(s: Sprite, p: u16): Sprite {
322
+ return pack(attr0(s), attr1(s), (attr2(s) & ~0x0c00) | ((p & 3) << 10));
323
+ }
324
+ export function hflip(s: Sprite, on: bool): Sprite {
325
+ return pack(attr0(s), on ? attr1(s) | 0x1000 : attr1(s) & ~0x1000, attr2(s));
326
+ }
327
+ export function vflip(s: Sprite, on: bool): Sprite {
328
+ return pack(attr0(s), on ? attr1(s) | 0x2000 : attr1(s) & ~0x2000, attr2(s));
329
+ }
330
+ export function semiTransparent(s: Sprite): Sprite {
331
+ return pack((attr0(s) & ~0x0c00) | 0x0400, attr1(s), attr2(s));
332
+ }
333
+ export function window(s: Sprite): Sprite {
334
+ return pack((attr0(s) & ~0x0c00) | 0x0800, attr1(s), attr2(s));
335
+ }
336
+ export function mosaic(s: Sprite): Sprite {
337
+ return pack(attr0(s) | 0x1000, attr1(s), attr2(s));
338
+ }
339
+ export function bpp8(s: Sprite): Sprite {
340
+ return pack(attr0(s) | 0x2000, attr1(s), attr2(s));
341
+ }
342
+ /** Uses affine matrix `slot` (0–31). With `doubleSize` the bounding box
343
+ * doubles so rotated or enlarged pixels are not clipped. */
344
+ export function affine(s: Sprite, slot: u16, doubleSize: bool): Sprite {
345
+ const a0 = attr0(s) | 0x0100 | (doubleSize ? 0x0200 : 0);
346
+ const a1 = (attr1(s) & ~0x3e00) | ((slot & 31) << 9);
347
+ return pack(<u16>a0, a1, attr2(s));
348
+ }
349
+ }
350
+
351
+ /** An affine matrix in 8.8 fixed point, mapping screen to texture space,
352
+ * packed as pa | pb << 16 | pc << 32 | pd << 48. */
353
+ export type AffineMatrix = u64;
354
+
355
+ export namespace affine {
356
+ export const IDENTITY: AffineMatrix = 256 | (<u64>256 << 48);
357
+
358
+ // @ts-ignore: decorator
359
+ @inline export function make(pa: i16, pb: i16, pc: i16, pd: i16): AffineMatrix {
360
+ return <u64><u16>pa | (<u64><u16>pb << 16) | (<u64><u16>pc << 32) | (<u64><u16>pd << 48);
361
+ }
362
+ // @ts-ignore: decorator
363
+ @inline export function pa(m: AffineMatrix): i16 {
364
+ return <i16>m;
365
+ }
366
+ // @ts-ignore: decorator
367
+ @inline export function pb(m: AffineMatrix): i16 {
368
+ return <i16>(m >> 16);
369
+ }
370
+ // @ts-ignore: decorator
371
+ @inline export function pc(m: AffineMatrix): i16 {
372
+ return <i16>(m >> 32);
373
+ }
374
+ // @ts-ignore: decorator
375
+ @inline export function pd(m: AffineMatrix): i16 {
376
+ return <i16>(m >> 48);
377
+ }
378
+
379
+ function clamp16(v: i32): i16 {
380
+ return <i16>(v < -32768 ? -32768 : v > 32767 ? 32767 : v);
381
+ }
382
+
383
+ /** Rotates counter-clockwise by `angle` 1/256 turns and scales by (sx, sy)
384
+ * in 8.8 fixed point (256 = 1×, nonzero). Integer math, identical in
385
+ * every WASM-AD SDK. */
386
+ export function rotateScaleFixed(angle: i32, sx: i32, sy: i32): AffineMatrix {
387
+ const s = fixed.sin(angle), c = fixed.cos(angle);
388
+ return make(clamp16((c * 4) / sx), clamp16((-s * 4) / sx), clamp16((s * 4) / sy), clamp16((c * 4) / sy));
389
+ }
390
+ }
391
+
392
+ /** OAM: 128 sprites, plus affine matrices stored in the gaps. */
393
+ export namespace oam {
394
+ export function set(i: i32, s: Sprite): void {
395
+ const at = OAM + (<usize>i << 3);
396
+ store<u16>(at, sprite.attr0(s));
397
+ store<u16>(at + 2, sprite.attr1(s));
398
+ store<u16>(at + 4, sprite.attr2(s));
399
+ }
400
+ export function hide(i: i32): void {
401
+ set(i, sprite.HIDDEN);
402
+ }
403
+ export function hideAll(): void {
404
+ for (let i = 0; i < 128; i++) hide(i);
405
+ }
406
+ export function setAffine(slot: i32, m: AffineMatrix): void {
407
+ const at = OAM + <usize>slot * 32 + 6;
408
+ store<u16>(at, <u16>affine.pa(m));
409
+ store<u16>(at + 8, <u16>affine.pb(m));
410
+ store<u16>(at + 16, <u16>affine.pc(m));
411
+ store<u16>(at + 24, <u16>affine.pd(m));
412
+ }
413
+ }
414
+
415
+ // ---- Fixed-point trigonometry --------------------------------------------------
416
+
417
+ /** Fixed-point trigonometry shared by all WASM-AD SDKs, so carts in any
418
+ * language compute bit-identical results (SPEC.md §12). */
419
+ export namespace fixed {
420
+ /** sin over a quarter turn in 1/256-turn steps, scaled by 16384 (Q14). */
421
+ export const SIN_Q14: usize = memory.data<i32>([
422
+ 0, 402, 804, 1205, 1606, 2006, 2404, 2801, 3196, 3590, 3981, 4370, 4756, 5139, 5520, 5897, 6270, 6639, 7005,
423
+ 7366, 7723, 8076, 8423, 8765, 9102, 9434, 9760, 10080, 10394, 10702, 11003, 11297, 11585, 11866, 12140, 12406,
424
+ 12665, 12916, 13160, 13395, 13623, 13842, 14053, 14256, 14449, 14635, 14811, 14978, 15137, 15286, 15426, 15557,
425
+ 15679, 15791, 15893, 15986, 16069, 16143, 16207, 16261, 16305, 16340, 16364, 16379, 16384,
426
+ ]);
427
+
428
+ // @ts-ignore: decorator
429
+ @inline function q(i: i32): i32 {
430
+ return load<i32>(SIN_Q14 + (<usize>i << 2));
431
+ }
432
+
433
+ /** sin of `angle` 1/256 turns (only the low 8 bits count), in Q14. */
434
+ export function sin(angle: i32): i32 {
435
+ const a = angle & 255;
436
+ if (a < 64) return q(a);
437
+ if (a < 128) return q(128 - a);
438
+ if (a < 192) return -q(a - 128);
439
+ return -q(256 - a);
440
+ }
441
+
442
+ /** cos of `angle` 1/256 turns, in Q14. */
443
+ export function cos(angle: i32): i32 {
444
+ return sin(angle + 64);
445
+ }
446
+ }
447
+
448
+ // ---- Input, time, save RAM -------------------------------------------------------
449
+
450
+ /** Buttons held this frame. */
451
+ // @ts-ignore: decorator
452
+ @inline export function keys(): u16 {
453
+ return load<u16>(reg.KEYS);
454
+ }
455
+
456
+ /** Edge detection over `KEYS`: call `buttons.update()` at the start of every
457
+ * frame. Its two words of state live in linear memory. */
458
+ export namespace buttons {
459
+ const STATE: usize = memory.data(4); // held: u16, prev: u16
460
+
461
+ export function update(): void {
462
+ store<u16>(STATE + 2, load<u16>(STATE));
463
+ store<u16>(STATE, keys());
464
+ }
465
+ export function held(k: u16): bool {
466
+ return (load<u16>(STATE) & k) != 0;
467
+ }
468
+ /** 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;
471
+ }
472
+ }
473
+
474
+ /** Frames since boot. */
475
+ // @ts-ignore: decorator
476
+ @inline export function frame(): u32 {
477
+ return load<u32>(reg.FRAME);
478
+ }
479
+
480
+ /** The boot-time random seed, the cart's only entropy (SPEC.md §12). */
481
+ // @ts-ignore: decorator
482
+ @inline export function seed(): u32 {
483
+ return load<u32>(reg.SEED);
484
+ }
485
+
486
+ /** Address of the 32 KB battery-backed save RAM. */
487
+ // @ts-ignore: decorator
488
+ @inline export function saveRam(): usize {
489
+ return SAVE;
490
+ }
491
+
492
+ /** Whether the host restored save RAM from storage at boot. */
493
+ // @ts-ignore: decorator
494
+ @inline export function saveRestored(): bool {
495
+ return (load<u16>(reg.SAVESTAT) & 1) != 0;
496
+ }
497
+
498
+ /** Asks the host to persist save RAM after this frame. */
499
+ // @ts-ignore: decorator
500
+ @inline export function requestSave(): void {
501
+ store<u16>(reg.SAVECNT, load<u16>(reg.SAVECNT) | 1);
502
+ }
503
+
504
+ // ---- Debug log -------------------------------------------------------------------
505
+
506
+ // @ts-ignore: decorator
507
+ @external("env", "trace")
508
+ declare function hostTrace(ptr: usize, len: i32): void;
509
+
510
+ /** Writes a message to the host's debug log, as UTF-8 (at most 1 KB). */
511
+ export function trace(msg: string): void {
512
+ const buf = memory.data(1024);
513
+ let n: usize = 0;
514
+ const len = msg.length;
515
+ for (let i = 0; i < len; i++) {
516
+ let c = <u32>msg.charCodeAt(i);
517
+ if (c >= 0xd800 && c < 0xdc00 && i + 1 < len) {
518
+ const lo = <u32>msg.charCodeAt(i + 1);
519
+ if (lo >= 0xdc00 && lo < 0xe000) {
520
+ c = 0x10000 + ((c - 0xd800) << 10) + (lo - 0xdc00);
521
+ i++;
522
+ }
523
+ }
524
+ const need: usize = c < 0x80 ? 1 : c < 0x800 ? 2 : c < 0x10000 ? 3 : 4;
525
+ if (n + need > 1024) break;
526
+ if (need == 1) store<u8>(buf + n, c);
527
+ else if (need == 2) {
528
+ store<u8>(buf + n, 0xc0 | (c >> 6));
529
+ store<u8>(buf + n + 1, 0x80 | (c & 63));
530
+ } else if (need == 3) {
531
+ store<u8>(buf + n, 0xe0 | (c >> 12));
532
+ store<u8>(buf + n + 1, 0x80 | ((c >> 6) & 63));
533
+ store<u8>(buf + n + 2, 0x80 | (c & 63));
534
+ } else {
535
+ store<u8>(buf + n, 0xf0 | (c >> 18));
536
+ store<u8>(buf + n + 1, 0x80 | ((c >> 12) & 63));
537
+ store<u8>(buf + n + 2, 0x80 | ((c >> 6) & 63));
538
+ store<u8>(buf + n + 3, 0x80 | (c & 63));
539
+ }
540
+ n += need;
541
+ }
542
+ hostTrace(buf, <i32>n);
543
+ }
544
+
545
+ /** Replaces AssemblyScript's `env.abort` import (`--use abort=...` in asconfig.json):
546
+ * a failed assertion or bounds check traps. */
547
+ export function abort(message: string | null, file: string | null, line: u32, column: u32): void {
548
+ unreachable();
549
+ }
package/bin/wasmad.mjs ADDED
@@ -0,0 +1,428 @@
1
+ #!/usr/bin/env node
2
+ // Command-line tools for WASM-AD carts, in any language. Needs Node 18+ and
3
+ // nothing else.
4
+ //
5
+ // wasmad check <cart.wasm> validate a cart against SPEC.md
6
+ // wasmad meta <cart.wasm> key=value... set the `wasm-ad` metadata section
7
+ // wasmad run <cart.wasm> [options] run frames headlessly
8
+ //
9
+ // `meta` replaces the metadata section in place (or writes `-o <file>`); `spec`
10
+ // defaults to the version this tool implements. It is for toolchains that
11
+ // cannot emit custom sections themselves (AssemblyScript, TinyGo).
12
+ //
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"
17
+ // --seed N value for SEED (default 1)
18
+ // --hash F,F,... print a hash of the frame rendered at each listed frame
19
+ // --ppm FILE write the last rendered frame as a PPM image
20
+ // --core FILE a different core.wasm
21
+
22
+ import { readFileSync, realpathSync, writeFileSync } from 'node:fs';
23
+ import { createHash } from 'node:crypto';
24
+ import { fileURLToPath } from 'node:url';
25
+
26
+ const SPEC = '0.1';
27
+ const SPEC_VERSION = 0x0001;
28
+ const W = 240, H = 160;
29
+ const A = {
30
+ IO: 0x400, PAL_END: 0xC00, OAM: 0xC00, VIDEO_END: 0x19000, SAVE: 0x19000, SAVE_SIZE: 0x8000, CART: 0x28000,
31
+ VCOUNT: 0x406, KEYS: 0x700, FRAME: 0x704, SEED: 0x708, SAVECNT: 0x70C, SAVESTAT: 0x70E, SPEC: 0x710,
32
+ };
33
+ const KEY = { a: 1, b: 2, select: 4, start: 8, right: 16, left: 32, up: 64, down: 128, r: 256, l: 512 };
34
+ const MAX_CART = 32 * 1024 * 1024;
35
+
36
+ // ---- WebAssembly binary reading -------------------------------------------
37
+
38
+ class Reader {
39
+ constructor(bytes, pos = 0, end = bytes.length) {
40
+ Object.assign(this, { bytes, pos, end });
41
+ }
42
+ byte() {
43
+ if (this.pos >= this.end) throw new Error('unexpected end of module');
44
+ return this.bytes[this.pos++];
45
+ }
46
+ u32() {
47
+ let result = 0, shift = 0, b;
48
+ do {
49
+ b = this.byte();
50
+ result |= (b & 0x7F) << shift;
51
+ shift += 7;
52
+ } while (b & 0x80);
53
+ return result >>> 0;
54
+ }
55
+ i32() {
56
+ let result = 0, shift = 0, b;
57
+ do {
58
+ b = this.byte();
59
+ result |= (b & 0x7F) << shift;
60
+ shift += 7;
61
+ } while (b & 0x80);
62
+ return shift < 32 && b & 0x40 ? result | (-1 << shift) : result | 0;
63
+ }
64
+ skip(n) { this.pos += n; }
65
+ name() {
66
+ const n = this.u32();
67
+ const s = new TextDecoder().decode(this.bytes.subarray(this.pos, this.pos + n));
68
+ this.pos += n;
69
+ return s;
70
+ }
71
+ limits() {
72
+ const flags = this.byte();
73
+ const min = this.u32();
74
+ const max = flags & 1 ? this.u32() : null;
75
+ return { flags, min, max };
76
+ }
77
+ // A constant expression; returns its value if it is a single i32.const.
78
+ constExpr() {
79
+ let value = null, first = true;
80
+ for (;;) {
81
+ const op = this.byte();
82
+ if (op === 0x0B) return value;
83
+ if (op === 0x41) value = first ? this.i32() : (this.i32(), null);
84
+ else if (op === 0x23) { this.u32(); value = null; } // global.get
85
+ else if (op === 0x42) { while (this.byte() & 0x80); value = null; }
86
+ else if (op === 0x43) { this.skip(4); value = null; }
87
+ else if (op === 0x44) { this.skip(8); value = null; }
88
+ else value = null;
89
+ first = false;
90
+ }
91
+ }
92
+ }
93
+
94
+ const VALTYPE = { 0x7F: 'i32', 0x7E: 'i64', 0x7D: 'f32', 0x7C: 'f64', 0x7B: 'v128', 0x70: 'funcref', 0x6F: 'externref' };
95
+
96
+ function sections(bytes) {
97
+ if (bytes.length < 8 || bytes[0] !== 0 || bytes[1] !== 0x61 || bytes[2] !== 0x73 || bytes[3] !== 0x6D) {
98
+ throw new Error('not a WebAssembly module');
99
+ }
100
+ const out = [];
101
+ const r = new Reader(bytes, 8);
102
+ while (r.pos < bytes.length) {
103
+ const start = r.pos;
104
+ const id = r.byte();
105
+ const size = r.u32();
106
+ const body = r.pos;
107
+ out.push({ id, start, body, end: body + size });
108
+ r.pos = body + size;
109
+ }
110
+ return out;
111
+ }
112
+
113
+ /** Parses the parts of a module that SPEC.md constrains. */
114
+ export function inspect(bytes) {
115
+ const m = { types: [], imports: [], funcs: [], memories: [], globals: [], exports: [], data: [], custom: [], start: null };
116
+ let importedFuncs = 0, importedGlobals = 0;
117
+ for (const s of sections(bytes)) {
118
+ const r = new Reader(bytes, s.body, s.end);
119
+ switch (s.id) {
120
+ case 0: m.custom.push({ name: r.name(), data: bytes.subarray(r.pos, s.end), start: s.start, end: s.end }); break;
121
+ case 1:
122
+ for (let n = r.u32(); n--;) {
123
+ if (r.byte() !== 0x60) throw new Error('unsupported type form');
124
+ const params = Array.from({ length: r.u32() }, () => VALTYPE[r.byte()]);
125
+ const results = Array.from({ length: r.u32() }, () => VALTYPE[r.byte()]);
126
+ m.types.push({ params, results });
127
+ }
128
+ break;
129
+ case 2:
130
+ for (let n = r.u32(); n--;) {
131
+ const imp = { module: r.name(), name: r.name(), kind: r.byte() };
132
+ if (imp.kind === 0) { imp.type = r.u32(); importedFuncs++; m.funcs.push(imp.type); }
133
+ else if (imp.kind === 1) { r.byte(); r.limits(); }
134
+ else if (imp.kind === 2) { imp.limits = r.limits(); m.memories.push(imp.limits); }
135
+ else if (imp.kind === 3) { r.byte(); imp.mutable = r.byte() === 1; importedGlobals++; m.globals.push({ imported: true, mutable: imp.mutable }); }
136
+ m.imports.push(imp);
137
+ }
138
+ break;
139
+ case 3: for (let n = r.u32(); n--;) m.funcs.push(r.u32()); break;
140
+ case 5: for (let n = r.u32(); n--;) m.memories.push(r.limits()); break;
141
+ case 6:
142
+ for (let n = r.u32(); n--;) {
143
+ const type = VALTYPE[r.byte()];
144
+ const mutable = r.byte() === 1;
145
+ m.globals.push({ type, mutable, init: r.constExpr() });
146
+ }
147
+ break;
148
+ case 7:
149
+ for (let n = r.u32(); n--;) m.exports.push({ name: r.name(), kind: r.byte(), index: r.u32() });
150
+ break;
151
+ case 8: m.start = r.u32(); break;
152
+ case 11:
153
+ for (let n = r.u32(); n--;) {
154
+ const flags = r.u32();
155
+ let offset = null, active = flags !== 1;
156
+ if (flags === 2) r.u32();
157
+ if (active) offset = r.constExpr();
158
+ const size = r.u32();
159
+ r.skip(size);
160
+ m.data.push({ active, offset, size });
161
+ }
162
+ break;
163
+ }
164
+ }
165
+ m.importedFuncs = importedFuncs;
166
+ m.importedGlobals = importedGlobals;
167
+ return m;
168
+ }
169
+
170
+ /** Validates a cart against SPEC.md. Returns { errors, warnings, meta, info }. */
171
+ export function check(bytes) {
172
+ const errors = [], warnings = [], info = [];
173
+ if (bytes.length > MAX_CART) errors.push(`cart is ${bytes.length} bytes; the limit is 32 MiB (§2.4)`);
174
+ let m;
175
+ try {
176
+ m = inspect(bytes);
177
+ new WebAssembly.Module(bytes);
178
+ } catch (e) {
179
+ return { errors: [`invalid module: ${e.message}`], warnings, meta: null, info };
180
+ }
181
+
182
+ // §2.2 imports
183
+ for (const imp of m.imports) {
184
+ if (imp.module === 'env' && imp.name === 'trace' && imp.kind === 0) {
185
+ const t = m.types[imp.type];
186
+ if (t.params.join() !== 'i32,i32' || t.results.length) errors.push('env.trace must have type (i32, i32) -> () (§2.2)');
187
+ } else {
188
+ errors.push(`imports ${imp.module}.${imp.name}; a cart may only import env.trace (§2.2)`);
189
+ }
190
+ }
191
+
192
+ // §2.1 exports
193
+ const exp = Object.fromEntries(m.exports.map((e) => [e.name, e]));
194
+ const funcType = (name) => m.types[m.funcs[exp[name].index]];
195
+ const sig = (t) => `(${t.params.join(', ')}) -> (${t.results.join(', ')})`;
196
+ const want = { update: '() -> ()', start: '() -> ()', scanline: '(i32) -> ()' };
197
+ if (!exp.memory || exp.memory.kind !== 2) errors.push('does not export memory (§2.1)');
198
+ else {
199
+ const mem = m.memories[exp.memory.index];
200
+ if (mem.min < 3) errors.push(`memory starts at ${mem.min} pages; at least 3 are required (§2.1)`);
201
+ if (mem.max !== null && mem.max < 256) warnings.push(`memory is capped at ${mem.max} pages; hosts allow growth to 16 MiB (§2.4)`);
202
+ info.push(`memory: ${mem.min} pages${mem.max !== null ? `, max ${mem.max}` : ''}`);
203
+ }
204
+ for (const [name, want_] of Object.entries(want)) {
205
+ if (!exp[name]) {
206
+ if (name === 'update') errors.push('does not export update (§2.1)');
207
+ continue;
208
+ }
209
+ if (exp[name].kind !== 0) { errors.push(`export ${name} is not a function (§2.1)`); continue; }
210
+ const got = sig(funcType(name));
211
+ if (got !== want_) errors.push(`${name} has type ${got}; expected ${want_} (§2.1)`);
212
+ }
213
+ info.push(`exports: ${['update', 'start', 'scanline'].filter((n) => exp[n]).join(', ')}`);
214
+ if (m.start !== null) errors.push('uses the WebAssembly start section; export `start` instead (§2.1)');
215
+
216
+ // §4 memory layout
217
+ for (const d of m.data) {
218
+ if (!d.active) continue;
219
+ if (d.offset === null) warnings.push('a data segment has a non-constant offset; cannot check it is above 0x28000');
220
+ else if ((d.offset >>> 0) < A.CART && d.size > 0) {
221
+ errors.push(`data segment at 0x${(d.offset >>> 0).toString(16)} lies below 0x28000 (§4, §13)`);
222
+ }
223
+ }
224
+ // Only the stack pointer may be a mutable global; we can't see names in a
225
+ // stripped module, so a lone i32 starting in the cart region is taken as it.
226
+ const mutable = m.globals.filter((g) => !g.imported && g.mutable);
227
+ if (mutable.length > 1) {
228
+ errors.push(`${mutable.length} mutable globals; cart state must live in linear memory, only the stack pointer may be a global (§12)`);
229
+ } else if (mutable.length === 1) {
230
+ const [g] = mutable;
231
+ if (g.type !== 'i32') errors.push(`a mutable ${g.type} global holds cart state; it must live in linear memory (§12)`);
232
+ else if (g.init !== null && (g.init >>> 0) < A.CART) {
233
+ errors.push(`a mutable global starts at 0x${(g.init >>> 0).toString(16)}: as the stack pointer it would be inside the hardware region (§4, §13), and anything else is cart state that must live in linear memory (§12)`);
234
+ } else if (g.init !== null) info.push(`stack pointer: 0x${(g.init >>> 0).toString(16)}`);
235
+ }
236
+
237
+ // §2.3 metadata
238
+ let meta = null;
239
+ const sections_ = m.custom.filter((c) => c.name === 'wasm-ad');
240
+ if (!sections_.length) errors.push('missing the "wasm-ad" metadata section (§2.3)');
241
+ else {
242
+ if (sections_.length > 1) warnings.push('more than one "wasm-ad" section; hosts read the first');
243
+ meta = parseMeta(sections_[0].data);
244
+ if (!meta.spec) errors.push('metadata has no spec key (§2.3)');
245
+ else if (!/^\d+\.\d+$/.test(meta.spec)) errors.push(`metadata spec "${meta.spec}" is not major.minor (§2.3)`);
246
+ if (!meta.title) errors.push('metadata has no title (§2.3)');
247
+ }
248
+ return { errors, warnings, meta, info };
249
+ }
250
+
251
+ export function parseMeta(data) {
252
+ const meta = {};
253
+ for (const line of new TextDecoder().decode(data).split('\n')) {
254
+ const i = line.indexOf('=');
255
+ if (i > 0) meta[line.slice(0, i).trim().toLowerCase()] = line.slice(i + 1).trim();
256
+ }
257
+ return meta;
258
+ }
259
+
260
+ function leb(n) {
261
+ const out = [];
262
+ do {
263
+ let b = n & 0x7F;
264
+ n >>>= 7;
265
+ if (n) b |= 0x80;
266
+ out.push(b);
267
+ } while (n);
268
+ return out;
269
+ }
270
+
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]);
277
+ const parts = [bytes.subarray(0, 8)];
278
+ for (const s of sections(bytes)) {
279
+ if (s.id === 0 && new Reader(bytes, s.body, s.end).name() === 'wasm-ad') continue;
280
+ parts.push(bytes.subarray(s.start, s.end));
281
+ }
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; }
286
+ return out;
287
+ }
288
+
289
+ // ---- Headless host ----------------------------------------------------------
290
+
291
+ /** Parses "0-29:right,30-59:down+b,60:a" into a frame -> KEYS function. */
292
+ export function parseKeys(script) {
293
+ const spans = (script ?? '').split(',').filter(Boolean).map((part) => {
294
+ const [range, buttons] = part.split(':');
295
+ const [from, to = from] = range.split('-').map(Number);
296
+ let mask = 0;
297
+ 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];
300
+ }
301
+ return { from, to, mask };
302
+ });
303
+ return (frame) => spans.reduce((k, s) => (frame >= s.from && frame <= s.to ? k | s.mask : k), 0);
304
+ }
305
+
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 = () => {} } = {}) {
308
+ const core = (await WebAssembly.instantiate(readFileSync(corePath))).instance.exports;
309
+ let instance;
310
+ const decoder = new TextDecoder();
311
+ const imports = { env: { trace: (ptr, len) => onTrace(decoder.decode(new Uint8Array(instance.exports.memory.buffer, ptr, len))) } };
312
+ instance = (await WebAssembly.instantiate(bytes, imports)).instance;
313
+ const ex = instance.exports;
314
+ const mem = () => new Uint8Array(ex.memory.buffer);
315
+ 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?.();
321
+
322
+ let frame = 0;
323
+ return {
324
+ exports: ex,
325
+ /** Runs one frame with `keys` held and returns the rendered RGBA8 pixels. */
326
+ frame(keys = 0) {
327
+ dv().setUint16(A.KEYS, keys & 0x3FF, true);
328
+ dv().setUint32(A.FRAME, frame >>> 0, true);
329
+ dv().setUint16(A.VCOUNT, 160, true);
330
+ ex.update();
331
+ const coreMem = new Uint8Array(core.memory.buffer, core.ad_mem(), core.ad_mem_len());
332
+ coreMem.set(mem().subarray(0, A.VIDEO_END));
333
+ for (let y = 0; y < H; y++) {
334
+ if (ex.scanline) {
335
+ dv().setUint16(A.VCOUNT, y, true);
336
+ ex.scanline(y);
337
+ coreMem.set(mem().subarray(A.IO, A.PAL_END), A.IO);
338
+ }
339
+ core.ad_render_line(y);
340
+ }
341
+ if (dv().getUint16(A.SAVECNT, true) & 1) dv().setUint16(A.SAVECNT, dv().getUint16(A.SAVECNT, true) & ~1, true);
342
+ frame++;
343
+ return new Uint8Array(core.memory.buffer, core.ad_framebuffer(), W * H * 4).slice();
344
+ },
345
+ };
346
+ }
347
+
348
+ export function hash(pixels) {
349
+ return createHash('sha256').update(pixels).digest('hex').slice(0, 16);
350
+ }
351
+
352
+ function ppm(pixels) {
353
+ const head = new TextEncoder().encode(`P6 ${W} ${H} 255\n`);
354
+ const out = new Uint8Array(head.length + W * H * 3);
355
+ out.set(head);
356
+ for (let i = 0; i < W * H; i++) out.set(pixels.subarray(i * 4, i * 4 + 3), head.length + i * 3);
357
+ return out;
358
+ }
359
+
360
+ // ---- CLI ----------------------------------------------------------------------
361
+
362
+ function options(args) {
363
+ const opts = { _: [] };
364
+ for (let i = 0; i < args.length; i++) {
365
+ 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]);
368
+ }
369
+ return opts;
370
+ }
371
+
372
+ async function main([cmd, ...args]) {
373
+ const opts = options(args);
374
+ 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]');
377
+ return 2;
378
+ }
379
+ const bytes = new Uint8Array(readFileSync(file));
380
+
381
+ if (cmd === 'meta') {
382
+ const meta = { spec: SPEC };
383
+ for (const kv of rest) {
384
+ const i = kv.indexOf('=');
385
+ if (i <= 0) throw new Error(`expected key=value, got "${kv}"`);
386
+ meta[kv.slice(0, i).toLowerCase()] = kv.slice(i + 1);
387
+ }
388
+ if (!meta.title) throw new Error('title=... is required');
389
+ writeFileSync(opts.o ?? file, setMeta(bytes, meta));
390
+ return 0;
391
+ }
392
+
393
+ if (cmd === 'check') {
394
+ const { errors, warnings, meta, info } = check(bytes);
395
+ if (meta) console.log(`${meta.title}${meta.author ? ` by ${meta.author}` : ''} (spec ${meta.spec}), ${bytes.length} bytes`);
396
+ for (const line of info) console.log(` ${line}`);
397
+ for (const w of warnings) console.log(`warning: ${w}`);
398
+ for (const e of errors) console.log(`error: ${e}`);
399
+ if (!errors.length) console.log('ok');
400
+ return errors.length ? 1 : 0;
401
+ }
402
+
403
+ const { errors } = check(bytes);
404
+ if (errors.length) {
405
+ for (const e of errors) console.error(`error: ${e}`);
406
+ return 1;
407
+ }
408
+ 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}`) });
410
+ const keys = parseKeys(opts.keys);
411
+ const frames = Number(opts.frames ?? 1);
412
+ const marks = new Set((opts.hash ?? '').split(',').filter(Boolean).map(Number));
413
+ let pixels;
414
+ for (let f = 0; f < frames; f++) {
415
+ pixels = host.frame(keys(f));
416
+ if (marks.has(f)) console.log(`frame ${f}: ${hash(pixels)}`);
417
+ }
418
+ if (opts.ppm) writeFileSync(opts.ppm, ppm(pixels));
419
+ return 0;
420
+ }
421
+
422
+ // Run as a command, also through a symlink such as node_modules/.bin/wasmad.
423
+ if (process.argv[1] && realpathSync(fileURLToPath(import.meta.url)) === realpathSync(process.argv[1])) {
424
+ main(process.argv.slice(2)).then((code) => process.exit(code), (e) => {
425
+ console.error(`error: ${e.message}`);
426
+ process.exit(1);
427
+ });
428
+ }
@@ -0,0 +1,12 @@
1
+ {
2
+ "options": {
3
+ "runtime": "stub",
4
+ "memoryBase": 163840,
5
+ "initialMemory": 4,
6
+ "use": ["abort=~lib/@raskell/wasm-ad/assembly/index/abort"],
7
+ "optimizeLevel": 3,
8
+ "shrinkLevel": 1,
9
+ "noAssert": true,
10
+ "exportRuntime": false
11
+ }
12
+ }
package/package.json CHANGED
@@ -1,6 +1,43 @@
1
1
  {
2
2
  "name": "@raskell/wasm-ad",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "AssemblyScript SDK for writing WASM-AD carts",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/raskell-io/wasm-ad.git",
9
+ "directory": "sdk/assemblyscript"
10
+ },
11
+ "type": "module",
12
+ "main": "assembly/index.ts",
13
+ "types": "assembly/index.ts",
14
+ "files": [
15
+ "assembly",
16
+ "bin",
17
+ "asconfig.json",
18
+ "cart.asconfig.json",
19
+ "README.md",
20
+ "LICENSE"
21
+ ],
22
+ "scripts": {
23
+ "build": "asc examples/starter.ts --config asconfig.json -o build/starter.wasm && node ../../tools/wasmad.mjs meta build/starter.wasm title=Starter author=raskell.io \"description=The WASM-AD starter cart, in AssemblyScript\"",
24
+ "prepack": "mkdir -p bin && cp ../../tools/wasmad.mjs bin/wasmad.mjs && cp ../../LICENSE LICENSE"
25
+ },
26
+ "devDependencies": {
27
+ "assemblyscript": "0.28.20"
28
+ },
29
+ "keywords": [
30
+ "wasm-ad",
31
+ "assemblyscript",
32
+ "webassembly",
33
+ "fantasy-console",
34
+ "gba"
35
+ ],
36
+ "homepage": "https://wasmad.raskell.io",
37
+ "peerDependencies": {
38
+ "assemblyscript": ">=0.28.0 <0.29.0"
39
+ },
40
+ "bin": {
41
+ "wasmad": "bin/wasmad.mjs"
42
+ }
43
+ }