From 09aa409389acb01c664f9a6e8e3d39de38b8e76a Mon Sep 17 00:00:00 2001 From: Pabloader Date: Thu, 21 May 2026 16:04:25 +0000 Subject: [PATCH] Parse callbacks --- build/plugins/wasmExports.ts | 125 +++++++++++++++++++++++++-------- build/plugins/wasmPlugin.ts | 70 +++++++++++++----- build/plugins/wasmTypes.ts | 37 ++++++++-- src/common/physics/index.ts | 3 +- src/games/playground/index.tsx | 1 + 5 files changed, 184 insertions(+), 52 deletions(-) diff --git a/build/plugins/wasmExports.ts b/build/plugins/wasmExports.ts index 1e9f3f9..4aeea11 100644 --- a/build/plugins/wasmExports.ts +++ b/build/plugins/wasmExports.ts @@ -1,11 +1,67 @@ // Extracts JS_EXPORT-marked functions from a C/C++ source via regex + balanced -// paren scanning. Handles function-pointer params, templates, and JS_EXPORT_AS -// renames. Limitations: assumes JS_EXPORT markers live in the file itself (not -// in transitively-included headers); doesn't honor #if conditionals. +// paren scanning. Handles function-pointer params (inline and typedef'd), +// templates, and JS_EXPORT_AS renames. Limitations: assumes JS_EXPORT markers +// and any fn-ptr typedefs they use live in the source file itself, not in +// transitively-included headers; doesn't honor #if conditionals. + +export type WasmType = 'i32' | 'i64' | 'f32' | 'f64'; + +export interface FnSig { + params: WasmType[]; + results: WasmType[]; +} export interface ExportInfo { name: string; paramNames: string[]; + // Index → wasm signature, for params that are C function pointers. + fnPtrSigs: Map; +} + +function cTypeToWasm(cType: string): WasmType { + const t = cType.trim(); + if (/[*&]/.test(t) || /\[/.test(t)) return 'i32'; + if (/\b(int64_t|uint64_t|long\s+long)\b/.test(t)) return 'i64'; + if (/\bdouble\b/.test(t)) return 'f64'; + if (/\bfloat\b/.test(t)) return 'f32'; + return 'i32'; +} + +function buildFnSig(retType: string, argList: string): FnSig { + const ret = retType.trim() === 'void' ? null : cTypeToWasm(retType); + const results: WasmType[] = ret ? [ret] : []; + + const params: WasmType[] = []; + const trimmed = argList.trim(); + if (trimmed && trimmed !== 'void') { + for (const a of splitArgs(trimmed)) { + const typeOnly = a.replace(/\s+\w+\s*$/, '').replace(/\[[^\]]*\]\s*$/, '').trim(); + params.push(cTypeToWasm(typeOnly)); + } + } + + return { params, results }; +} + +// Parse a single function-pointer-shaped argument like "void (*cb)(int, float)". +// Returns null if `arg` doesn't match the inline fn-ptr pattern. +function parseInlineFnPtrSig(arg: string): FnSig | null { + const m = arg.match(/^([\s\S]+?)\(\s*\*\s*\w*\s*\)\s*\(([\s\S]*)\)\s*$/); + if (!m) return null; + return buildFnSig(m[1], m[2]); +} + +// Find all `typedef (*)();` declarations and build a name → sig map. +// The retType char class excludes `;{}` so we don't span across nearby struct +// declarations or other statements. +function findFnPtrTypedefs(src: string): Map { + const out = new Map(); + const re = /\btypedef\s+([^;{}]+?)\(\s*\*\s*(\w+)\s*\)\s*\(([^)]*)\)\s*;/g; + let m: RegExpExecArray | null; + while ((m = re.exec(src)) !== null) { + out.set(m[2], buildFnSig(m[1], m[3])); + } + return out; } function stripComments(src: string): string { @@ -14,23 +70,6 @@ function stripComments(src: string): string { .replace(/\/\*[\s\S]*?\*\//g, ''); } -// Pull the parameter identifier out of a single argument declaration. -// Recognizes: -// - function pointer: void (*cb)(int) → cb -// - array: float arr[4] → arr -// - default value: int x = 5 → x -// - plain: const char* name → name -function extractParamName(arg: string): string { - const noDefault = arg.replace(/\s*=\s*[\s\S]*$/, '').trim(); - if (!noDefault) return ''; - - const fnPtr = noDefault.match(/\(\s*\*\s*(\w+)\s*\)/); - if (fnPtr) return fnPtr[1]; - - const m = noDefault.match(/(\w+)\s*(?:\[[^\]]*\])?\s*$/); - return m ? m[1] : ''; -} - function splitArgs(argsStr: string): string[] { const trimmed = argsStr.trim(); if (!trimmed || trimmed === 'void') return []; @@ -51,8 +90,6 @@ function splitArgs(argsStr: string): string[] { return parts; } -// Find the matching close `)` starting just past an opening `(` at `openIdx`. -// Returns the index of the close paren, or -1 if unmatched. function matchParen(src: string, openIdx: number): number { let depth = 1; for (let i = openIdx + 1; i < src.length; i++) { @@ -65,11 +102,37 @@ function matchParen(src: string, openIdx: number): number { return -1; } +// Parse one C parameter declaration, recovering the identifier name and (if any) +// the function-pointer signature it carries — either via an inline `(*name)(...)` +// shape or via a typedef name we've previously collected. +function parseParam( + arg: string, + typedefs: Map, +): { name: string; fnSig: FnSig | null } { + const noDefault = arg.replace(/\s*=\s*[\s\S]*$/, '').trim(); + if (!noDefault) return { name: '', fnSig: null }; + + const inlineSig = parseInlineFnPtrSig(noDefault); + if (inlineSig) { + const m = noDefault.match(/\(\s*\*\s*(\w+)\s*\)/); + return { name: m?.[1] ?? '', fnSig: inlineSig }; + } + + const nameMatch = noDefault.match(/(\w+)\s*(?:\[[^\]]*\])?\s*$/); + const name = nameMatch ? nameMatch[1] : ''; + + const typeOnly = noDefault.replace(/\s+\w+\s*$/, '').replace(/\[[^\]]*\]\s*$/, '').trim(); + const typedefSig = typedefs.get(typeOnly); + if (typedefSig) return { name, fnSig: typedefSig }; + + return { name, fnSig: null }; +} + export function extractExports(source: string): ExportInfo[] { const src = stripComments(source); + const typedefs = findFnPtrTypedefs(src); const out: ExportInfo[] = []; const seen = new Set(); - // Match either JS_EXPORT or JS_EXPORT_AS as the entry into a declaration. const re = /\bJS_EXPORT(_AS)?\b/g; let m: RegExpExecArray | null; @@ -77,7 +140,6 @@ export function extractExports(source: string): ExportInfo[] { let pos = m.index + m[0].length; let alias: string | undefined; - // If the entry marker is JS_EXPORT_AS, consume its (name) argument. if (m[1]) { const a = /^\s*\(\s*(\w+)\s*\)/.exec(src.slice(pos)); if (!a) continue; @@ -85,8 +147,6 @@ export function extractExports(source: string): ExportInfo[] { pos += a[0].length; } - // Walk forward until the function args `(`. Pick up any additional - // JS_EXPORT / JS_EXPORT_AS attached to the same declaration. let parenOpen = -1; while (pos < src.length) { const ch = src[pos]; @@ -123,16 +183,21 @@ export function extractExports(source: string): ExportInfo[] { const parenClose = matchParen(src, parenOpen); if (parenClose === -1) continue; - const argsStr = src.slice(parenOpen + 1, parenClose); - const paramNames = splitArgs(argsStr).map(a => extractParamName(a)); + const argParts = splitArgs(src.slice(parenOpen + 1, parenClose)); + const paramNames: string[] = []; + const fnPtrSigs = new Map(); + argParts.forEach((a, i) => { + const { name, fnSig } = parseParam(a, typedefs); + paramNames.push(name); + if (fnSig) fnPtrSigs.set(i, fnSig); + }); const name = alias ?? funcName; if (!seen.has(name)) { seen.add(name); - out.push({ name, paramNames }); + out.push({ name, paramNames, fnPtrSigs }); } - // Skip past the consumed declaration so we don't re-match its markers. re.lastIndex = parenClose + 1; } diff --git a/build/plugins/wasmPlugin.ts b/build/plugins/wasmPlugin.ts index 8201561..4541215 100644 --- a/build/plugins/wasmPlugin.ts +++ b/build/plugins/wasmPlugin.ts @@ -131,7 +131,10 @@ async function compileLibGroup( return objPaths; } -async function instantiate(url: string) { +type FnSig = { params: ('i32' | 'i64' | 'f32' | 'f64')[]; results: ('i32' | 'i64' | 'f32' | 'f64')[] }; +type FnPtrMeta = Record; + +async function instantiate(url: string, fnPtrs: FnPtrMeta = {}) { if (typeof WebAssembly.Function !== "function") { const typeCodes = { i32: 0x7f, @@ -242,13 +245,37 @@ async function instantiate(url: string) { (instance.exports._initialize as (() => void) | undefined)?.(); + const table = instance.exports.__indirect_function_table as WebAssembly.Table | undefined; + const exported: Record = Object.fromEntries( + Object.entries(instance.exports).filter(([k]) => !k.startsWith('_')) + ); + + // Wrap exports that take function-pointer args so callers can pass JS functions + // directly. Same JS function reuses the same table slot across calls. + const fnPtrCache = new WeakMap(); + for (const [name, ptrs] of Object.entries(fnPtrs)) { + const orig = exported[name]; + if (typeof orig !== 'function' || !table) continue; + exported[name] = (...args: unknown[]) => { + for (const [i, sig] of ptrs) { + const v = args[i]; + if (typeof v !== 'function') continue; + let idx = fnPtrCache.get(v); + if (idx === undefined) { + const wasmFn = new WebAssembly.Function({ parameters: sig.params, results: sig.results }, v as (...a: number[]) => void); + idx = table.grow(1, wasmFn); + fnPtrCache.set(v, idx); + } + args[i] = idx; + } + return (orig as (...a: unknown[]) => unknown)(...args); + }; + } + return { - ...Object.fromEntries( - Object.entries(instance.exports) - .filter(([k]) => !k.startsWith('_')) - ), + ...exported, memory, - table: instance.exports.__indirect_function_table, + table, get data() { return new DataView(memory.buffer); }, }; } @@ -261,12 +288,7 @@ const wasmPlugin = ({ production }: WasmLoaderConfig = {}): BunPlugin => { const distDir = path.resolve(import.meta.dir, '..', '..', 'dist'); const inputBasename = path.basename(args.path); let wasmPath = path.resolve(distDir, inputBasename.replace(/\.(c|cpp)$/, '.wasm')); - let jsContent: string = ` - ${instantiate} - const module = await instantiate(new URL($WASM$)); - - export default module; - `; + const fnPtrMeta: Record = {}; if (args.path.endsWith('.wasm')) { wasmPath = args.path; } else { @@ -333,9 +355,18 @@ const wasmPlugin = ({ production }: WasmLoaderConfig = {}): BunPlugin => { const allObjs = [inputObjPath, ...libCObjs, ...libCppObjs]; + const exportInfos = extractExports(await Bun.file(args.path).text()); + for (const e of exportInfos) { + if (e.fnPtrSigs.size > 0) { + fnPtrMeta[e.name] = [...e.fnPtrSigs.entries()]; + } + } + if (await needsRebuild(wasmPath, allObjs)) { - const exportInfos = extractExports(await Bun.file(args.path).text()); - const paramNames = new Map(exportInfos.map(e => [e.name, e.paramNames])); + const meta = new Map(exportInfos.map(e => [e.name, { + paramNames: e.paramNames, + fnPtrSigs: e.fnPtrSigs, + }])); const linkFlags = [ '--strip-all', @@ -361,7 +392,7 @@ const wasmPlugin = ({ production }: WasmLoaderConfig = {}): BunPlugin => { } const wasmBytes = new Uint8Array(await Bun.file(wasmPath).arrayBuffer()); - await Bun.write(`${args.path}.d.ts`, renderDts(wasmBytes, paramNames)); + await Bun.write(`${args.path}.d.ts`, renderDts(wasmBytes, meta)); } } @@ -369,9 +400,16 @@ const wasmPlugin = ({ production }: WasmLoaderConfig = {}): BunPlugin => { const wasmBuffer = Buffer.from(wasmContent).toString('base64'); const wasmURL = `data:application/wasm;base64,${wasmBuffer}`; + const jsContent = ` + ${instantiate} + const module = await instantiate(new URL(${JSON.stringify(wasmURL)}), ${JSON.stringify(fnPtrMeta)}); + + export default module; + `; + return { loader: 'js', - contents: jsContent.replace(/new URL\([^)]*\)/, `new URL(${JSON.stringify(wasmURL)})`), + contents: jsContent, }; }); } diff --git a/build/plugins/wasmTypes.ts b/build/plugins/wasmTypes.ts index c22a29a..73dd6d3 100644 --- a/build/plugins/wasmTypes.ts +++ b/build/plugins/wasmTypes.ts @@ -1,8 +1,15 @@ // Parses a wasm binary's type / function / import / export sections to recover // each function export's signature, then renders TypeScript declarations. +import type { FnSig, WasmType } from './wasmExports'; + type Sig = { params: number[]; results: number[] }; +export interface ExportMeta { + paramNames: string[]; + fnPtrSigs: Map; +} + const VALUE_TYPE_TS: Record = { 0x7f: 'number', // i32 0x7e: 'bigint', // i64 @@ -10,6 +17,13 @@ const VALUE_TYPE_TS: Record = { 0x7c: 'number', // f64 }; +const WASM_STR_TO_TS: Record = { + i32: 'number', + i64: 'bigint', + f32: 'number', + f64: 'number', +}; + function readULEB128(bytes: Uint8Array, offset: number): [number, number] { let result = 0; let shift = 0; @@ -118,9 +132,24 @@ function tsType(valType: number): string { return VALUE_TYPE_TS[valType] ?? 'unknown'; } -function tsSignature({ params, results }: Sig, names?: string[]): string { +function renderFnPtrType(sig: FnSig): string { + const params = sig.params.map((t, i) => `a${i}: ${WASM_STR_TO_TS[t]}`).join(', '); + const ret = sig.results.length === 0 + ? 'void' + : sig.results.length === 1 + ? WASM_STR_TO_TS[sig.results[0]] + : `[${sig.results.map(t => WASM_STR_TO_TS[t]).join(', ')}]`; + return `(${params}) => ${ret}`; +} + +function tsSignature({ params, results }: Sig, meta?: ExportMeta): string { const paramList = params - .map((t, i) => `${names?.[i] || `a${i}`}: ${tsType(t)}`) + .map((t, i) => { + const name = meta?.paramNames[i] || `a${i}`; + const fnSig = meta?.fnPtrSigs.get(i); + if (fnSig) return `${name}: (${renderFnPtrType(fnSig)}) | number`; + return `${name}: ${tsType(t)}`; + }) .join(', '); let ret: string; if (results.length === 0) ret = 'void'; @@ -129,7 +158,7 @@ function tsSignature({ params, results }: Sig, names?: string[]): string { return `(${paramList}) => ${ret}`; } -export function renderDts(wasmBytes: Uint8Array, paramNames?: Map): string { +export function renderDts(wasmBytes: Uint8Array, meta?: Map): string { const sigs = parseWasmExports(wasmBytes); const fns = Array.from(sigs.entries()) .filter(([name]) => !name.startsWith('_')) @@ -138,7 +167,7 @@ export function renderDts(wasmBytes: Uint8Array, paramNames?: Map ` ${name}: ${tsSignature(sig, paramNames?.get(name))};`), + ...fns.map(([name, sig]) => ` ${name}: ${tsSignature(sig, meta?.get(name))};`), ' memory: WebAssembly.Memory;', ' table: WebAssembly.Table;', ' readonly data: DataView;', diff --git a/src/common/physics/index.ts b/src/common/physics/index.ts index 0435ff3..4f28cd3 100644 --- a/src/common/physics/index.ts +++ b/src/common/physics/index.ts @@ -54,8 +54,7 @@ namespace Physics { } export function setCollisionCallback(cb: (a: number, b: number) => void) { - const cbPtr = E.table.grow(1, new WebAssembly.Function({ parameters: ['i32', 'i32'], results: [] }, cb)); - E.rigid_body_set_collision_callback(cbPtr); + E.rigid_body_set_collision_callback(cb); } } diff --git a/src/games/playground/index.tsx b/src/games/playground/index.tsx index 4ae5147..ca97c4b 100644 --- a/src/games/playground/index.tsx +++ b/src/games/playground/index.tsx @@ -3,4 +3,5 @@ import awoo from './awoo.cpp'; export default function main() { console.log(awoo) awoo.awoo(); + awoo.something(() => console.log('called')); } \ No newline at end of file