From 4efd4ce7e96bb9179d9ce79e97d4a3c3cb97af06 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 02:34:15 +0000 Subject: [PATCH] Implement Hemlock WASM execution path with TS interpreter fallback Completes the architecture promised in the README: blocks now execute through the Hemlock WASM interpreter when the binary is present. - Add hemlock-codegen: emits standalone Hemlock scripts per event handler from compiled BlockAction trees, plus a prelude defining print/create/destroy helpers and variable-name collection - Add HemlockRunner: compiles handler scripts at game start, then per run serializes instance state into the context, executes via hemlock_run_script, and reads mutated state, output, and instance creations back - Wire the engine to route handler dispatch through the runner, with all-or-nothing fallback to the built-in TS interpreter when the binary is absent or any script fails to compile - Harden the bridge: non-throwing tryLoadHemlock() probe (handles the dev server's SPA fallback), runtime-dynamic import so Vite/Rollup no longer try to resolve /wasm/hemlock.js at build time - fetch-wasm: support GITHUB_TOKEN auth to avoid rate limits; drop unused import - Update README to describe the implemented execution path https://claude.ai/code/session_01AhnuhNAb1MueTczkkupims --- README.md | 6 +- scripts/fetch-wasm.mjs | 10 +- src/bridge/hemlock.ts | 28 +++- src/runtime/engine.ts | 69 ++++++++- src/runtime/hemlock-codegen.ts | 253 +++++++++++++++++++++++++++++++++ src/runtime/hemlock-runner.ts | 230 ++++++++++++++++++++++++++++++ 6 files changed, 582 insertions(+), 14 deletions(-) create mode 100644 src/runtime/hemlock-codegen.ts create mode 100644 src/runtime/hemlock-runner.ts diff --git a/README.md b/README.md index fd7408c..e06153b 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ npm run fetch-wasm # latest release npm run fetch-wasm v1.9.0 # specific version ``` -This downloads `hemlock.js` and `hemlock.wasm` into `public/wasm/`. The app runs without the binary (block editing and code preview work), but game execution requires it. +This downloads `hemlock.js` and `hemlock.wasm` into `public/wasm/`. Set `GITHUB_TOKEN` to authenticate the download (higher rate limits). The app is fully functional without the binary — when it is absent, game execution falls back to the built-in TypeScript interpreter. ## Architecture @@ -32,9 +32,9 @@ Visual Blocks → Block AST → Hemlock Source → hemlock_context_eval() → Ou (Code Preview panel) ``` -The game runtime lives entirely in TypeScript. Hemlock only runs user-authored event handler logic. Per frame, the JS engine serializes game state, passes it to the WASM interpreter, and reads back the result. +The game runtime lives entirely in TypeScript. Hemlock only runs user-authored event handler logic. At game start the engine compiles each event handler's blocks to a Hemlock script (`hemlock_compile_script`); per frame it serializes instance state into the interpreter context, runs the handler (`hemlock_run_script`), and reads the mutated state back. -For development without the WASM binary, a built-in TypeScript interpreter executes compiled block actions directly. +When the WASM binary is absent (or any script fails to compile), the engine automatically falls back to a built-in TypeScript interpreter that executes compiled block actions directly — same blocks, same behavior. ### Project Structure diff --git a/scripts/fetch-wasm.mjs b/scripts/fetch-wasm.mjs index 2759f0f..63efb00 100644 --- a/scripts/fetch-wasm.mjs +++ b/scripts/fetch-wasm.mjs @@ -10,7 +10,6 @@ * Files are written to public/wasm/hemlock.{js,wasm}. */ -import { execSync } from "node:child_process"; import { mkdirSync, writeFileSync } from "node:fs"; import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; @@ -22,13 +21,18 @@ const REPO = "hemlang/hemlock"; const version = process.argv[2] || "latest"; +// Set GITHUB_TOKEN to authenticate (higher rate limits, private repos). +const authHeaders = process.env.GITHUB_TOKEN + ? { Authorization: `Bearer ${process.env.GITHUB_TOKEN}` } + : {}; + async function fetchRelease() { const apiUrl = version === "latest" ? `https://api.github.com/repos/${REPO}/releases/latest` : `https://api.github.com/repos/${REPO}/releases/tags/${version}`; - const res = await fetch(apiUrl); + const res = await fetch(apiUrl, { headers: authHeaders }); if (!res.ok) { throw new Error(`GitHub API returned ${res.status}: ${await res.text()}`); } @@ -37,7 +41,7 @@ async function fetchRelease() { async function downloadAsset(url, dest) { const res = await fetch(url, { - headers: { Accept: "application/octet-stream" }, + headers: { Accept: "application/octet-stream", ...authHeaders }, }); if (!res.ok) throw new Error(`Failed to download ${url}: ${res.status}`); const buf = Buffer.from(await res.arrayBuffer()); diff --git a/src/bridge/hemlock.ts b/src/bridge/hemlock.ts index df0d588..7337b5e 100644 --- a/src/bridge/hemlock.ts +++ b/src/bridge/hemlock.ts @@ -26,12 +26,36 @@ export async function loadHemlock(): Promise { // The prebuilt hemlock.js module (from Hemlock GitHub releases) exposes an // init function that loads the .wasm file and returns the exports. - // @ts-expect-error — dynamic WASM module loaded from public/wasm at runtime - const mod = await import("/wasm/hemlock.js"); + // The path is built at runtime so Vite/Rollup leave the import dynamic; + // the file lives in public/wasm and is not part of the bundle. + const modulePath = "/wasm/hemlock.js"; + const mod = await import(/* @vite-ignore */ modulePath); instance = await mod.default(); return instance!; } +/** + * Load the Hemlock WASM interpreter if the binary is present, or return + * null without throwing. Used by the engine to decide between WASM + * execution and the built-in TypeScript interpreter. + */ +export async function tryLoadHemlock(): Promise { + if (instance) return instance; + try { + // Probe before importing: the Vite dev server answers missing paths + // with the SPA index.html (status 200), so check the content type too. + const probe = await fetch("/wasm/hemlock.js", { method: "HEAD" }); + if (!probe.ok) return null; + const type = probe.headers.get("content-type") ?? ""; + if (type.includes("text/html")) return null; + + return await loadHemlock(); + } catch (err) { + console.warn("Hemlock WASM unavailable, using built-in interpreter:", err); + return null; + } +} + export function getHemlock(): HemlockExports { if (!instance) throw new Error("Hemlock WASM not loaded — call loadHemlock() first"); return instance; diff --git a/src/runtime/engine.ts b/src/runtime/engine.ts index c02f165..cb95071 100644 --- a/src/runtime/engine.ts +++ b/src/runtime/engine.ts @@ -14,11 +14,14 @@ import { GameInstance, GameState, CompiledSprite, + CompiledHandler, KeyboardState, } from "./types.js"; import { InputManager } from "./input.js"; import { Renderer } from "./renderer.js"; import { executeActions } from "./interpreter.js"; +import { HemlockRunner } from "./hemlock-runner.js"; +import { tryLoadHemlock } from "../bridge/hemlock.js"; import { compileWorkspace } from "../editor/compiler.js"; import { BlockWorkspace } from "../editor/workspace.js"; @@ -39,6 +42,7 @@ export class GameEngine { private animFrameId: number = 0; private running: boolean = false; private callbacks: EngineCallbacks; + private hemlockRunner: HemlockRunner | null = null; constructor(canvas: HTMLCanvasElement, callbacks: EngineCallbacks) { this.canvas = canvas; @@ -90,6 +94,26 @@ export class GameEngine { } } + // Prefer the Hemlock WASM interpreter when the binary is present; + // otherwise event handlers run through the built-in TS interpreter. + this.hemlockRunner = null; + const hemlock = await tryLoadHemlock(); + if (hemlock) { + this.hemlockRunner = HemlockRunner.create(hemlock, [ + ...this.compiledSprites.values(), + ]); + if (!this.hemlockRunner) { + console.warn( + "Hemlock script compilation failed; falling back to the built-in interpreter." + ); + } + } + console.info( + `Acorn engine: executing via ${ + this.hemlockRunner ? "Hemlock WASM interpreter" : "built-in TypeScript interpreter" + }` + ); + // Set up current room (first room) this.currentRoom = project.rooms[0] || null; if (!this.currentRoom) { @@ -128,6 +152,8 @@ export class GameEngine { this.animFrameId = 0; } this.input.detach(); + this.hemlockRunner?.dispose(); + this.hemlockRunner = null; } isRunning(): boolean { @@ -183,10 +209,10 @@ export class GameEngine { const compiled = this.compiledSprites.get(instance.spriteId); if (!compiled) continue; - for (const handler of compiled.handlers) { + for (let i = 0; i < compiled.handlers.length; i++) { + const handler = compiled.handlers[i]; if (handler.event !== event) continue; - const output = executeActions(handler.actions, instance, this.state); - this.handleOutput(output); + this.runHandler(compiled.spriteId, i, handler, instance); } } } @@ -198,7 +224,8 @@ export class GameEngine { const compiled = this.compiledSprites.get(instance.spriteId); if (!compiled) continue; - for (const handler of compiled.handlers) { + for (let i = 0; i < compiled.handlers.length; i++) { + const handler = compiled.handlers[i]; if (!handler.key) continue; let shouldFire = false; @@ -215,13 +242,43 @@ export class GameEngine { } if (shouldFire) { - const output = executeActions(handler.actions, instance, this.state); - this.handleOutput(output); + this.runHandler(compiled.spriteId, i, handler, instance); } } } } + /** + * Execute one event handler for one instance, routing through the + * Hemlock WASM runner when active, otherwise the TS interpreter. + */ + private runHandler( + spriteId: string, + handlerIndex: number, + handler: CompiledHandler, + instance: GameInstance + ): void { + if (this.hemlockRunner) { + const result = this.hemlockRunner.runHandler( + spriteId, + handlerIndex, + instance + ); + if (result) { + if (result.error) { + this.callbacks.onError(`Error: ${result.error}`); + } + this.state.pendingCreations.push(...result.creations); + this.handleOutput(result.output); + return; + } + // No compiled script for this handler — fall through to the TS path. + } + + const output = executeActions(handler.actions, instance, this.state); + this.handleOutput(output); + } + private createInstance( spriteId: string, x: number, diff --git a/src/runtime/hemlock-codegen.ts b/src/runtime/hemlock-codegen.ts new file mode 100644 index 0000000..884839e --- /dev/null +++ b/src/runtime/hemlock-codegen.ts @@ -0,0 +1,253 @@ +/** + * Generates executable Hemlock source from compiled BlockAction trees. + * + * Unlike the code-preview generator (editor/generators.ts), which renders + * directly from Blockly blocks for display, this emitter produces one + * standalone script per event handler for execution through the Hemlock + * WASM interpreter. + * + * Execution protocol (per handler run): + * - `inst` — object with x, y, direction, speed, visible, size, destroyed + * - sprite variables are bound as top-level context variables + * - `print(...)` appends to `__out`; `create(...)` appends to `__new` + * (helpers defined by HEMLOCK_PRELUDE, reset before each run) + */ + +import { BlockAction, Expression, CompiledHandler } from "./types.js"; + +/** Degrees → radians factor; block sin/cos and directions use degrees. */ +const DEG = "0.017453292519943295"; + +/** + * Helper functions evaluated once per Hemlock context. Output and instance + * creation are collected into arrays the host reads back after each run. + */ +export const HEMLOCK_PRELUDE = `__out = []; +__new = []; + +fn print(msg) { + __out.push(msg); +} + +fn create(sprite, x, y) { + __new.push({ sprite: sprite, x: x, y: y }); +} + +fn destroy() { + inst.destroyed = true; +} +`; + +/** Generate a standalone Hemlock script for one event handler. */ +export function generateHandlerSource(handler: CompiledHandler): string { + return emitActions(handler.actions, ""); +} + +/** + * Collect all variable names referenced by a sprite's handlers, so the + * runner can bind them into the context before each run and read them + * back after. + */ +export function collectVariableNames(handlers: CompiledHandler[]): string[] { + const names = new Set(); + for (const handler of handlers) { + collectFromActions(handler.actions, names); + } + return [...names]; +} + +/** + * Map a user-entered variable name to a valid Hemlock identifier. + * The TS interpreter accepts any name as a dictionary key, but Hemlock + * source requires identifiers. + */ +export function sanitizeIdentifier(name: string): string { + const safe = name.replace(/[^A-Za-z0-9_]/g, "_"); + return /^[A-Za-z_]/.test(safe) ? safe : `_${safe}`; +} + +function collectFromActions(actions: BlockAction[], names: Set): void { + for (const action of actions) { + switch (action.type) { + case "set_variable": + case "change_variable": + names.add(action.name); + collectFromExpression(action.value, names); + break; + case "if": + collectFromExpression(action.condition, names); + collectFromActions(action.body, names); + break; + case "if_else": + collectFromExpression(action.condition, names); + collectFromActions(action.thenBody, names); + collectFromActions(action.elseBody, names); + break; + case "repeat": + collectFromExpression(action.count, names); + collectFromActions(action.body, names); + break; + case "while": + collectFromExpression(action.condition, names); + collectFromActions(action.body, names); + break; + case "set_x": + case "set_y": + case "change_x": + case "change_y": + case "set_direction": + case "move_forward": + case "set_visible": + case "set_size": + collectFromExpression(action.value, names); + break; + case "create_instance": + collectFromExpression(action.x, names); + collectFromExpression(action.y, names); + break; + case "print": + collectFromExpression(action.message, names); + break; + default: + break; + } + } +} + +function collectFromExpression(expr: Expression, names: Set): void { + switch (expr.type) { + case "variable": + names.add(expr.name); + break; + case "arithmetic": + case "comparison": + case "logic": + collectFromExpression(expr.left, names); + collectFromExpression(expr.right, names); + break; + case "not": + collectFromExpression(expr.operand, names); + break; + case "random": + collectFromExpression(expr.min, names); + collectFromExpression(expr.max, names); + break; + case "math_fn": + collectFromExpression(expr.arg, names); + break; + default: + break; + } +} + +function emitActions(actions: BlockAction[], indent: string): string { + return actions.map((a) => emitAction(a, indent)).join(""); +} + +function emitAction(action: BlockAction, indent: string): string { + const inner = indent + " "; + + switch (action.type) { + case "set_x": + return `${indent}inst.x = ${emitExpr(action.value)};\n`; + case "set_y": + return `${indent}inst.y = ${emitExpr(action.value)};\n`; + case "change_x": + return `${indent}inst.x += ${emitExpr(action.value)};\n`; + case "change_y": + return `${indent}inst.y += ${emitExpr(action.value)};\n`; + case "set_direction": + return `${indent}inst.direction = ${emitExpr(action.value)};\n`; + + case "move_forward": { + // Directions are in degrees, matching the TS interpreter. + const dist = emitExpr(action.value); + return ( + `${indent}inst.x += ${dist} * cos(inst.direction * ${DEG});\n` + + `${indent}inst.y += ${dist} * sin(inst.direction * ${DEG});\n` + ); + } + + case "if": + return ( + `${indent}if (${emitExpr(action.condition)}) {\n` + + emitActions(action.body, inner) + + `${indent}}\n` + ); + + case "if_else": + return ( + `${indent}if (${emitExpr(action.condition)}) {\n` + + emitActions(action.thenBody, inner) + + `${indent}} else {\n` + + emitActions(action.elseBody, inner) + + `${indent}}\n` + ); + + case "repeat": + return ( + `${indent}for (__i in range(${emitExpr(action.count)})) {\n` + + emitActions(action.body, inner) + + `${indent}}\n` + ); + + case "while": + return ( + `${indent}while (${emitExpr(action.condition)}) {\n` + + emitActions(action.body, inner) + + `${indent}}\n` + ); + + case "set_variable": + return `${indent}${sanitizeIdentifier(action.name)} = ${emitExpr(action.value)};\n`; + case "change_variable": + return `${indent}${sanitizeIdentifier(action.name)} += ${emitExpr(action.value)};\n`; + + case "set_visible": + return `${indent}inst.visible = ${emitExpr(action.value)};\n`; + case "set_size": + return `${indent}inst.size = ${emitExpr(action.value)};\n`; + + case "create_instance": + return `${indent}create(${JSON.stringify(action.spriteId)}, ${emitExpr(action.x)}, ${emitExpr(action.y)});\n`; + case "destroy_instance": + return `${indent}destroy();\n`; + + case "print": + return `${indent}print(${emitExpr(action.message)});\n`; + + case "stop": + return `${indent}return;\n`; + } +} + +function emitExpr(expr: Expression): string { + switch (expr.type) { + case "number": + return String(expr.value); + case "string": + return JSON.stringify(expr.value); + case "boolean": + return expr.value ? "true" : "false"; + case "variable": + return sanitizeIdentifier(expr.name); + case "property": + return `inst.${expr.property}`; + case "arithmetic": + return `(${emitExpr(expr.left)} ${expr.op} ${emitExpr(expr.right)})`; + case "comparison": + return `(${emitExpr(expr.left)} ${expr.op} ${emitExpr(expr.right)})`; + case "logic": + return `(${emitExpr(expr.left)} ${expr.op} ${emitExpr(expr.right)})`; + case "not": + return `(not ${emitExpr(expr.operand)})`; + case "random": + return `random(${emitExpr(expr.min)}, ${emitExpr(expr.max)})`; + case "math_fn": + // Block sin/cos take degrees; Hemlock builtins take radians. + if (expr.fn === "sin" || expr.fn === "cos") { + return `${expr.fn}(${emitExpr(expr.arg)} * ${DEG})`; + } + return `${expr.fn}(${emitExpr(expr.arg)})`; + } +} diff --git a/src/runtime/hemlock-runner.ts b/src/runtime/hemlock-runner.ts new file mode 100644 index 0000000..10d8fb4 --- /dev/null +++ b/src/runtime/hemlock-runner.ts @@ -0,0 +1,230 @@ +/** + * Hemlock WASM execution backend for the game engine. + * + * Implements the architecture's execution path: per handler run, the engine + * serializes instance state into a Hemlock context, runs the precompiled + * handler script via hemlock_run_script, and reads the mutated state back. + * + * Construction is all-or-nothing: if the prelude or any handler script fails + * to compile, create() returns null and the engine falls back to the + * built-in TypeScript interpreter, so a project never runs half-and-half. + */ + +import { HemlockExports } from "../bridge/hemlock.js"; +import { CompiledSprite, GameInstance } from "./types.js"; +import { + HEMLOCK_PRELUDE, + generateHandlerSource, + collectVariableNames, + sanitizeIdentifier, +} from "./hemlock-codegen.js"; + +export interface HandlerRunResult { + output: string[]; + creations: Array<{ spriteId: string; x: number; y: number }>; + error: string | null; +} + +interface SpriteScripts { + /** Compiled script handle per handler index. */ + handles: number[]; + /** Variable names referenced anywhere in this sprite's handlers. */ + variableNames: string[]; +} + +export class HemlockRunner { + private hemlock: HemlockExports; + private ctx: number; + private sprites: Map; + + private constructor( + hemlock: HemlockExports, + ctx: number, + sprites: Map + ) { + this.hemlock = hemlock; + this.ctx = ctx; + this.sprites = sprites; + } + + /** + * Create a runner for a set of compiled sprites. Returns null if the + * context, prelude, or any handler script fails to compile. + */ + static create( + hemlock: HemlockExports, + compiledSprites: CompiledSprite[] + ): HemlockRunner | null { + let ctx = 0; + const sprites = new Map(); + + const cleanup = () => { + for (const scripts of sprites.values()) { + for (const handle of scripts.handles) { + if (handle) hemlock.hemlock_free_script(handle); + } + } + if (ctx) hemlock.hemlock_context_destroy(ctx); + }; + + try { + ctx = hemlock.hemlock_context_create(); + if (!ctx) return null; + + hemlock.hemlock_context_eval(ctx, HEMLOCK_PRELUDE); + const preludeError = hemlock.hemlock_context_last_error(ctx); + if (preludeError) { + console.warn("Hemlock prelude failed:", preludeError); + cleanup(); + return null; + } + + for (const sprite of compiledSprites) { + const handles: number[] = []; + for (const handler of sprite.handlers) { + const source = generateHandlerSource(handler); + const handle = hemlock.hemlock_compile_script(source); + if (!handle) { + console.warn( + `Hemlock failed to compile a "${handler.event}" handler for sprite ${sprite.spriteId}:\n${source}` + ); + cleanup(); + return null; + } + handles.push(handle); + } + sprites.set(sprite.spriteId, { + handles, + variableNames: collectVariableNames(sprite.handlers), + }); + } + + return new HemlockRunner(hemlock, ctx, sprites); + } catch (err) { + console.warn("Hemlock runner initialization failed:", err); + cleanup(); + return null; + } + } + + /** + * Run one handler for one instance. Mutates the instance in place from + * the state Hemlock writes back. Returns null if no script exists for + * this sprite/handler. + */ + runHandler( + spriteId: string, + handlerIndex: number, + instance: GameInstance + ): HandlerRunResult | null { + const scripts = this.sprites.get(spriteId); + const handle = scripts?.handles[handlerIndex]; + if (!scripts || !handle) return null; + + const h = this.hemlock; + const result: HandlerRunResult = { output: [], creations: [], error: null }; + + // Serialize state into the context + h.hemlock_context_set( + this.ctx, + "inst", + JSON.stringify({ + x: instance.x, + y: instance.y, + direction: instance.direction, + speed: instance.speed, + visible: instance.visible, + size: instance.size, + destroyed: false, + }) + ); + for (const name of scripts.variableNames) { + h.hemlock_context_set( + this.ctx, + sanitizeIdentifier(name), + JSON.stringify(instance.variables[name] ?? 0) + ); + } + h.hemlock_context_set(this.ctx, "__out", "[]"); + h.hemlock_context_set(this.ctx, "__new", "[]"); + + // Execute + h.hemlock_run_script(this.ctx, handle); + const error = h.hemlock_context_last_error(this.ctx); + if (error) { + result.error = error; + return result; + } + + // Read state back + const inst = this.getJson(this.ctx, "inst") as Record | null; + if (inst) { + instance.x = toNumber(inst.x, instance.x); + instance.y = toNumber(inst.y, instance.y); + instance.direction = toNumber(inst.direction, instance.direction); + instance.speed = toNumber(inst.speed, instance.speed); + instance.visible = Boolean(inst.visible); + instance.size = toNumber(inst.size, instance.size); + if (inst.destroyed) instance.destroyed = true; + } + + for (const name of scripts.variableNames) { + const value = this.getJson(this.ctx, sanitizeIdentifier(name)); + if ( + typeof value === "number" || + typeof value === "string" || + typeof value === "boolean" + ) { + instance.variables[name] = value; + } + } + + const out = this.getJson(this.ctx, "__out"); + if (Array.isArray(out)) { + result.output = out.map((v) => String(v)); + } + + const created = this.getJson(this.ctx, "__new"); + if (Array.isArray(created)) { + for (const item of created) { + if (item && typeof item === "object") { + const c = item as Record; + result.creations.push({ + spriteId: String(c.sprite ?? ""), + x: toNumber(c.x, 0), + y: toNumber(c.y, 0), + }); + } + } + } + + return result; + } + + /** Free all compiled scripts and destroy the context. */ + dispose(): void { + for (const scripts of this.sprites.values()) { + for (const handle of scripts.handles) { + if (handle) this.hemlock.hemlock_free_script(handle); + } + } + this.sprites.clear(); + this.hemlock.hemlock_context_destroy(this.ctx); + this.ctx = 0; + } + + private getJson(ctx: number, name: string): unknown { + const raw = this.hemlock.hemlock_context_get(ctx, name); + if (raw == null) return null; + try { + return JSON.parse(raw); + } catch { + return raw; + } + } +} + +function toNumber(value: unknown, fallback: number): number { + const n = Number(value); + return Number.isFinite(n) ? n : fallback; +}