build: binary patch to add growable tables

This commit is contained in:
Mitchell Hashimoto
2026-03-30 11:37:28 -07:00
parent 624b4884c3
commit 6c085e5442
2 changed files with 29 additions and 78 deletions

View File

@@ -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: {

View File

@@ -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,