diff --git a/affinescript-tea/README.adoc b/affinescript-tea/README.adoc new file mode 100644 index 00000000..24191dcd --- /dev/null +++ b/affinescript-tea/README.adoc @@ -0,0 +1,66 @@ +// SPDX-License-Identifier: PMPL-1.0-or-later +// SPDX-FileCopyrightText: 2026 hyperpolymath += affinescript-tea +:toc: macro +:icons: font + +The host-side *TEA (The Elm Architecture)* runtime + run loop for +AffineScript modules. INT-07 (issue #182). + +toc::[] + +== What this is + +The compiler-internal `lib/tea_bridge.ml` defines a TEA runtime ABI a +conforming WASM module exposes: `affinescript_init()`, +`affinescript_update(msg: i32)` (with `msg` *Linear* — consumed exactly +once per update cycle), optional `affinescript_get_*` getters / +`affinescript_set_screen`, an exported `memory`, and two custom +sections — `affinescript.tea_layout` (model field layout) and +`affinescript.ownership` (the per-function ownership kinds, including +the Linear `msg` proof). + +This package is the *generic* host runtime for any such module. It +discovers the model from `affinescript.tea_layout` (it does **not** +hard-code the bridge's demo `TitleModel`) and reuses the INT-02 +host-agnostic loader (`packages/affine-js/loader.js`) for Deno / Node / +browser parity. + +== Usage + +[source,javascript] +---- +import { TeaApp } from "@hyperpolymath/affinescript-tea"; + +const app = await TeaApp.load("./app.wasm"); + +// Low-level: drive cycles yourself. +app.init(); // -> initial model object +app.dispatch(0); // one TEA update cycle +app.model(); // current model (layout-driven) + +// Managed run loop: `messages` is any (async) iterable of i32 msgs +// (DOM events, a channel, a timer, a test array — all adapt). +await app.run({ + messages: eventStream, + view: (model) => render(model), +}); +---- + +== Linear-msg invariant + +`affinescript_update`'s `msg` is annotated `Linear` in +`affinescript.ownership` — a message is consumed exactly once per +update cycle. `dispatch()` enforces this host-side: `affinescript_update` +is invoked exactly once, and the call is non-re-entrant — a message +source or effect that tries to dispatch again *before the in-flight +cycle completes* throws (that would consume a second message inside one +cycle). `app.ownership` exposes the annotations. + +== Status + +INT-07 first general runtime: `TeaApp` (`load`/`init`/`dispatch`/ +`model`/`setScreen`/`run`) + `parseTeaLayout`. Verified against the +canonical bridge (`affinescript tea-bridge`) and a hand-built +re-entrancy fixture — see `mod_test.js` (9 tests). The router/navigation +runtime is a separate satellite (INT-09, `lib/tea_router.ml`). diff --git a/affinescript-tea/deno.json b/affinescript-tea/deno.json new file mode 100644 index 00000000..5aea992c --- /dev/null +++ b/affinescript-tea/deno.json @@ -0,0 +1,11 @@ +{ + "name": "@hyperpolymath/affinescript-tea", + "version": "0.1.0", + "exports": { + ".": "./mod.js" + }, + "license": "PMPL-1.0-or-later", + "tasks": { + "test": "deno test --allow-read --allow-write --allow-run mod_test.js" + } +} diff --git a/affinescript-tea/mod.js b/affinescript-tea/mod.js new file mode 100644 index 00000000..09b93fbb --- /dev/null +++ b/affinescript-tea/mod.js @@ -0,0 +1,243 @@ +// SPDX-License-Identifier: PMPL-1.0-or-later +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// affinescript-tea: the host-side TEA (The Elm Architecture) runtime + run +// loop for AffineScript modules (INT-07, issue #182). +// +// The compiler-internal `lib/tea_bridge.ml` defines the TEA runtime ABI a +// conforming WASM module exposes: +// +// exports: +// affinescript_init() -> () write the initial model +// affinescript_update(msg: i32) -> () msg is LINEAR (consumed +// exactly once per cycle) +// affinescript_get_() -> i32 (optional getters) +// affinescript_set_screen(w, h) -> () (optional) +// memory +// custom sections: +// affinescript.tea_layout model field layout (parsed here, generically) +// affinescript.ownership per-fn ownership kinds (the Linear-msg proof) +// +// This runtime is GENERIC: it discovers the model from `tea_layout` rather +// than hard-coding any one model (the bridge's TitleModel was only a demo). +// It reuses the INT-02 host-agnostic loader for Deno/Node/browser parity. + +import { + parseOwnershipSection, + readBytes, +} from "../packages/affine-js/loader.js"; + +/** + * One model field, decoded from the `affinescript.tea_layout` section. + * @typedef {Object} TeaField + * @property {string} name + * @property {number} offset byte offset relative to the model base + * @property {"i32"} type + */ + +/** + * @typedef {Object} TeaLayout + * @property {number} version + * @property {number} baseAddr + * @property {TeaField[]} fields + */ + +/** + * Parse the `affinescript.tea_layout` custom section. + * + * Binary format (must match `Tea_bridge.build_tea_layout_section`): + * u8 version + * u8 base_addr + * u8 field_count + * per field: u8 name_len, name_bytes, u8 offset, u8 type_tag (0x49='i32') + * + * @param {WebAssembly.Module} wasmModule + * @returns {TeaLayout | null} null when the section is absent + */ +export function parseTeaLayout(wasmModule) { + const secs = WebAssembly.Module.customSections( + wasmModule, + "affinescript.tea_layout", + ); + if (secs.length === 0) return null; + const b = new Uint8Array(secs[0]); + let p = 0; + const version = b[p++]; + const baseAddr = b[p++]; + const count = b[p++]; + const dec = new TextDecoder("utf-8"); + /** @type {TeaField[]} */ + const fields = []; + for (let i = 0; i < count; i++) { + const nameLen = b[p++]; + const name = dec.decode(b.subarray(p, p + nameLen)); + p += nameLen; + const offset = b[p++]; + const typeTag = b[p++]; + fields.push({ + name, + offset, + type: typeTag === 0x49 ? "i32" : `tag:0x${typeTag.toString(16)}`, + }); + } + return { version, baseAddr, fields }; +} + +/** + * A loaded, running TEA application. + * + * Lifecycle: `await TeaApp.load(src)` → `app.init()` → `app.dispatch(msg)` + * (each call drives one TEA update cycle) → `app.model()`. Or hand it to + * `app.run(...)` for a managed loop. + */ +export class TeaApp { + /** @type {WebAssembly.Instance} */ #instance; + /** @type {WebAssembly.Memory} */ #memory; + /** @type {TeaLayout} */ #layout; + /** @type {import("../packages/affine-js/loader.js").OwnershipEntry[]} */ #ownership; + /** Re-entrancy guard enforcing the Linear-msg invariant. */ + #inCycle = false; + + /** + * @param {WebAssembly.Instance} instance + * @param {TeaLayout} layout + * @param {import("../packages/affine-js/loader.js").OwnershipEntry[]} ownership + */ + constructor(instance, layout, ownership) { + this.#instance = instance; + const mem = instance.exports.memory; + if (!(mem instanceof WebAssembly.Memory)) { + throw new Error( + "affinescript-tea: module must export 'memory' (TEA ABI)", + ); + } + this.#memory = mem; + if (!layout) { + throw new Error( + "affinescript-tea: module has no 'affinescript.tea_layout' custom " + + "section — not a TEA-conformant module", + ); + } + this.#layout = layout; + this.#ownership = ownership; + for (const fn of ["affinescript_init", "affinescript_update"]) { + if (typeof instance.exports[fn] !== "function") { + throw new Error(`affinescript-tea: module must export '${fn}' (TEA ABI)`); + } + } + } + + /** + * Load + instantiate a TEA-conformant module from any source/host. + * @param {string | URL | Uint8Array | ArrayBuffer} source + * @param {{ base?: string | URL, imports?: Record }} [options] + * @returns {Promise} + */ + static async load(source, options = {}) { + const bytes = await readBytes(source, { base: options.base }); + const { instance, module } = await WebAssembly.instantiate(bytes, { + env: { ...(options.imports ?? {}) }, + }); + const layout = parseTeaLayout(module); + const ownership = parseOwnershipSection(module); + return new TeaApp(instance, layout, ownership); + } + + /** The parsed model layout (from `affinescript.tea_layout`). */ + get layout() { + return this.#layout; + } + + /** + * The ownership annotations. `affinescript_update`'s `msg` parameter is + * Linear (kind `"linear"`) — the host-visible proof that a message is + * consumed exactly once per update cycle. + * @type {import("../packages/affine-js/loader.js").OwnershipEntry[]} + */ + get ownership() { + return this.#ownership; + } + + /** Read the current model as a plain object (generic, layout-driven). */ + model() { + const dv = new DataView(this.#memory.buffer); + /** @type {Record} */ + const m = {}; + for (const f of this.#layout.fields) { + m[f.name] = dv.getInt32(this.#layout.baseAddr + f.offset, true); + } + return m; + } + + /** Run `affinescript_init()`; returns the initial model. */ + init() { + this.#instance.exports.affinescript_init(); + return this.model(); + } + + /** + * Drive one TEA update cycle with `msg`, then return the new model. + * + * Enforces the Linear-msg invariant host-side: `affinescript_update` is + * invoked exactly once, and the call is non-re-entrant — dispatching + * again from within a view/effect triggered by this cycle throws (that + * would consume a second message inside one cycle, violating the + * linearity the `affinescript.ownership` section asserts). + * + * @param {number} msg + * @returns {Record} the model after the update + */ + dispatch(msg) { + if (!Number.isInteger(msg)) { + throw new TypeError(`affinescript-tea: msg must be an i32, got ${msg}`); + } + if (this.#inCycle) { + throw new Error( + "affinescript-tea: re-entrant dispatch — a message must be consumed " + + "exactly once per update cycle (Linear-msg invariant)", + ); + } + this.#inCycle = true; + try { + this.#instance.exports.affinescript_update(msg); + } finally { + this.#inCycle = false; + } + return this.model(); + } + + /** Optional resize hook (present iff the module exports it). */ + setScreen(w, h) { + const fn = this.#instance.exports.affinescript_set_screen; + if (typeof fn !== "function") { + throw new Error( + "affinescript-tea: module does not export 'affinescript_set_screen'", + ); + } + fn(w, h); + return this.model(); + } + + /** + * The managed run loop. Generic over the message source: `messages` is + * any (async) iterable of i32 msgs (DOM events, a channel, a test array, + * a timer — all adapt to this). `view(model)` is called once after + * `init()` and once after every dispatched message. + * + * @param {{ messages: Iterable | AsyncIterable, + * view: (model: Record) => void }} driver + * @returns {Promise>} the final model + */ + async run({ messages, view }) { + if (typeof view !== "function") { + throw new TypeError("affinescript-tea: run() needs a view(model) fn"); + } + let model = this.init(); + view(model); + for await (const msg of messages) { + model = this.dispatch(msg); + view(model); + } + return model; + } +} diff --git a/affinescript-tea/mod_test.js b/affinescript-tea/mod_test.js new file mode 100644 index 00000000..44ff1eed --- /dev/null +++ b/affinescript-tea/mod_test.js @@ -0,0 +1,161 @@ +// SPDX-License-Identifier: PMPL-1.0-or-later +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// affinescript-tea runtime tests (INT-07, issue #182). +// +// Drives the canonical TEA bridge module (emitted by the compiler: +// `affinescript tea-bridge -o tea.wasm`, see lib/tea_bridge.ml) through +// the generic runtime — proving the run loop, layout-driven model +// decode, the Linear-msg ownership annotation, and the re-entrancy guard. +// +// Run: deno test --allow-read --allow-write --allow-run affinescript-tea/mod_test.js + +import { assertEquals, assertThrows } from "jsr:@std/assert@1"; +import { parseTeaLayout, TeaApp } from "./mod.js"; + +const COMPILER = new URL( + "../_build/default/bin/main.exe", + import.meta.url, +).pathname; + +async function bridgeWasm() { + const out = await Deno.makeTempFile({ suffix: ".wasm" }); + const cmd = new Deno.Command(COMPILER, { args: ["tea-bridge", "-o", out] }); + const { success, stderr } = await cmd.output(); + if (!success) { + throw new Error( + "tea-bridge generation failed: " + new TextDecoder().decode(stderr), + ); + } + const bytes = await Deno.readFile(out); + await Deno.remove(out); + return bytes; +} + +Deno.test("parseTeaLayout decodes the model layout", async () => { + const mod = await WebAssembly.compile(await bridgeWasm()); + const layout = parseTeaLayout(mod); + assertEquals(layout.version, 1); + assertEquals(layout.baseAddr, 64); + assertEquals(layout.fields, [ + { name: "screen_w", offset: 0, type: "i32" }, + { name: "screen_h", offset: 4, type: "i32" }, + { name: "bgm_playing", offset: 8, type: "i32" }, + { name: "selected", offset: 12, type: "i32" }, + ]); +}); + +Deno.test("ownership marks update's msg Linear", async () => { + const app = await TeaApp.load(await bridgeWasm()); + // fn 1 = affinescript_update(msg) — msg consumed exactly once / cycle. + const update = app.ownership.find((e) => e.funcIdx === 1); + assertEquals(update.paramKinds, ["linear"]); +}); + +Deno.test("init yields the default model", async () => { + const app = await TeaApp.load(await bridgeWasm()); + assertEquals(app.init(), { + screen_w: 1280, + screen_h: 720, + bgm_playing: 0, + selected: 0, + }); +}); + +Deno.test("dispatch runs one TEA cycle (selected := msg + 1)", async () => { + const app = await TeaApp.load(await bridgeWasm()); + app.init(); + assertEquals(app.dispatch(0).selected, 1); // NewGame + assertEquals(app.dispatch(3).selected, 4); // Credits +}); + +Deno.test("setScreen updates the model", async () => { + const app = await TeaApp.load(await bridgeWasm()); + app.init(); + const m = app.setScreen(800, 600); + assertEquals([m.screen_w, m.screen_h], [800, 600]); +}); + +Deno.test("run loop: view fires after init and each message", async () => { + const app = await TeaApp.load(await bridgeWasm()); + const seen = []; + const final = await app.run({ + messages: [0, 1, 2, 3], + view: (m) => seen.push(m.selected), + }); + // init (selected 0) then msg+1 for each of 0,1,2,3. + assertEquals(seen, [0, 1, 2, 3, 4]); + assertEquals(final.selected, 4); +}); + +Deno.test("dispatch rejects a non-integer msg", async () => { + const app = await TeaApp.load(await bridgeWasm()); + app.init(); + assertThrows(() => app.dispatch(1.5), TypeError, "i32"); +}); + +// Minimal hand-built TEA-conformant module whose `affinescript_update` +// synchronously calls an imported `env.reenter` — the exact shape of a +// future effectful module that could re-enter the runtime. Used to +// genuinely exercise the Linear-msg re-entrancy guard. +function reentryWasm() { + const s = (str) => { + const b = [...new TextEncoder().encode(str)]; + return [b.length, ...b]; + }; + const sec = (id, payload) => [id, payload.length, ...payload]; + const bytes = [ + 0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, + // Type: t0 ()->() , t1 (i32)->() + ...sec(1, [0x02, 0x60, 0x00, 0x00, 0x60, 0x01, 0x7f, 0x00]), + // Import: env.reenter : t0 + ...sec(2, [0x01, ...s("env"), ...s("reenter"), 0x00, 0x00]), + // Function: f1=init:t0, f2=update:t1 (f0 is the import) + ...sec(3, [0x02, 0x00, 0x01]), + // Memory: 1 page + ...sec(5, [0x01, 0x00, 0x01]), + // Export: init->f1, update->f2, memory->m0 + ...sec(7, [ + 0x03, + ...s("affinescript_init"), 0x00, 0x01, + ...s("affinescript_update"), 0x00, 0x02, + ...s("memory"), 0x02, 0x00, + ]), + // Code: init = {end}; update = {call 0; end} + ...sec(10, [ + 0x02, + 0x02, 0x00, 0x0b, + 0x04, 0x00, 0x10, 0x00, 0x0b, + ]), + // Custom affinescript.tea_layout: version1, base0, 0 fields + ...sec(0, [...s("affinescript.tea_layout"), 0x01, 0x00, 0x00]), + ]; + return new Uint8Array(bytes); +} + +Deno.test("re-entrant dispatch is rejected (Linear-msg invariant)", async () => { + let app; + // The module's update() calls env.reenter, which re-enters dispatch + // while the first cycle is still in flight — must throw. + app = await TeaApp.load(reentryWasm(), { + imports: { + reenter: () => { + app.dispatch(1); + }, + }, + }); + app.init(); + assertThrows( + () => app.dispatch(0), + Error, + "re-entrant dispatch", + ); +}); + +Deno.test("a normal dispatch after another is NOT locked out", async () => { + const app = await TeaApp.load(await bridgeWasm()); + app.init(); + app.dispatch(0); + // guard must have cleared in `finally` — no false-positive lockout. + assertEquals(app.dispatch(2).selected, 3); +}); diff --git a/docs/ECOSYSTEM.adoc b/docs/ECOSYSTEM.adoc index 5aba99a3..f0fda44d 100644 --- a/docs/ECOSYSTEM.adoc +++ b/docs/ECOSYSTEM.adoc @@ -152,8 +152,11 @@ wasm loop-codegen defect). |`affinescript-deno-test` |works |Smoke-test harness used by the Deno-ESM target. -|`affinescript-tea` |first slice |Was imaginary until the #175 scaffold; -a real first slice exists. INT-07 (#182) builds the runtime + run loop. +|`affinescript-tea` |runtime |INT-07 (#182): real host-side TEA runtime ++ run loop (`TeaApp`: load/init/dispatch/model/setScreen/run, +`parseTeaLayout`). Generic over `affinescript.tea_layout`; enforces the +Linear-msg invariant; reuses the INT-02 loader. 9 Deno tests vs the +canonical `affinescript tea-bridge` + a re-entrancy fixture. |`affinescript-dom-loader` |scaffold |Was imaginary until #175. INT-02 (#179) builds the host-agnostic loader. Blocks INT-05/08/11. @@ -200,8 +203,9 @@ S1..S6; legacy preview1 stdout path is the default until S6 (blocked by INT-02) |INT-06 |Server-side runtime profile (on INT-03 WASI p2) |ledger-only |planned (blocked by INT-03) -|INT-07 |`affinescript-tea` runtime satellite |#182 |open, S2 (blocked by -INT-01) +|INT-07 |`affinescript-tea` runtime satellite |#182 |runtime + run loop +shipped (`TeaApp`/`parseTeaLayout`, Linear-msg enforced); INT-01 dep +cleared (#253). Router/nav = separate INT-09 |INT-08 |DOM reconciler in `affinescript-dom` |#183 |reconciler implemented + compiles (resolve→typecheck→codegen→wasm); `.as`→`.affine` corrected. INT-02 dep cleared. Runtime BLOCKED by #255 (wasm diff --git a/docs/TECH-DEBT.adoc b/docs/TECH-DEBT.adoc index fa4e22c9..fea49d42 100644 --- a/docs/TECH-DEBT.adoc +++ b/docs/TECH-DEBT.adoc @@ -161,7 +161,8 @@ follow-up Component-Model re-target, staged S1..S6); S3+ hard-gated on S2 toolchain (`wasm-tools`/`wasm-component-ld`) |INT-04 |Publish to JSR/npm |S2 |open #181 (◄ INT-01) -|INT-07 |`affinescript-tea` runtime |S2 |open #182 (◄ INT-01) +|INT-07 |`affinescript-tea` runtime |S2 |#182 runtime + run loop shipped +(TeaApp/parseTeaLayout, Linear-msg enforced); INT-01 cleared (#253) |INT-08 |DOM reconciler |S2 |#183 implemented + compiles; `.as`→`.affine` fixed; runtime blocked by #255 (wasm loop-codegen defect) |INT-05/06/09/10/11/12 |ledger-only; filed when blocker closes |— |planned