@8bitscript/backend-web 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.
Files changed (3) hide show
  1. package/LICENSE +21 -0
  2. package/package.json +28 -0
  3. package/src/index.mjs +360 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 8BitScript contributors
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/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "@8bitscript/backend-web",
3
+ "version": "0.1.0",
4
+ "description": "Internal: lowers IR to generated AssemblyScript and drives asc to a .wasm.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "engines": {
8
+ "node": ">=26"
9
+ },
10
+ "dependencies": {
11
+ "assemblyscript": "0.28.20",
12
+ "@8bitscript/compiler": "0.1.0"
13
+ },
14
+ "main": "./src/index.mjs",
15
+ "exports": {
16
+ ".": "./src/index.mjs",
17
+ "./package.json": "./package.json"
18
+ },
19
+ "files": [
20
+ "src"
21
+ ],
22
+ "publishConfig": {
23
+ "access": "public"
24
+ },
25
+ "scripts": {
26
+ "test": "node --test"
27
+ }
28
+ }
package/src/index.mjs ADDED
@@ -0,0 +1,360 @@
1
+ // The web backend: IR in, .wasm out.
2
+ //
3
+ // It emits AssemblyScript and hands it to asc. AssemblyScript's sized integer
4
+ // types match the machine types one-to-one, which is most of why it is the web
5
+ // target's language: `u8` means the same wrapped byte in both worlds. The one
6
+ // wrinkle is that AssemblyScript widens integer arithmetic to i32, so every
7
+ // store narrows back explicitly — `x = <u8>(x + 1)` — which is exactly the
8
+ // wrap-at-assignment semantics the design specifies for this target.
9
+ import { spawn } from 'node:child_process';
10
+ import { createRequire } from 'node:module';
11
+ import { mkdir, writeFile } from 'node:fs/promises';
12
+ import { dirname } from 'node:path';
13
+
14
+ import { PRIMITIVE_INTEGER_TYPES, entryOf } from '@8bitscript/compiler';
15
+
16
+ // AssemblyScript has no 24-bit integer either, so mediumint/umediumint widen
17
+ // the same way they do for the 6502 backend. Bits/signedness come from the
18
+ // compiler's shared type registry rather than a second hand-written table.
19
+ const NATIVE_WIDTH = { 8: 8, 16: 16, 24: 32, 32: 32 };
20
+ const AS_TYPE = Object.fromEntries(
21
+ PRIMITIVE_INTEGER_TYPES.map((t) => [t.canonicalName, `${t.signed ? 'i' : 'u'}${NATIVE_WIDTH[t.bits]}`]),
22
+ );
23
+ AS_TYPE.bool = 'bool';
24
+ // A string value is a pointer into linear memory at its constant,
25
+ // length-prefixed bytes — a static data segment asc lays out from
26
+ // STRING_DATA_BASE (see buildWasm), above the screen agreement in
27
+ // @8bitscript/web so a `clearScreen()` can never write over a label.
28
+ AS_TYPE.string = 'usize';
29
+ // An array parameter is the address of the array's first element, the same
30
+ // way a string parameter is: linear memory, indexed by elementAddress().
31
+ // The element type and the length live in the callee's own signature, so
32
+ // neither travels with the call.
33
+ AS_TYPE.array = 'usize';
34
+ // Bytes one element of each type takes in linear memory: the stride of an
35
+ // array, and what `memory.data(size)` reserves for a `let` array.
36
+ const AS_SIZE = Object.fromEntries(
37
+ PRIMITIVE_INTEGER_TYPES.map((t) => [t.canonicalName, NATIVE_WIDTH[t.bits] / 8]),
38
+ );
39
+ AS_SIZE.bool = 1;
40
+
41
+ /** The address of element `index` of the array `expr.array`, as AssemblyScript. */
42
+ function elementAddress(expr, signatures) {
43
+ const size = AS_SIZE[expr.elementType];
44
+ const index = emitExpression(expr.index, signatures);
45
+ return `<usize>(${emitExpression(expr.array, signatures)} + <usize>(${index})${size === 1 ? '' : ` * ${size}`})`;
46
+ }
47
+
48
+ const stringName = (index) => `__8bs_str_${index}`;
49
+
50
+ // Where asc places static data (`--memoryBase`): 0xE000, the top 8KB of
51
+ // the 64KB page — where a Commodore's ROM sits — so constant data never
52
+ // overlaps the character screen at the bottom (@8bitscript/web's
53
+ // WebRegisters agreement ends at 2003) nor anything a program is likely to
54
+ // memory.write() itself.
55
+ export const STRING_DATA_BASE = 0xE000;
56
+
57
+ // `memory.read`/`memory.write` map straight onto AssemblyScript's own
58
+ // linear-memory intrinsics: a byte at a runtime offset is exactly what
59
+ // `load<u8>`/`store<u8>` are for. This makes raw memory access target-
60
+ // symmetric — real hardware on native, a flat 64KB buffer standing in for it
61
+ // on the web, per the `--initialMemory 1` reservation in `buildWasm` below.
62
+ // `signatures` maps every function's name to its parameters' AssemblyScript
63
+ // types, so a call's arguments narrow to what the callee declared — the
64
+ // same wrap-at-assignment rule a store gets, since AssemblyScript widens
65
+ // arithmetic to i32 and refuses to pass a widened value to a `u8` on its
66
+ // own.
67
+ function emitCall(call, signatures) {
68
+ const paramTypes = signatures.get(call.name) ?? [];
69
+ const args = call.args.map((arg, i) => {
70
+ const emitted = emitExpression(arg, signatures);
71
+ return paramTypes[i] ? `<${paramTypes[i]}>${emitted}` : emitted;
72
+ });
73
+ return `${call.name}(${args.join(', ')})`;
74
+ }
75
+
76
+ function emitExpression(expr, signatures) {
77
+ switch (expr.kind) {
78
+ case 'const': return String(expr.value);
79
+ case 'ref': return expr.name;
80
+ case 'binop':
81
+ return `(${emitExpression(expr.left, signatures)} ${expr.operator} ${emitExpression(expr.right, signatures)})`;
82
+ case 'unop':
83
+ return `(${expr.operator}${emitExpression(expr.argument, signatures)})`;
84
+ case 'call':
85
+ return emitCall(expr, signatures);
86
+ case 'memoryRead':
87
+ return `load<u8>(${emitExpression(expr.address, signatures)})`;
88
+ case 'string':
89
+ return stringName(expr.index);
90
+ case 'stringLength':
91
+ // Byte 0 is the length; the characters follow.
92
+ return `load<u8>(${emitExpression(expr.string, signatures)})`;
93
+ case 'stringByte':
94
+ return `load<u8>(<usize>(${emitExpression(expr.string, signatures)} + 1 + ${emitExpression(expr.index, signatures)}))`;
95
+ case 'index':
96
+ return `load<${AS_TYPE[expr.elementType]}>(${elementAddress(expr, signatures)})`;
97
+ default:
98
+ throw new Error(`backend-web: unknown IR expression '${expr.kind}'`);
99
+ }
100
+ }
101
+
102
+ function emitStatement(statement, indent, types, returnType, signatures) {
103
+ const pad = ' '.repeat(indent);
104
+ switch (statement.kind) {
105
+ case 'assign': {
106
+ const type = types.get(statement.target) ?? 'i32';
107
+ return `${pad}${statement.target} = <${type}>${emitExpression(statement.value, signatures)};\n`;
108
+ }
109
+ case 'local': {
110
+ const type = AS_TYPE[statement.type];
111
+ return `${pad}let ${statement.name}: ${type} = <${type}>${emitExpression(statement.init, signatures)};\n`;
112
+ }
113
+ case 'stringCopy':
114
+ return `${pad}__8bs_string_copy(${emitExpression(statement.target, signatures)}, ${emitExpression(statement.source, signatures)}, ${statement.capacity});\n`;
115
+ case 'for': {
116
+ const clause = (s) => (s ? emitStatement(s, 0, types, returnType, signatures).trim().replace(/;$/, '') : '');
117
+ let out = `${pad}for (${clause(statement.init)}; ${statement.test ? emitExpression(statement.test, signatures) : ''}; ${clause(statement.update)}) {\n`;
118
+ out += statement.body.map((s) => emitStatement(s, indent + 1, types, returnType, signatures)).join('');
119
+ return `${out}${pad}}\n`;
120
+ }
121
+ case 'storeIndex': {
122
+ // The same narrowing a store to a scalar gets, to the element's width.
123
+ const type = AS_TYPE[statement.elementType];
124
+ return `${pad}store<${type}>(${elementAddress(statement, signatures)}, <${type}>${emitExpression(statement.value, signatures)});\n`;
125
+ }
126
+ case 'call':
127
+ return `${pad}${emitCall(statement, signatures)};\n`;
128
+ case 'waitFrame':
129
+ // The host import declared at the top of the module (see
130
+ // emitAssemblyScript) — the page's worker blocks it on the frame clock,
131
+ // a headless host counts it. Not something the wasm can do alone.
132
+ return `${pad}waitFrame();\n`;
133
+ case 'memoryWrite':
134
+ return `${pad}store<u8>(${emitExpression(statement.address, signatures)}, ${emitExpression(statement.value, signatures)});\n`;
135
+ case 'memoryRead':
136
+ // Only reachable as a bare statement; the byte read is discarded.
137
+ return `${pad}${emitExpression(statement, signatures)};\n`;
138
+ case 'if': {
139
+ let out = `${pad}if (${emitExpression(statement.test, signatures)}) {\n`;
140
+ out += statement.then.map((s) => emitStatement(s, indent + 1, types, returnType, signatures)).join('');
141
+ if (statement.else) {
142
+ out += `${pad}} else {\n`;
143
+ out += statement.else.map((s) => emitStatement(s, indent + 1, types, returnType, signatures)).join('');
144
+ }
145
+ return `${out}${pad}}\n`;
146
+ }
147
+ case 'while': {
148
+ let out = `${pad}while (${emitExpression(statement.test, signatures)}) {\n`;
149
+ out += statement.body.map((s) => emitStatement(s, indent + 1, types, returnType, signatures)).join('');
150
+ return `${out}${pad}}\n`;
151
+ }
152
+ case 'block': {
153
+ let out = `${pad}{\n`;
154
+ out += statement.body.map((s) => emitStatement(s, indent + 1, types, returnType, signatures)).join('');
155
+ return `${out}${pad}}\n`;
156
+ }
157
+ case 'return':
158
+ // Same wrap-at-assignment narrowing a store gets: AS widens arithmetic
159
+ // to i32, so a returned expression is cast back to the declared width.
160
+ return statement.value
161
+ ? `${pad}return <${AS_TYPE[returnType]}>${emitExpression(statement.value, signatures)};\n`
162
+ : `${pad}return;\n`;
163
+ case 'break': return `${pad}break;\n`;
164
+ case 'continue': return `${pad}continue;\n`;
165
+ case 'asm':
166
+ throw Object.assign(
167
+ new Error('asm6502 blocks are 6502 code and cannot run on the web target'),
168
+ { targetLimitation: true },
169
+ );
170
+ default:
171
+ throw new Error(`backend-web: unknown IR statement '${statement.kind}'`);
172
+ }
173
+ }
174
+
175
+ /** Every statement in every function, nested ones included. */
176
+ function forEachStatement(functions, visit) {
177
+ const walkBody = (body) => {
178
+ for (const s of body) {
179
+ visit(s);
180
+ if (s.kind === 'if') { walkBody(s.then); if (s.else) walkBody(s.else); }
181
+ else if (s.kind === 'while' || s.kind === 'block') walkBody(s.body);
182
+ else if (s.kind === 'for') { if (s.init) visit(s.init); if (s.update) visit(s.update); walkBody(s.body); }
183
+ }
184
+ };
185
+ for (const fn of functions) walkBody(fn.body);
186
+ }
187
+
188
+ /**
189
+ * Generate the AssemblyScript module for an IR program.
190
+ *
191
+ * `usesWaitFrame` tells buildWasm() to build with shared memory: the page
192
+ * runs a waitFrame() program in a worker that blocks on the frame clock,
193
+ * and paints its memory from the main thread.
194
+ *
195
+ * @returns {{ ok: true, source: string, usesWaitFrame: boolean } | { ok: false, error: string }}
196
+ */
197
+ export function emitAssemblyScript(ir) {
198
+ // An array's name is its address (a `usize`), never assigned as a whole,
199
+ // so it has no entry here; a store into it narrows to the element type.
200
+ const types = new Map(ir.globals.filter((g) => !g.array).map((g) => [g.name, AS_TYPE[g.type]]));
201
+ const signatures = new Map(ir.functions.map((fn) => [fn.name, fn.params.map((p) => AS_TYPE[p.type])]));
202
+ let out = '// Generated by 8bs. Do not edit: the source of truth is the .8bs file.\n';
203
+
204
+ let usesWaitFrame = false;
205
+ let usesStringCopy = false;
206
+ forEachStatement(ir.functions, (s) => {
207
+ if (s.kind === 'waitFrame') usesWaitFrame = true;
208
+ if (s.kind === 'stringCopy') usesStringCopy = true;
209
+ });
210
+ if (usesWaitFrame) {
211
+ // A host import: the wasm cannot wait on its own (a browser needs the
212
+ // thread back to paint), so "block until the next logical frame" is the
213
+ // one thing the host supplies — see packages/cli/src/web-runtime.mjs
214
+ // (worker + Atomics.wait) and packages/cli/src/wasm-host.mjs (a counter).
215
+ out += '// @ts-ignore: decorator\n';
216
+ out += '@external("env", "waitFrame")\n';
217
+ out += 'declare function waitFrame(): void;\n';
218
+ }
219
+ out += '\n';
220
+
221
+ for (const [index, s] of (ir.strings ?? []).entries()) {
222
+ out += `const ${stringName(index)}: usize = memory.data<u8>([${[s.bytes.length, ...s.bytes].join(', ')}]); // "${s.text}"\n`;
223
+ }
224
+ if (ir.strings?.length) out += '\n';
225
+ if (usesStringCopy) {
226
+ // `name = other` on a string<N>: length byte, then the characters, cut
227
+ // to the capacity — the same helper the 6502 backend emits, in
228
+ // AssemblyScript.
229
+ out += 'function __8bs_string_copy(dst: usize, src: usize, capacity: u8): void {\n';
230
+ out += ' let n: u8 = load<u8>(src);\n';
231
+ out += ' if (n > capacity) n = capacity;\n';
232
+ out += ' store<u8>(dst, n);\n';
233
+ out += ' for (let i: u8 = 0; i < n; i++) store<u8>(dst + 1 + i, load<u8>(src + 1 + i));\n';
234
+ out += '}\n\n';
235
+ }
236
+
237
+ for (const g of ir.globals) {
238
+ if (g.array) {
239
+ // An array is a static chunk of linear memory, laid out by asc above
240
+ // STRING_DATA_BASE with the string constants: `const` with its
241
+ // values, `let` zeroed (or with its values), and an `@address` array
242
+ // N cells from that offset in the 64KB page — the web target has no
243
+ // hardware there, but the memory exists, so a screen-RAM-shaped array
244
+ // over the @8bitscript/web agreement is as real as on a Commodore.
245
+ const type = AS_TYPE[g.type];
246
+ const data = g.address !== null ? String(g.address)
247
+ : g.init ? `memory.data<${type}>([${g.init.join(', ')}])`
248
+ : `memory.data(${g.array * AS_SIZE[g.type]})`;
249
+ out += `export const ${g.name}: usize = ${data};\n`;
250
+ continue;
251
+ }
252
+ if (g.address !== null) {
253
+ return {
254
+ ok: false,
255
+ error: `'${g.name}' is mapped to hardware address 0x${g.address.toString(16)} with @address; ` +
256
+ 'there is no such hardware on the web target',
257
+ };
258
+ }
259
+ // Exported so a host can observe the program's state; wasm mutable-global
260
+ // exports are exactly this use case.
261
+ out += `export let ${g.name}: ${AS_TYPE[g.type]} = ${g.init};\n`;
262
+ }
263
+ out += '\n';
264
+
265
+ // Only the entry is a wasm export — the artifact mirrors the language rule
266
+ // (the entry module's one export is the program), and a host finds the
267
+ // program as "the exported function" rather than by a magic name.
268
+ const entry = entryOf(ir);
269
+ try {
270
+ for (const fn of ir.functions) {
271
+ const params = fn.params.map((p) => `${p.name}: ${AS_TYPE[p.type]}`).join(', ');
272
+ const returnType = fn.returnType === 'void' ? 'void' : AS_TYPE[fn.returnType];
273
+ // A parameter is assignable like a global (`value = value / 10`), and
274
+ // narrows back to its own width the same way; it shadows a same-named
275
+ // global inside its function, as the linker's scoping already says.
276
+ // Locals too: a store to one narrows to its declared width. Names
277
+ // are function-wide here (a local shadows a global of the name in
278
+ // every store after its declaration, which the linker's scoping
279
+ // already guarantees is the only place it is named).
280
+ const locals = [];
281
+ forEachStatement([fn], (s) => { if (s.kind === 'local') locals.push([s.name, AS_TYPE[s.type]]); });
282
+ const fnTypes = fn.params.length || locals.length
283
+ ? new Map([...types, ...fn.params.map((p) => [p.name, AS_TYPE[p.type]]), ...locals])
284
+ : types;
285
+ out += `${fn.name === entry ? 'export ' : ''}function ${fn.name}(${params}): ${returnType} {\n`;
286
+ out += fn.body.map((s) => emitStatement(s, 1, fnTypes, fn.returnType, signatures)).join('');
287
+ out += '}\n\n';
288
+ }
289
+ } catch (error) {
290
+ if (error.targetLimitation) return { ok: false, error: error.message };
291
+ throw error;
292
+ }
293
+ return { ok: true, source: out, usesWaitFrame };
294
+ }
295
+
296
+ /** The asc binary, from this package's own dependencies. */
297
+ function findAsc() {
298
+ const require = createRequire(import.meta.url);
299
+ return require.resolve('assemblyscript/bin/asc.js');
300
+ }
301
+
302
+ /**
303
+ * Compile IR to a .wasm via asc.
304
+ *
305
+ * @param {object} ir
306
+ * @param {{ outFile: string }} options
307
+ * @returns {Promise<{ ok: boolean, asFile?: string, error?: string }>}
308
+ */
309
+ export async function buildWasm(ir, { outFile }) {
310
+ if (ir.imports?.length) {
311
+ // Unresolved imports mean the caller skipped the linker. Refusing here is
312
+ // what keeps a lower→backend shortcut from silently dropping modules.
313
+ return { ok: false, error: 'the IR still has unresolved imports: link() it before the backend' };
314
+ }
315
+ const emitted = emitAssemblyScript(ir);
316
+ if (!emitted.ok) return { ok: false, error: emitted.error };
317
+
318
+ const asFile = outFile.replace(/\.wasm$/, '.ts');
319
+ await mkdir(dirname(outFile), { recursive: true });
320
+ await writeFile(asFile, emitted.source, 'utf8');
321
+
322
+ return new Promise((resolvePromise) => {
323
+ const child = spawn(
324
+ process.execPath,
325
+ [
326
+ findAsc(), asFile, '-o', outFile, '-O3', '--runtime', 'stub',
327
+ // Static data (string constants) starts here — see STRING_DATA_BASE.
328
+ '--memoryBase', String(STRING_DATA_BASE),
329
+ // One page (64KB) reserved unconditionally, matching the 6502's own
330
+ // 16-bit address space — what memory.read/write address on the web
331
+ // target when a program uses them.
332
+ '--initialMemory', '1',
333
+ // A waitFrame() program's memory is shared: it runs in a worker that
334
+ // blocks on the frame clock while the page paints the same bytes
335
+ // from the main thread. Shared memory needs a maximum, and atomics
336
+ // are behind the threads feature flag. Memory stays exported either
337
+ // way, so every host reads `instance.exports.memory` regardless.
338
+ ...(emitted.usesWaitFrame
339
+ ? ['--maximumMemory', '1', '--sharedMemory', '--enable', 'threads']
340
+ : []),
341
+ ],
342
+ { stdio: ['ignore', 'pipe', 'pipe'] },
343
+ );
344
+ let stderr = '';
345
+ child.stderr.on('data', (d) => { stderr += d; });
346
+ child.on('close', (code) => {
347
+ if (code === 0) { resolvePromise({ ok: true, asFile }); return; }
348
+ // Static data — arrays, string<N> variables, string constants — is
349
+ // laid out from STRING_DATA_BASE to the end of the one 64KB page, so
350
+ // there is room for about 8KB of it. asc reports going past the end
351
+ // as needing a second page; that is the limit, in the program's terms.
352
+ const overPage = /requires at least '\d+' pages of maximum memory/.test(stderr);
353
+ const error = overPage
354
+ ? `this program's arrays, strings, and string variables need more than the ${65536 - STRING_DATA_BASE} bytes of static data the web target has `
355
+ + `(the top of its one 64KB page, from 0x${STRING_DATA_BASE.toString(16).toUpperCase()}). Smaller arrays bring it down.\nasc failed:\n${stderr}`
356
+ : `asc failed:\n${stderr}`;
357
+ resolvePromise({ ok: false, asFile, error });
358
+ });
359
+ });
360
+ }