From ee19c8ff7f5f4e20e6279bc81f0bc5ede0b172d9 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 30 Mar 2026 11:20:06 -0700 Subject: [PATCH 1/5] wasm: binary patching wow --- example/wasm-vt/index.html | 274 ++++++++++++++++++++++++++++++++++++- 1 file changed, 269 insertions(+), 5 deletions(-) diff --git a/example/wasm-vt/index.html b/example/wasm-vt/index.html index d720e2375..2fe30b801 100644 --- a/example/wasm-vt/index.html +++ b/example/wasm-vt/index.html @@ -37,6 +37,26 @@ box-sizing: border-box; resize: vertical; } + .effects-log { + background: #1a1a2e; + color: #e0e0e0; + border: 1px solid #444; + border-radius: 4px; + padding: 15px; + margin: 20px 0; + font-family: 'Courier New', monospace; + white-space: pre-wrap; + font-size: 13px; + max-height: 200px; + overflow-y: auto; + } + .effects-log .effect-label { + color: #00d9ff; + font-weight: bold; + } + .effects-log .effect-data { + color: #a0ffa0; + } button { background: #0066cc; color: white; @@ -105,11 +125,14 @@

VT Input

- -

Use \x1b for ESC, \r\n for CR+LF. Press "Run" to process.

+ +

Use \x1b for ESC, \r\n for CR+LF, \x07 for BEL. Press "Run" to process.

+

Effects Log

+
No effects triggered yet.
+
Waiting for input...

Note: This example must be served via HTTP (not opened directly as a file). See the README for instructions.

@@ -118,11 +141,119 @@ let wasmInstance = null; let wasmMemory = null; let typeLayout = null; + let effectsLog = []; + + function logEffect(label, data) { + effectsLog.push({ label, data }); + } + + // Patch a WASM binary to: (1) make the function table growable by + // removing its max limit, and (2) export it as "tbl" so JS can add + // effect callback entries. Zig's WASM linker sets a fixed max on + // the table and does not export it by default. + function patchWasmForEffects(buffer) { + let bytes = new Uint8Array(buffer); + + function readLEB128(arr, offset) { + let result = 0, shift = 0, bytesRead = 0; + while (true) { + const byte = arr[offset + bytesRead]; + result |= (byte & 0x7f) << shift; + bytesRead++; + if ((byte & 0x80) === 0) break; + shift += 7; + } + return { value: result, bytesRead }; + } + + function encodeLEB128(value) { + const out = []; + do { + let b = value & 0x7f; + value >>>= 7; + if (value !== 0) b |= 0x80; + out.push(b); + } while (value !== 0); + return out; + } + + // Rebuild a section from parts: [before_section | id | new_size | new_payload | after_section] + function rebuildSection(buf, sectionPos, sectionStart, oldSectionSize, newPayload) { + const newSize = encodeLEB128(newPayload.length); + const afterStart = sectionStart + oldSectionSize; + const result = new Uint8Array(sectionPos + 1 + newSize.length + newPayload.length + (buf.length - afterStart)); + result.set(buf.subarray(0, sectionPos)); + let w = sectionPos; + result[w++] = buf[sectionPos]; // section id + for (const b of newSize) result[w++] = b; + for (const b of newPayload) result[w++] = b; + result.set(buf.subarray(afterStart), w); + return result; + } + + // Pass 1: Patch table section (id=4) to remove max limit so the table is growable. + let pos = 8; + while (pos < bytes.length) { + const sectionId = bytes[pos]; + const { value: sectionSize, bytesRead: sizeLen } = readLEB128(bytes, pos + 1); + const sectionStart = pos + 1 + sizeLen; + if (sectionId === 4) { + const { value: tableCount, bytesRead: countLen } = readLEB128(bytes, sectionStart); + let tpos = sectionStart + countLen; + const elemType = bytes[tpos++]; // 0x70 = funcref + const flags = bytes[tpos]; + if (flags & 1) { + // Has max — rebuild without it: flag=0, keep only min + const { value: min, bytesRead: minLen } = readLEB128(bytes, tpos + 1); + const { value: max, bytesRead: maxLen } = readLEB128(bytes, tpos + 1 + minLen); + const afterLimits = tpos + 1 + minLen + maxLen; + const newPayload = [ + ...encodeLEB128(tableCount), + elemType, + 0x00, // flags: no max + ...encodeLEB128(min), + ...bytes.slice(afterLimits, sectionStart + sectionSize), + ]; + bytes = rebuildSection(bytes, pos, sectionStart, sectionSize, new Uint8Array(newPayload)); + } + break; + } + pos = sectionStart + sectionSize; + } + + // Pass 2: Add table export to export section (id=7) if not already present. + const mod = new WebAssembly.Module(bytes.buffer); + if (WebAssembly.Module.exports(mod).some(e => e.kind === 'table')) { + return bytes.buffer; + } + const exportEntry = [0x03, 0x74, 0x62, 0x6c, 0x01, 0x00]; // name="tbl", kind=table, index=0 + pos = 8; + while (pos < bytes.length) { + const sectionId = bytes[pos]; + const { value: sectionSize, bytesRead: sizeLen } = readLEB128(bytes, pos + 1); + const sectionStart = pos + 1 + sizeLen; + if (sectionId === 7) { + const { value: count, bytesRead: countLen } = readLEB128(bytes, sectionStart); + const restStart = sectionStart + countLen; + const restLen = sectionSize - countLen; + const newPayload = new Uint8Array([ + ...encodeLEB128(count + 1), + ...bytes.slice(restStart, restStart + restLen), + ...exportEntry, + ]); + bytes = rebuildSection(bytes, pos, sectionStart, sectionSize, newPayload); + break; + } + pos = sectionStart + sectionSize; + } + + return bytes.buffer; + } async function loadWasm() { try { const response = await fetch('../../zig-out/bin/ghostty-vt.wasm'); - const wasmBytes = await response.arrayBuffer(); + const wasmBytes = patchWasmForEffects(await response.arrayBuffer()); const wasmModule = await WebAssembly.instantiate(wasmBytes, { env: { @@ -191,7 +322,119 @@ // GHOSTTY_SUCCESS = 0 const GHOSTTY_SUCCESS = 0; - function run() { + // GhosttyTerminalOption enum values + const GHOSTTY_TERMINAL_OPT_WRITE_PTY = 1; + const GHOSTTY_TERMINAL_OPT_BELL = 2; + const GHOSTTY_TERMINAL_OPT_TITLE_CHANGED = 5; + + // Allocate slots in the WASM indirect function table for JS callbacks. + // Returns the table index (i.e. the function pointer value in WASM). + let effectTableIndices = []; + function addToWasmTable(func) { + const table = wasmInstance.exports.tbl || wasmInstance.exports.__indirect_function_table; + const idx = table.length; + table.grow(1); + table.set(idx, func); + effectTableIndices.push(idx); + return idx; + } + + // Build a tiny WASM trampoline module that imports JS callbacks and + // re-exports them as properly typed WASM functions. This is needed + // because adding JS functions to a WASM table requires them to be + // wrapped as WebAssembly function objects with the correct signature. + // WebAssembly.Function is not supported in all browsers (e.g. Safari), + // so we compile a minimal module instead. + let effectWrappers = null; + async function buildEffectWrappers() { + if (effectWrappers) return effectWrappers; + + // Hand-coded WASM module with: + // Type 0: (i32, i32, i32, i32) -> void [write_pty] + // Type 1: (i32, i32) -> void [bell, title_changed] + // Imports: env.a (type 0), env.b (type 1), env.c (type 1) + // Functions 3,4,5 wrap imports 0,1,2 respectively + // Exports: "a" -> func 3, "b" -> func 4, "c" -> func 5 + const bytes = new Uint8Array([ + 0x00, 0x61, 0x73, 0x6d, // magic + 0x01, 0x00, 0x00, 0x00, // version + + // Type section (id=1) + 0x01, 0x0d, // section id, size=13 + 0x02, // 2 types + // type 0: (i32, i32, i32, i32) -> () + 0x60, 0x04, 0x7f, 0x7f, 0x7f, 0x7f, 0x00, + // type 1: (i32, i32) -> () + 0x60, 0x02, 0x7f, 0x7f, 0x00, + + // Import section (id=2) + 0x02, 0x19, // section id, size=25 + 0x03, // 3 imports + // import 0: env.a type 0 + 0x03, 0x65, 0x6e, 0x76, // "env" + 0x01, 0x61, // "a" + 0x00, 0x00, // func, type 0 + // import 1: env.b type 1 + 0x03, 0x65, 0x6e, 0x76, // "env" + 0x01, 0x62, // "b" + 0x00, 0x01, // func, type 1 + // import 2: env.c type 1 + 0x03, 0x65, 0x6e, 0x76, // "env" + 0x01, 0x63, // "c" + 0x00, 0x01, // func, type 1 + + // Function section (id=3) + 0x03, 0x04, // section id, size=4 + 0x03, // 3 functions + 0x00, 0x01, 0x01, // types: 0, 1, 1 + + // Export section (id=7) + 0x07, 0x0d, // section id, size=13 + 0x03, // 3 exports + 0x01, 0x61, 0x00, 0x03, // "a" -> func 3 + 0x01, 0x62, 0x00, 0x04, // "b" -> func 4 + 0x01, 0x63, 0x00, 0x05, // "c" -> func 5 + + // Code section (id=10) + 0x0a, 0x20, // section id, size=32 + 0x03, // 3 function bodies + // func 3: call import 0 with 4 args + 0x0c, 0x00, // body size=12, 0 locals + 0x20, 0x00, 0x20, 0x01, 0x20, 0x02, 0x20, 0x03, 0x10, 0x00, 0x0b, + // func 4: call import 1 with 2 args + 0x08, 0x00, // body size=8, 0 locals + 0x20, 0x00, 0x20, 0x01, 0x10, 0x01, 0x0b, + // func 5: call import 2 with 2 args + 0x08, 0x00, // body size=8, 0 locals + 0x20, 0x00, 0x20, 0x01, 0x10, 0x02, 0x0b, + ]); + + const mod = await WebAssembly.instantiate(bytes, { + env: { + a: (terminal, userdata, dataPtr, len) => { + const b = new Uint8Array(getBuffer(), dataPtr, len); + const text = new TextDecoder().decode(b.slice()); + const hex = Array.from(b).map(v => v.toString(16).padStart(2, '0')).join(' '); + logEffect('write_pty', `${len} bytes: ${hex} "${text}"`); + }, + b: (terminal, userdata) => { + logEffect('bell', 'BEL received!'); + }, + c: (terminal, userdata) => { + logEffect('title_changed', 'Terminal title changed'); + }, + }, + }); + + effectWrappers = { + writePtyWrapper: mod.instance.exports.a, + bellWrapper: mod.instance.exports.b, + titleChangedWrapper: mod.instance.exports.c, + }; + return effectWrappers; + } + + async function run() { const outputDiv = document.getElementById('output'); try { @@ -221,6 +464,17 @@ const termPtr = new DataView(getBuffer()).getUint32(termPtrPtr, true); wasmInstance.exports.ghostty_wasm_free_opaque(termPtrPtr); + // Register effect callbacks + effectsLog = []; + const wrappers = await buildEffectWrappers(); + const writePtyIdx = addToWasmTable(wrappers.writePtyWrapper); + const bellIdx = addToWasmTable(wrappers.bellWrapper); + const titleIdx = addToWasmTable(wrappers.titleChangedWrapper); + + wasmInstance.exports.ghostty_terminal_set(termPtr, GHOSTTY_TERMINAL_OPT_WRITE_PTY, writePtyIdx); + wasmInstance.exports.ghostty_terminal_set(termPtr, GHOSTTY_TERMINAL_OPT_BELL, bellIdx); + wasmInstance.exports.ghostty_terminal_set(termPtr, GHOSTTY_TERMINAL_OPT_TITLE_CHANGED, titleIdx); + // Write VT data to the terminal const vtBytes = new TextEncoder().encode(vtText); const dataPtr = wasmInstance.exports.ghostty_wasm_alloc_u8_array(vtBytes.length); @@ -290,6 +544,16 @@ outputDiv.className = 'output'; outputDiv.textContent = output; + // Render effects log + const effectsDiv = document.getElementById('effectsLog'); + if (effectsLog.length === 0) { + effectsDiv.textContent = 'No effects triggered.'; + } else { + effectsDiv.innerHTML = effectsLog.map(e => + `[${e.label}] ${e.data}` + ).join('\n'); + } + // Clean up wasmInstance.exports.ghostty_free(0, outPtr, outLen); wasmInstance.exports.ghostty_wasm_free_opaque(outPtrPtr); @@ -328,7 +592,7 @@ runBtn.addEventListener('click', run); // Run the default example on load - run(); + await run(); return; } catch (e) { statusDiv.textContent = `Error: ${e.message}`; From 624b4884c343a53fd256ca8da1628a4690a7b6f2 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 30 Mar 2026 11:33:55 -0700 Subject: [PATCH 2/5] Add build_table --- AGENTS.md | 5 ++ example/wasm-vt/index.html | 97 +++++++++++++------------------------- src/build/GhosttyLibVt.zig | 4 ++ 3 files changed, 41 insertions(+), 65 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 3298f2160..f4c4db7a9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,6 +16,11 @@ A file for [guiding coding agents](https://agents.md/). - **Formatting (Swift)**: `swiftlint lint --strict --fix` - **Formatting (other)**: `prettier -w .` +## libghostty-vt + +- Build: `zig build -Demit-lib-vt` +- Build WASM: `zig build -Demit-lib-vt -Dtarget=wasm32-freestanding -Doptimize=ReleaseSmall` + ## Directory Structure - Shared Zig core: `src/` diff --git a/example/wasm-vt/index.html b/example/wasm-vt/index.html index 2fe30b801..f81ffdd47 100644 --- a/example/wasm-vt/index.html +++ b/example/wasm-vt/index.html @@ -147,12 +147,14 @@ effectsLog.push({ label, data }); } - // Patch a WASM binary to: (1) make the function table growable by - // removing its max limit, and (2) export it as "tbl" so JS can add - // effect callback entries. Zig's WASM linker sets a fixed max on - // the table and does not export it by default. - function patchWasmForEffects(buffer) { - let bytes = new Uint8Array(buffer); + // Patch the WASM binary to make the function table growable by + // removing its max limit. The build already exports the table + // (via --export-table), but Zig's linker doesn't support + // --growable-table, so the table has a fixed max that prevents + // Table.grow() from JS. This patches the table section (id=4) + // to use flag=0 (no max), allowing JS to add callback entries. + function patchTableGrowable(buffer) { + const bytes = new Uint8Array(buffer); function readLEB128(arr, offset) { let result = 0, shift = 0, bytesRead = 0; @@ -177,22 +179,7 @@ return out; } - // Rebuild a section from parts: [before_section | id | new_size | new_payload | after_section] - function rebuildSection(buf, sectionPos, sectionStart, oldSectionSize, newPayload) { - const newSize = encodeLEB128(newPayload.length); - const afterStart = sectionStart + oldSectionSize; - const result = new Uint8Array(sectionPos + 1 + newSize.length + newPayload.length + (buf.length - afterStart)); - result.set(buf.subarray(0, sectionPos)); - let w = sectionPos; - result[w++] = buf[sectionPos]; // section id - for (const b of newSize) result[w++] = b; - for (const b of newPayload) result[w++] = b; - result.set(buf.subarray(afterStart), w); - return result; - } - - // Pass 1: Patch table section (id=4) to remove max limit so the table is growable. - let pos = 8; + let pos = 8; // skip magic + version while (pos < bytes.length) { const sectionId = bytes[pos]; const { value: sectionSize, bytesRead: sizeLen } = readLEB128(bytes, pos + 1); @@ -202,58 +189,38 @@ let tpos = sectionStart + countLen; const elemType = bytes[tpos++]; // 0x70 = funcref const flags = bytes[tpos]; - if (flags & 1) { - // Has max — rebuild without it: flag=0, keep only min - const { value: min, bytesRead: minLen } = readLEB128(bytes, tpos + 1); - const { value: max, bytesRead: maxLen } = readLEB128(bytes, tpos + 1 + minLen); - const afterLimits = tpos + 1 + minLen + maxLen; - const newPayload = [ - ...encodeLEB128(tableCount), - elemType, - 0x00, // flags: no max - ...encodeLEB128(min), - ...bytes.slice(afterLimits, sectionStart + sectionSize), - ]; - bytes = rebuildSection(bytes, pos, sectionStart, sectionSize, new Uint8Array(newPayload)); - } - break; - } - pos = sectionStart + sectionSize; - } - - // Pass 2: Add table export to export section (id=7) if not already present. - const mod = new WebAssembly.Module(bytes.buffer); - if (WebAssembly.Module.exports(mod).some(e => e.kind === 'table')) { - return bytes.buffer; - } - const exportEntry = [0x03, 0x74, 0x62, 0x6c, 0x01, 0x00]; // name="tbl", kind=table, index=0 - pos = 8; - while (pos < bytes.length) { - const sectionId = bytes[pos]; - const { value: sectionSize, bytesRead: sizeLen } = readLEB128(bytes, pos + 1); - const sectionStart = pos + 1 + sizeLen; - if (sectionId === 7) { - const { value: count, bytesRead: countLen } = readLEB128(bytes, sectionStart); - const restStart = sectionStart + countLen; - const restLen = sectionSize - countLen; + if (!(flags & 1)) return buffer; // already no max + // Has max — rebuild section without it + const { value: min, bytesRead: minLen } = readLEB128(bytes, tpos + 1); + const { bytesRead: maxLen } = readLEB128(bytes, tpos + 1 + minLen); + const afterLimits = tpos + 1 + minLen + maxLen; const newPayload = new Uint8Array([ - ...encodeLEB128(count + 1), - ...bytes.slice(restStart, restStart + restLen), - ...exportEntry, + ...encodeLEB128(tableCount), + elemType, + 0x00, // flags: no max + ...encodeLEB128(min), + ...bytes.slice(afterLimits, sectionStart + sectionSize), ]); - bytes = rebuildSection(bytes, pos, sectionStart, sectionSize, newPayload); - break; + const newSize = encodeLEB128(newPayload.length); + const afterSection = sectionStart + sectionSize; + const result = new Uint8Array(pos + 1 + newSize.length + newPayload.length + (bytes.length - afterSection)); + result.set(bytes.subarray(0, pos)); + let w = pos; + result[w++] = 4; // section id + for (const b of newSize) result[w++] = b; + for (const b of newPayload) result[w++] = b; + result.set(bytes.subarray(afterSection), w); + return result.buffer; } pos = sectionStart + sectionSize; } - - return bytes.buffer; + return buffer; } async function loadWasm() { try { const response = await fetch('../../zig-out/bin/ghostty-vt.wasm'); - const wasmBytes = patchWasmForEffects(await response.arrayBuffer()); + const wasmBytes = patchTableGrowable(await response.arrayBuffer()); const wasmModule = await WebAssembly.instantiate(wasmBytes, { env: { @@ -331,7 +298,7 @@ // Returns the table index (i.e. the function pointer value in WASM). let effectTableIndices = []; function addToWasmTable(func) { - const table = wasmInstance.exports.tbl || wasmInstance.exports.__indirect_function_table; + const table = wasmInstance.exports.__indirect_function_table; const idx = table.length; table.grow(1); table.set(idx, func); diff --git a/src/build/GhosttyLibVt.zig b/src/build/GhosttyLibVt.zig index 408f1ebc8..c3972e42c 100644 --- a/src/build/GhosttyLibVt.zig +++ b/src/build/GhosttyLibVt.zig @@ -44,6 +44,10 @@ pub fn initWasm( // Allow exported symbols to actually be exported. exe.rdynamic = true; + // Export the indirect function table so that embedders (e.g. JS in + // a browser) can insert callback entries for terminal effects. + exe.export_table = true; + // There is no entrypoint for this wasm module. exe.entry = .disabled; From 6c085e54426b83a8059273dc4b2a6ed159a76f15 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 30 Mar 2026 11:37:28 -0700 Subject: [PATCH 3/5] build: binary patch to add growable tables --- example/wasm-vt/index.html | 72 +------------------------------------- src/build/GhosttyLibVt.zig | 35 ++++++++++++++---- 2 files changed, 29 insertions(+), 78 deletions(-) diff --git a/example/wasm-vt/index.html b/example/wasm-vt/index.html index f81ffdd47..96acb22ab 100644 --- a/example/wasm-vt/index.html +++ b/example/wasm-vt/index.html @@ -147,80 +147,10 @@ effectsLog.push({ label, data }); } - // Patch the WASM binary to make the function table growable by - // removing its max limit. The build already exports the table - // (via --export-table), but Zig's linker doesn't support - // --growable-table, so the table has a fixed max that prevents - // Table.grow() from JS. This patches the table section (id=4) - // to use flag=0 (no max), allowing JS to add callback entries. - function patchTableGrowable(buffer) { - const bytes = new Uint8Array(buffer); - - function readLEB128(arr, offset) { - let result = 0, shift = 0, bytesRead = 0; - while (true) { - const byte = arr[offset + bytesRead]; - result |= (byte & 0x7f) << shift; - bytesRead++; - if ((byte & 0x80) === 0) break; - shift += 7; - } - return { value: result, bytesRead }; - } - - function encodeLEB128(value) { - const out = []; - do { - let b = value & 0x7f; - value >>>= 7; - if (value !== 0) b |= 0x80; - out.push(b); - } while (value !== 0); - return out; - } - - let pos = 8; // skip magic + version - while (pos < bytes.length) { - const sectionId = bytes[pos]; - const { value: sectionSize, bytesRead: sizeLen } = readLEB128(bytes, pos + 1); - const sectionStart = pos + 1 + sizeLen; - if (sectionId === 4) { - const { value: tableCount, bytesRead: countLen } = readLEB128(bytes, sectionStart); - let tpos = sectionStart + countLen; - const elemType = bytes[tpos++]; // 0x70 = funcref - const flags = bytes[tpos]; - if (!(flags & 1)) return buffer; // already no max - // Has max — rebuild section without it - const { value: min, bytesRead: minLen } = readLEB128(bytes, tpos + 1); - const { bytesRead: maxLen } = readLEB128(bytes, tpos + 1 + minLen); - const afterLimits = tpos + 1 + minLen + maxLen; - const newPayload = new Uint8Array([ - ...encodeLEB128(tableCount), - elemType, - 0x00, // flags: no max - ...encodeLEB128(min), - ...bytes.slice(afterLimits, sectionStart + sectionSize), - ]); - const newSize = encodeLEB128(newPayload.length); - const afterSection = sectionStart + sectionSize; - const result = new Uint8Array(pos + 1 + newSize.length + newPayload.length + (bytes.length - afterSection)); - result.set(bytes.subarray(0, pos)); - let w = pos; - result[w++] = 4; // section id - for (const b of newSize) result[w++] = b; - for (const b of newPayload) result[w++] = b; - result.set(bytes.subarray(afterSection), w); - return result.buffer; - } - pos = sectionStart + sectionSize; - } - return buffer; - } - async function loadWasm() { try { const response = await fetch('../../zig-out/bin/ghostty-vt.wasm'); - const wasmBytes = patchTableGrowable(await response.arrayBuffer()); + const wasmBytes = await response.arrayBuffer(); const wasmModule = await WebAssembly.instantiate(wasmBytes, { env: { diff --git a/src/build/GhosttyLibVt.zig b/src/build/GhosttyLibVt.zig index c3972e42c..db7f2e2a5 100644 --- a/src/build/GhosttyLibVt.zig +++ b/src/build/GhosttyLibVt.zig @@ -9,8 +9,8 @@ const GhosttyZig = @import("GhosttyZig.zig"); /// The step that generates the file. step: *std.Build.Step, -/// The artifact result -artifact: *std.Build.Step.InstallArtifact, +/// The install step for the library output. +artifact: *std.Build.Step, /// The kind of library kind: Kind, @@ -51,11 +51,32 @@ pub fn initWasm( // There is no entrypoint for this wasm module. exe.entry = .disabled; + // Zig's WASM linker doesn't support --growable-table, so the table + // has a fixed max equal to its initial size. Post-process with wabt + // tools (wasm2wat → sed → wat2wasm) to remove the max limit, making + // the table growable from JS via Table.grow(). + const wasm2wat = b.addSystemCommand(&.{"wasm2wat"}); + wasm2wat.addFileArg(exe.getEmittedBin()); + + const awk = b.addSystemCommand(&.{ + "awk", + // Remove the table max from "(table (;0;) MIN MAX funcref)" + // so that it becomes "(table (;0;) MIN funcref)", making the + // table growable from JS. + "/\\(table \\(;[0-9]+;\\) [0-9]+ [0-9]+ funcref\\)/ { sub(/ [0-9]+ funcref\\)/, \" funcref)\") } 1", + }); + awk.addFileArg(wasm2wat.captureStdOut()); + + const wat2wasm = b.addSystemCommand(&.{ "wat2wasm", "--enable-all" }); + wat2wasm.addFileArg(awk.captureStdOut()); + wat2wasm.addArgs(&.{"-o"}); + const output = wat2wasm.addOutputFileArg("ghostty-vt.wasm"); + return .{ - .step = &exe.step, - .artifact = b.addInstallArtifact(exe, .{}), + .step = &wat2wasm.step, + .artifact = &b.addInstallFileWithDir(output, .bin, "ghostty-vt.wasm").step, .kind = .wasm, - .output = exe.getEmittedBin(), + .output = output, .dsym = null, .pkg_config = null, }; @@ -168,7 +189,7 @@ fn initLib( return .{ .step = &lib.step, - .artifact = b.addInstallArtifact(lib, .{}), + .artifact = &b.addInstallArtifact(lib, .{}).step, .kind = kind, .output = lib.getEmittedBin(), .dsym = dsymutil, @@ -181,7 +202,7 @@ pub fn install( step: *std.Build.Step, ) void { const b = step.owner; - step.dependOn(&self.artifact.step); + step.dependOn(self.artifact); if (self.pkg_config) |pkg_config| { step.dependOn(&b.addInstallFileWithDir( pkg_config, From 01a8ea72125c4de72101637805a4c920eb54d34b Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 30 Mar 2026 11:46:07 -0700 Subject: [PATCH 4/5] build: binary patching with Zig --- src/build/GhosttyLibVt.zig | 43 ++-- src/build/wasm_patch_growable_table.zig | 269 ++++++++++++++++++++++++ 2 files changed, 291 insertions(+), 21 deletions(-) create mode 100644 src/build/wasm_patch_growable_table.zig diff --git a/src/build/GhosttyLibVt.zig b/src/build/GhosttyLibVt.zig index db7f2e2a5..10d988b38 100644 --- a/src/build/GhosttyLibVt.zig +++ b/src/build/GhosttyLibVt.zig @@ -52,29 +52,30 @@ pub fn initWasm( exe.entry = .disabled; // Zig's WASM linker doesn't support --growable-table, so the table - // has a fixed max equal to its initial size. Post-process with wabt - // tools (wasm2wat → sed → wat2wasm) to remove the max limit, making - // the table growable from JS via Table.grow(). - const wasm2wat = b.addSystemCommand(&.{"wasm2wat"}); - wasm2wat.addFileArg(exe.getEmittedBin()); - - const awk = b.addSystemCommand(&.{ - "awk", - // Remove the table max from "(table (;0;) MIN MAX funcref)" - // so that it becomes "(table (;0;) MIN funcref)", making the - // table growable from JS. - "/\\(table \\(;[0-9]+;\\) [0-9]+ [0-9]+ funcref\\)/ { sub(/ [0-9]+ funcref\\)/, \" funcref)\") } 1", - }); - awk.addFileArg(wasm2wat.captureStdOut()); - - const wat2wasm = b.addSystemCommand(&.{ "wat2wasm", "--enable-all" }); - wat2wasm.addFileArg(awk.captureStdOut()); - wat2wasm.addArgs(&.{"-o"}); - const output = wat2wasm.addOutputFileArg("ghostty-vt.wasm"); + // is emitted with max == min and can't be grown from JS. Run a + // small Zig build tool that patches the binary's table section to + // remove the max limit. + const patch_run = patch: { + const patcher = b.addExecutable(.{ + .name = "wasm_patch_growable_table", + .root_module = b.createModule(.{ + .root_source_file = b.path("src/build/wasm_patch_growable_table.zig"), + .target = b.graph.host, + }), + }); + break :patch b.addRunArtifact(patcher); + }; + patch_run.addFileArg(exe.getEmittedBin()); + const output = patch_run.addOutputFileArg("ghostty-vt.wasm"); + const artifact_install = b.addInstallFileWithDir( + output, + .bin, + "ghostty-vt.wasm", + ); return .{ - .step = &wat2wasm.step, - .artifact = &b.addInstallFileWithDir(output, .bin, "ghostty-vt.wasm").step, + .step = &patch_run.step, + .artifact = &artifact_install.step, .kind = .wasm, .output = output, .dsym = null, diff --git a/src/build/wasm_patch_growable_table.zig b/src/build/wasm_patch_growable_table.zig new file mode 100644 index 000000000..c40c0a9c8 --- /dev/null +++ b/src/build/wasm_patch_growable_table.zig @@ -0,0 +1,269 @@ +//! Build tool that patches a WASM binary to make the function table +//! growable by removing its maximum size limit. +//! +//! Zig's WASM linker doesn't support `--growable-table`, so the table +//! is emitted with max == min. This tool finds the table section (id 4) +//! and changes the limits flag from 0x01 (has max) to 0x00 (no max), +//! removing the max field. +//! +//! Usage: wasm_growable_table + +const std = @import("std"); +const testing = std.testing; +const Allocator = std.mem.Allocator; + +pub fn main() !void { + // This is a one-off patcher, so we leak all our memory on purpose + // and let the OS clean it up when we exit. + var gpa: std.heap.GeneralPurposeAllocator(.{}) = .init; + const alloc = gpa.allocator(); + + // Parse args: program input output + const args = try std.process.argsAlloc(alloc); + defer std.process.argsFree(alloc, args); + if (args.len != 3) { + std.log.err("usage: wasm_growable_table ", .{}); + std.process.exit(1); + unreachable; + } + + // Patch the file. + const output: []const u8 = try patchTableGrowable( + alloc, + try std.fs.cwd().readFileAlloc( + alloc, + args[1], + std.math.maxInt(usize), + ), + ); + + // Write our output + const out_file = try std.fs.cwd().createFile(args[2], .{}); + defer out_file.close(); + try out_file.writeAll(output); +} + +/// Patch the WASM binary's table section to remove the maximum size +/// limit, making the table growable. If the table already has no max +/// or no table section is found, the input is returned unchanged. +/// +/// The WASM table section (id=4) encodes table limits as: +/// - flags=0x00, min (LEB128) — no max, growable +/// - flags=0x01, min (LEB128), max (LEB128) — bounded, not growable +/// +/// This function rewrites the section to use flags=0x00, dropping the +/// max field entirely. +fn patchTableGrowable( + alloc: Allocator, + input: []const u8, +) (error{InvalidWasm} || std.Io.Writer.Error)![]const u8 { + if (input.len < 8) return error.InvalidWasm; + + // Start after the 8-byte WASM header (magic + version). + var pos: usize = 8; + + while (pos < input.len) { + const section_id = input[pos]; + pos += 1; + const section_size = readLeb128(input, &pos); + const section_start = pos; + + // We're looking for section 4 (the table section). + if (section_id != 4) { + pos = section_start + section_size; + continue; + } + + _ = readLeb128(input, &pos); // table count + pos += 1; // elem_type (0x70 = funcref) + const flags = input[pos]; + + // flags bit 0 indicates whether a max is present. + if (flags & 1 == 0) { + // Already no max, nothing to patch. + return input; + } + + // Record positions of each field so we can reconstruct + // the section without the max value. + const flags_pos = pos; + pos += 1; // skip flags byte + const min_start = pos; + _ = readLeb128(input, &pos); // min + const max_start = pos; + _ = readLeb128(input, &pos); // max + const max_end = pos; + const section_end = section_start + section_size; + + // Build the new section payload with the max removed: + // [table count + elem_type] [flags=0x00] [min] [trailing data] + var payload: std.Io.Writer.Allocating = .init(alloc); + try payload.writer.writeAll(input[section_start..flags_pos]); + try payload.writer.writeByte(0x00); // flags: no max + try payload.writer.writeAll(input[min_start..max_start]); + try payload.writer.writeAll(input[max_end..section_end]); + + // Reassemble the full binary: + // [everything before this section] [section id] [new size] [new payload] [everything after] + const before_section = input[0 .. section_start - 1 - uleb128Size(section_size)]; + var result: std.Io.Writer.Allocating = .init(alloc); + try result.writer.writeAll(before_section); + try result.writer.writeByte(4); // table section id + try result.writer.writeUleb128(@as(u32, @intCast(payload.written().len))); + try result.writer.writeAll(payload.written()); + try result.writer.writeAll(input[section_end..]); + return result.written(); + } + + // No table section found; return input unchanged. + return input; +} + +/// Decode an unsigned LEB128 value from `bytes` starting at `pos.*`, +/// advancing `pos` past the encoded bytes. +fn readLeb128(bytes: []const u8, pos: *usize) u32 { + var result: u32 = 0; + var shift: u5 = 0; + while (true) { + const byte = bytes[pos.*]; + pos.* += 1; + result |= @as(u32, byte & 0x7f) << shift; + if (byte & 0x80 == 0) return result; + shift +%= 7; + } +} + +/// Return the number of bytes needed to encode `value` as unsigned LEB128. +fn uleb128Size(value: u32) usize { + var v = value; + var size: usize = 0; + while (true) { + v >>= 7; + size += 1; + if (v == 0) return size; + } +} + +/// Minimal valid WASM module with a bounded table (min=1, max=1). +/// Sections: type(1), table(4), export(7). +const test_wasm_bounded_table = [_]u8{ + 0x00, 0x61, 0x73, 0x6d, // magic + 0x01, 0x00, 0x00, 0x00, // version + // Type section (id=1): 1 type, () -> () + 0x01, 0x04, 0x01, 0x60, + 0x00, 0x00, + // Table section (id=4): 1 table, funcref, flags=1, min=1, max=1 + 0x04, 0x05, + 0x01, 0x70, 0x01, 0x01, + 0x01, + // Export section (id=7): 0 exports + 0x07, 0x01, 0x00, +}; + +/// Same module but the table already has no max (flags=0). +const test_wasm_growable_table = [_]u8{ + 0x00, 0x61, 0x73, 0x6d, // magic + 0x01, 0x00, 0x00, 0x00, // version + // Type section (id=1) + 0x01, 0x04, 0x01, 0x60, + 0x00, 0x00, + // Table section (id=4): 1 table, funcref, flags=0, min=1 + 0x04, 0x04, + 0x01, 0x70, 0x00, 0x01, + // Export section (id=7): 0 exports + 0x07, 0x01, 0x00, +}; + +/// Module with no table section at all. +const test_wasm_no_table = [_]u8{ + 0x00, 0x61, 0x73, 0x6d, // magic + 0x01, 0x00, 0x00, 0x00, // version + // Type section (id=1) + 0x01, 0x04, 0x01, 0x60, + 0x00, 0x00, + // Export section (id=7): 0 exports + 0x07, 0x01, + 0x00, +}; + +test "patches bounded table to remove max" { + // We use a non-checking allocator because the patched result is + // intentionally leaked (matches the real main() usage). + const result = try patchTableGrowable( + std.heap.page_allocator, + &test_wasm_bounded_table, + ); + + // Result should differ from input (max was removed). + try testing.expect(!std.mem.eql( + u8, + result, + &test_wasm_bounded_table, + )); + + // Find the table section in the output and verify flags=0x00. + var pos: usize = 8; + while (pos < result.len) { + const id = result[pos]; + pos += 1; + const size = readLeb128(result, &pos); + if (id == 4) { + _ = readLeb128(result, &pos); // table count + pos += 1; // elem_type + // flags should now be 0x00 (no max). + try testing.expectEqual(@as(u8, 0x00), result[pos]); + return; + } + pos += size; + } + return error.TableSectionNotFound; +} + +test "already growable table is returned unchanged" { + const result = try patchTableGrowable( + testing.allocator, + &test_wasm_growable_table, + ); + try testing.expectEqual( + @as([*]const u8, &test_wasm_growable_table), + result.ptr, + ); +} + +test "no table section returns input unchanged" { + const result = try patchTableGrowable( + testing.allocator, + &test_wasm_no_table, + ); + try testing.expectEqual(@as([*]const u8, &test_wasm_no_table), result.ptr); +} + +test "too short input returns InvalidWasm" { + try testing.expectError( + error.InvalidWasm, + patchTableGrowable(testing.allocator, "short"), + ); +} + +test "readLeb128 single byte" { + const bytes = [_]u8{0x05}; + var pos: usize = 0; + try testing.expectEqual(@as(u32, 5), readLeb128(&bytes, &pos)); + try testing.expectEqual(@as(usize, 1), pos); +} + +test "readLeb128 multi byte" { + // 300 = 0b100101100 → LEB128: 0xAC 0x02 + const bytes = [_]u8{ 0xAC, 0x02 }; + var pos: usize = 0; + try testing.expectEqual(@as(u32, 300), readLeb128(&bytes, &pos)); + try testing.expectEqual(@as(usize, 2), pos); +} + +test "uleb128Size" { + try testing.expectEqual(@as(usize, 1), uleb128Size(0)); + try testing.expectEqual(@as(usize, 1), uleb128Size(0x7f)); + try testing.expectEqual(@as(usize, 2), uleb128Size(0x80)); + try testing.expectEqual(@as(usize, 2), uleb128Size(300)); + try testing.expectEqual(@as(usize, 5), uleb128Size(std.math.maxInt(u32))); +} From f89195ace9d5157e7a265acf807fbeb9b839b7c2 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 30 Mar 2026 15:21:24 -0700 Subject: [PATCH 5/5] revert example/wasm-vt --- example/wasm-vt/index.html | 169 +------------------------------------ 1 file changed, 4 insertions(+), 165 deletions(-) diff --git a/example/wasm-vt/index.html b/example/wasm-vt/index.html index 96acb22ab..d720e2375 100644 --- a/example/wasm-vt/index.html +++ b/example/wasm-vt/index.html @@ -37,26 +37,6 @@ box-sizing: border-box; resize: vertical; } - .effects-log { - background: #1a1a2e; - color: #e0e0e0; - border: 1px solid #444; - border-radius: 4px; - padding: 15px; - margin: 20px 0; - font-family: 'Courier New', monospace; - white-space: pre-wrap; - font-size: 13px; - max-height: 200px; - overflow-y: auto; - } - .effects-log .effect-label { - color: #00d9ff; - font-weight: bold; - } - .effects-log .effect-data { - color: #a0ffa0; - } button { background: #0066cc; color: white; @@ -125,14 +105,11 @@

VT Input

- -

Use \x1b for ESC, \r\n for CR+LF, \x07 for BEL. Press "Run" to process.

+ +

Use \x1b for ESC, \r\n for CR+LF. Press "Run" to process.

-

Effects Log

-
No effects triggered yet.
-
Waiting for input...

Note: This example must be served via HTTP (not opened directly as a file). See the README for instructions.

@@ -141,11 +118,6 @@ let wasmInstance = null; let wasmMemory = null; let typeLayout = null; - let effectsLog = []; - - function logEffect(label, data) { - effectsLog.push({ label, data }); - } async function loadWasm() { try { @@ -219,119 +191,7 @@ // GHOSTTY_SUCCESS = 0 const GHOSTTY_SUCCESS = 0; - // GhosttyTerminalOption enum values - const GHOSTTY_TERMINAL_OPT_WRITE_PTY = 1; - const GHOSTTY_TERMINAL_OPT_BELL = 2; - const GHOSTTY_TERMINAL_OPT_TITLE_CHANGED = 5; - - // Allocate slots in the WASM indirect function table for JS callbacks. - // Returns the table index (i.e. the function pointer value in WASM). - let effectTableIndices = []; - function addToWasmTable(func) { - const table = wasmInstance.exports.__indirect_function_table; - const idx = table.length; - table.grow(1); - table.set(idx, func); - effectTableIndices.push(idx); - return idx; - } - - // Build a tiny WASM trampoline module that imports JS callbacks and - // re-exports them as properly typed WASM functions. This is needed - // because adding JS functions to a WASM table requires them to be - // wrapped as WebAssembly function objects with the correct signature. - // WebAssembly.Function is not supported in all browsers (e.g. Safari), - // so we compile a minimal module instead. - let effectWrappers = null; - async function buildEffectWrappers() { - if (effectWrappers) return effectWrappers; - - // Hand-coded WASM module with: - // Type 0: (i32, i32, i32, i32) -> void [write_pty] - // Type 1: (i32, i32) -> void [bell, title_changed] - // Imports: env.a (type 0), env.b (type 1), env.c (type 1) - // Functions 3,4,5 wrap imports 0,1,2 respectively - // Exports: "a" -> func 3, "b" -> func 4, "c" -> func 5 - const bytes = new Uint8Array([ - 0x00, 0x61, 0x73, 0x6d, // magic - 0x01, 0x00, 0x00, 0x00, // version - - // Type section (id=1) - 0x01, 0x0d, // section id, size=13 - 0x02, // 2 types - // type 0: (i32, i32, i32, i32) -> () - 0x60, 0x04, 0x7f, 0x7f, 0x7f, 0x7f, 0x00, - // type 1: (i32, i32) -> () - 0x60, 0x02, 0x7f, 0x7f, 0x00, - - // Import section (id=2) - 0x02, 0x19, // section id, size=25 - 0x03, // 3 imports - // import 0: env.a type 0 - 0x03, 0x65, 0x6e, 0x76, // "env" - 0x01, 0x61, // "a" - 0x00, 0x00, // func, type 0 - // import 1: env.b type 1 - 0x03, 0x65, 0x6e, 0x76, // "env" - 0x01, 0x62, // "b" - 0x00, 0x01, // func, type 1 - // import 2: env.c type 1 - 0x03, 0x65, 0x6e, 0x76, // "env" - 0x01, 0x63, // "c" - 0x00, 0x01, // func, type 1 - - // Function section (id=3) - 0x03, 0x04, // section id, size=4 - 0x03, // 3 functions - 0x00, 0x01, 0x01, // types: 0, 1, 1 - - // Export section (id=7) - 0x07, 0x0d, // section id, size=13 - 0x03, // 3 exports - 0x01, 0x61, 0x00, 0x03, // "a" -> func 3 - 0x01, 0x62, 0x00, 0x04, // "b" -> func 4 - 0x01, 0x63, 0x00, 0x05, // "c" -> func 5 - - // Code section (id=10) - 0x0a, 0x20, // section id, size=32 - 0x03, // 3 function bodies - // func 3: call import 0 with 4 args - 0x0c, 0x00, // body size=12, 0 locals - 0x20, 0x00, 0x20, 0x01, 0x20, 0x02, 0x20, 0x03, 0x10, 0x00, 0x0b, - // func 4: call import 1 with 2 args - 0x08, 0x00, // body size=8, 0 locals - 0x20, 0x00, 0x20, 0x01, 0x10, 0x01, 0x0b, - // func 5: call import 2 with 2 args - 0x08, 0x00, // body size=8, 0 locals - 0x20, 0x00, 0x20, 0x01, 0x10, 0x02, 0x0b, - ]); - - const mod = await WebAssembly.instantiate(bytes, { - env: { - a: (terminal, userdata, dataPtr, len) => { - const b = new Uint8Array(getBuffer(), dataPtr, len); - const text = new TextDecoder().decode(b.slice()); - const hex = Array.from(b).map(v => v.toString(16).padStart(2, '0')).join(' '); - logEffect('write_pty', `${len} bytes: ${hex} "${text}"`); - }, - b: (terminal, userdata) => { - logEffect('bell', 'BEL received!'); - }, - c: (terminal, userdata) => { - logEffect('title_changed', 'Terminal title changed'); - }, - }, - }); - - effectWrappers = { - writePtyWrapper: mod.instance.exports.a, - bellWrapper: mod.instance.exports.b, - titleChangedWrapper: mod.instance.exports.c, - }; - return effectWrappers; - } - - async function run() { + function run() { const outputDiv = document.getElementById('output'); try { @@ -361,17 +221,6 @@ const termPtr = new DataView(getBuffer()).getUint32(termPtrPtr, true); wasmInstance.exports.ghostty_wasm_free_opaque(termPtrPtr); - // Register effect callbacks - effectsLog = []; - const wrappers = await buildEffectWrappers(); - const writePtyIdx = addToWasmTable(wrappers.writePtyWrapper); - const bellIdx = addToWasmTable(wrappers.bellWrapper); - const titleIdx = addToWasmTable(wrappers.titleChangedWrapper); - - wasmInstance.exports.ghostty_terminal_set(termPtr, GHOSTTY_TERMINAL_OPT_WRITE_PTY, writePtyIdx); - wasmInstance.exports.ghostty_terminal_set(termPtr, GHOSTTY_TERMINAL_OPT_BELL, bellIdx); - wasmInstance.exports.ghostty_terminal_set(termPtr, GHOSTTY_TERMINAL_OPT_TITLE_CHANGED, titleIdx); - // Write VT data to the terminal const vtBytes = new TextEncoder().encode(vtText); const dataPtr = wasmInstance.exports.ghostty_wasm_alloc_u8_array(vtBytes.length); @@ -441,16 +290,6 @@ outputDiv.className = 'output'; outputDiv.textContent = output; - // Render effects log - const effectsDiv = document.getElementById('effectsLog'); - if (effectsLog.length === 0) { - effectsDiv.textContent = 'No effects triggered.'; - } else { - effectsDiv.innerHTML = effectsLog.map(e => - `[${e.label}] ${e.data}` - ).join('\n'); - } - // Clean up wasmInstance.exports.ghostty_free(0, outPtr, outLen); wasmInstance.exports.ghostty_wasm_free_opaque(outPtrPtr); @@ -489,7 +328,7 @@ runBtn.addEventListener('click', run); // Run the default example on load - await run(); + run(); return; } catch (e) { statusDiv.textContent = `Error: ${e.message}`;