2e414911
feat: wasm core — replica+keymap on wasm32-freestanding, flat damage-hinted viewport ABI
a73x 2026-08-13 10:27
Commit message
build.zig
| Old | New | ||
|---|---|---|---|
| @@ -401,6 +401,65 @@ pub fn build(b: *std.Build) void { | |||
| 401 | ptyclient_exe.use_lld = true; | 401 | ptyclient_exe.use_lld = true; |
| 402 | b.installArtifact(ptyclient_exe); | 402 | b.installArtifact(ptyclient_exe); |
| 403 | 403 | ||
| 404 | // ---- The wasm core (M-web Task 4) ---- | ||
| 405 | // A SECOND resolved target: modules are target-bound, so the four | ||
| 406 | // wasm-clean modules (engine, protocol, replica, keymap) and the | ||
| 407 | // ghostty dependency are instantiated again against wasm32. Always | ||
| 408 | // ReleaseSmall — the artifact is @embedFile'd into muxweb, and its | ||
| 409 | // Debug build is 3.7MB against ReleaseSmall's 345KB (spike-measured). | ||
| 410 | // Safety checks are the price; the native test suite runs the same | ||
| 411 | // code checked. | ||
| 412 | const wasm_target = b.resolveTargetQuery(.{ | ||
| 413 | .cpu_arch = .wasm32, | ||
| 414 | .os_tag = .freestanding, | ||
| 415 | }); | ||
| 416 | const ghostty_wasm_dep = b.lazyDependency("ghostty", .{ | ||
| 417 | .target = wasm_target, | ||
| 418 | .optimize = .ReleaseSmall, | ||
| 419 | }); | ||
| 420 | const engine_wasm_mod = b.createModule(.{ | ||
| 421 | .root_source_file = b.path("src/engine.zig"), | ||
| 422 | .target = wasm_target, | ||
| 423 | .optimize = .ReleaseSmall, | ||
| 424 | }); | ||
| 425 | if (ghostty_wasm_dep) |dep| { | ||
| 426 | engine_wasm_mod.addImport("ghostty-vt", dep.module("ghostty-vt")); | ||
| 427 | } | ||
| 428 | const protocol_wasm_mod = b.createModule(.{ | ||
| 429 | .root_source_file = b.path("src/protocol.zig"), | ||
| 430 | .target = wasm_target, | ||
| 431 | .optimize = .ReleaseSmall, | ||
| 432 | }); | ||
| 433 | const replica_wasm_mod = b.createModule(.{ | ||
| 434 | .root_source_file = b.path("src/replica.zig"), | ||
| 435 | .target = wasm_target, | ||
| 436 | .optimize = .ReleaseSmall, | ||
| 437 | }); | ||
| 438 | replica_wasm_mod.addImport("engine", engine_wasm_mod); | ||
| 439 | replica_wasm_mod.addImport("protocol", protocol_wasm_mod); | ||
| 440 | const keymap_wasm_mod = b.createModule(.{ | ||
| 441 | .root_source_file = b.path("src/keymap.zig"), | ||
| 442 | .target = wasm_target, | ||
| 443 | .optimize = .ReleaseSmall, | ||
| 444 | }); | ||
| 445 | const wasm_core_mod = b.createModule(.{ | ||
| 446 | .root_source_file = b.path("src/wasm_core.zig"), | ||
| 447 | .target = wasm_target, | ||
| 448 | .optimize = .ReleaseSmall, | ||
| 449 | }); | ||
| 450 | wasm_core_mod.addImport("engine", engine_wasm_mod); | ||
| 451 | wasm_core_mod.addImport("protocol", protocol_wasm_mod); | ||
| 452 | wasm_core_mod.addImport("replica", replica_wasm_mod); | ||
| 453 | wasm_core_mod.addImport("keymap", keymap_wasm_mod); | ||
| 454 | const wasm_exe = b.addExecutable(.{ .name = "mux_core", .root_module = wasm_core_mod }); | ||
| 455 | // A wasm reactor, not a command: no _start, and the exports must | ||
| 456 | // survive the linker's dead-strip. Deliberately NOT use_llvm/use_lld — | ||
| 457 | // that pair is the native x86-64 self-hosted-linker workaround and | ||
| 458 | // must not be copied onto the wasm exe (spike-proven recipe). | ||
| 459 | wasm_exe.entry = .disabled; | ||
| 460 | wasm_exe.rdynamic = true; | ||
| 461 | b.installArtifact(wasm_exe); | ||
| 462 | |||
| 404 | const test_step = b.step("test", "Run unit tests"); | 463 | const test_step = b.step("test", "Run unit tests"); |
| 405 | // delta_mod and sockpath_mod sit BEFORE server_mod, deliberately: their | 464 | // delta_mod and sockpath_mod sit BEFORE server_mod, deliberately: their |
| 406 | // tests are seconds-long and socket-free, while a regression in either | 465 | // tests are seconds-long and socket-free, while a regression in either |
src/engine.zig
| Old | New | ||
|---|---|---|---|
| @@ -225,7 +225,11 @@ pub const Engine = struct { | |||
| 225 | 225 | ||
| 226 | fn onWritePty(handler: *vt.TerminalStream.Handler, data: [:0]const u8) void { | 226 | fn onWritePty(handler: *vt.TerminalStream.Handler, data: [:0]const u8) void { |
| 227 | const stream_ptr: *vt.TerminalStream = @fieldParentPtr("handler", handler); | 227 | const stream_ptr: *vt.TerminalStream = @fieldParentPtr("handler", handler); |
| 228 | const self: *Engine = @fieldParentPtr("stream", stream_ptr); | 228 | // The @alignCast is for wasm32, where pointers default to 4-byte |
| 229 | // alignment while Engine needs 8. Sound: the parent really is | ||
| 230 | // 8-aligned — every Engine comes from alloc.create (init's | ||
| 231 | // never-moves contract). A no-op assert on native. | ||
| 232 | const self: *Engine = @alignCast(@fieldParentPtr("stream", stream_ptr)); | ||
| 229 | self.pty_out.appendSlice(self.alloc, data) catch {}; | 233 | self.pty_out.appendSlice(self.alloc, data) catch {}; |
| 230 | } | 234 | } |
| 231 | }; | 235 | }; |
src/wasm_core.zig
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,515 @@ | |||
| 1 | //! The browser replica core: Engine + Replica + keymap compiled to | ||
| 2 | //! wasm32-freestanding (M-web Task 4). The JS shell is glue; every | ||
| 3 | //! decision lives on this side of the boundary. | ||
| 4 | //! | ||
| 5 | //! FRAME-driven, not byte-driven: the host stages one mux protocol frame | ||
| 6 | //! payload and calls mux_apply_frame(type, len), so the core sees the same | ||
| 7 | //! replay the CLI client does (replica.zig) and can track damage from the | ||
| 8 | //! delta row headers itself. | ||
| 9 | //! | ||
| 10 | //! THE JS GOTCHA THAT WILL BITE (from the feasibility spike): the wasm | ||
| 11 | //! allocator grows linear memory, and growth DETACHES every cached | ||
| 12 | //! ArrayBuffer view. JS must re-read exports.memory.buffer after every | ||
| 13 | //! call that can allocate — which is any of them — and never cache a view. | ||
| 14 | //! | ||
| 15 | //! Wire safety: this module trusts nothing it is fed. Payloads come from | ||
| 16 | //! the daemon via the hub, but a bad length or type is a return code, not | ||
| 17 | //! a trap. | ||
| 18 | |||
| 19 | const std = @import("std"); | ||
| 20 | const builtin = @import("builtin"); | ||
| 21 | const Engine = @import("engine").Engine; | ||
| 22 | const Replica = @import("replica").Replica; | ||
| 23 | const keymap = @import("keymap"); | ||
| 24 | const proto = @import("protocol"); | ||
| 25 | |||
| 26 | /// std.heap.wasm_allocator grows linear memory with @wasmMemoryGrow and | ||
| 27 | /// needs no libc, no syscalls, no host imports. | ||
| 28 | const alloc = std.heap.wasm_allocator; | ||
| 29 | |||
| 30 | /// Trap explicitly on panic. This is also the hook a later version uses | ||
| 31 | /// to surface panic text to JS (a host-imported log before the trap). | ||
| 32 | pub const panic = std.debug.FullPanic(struct { | ||
| 33 | fn f(_: []const u8, _: ?usize) noreturn { | ||
| 34 | @trap(); | ||
| 35 | } | ||
| 36 | }.f); | ||
| 37 | |||
| 38 | /// Load-bearing (spike obstacle 4): ghostty-vt logs warnings on some | ||
| 39 | /// unsupported sequences, and std's default logFn writes to stderr, which | ||
| 40 | /// on wasm32-freestanding drags in posix.writev/lseek and std.Thread. | ||
| 41 | /// Without this no-op the build fails inside std before any ghostty code. | ||
| 42 | pub const std_options: std.Options = .{ | ||
| 43 | .logFn = struct { | ||
| 44 | fn f( | ||
| 45 | comptime _: std.log.Level, | ||
| 46 | comptime _: @Type(.enum_literal), | ||
| 47 | comptime _: []const u8, | ||
| 48 | _: anytype, | ||
| 49 | ) void {} | ||
| 50 | }.f, | ||
| 51 | }; | ||
| 52 | |||
| 53 | const Core = struct { | ||
| 54 | eng: *Engine, | ||
| 55 | rep: Replica, | ||
| 56 | /// Grid the readout buffers are sized for; follows rep.grid. | ||
| 57 | cols: u16, | ||
| 58 | rows: u16, | ||
| 59 | /// Per-row damage since the last mux_read_viewport. Snapshots and | ||
| 60 | /// grid moves mark everything; deltas mark their row headers plus | ||
| 61 | /// the cursor's old and new rows (the renderer draws the cursor). | ||
| 62 | dirty: []bool, | ||
| 63 | /// Scratch list the last read filled: the row indices it repainted. | ||
| 64 | dirty_list: []u32, | ||
| 65 | dirty_count: u32 = 0, | ||
| 66 | /// Packed cells, 4 u32 per cell (see mux_viewport_ptr). | ||
| 67 | viewport: []u32, | ||
| 68 | cursor_row: u16 = 0, | ||
| 69 | /// Scrollback view: a scratch terminal the host feeds fetched history | ||
| 70 | /// rows into. Never touches the live replica. | ||
| 71 | scroll_eng: ?*Engine = null, | ||
| 72 | }; | ||
| 73 | |||
| 74 | var core: ?*Core = null; | ||
| 75 | |||
| 76 | /// Bytes in (frame payloads, paste bytes) cross through this staging | ||
| 77 | /// buffer: one memcpy from JS, no malloc protocol to get wrong. 256 KiB — | ||
| 78 | /// a full snapshot payload must fit in ONE frame; verify.js asserts a | ||
| 79 | /// real snapshot at the default scrollback does. The hub's own inbound | ||
| 80 | /// bound is far smaller (64 KiB) because browser->hub messages are keys | ||
| 81 | /// and pastes; daemon->browser frames ride hub->browser with no bound. | ||
| 82 | var input_buf: [256 * 1024]u8 = undefined; | ||
| 83 | |||
| 84 | /// Variable-length results out (encoded keys, attach payloads, dumps). | ||
| 85 | var output_buf: [64 * 1024]u8 = undefined; | ||
| 86 | var output_len: u32 = 0; | ||
| 87 | |||
| 88 | // --------------------------------------------------------------------- | ||
| 89 | // Lifecycle | ||
| 90 | // --------------------------------------------------------------------- | ||
| 91 | |||
| 92 | fn teardown(c: *Core) void { | ||
| 93 | if (c.scroll_eng) |se| se.deinit(); | ||
| 94 | alloc.free(c.viewport); | ||
| 95 | alloc.free(c.dirty_list); | ||
| 96 | alloc.free(c.dirty); | ||
| 97 | c.eng.deinit(); | ||
| 98 | alloc.destroy(c); | ||
| 99 | } | ||
| 100 | |||
| 101 | fn allocGridBufs(c: *Core, cols: u16, rows: u16) !void { | ||
| 102 | const cells = @as(usize, cols) * rows; | ||
| 103 | const viewport = try alloc.alloc(u32, cells * 4); | ||
| 104 | errdefer alloc.free(viewport); | ||
| 105 | const dirty = try alloc.alloc(bool, rows); | ||
| 106 | errdefer alloc.free(dirty); | ||
| 107 | const dirty_list = try alloc.alloc(u32, rows); | ||
| 108 | errdefer alloc.free(dirty_list); | ||
| 109 | c.viewport = viewport; | ||
| 110 | c.dirty = dirty; | ||
| 111 | c.dirty_list = dirty_list; | ||
| 112 | c.cols = cols; | ||
| 113 | c.rows = rows; | ||
| 114 | @memset(c.dirty, true); | ||
| 115 | } | ||
| 116 | |||
| 117 | export fn mux_init(cols: u32, rows: u32) i32 { | ||
| 118 | if (core) |c| { | ||
| 119 | teardown(c); | ||
| 120 | core = null; | ||
| 121 | } | ||
| 122 | if (cols < 1 or cols > 4096 or rows < 1 or rows > 4096) return -2; | ||
| 123 | |||
| 124 | const c = alloc.create(Core) catch return -1; | ||
| 125 | c.* = .{ | ||
| 126 | .eng = undefined, | ||
| 127 | .rep = undefined, | ||
| 128 | .cols = 0, | ||
| 129 | .rows = 0, | ||
| 130 | .dirty = &.{}, | ||
| 131 | .dirty_list = &.{}, | ||
| 132 | .viewport = &.{}, | ||
| 133 | }; | ||
| 134 | c.eng = Engine.init(alloc, .{ | ||
| 135 | .cols = @intCast(cols), | ||
| 136 | .rows = @intCast(rows), | ||
| 137 | }) catch { | ||
| 138 | alloc.destroy(c); | ||
| 139 | return -2; | ||
| 140 | }; | ||
| 141 | c.rep = Replica.init(alloc, c.eng); | ||
| 142 | allocGridBufs(c, @intCast(cols), @intCast(rows)) catch { | ||
| 143 | c.eng.deinit(); | ||
| 144 | alloc.destroy(c); | ||
| 145 | return -1; | ||
| 146 | }; | ||
| 147 | core = c; | ||
| 148 | return 0; | ||
| 149 | } | ||
| 150 | |||
| 151 | export fn mux_deinit() void { | ||
| 152 | const c = core orelse return; | ||
| 153 | teardown(c); | ||
| 154 | core = null; | ||
| 155 | } | ||
| 156 | |||
| 157 | // --------------------------------------------------------------------- | ||
| 158 | // Staging and replay | ||
| 159 | // --------------------------------------------------------------------- | ||
| 160 | |||
| 161 | export fn mux_input_ptr() [*]u8 { | ||
| 162 | return &input_buf; | ||
| 163 | } | ||
| 164 | |||
| 165 | export fn mux_input_cap() u32 { | ||
| 166 | return input_buf.len; | ||
| 167 | } | ||
| 168 | |||
| 169 | /// Apply one staged frame payload. `msg_type` is the wire byte; only | ||
| 170 | /// snapshot (0x81) and delta (0x87) are replay frames — the host routes | ||
| 171 | /// everything else itself. | ||
| 172 | /// Returns 0 painted, 1 RESYNC-NEEDED (send a fresh attach quoting 0,0), | ||
| 173 | /// -1 uninitialized, -2 length over the staging cap, -3 not a replay | ||
| 174 | /// frame or a grid the core refuses. | ||
| 175 | export fn mux_apply_frame(msg_type: u32, len: u32) i32 { | ||
| 176 | const c = core orelse return -1; | ||
| 177 | if (len > input_buf.len) return -2; | ||
| 178 | if (msg_type > 0xff) return -3; | ||
| 179 | const t = std.meta.intToEnum(proto.MsgType, @as(u8, @intCast(msg_type))) catch return -3; | ||
| 180 | if (t != .snapshot and t != .delta) return -3; | ||
| 181 | const payload = input_buf[0..len]; | ||
| 182 | |||
| 183 | const cursor_before = c.rep.eng.cursorPos(); | ||
| 184 | const applied = c.rep.apply(t, payload) catch |err| switch (err) { | ||
| 185 | // A short snapshot proves nothing and paints nothing. | ||
| 186 | error.BadPayload => return -3, | ||
| 187 | // Engine resize failure: the grid the daemon named is beyond us. | ||
| 188 | else => return -3, | ||
| 189 | }; | ||
| 190 | if (applied == .resync) return 1; | ||
| 191 | |||
| 192 | // Damage bookkeeping. | ||
| 193 | if (c.rep.grid.cols != c.cols or c.rep.grid.rows != c.rows) { | ||
| 194 | // The grid moved: reallocate the readout for the new geometry | ||
| 195 | // (everything is dirty by construction). | ||
| 196 | const old_viewport = c.viewport; | ||
| 197 | const old_dirty = c.dirty; | ||
| 198 | const old_list = c.dirty_list; | ||
| 199 | allocGridBufs(c, c.rep.grid.cols, c.rep.grid.rows) catch { | ||
| 200 | // Readout buffers gone stale is unrecoverable in-place; the | ||
| 201 | // host treats it as fatal and re-inits. | ||
| 202 | return -1; | ||
| 203 | }; | ||
| 204 | alloc.free(old_viewport); | ||
| 205 | alloc.free(old_dirty); | ||
| 206 | alloc.free(old_list); | ||
| 207 | } else switch (t) { | ||
| 208 | .snapshot => @memset(c.dirty, true), | ||
| 209 | .delta => { | ||
| 210 | var it = proto.deltaRowIterator(payload); | ||
| 211 | while (it.next() catch null) |row| { | ||
| 212 | if (row.row < c.rows) c.dirty[row.row] = true; | ||
| 213 | } | ||
| 214 | // The renderer draws the cursor; both its old and new rows | ||
| 215 | // need repainting even when no content there changed. | ||
| 216 | if (cursor_before.y < c.rows) c.dirty[cursor_before.y] = true; | ||
| 217 | const cur = c.rep.eng.cursorPos(); | ||
| 218 | if (cur.y < c.rows) c.dirty[cur.y] = true; | ||
| 219 | }, | ||
| 220 | else => unreachable, | ||
| 221 | } | ||
| 222 | return 0; | ||
| 223 | } | ||
| 224 | |||
| 225 | /// Force a full repaint on the next mux_read_viewport (scroll-mode exit, | ||
| 226 | /// a canvas the host lost, first paint after tab restore). | ||
| 227 | export fn mux_mark_all_dirty() void { | ||
| 228 | const c = core orelse return; | ||
| 229 | @memset(c.dirty, true); | ||
| 230 | } | ||
| 231 | |||
| 232 | // --------------------------------------------------------------------- | ||
| 233 | // Attach / resume coordinates | ||
| 234 | // --------------------------------------------------------------------- | ||
| 235 | |||
| 236 | export fn mux_attach_seq() u64 { | ||
| 237 | const c = core orelse return 0; | ||
| 238 | return c.rep.attachArgs().have_seq; | ||
| 239 | } | ||
| 240 | |||
| 241 | export fn mux_attach_epoch() u64 { | ||
| 242 | const c = core orelse return 0; | ||
| 243 | return c.rep.attachArgs().have_epoch; | ||
| 244 | } | ||
| 245 | |||
| 246 | /// The whole 20-byte attach payload into the output buffer, so JS never | ||
| 247 | /// hand-assembles a u64. `cols`/`rows` are what this client claims — a | ||
| 248 | /// wall tile passes 1x1 (the passivity contract), the zoomed tile its | ||
| 249 | /// real size. Quotes the replica's resume coordinates; pass fresh=1 to | ||
| 250 | /// quote (0,0) instead (the resync re-attach). | ||
| 251 | export fn mux_attach_payload(cols: u32, rows: u32, fresh: u32) i32 { | ||
| 252 | const c = core orelse return -1; | ||
| 253 | if (cols > 0xffff or rows > 0xffff) return -3; | ||
| 254 | const args = if (fresh != 0) | ||
| 255 | Replica.AttachArgs{ .have_seq = 0, .have_epoch = 0 } | ||
| 256 | else | ||
| 257 | c.rep.attachArgs(); | ||
| 258 | const payload = proto.encodeAttach( | ||
| 259 | @intCast(cols), | ||
| 260 | @intCast(rows), | ||
| 261 | args.have_seq, | ||
| 262 | args.have_epoch, | ||
| 263 | ); | ||
| 264 | @memcpy(output_buf[0..payload.len], &payload); | ||
| 265 | output_len = payload.len; | ||
| 266 | return @intCast(payload.len); | ||
| 267 | } | ||
| 268 | |||
| 269 | // --------------------------------------------------------------------- | ||
| 270 | // Geometry, cursor, mode | ||
| 271 | // --------------------------------------------------------------------- | ||
| 272 | |||
| 273 | export fn mux_cols() u32 { | ||
| 274 | const c = core orelse return 0; | ||
| 275 | return c.rep.grid.cols; | ||
| 276 | } | ||
| 277 | |||
| 278 | export fn mux_rows() u32 { | ||
| 279 | const c = core orelse return 0; | ||
| 280 | return c.rep.grid.rows; | ||
| 281 | } | ||
| 282 | |||
| 283 | export fn mux_cursor_x() u32 { | ||
| 284 | const c = core orelse return 0; | ||
| 285 | return c.rep.eng.cursorPos().x; | ||
| 286 | } | ||
| 287 | |||
| 288 | export fn mux_cursor_y() u32 { | ||
| 289 | const c = core orelse return 0; | ||
| 290 | return c.rep.eng.cursorPos().y; | ||
| 291 | } | ||
| 292 | |||
| 293 | export fn mux_history_rows() u32 { | ||
| 294 | const c = core orelse return 0; | ||
| 295 | return c.rep.history_rows; | ||
| 296 | } | ||
| 297 | |||
| 298 | /// Screen-space start row for scroll page N (replica.zig's math). | ||
| 299 | export fn mux_scroll_start(pages_up: u32, view_rows: u32) u32 { | ||
| 300 | const c = core orelse return 0; | ||
| 301 | if (view_rows > 0xffff) return 0; | ||
| 302 | return c.rep.scrollStart(pages_up, @intCast(view_rows)); | ||
| 303 | } | ||
| 304 | |||
| 305 | // --------------------------------------------------------------------- | ||
| 306 | // Input encoding | ||
| 307 | // --------------------------------------------------------------------- | ||
| 308 | |||
| 309 | /// keymap.Key by @intFromEnum — JS mirrors this table (char=0, enter=1, | ||
| 310 | /// tab=2, backspace=3, escape=4, up=5, down=6, left=7, right=8, home=9, | ||
| 311 | /// end=10, insert=11, delete=12, page_up=13, page_down=14, f1..f12=15..26). | ||
| 312 | /// mods: bit0 shift, bit1 alt, bit2 ctrl. Returns the byte length written | ||
| 313 | /// to the output buffer (0 = nothing to send), -3 on an unknown key. | ||
| 314 | export fn mux_key_encode(key: u32, cp: u32, mods: u32) i32 { | ||
| 315 | const k = std.meta.intToEnum(keymap.Key, key) catch return -3; | ||
| 316 | if (cp > 0x10ffff) return -3; | ||
| 317 | const ev = keymap.Event{ | ||
| 318 | .key = k, | ||
| 319 | .cp = @intCast(cp), | ||
| 320 | .mods = .{ | ||
| 321 | .shift = mods & 1 != 0, | ||
| 322 | .alt = mods & 2 != 0, | ||
| 323 | .ctrl = mods & 4 != 0, | ||
| 324 | }, | ||
| 325 | }; | ||
| 326 | var buf: [keymap.max_seq_len]u8 = undefined; | ||
| 327 | const seq = keymap.encode(ev, &buf); | ||
| 328 | @memcpy(output_buf[0..seq.len], seq); | ||
| 329 | output_len = @intCast(seq.len); | ||
| 330 | return @intCast(seq.len); | ||
| 331 | } | ||
| 332 | |||
| 333 | /// Wrap `len` staged bytes in bracketed paste into the output buffer. | ||
| 334 | /// The shell chunks pastes at 32 KiB, so the wrap always fits the 64 KiB | ||
| 335 | /// output buffer; -2 if the host ignored that contract. | ||
| 336 | export fn mux_paste_encode(len: u32) i32 { | ||
| 337 | if (len > input_buf.len) return -2; | ||
| 338 | const total = keymap.paste_begin.len + len + keymap.paste_end.len; | ||
| 339 | if (total > output_buf.len) return -2; | ||
| 340 | @memcpy(output_buf[0..keymap.paste_begin.len], keymap.paste_begin); | ||
| 341 | @memcpy(output_buf[keymap.paste_begin.len..][0..len], input_buf[0..len]); | ||
| 342 | @memcpy(output_buf[keymap.paste_begin.len + len ..][0..keymap.paste_end.len], keymap.paste_end); | ||
| 343 | output_len = @intCast(total); | ||
| 344 | return @intCast(total); | ||
| 345 | } | ||
| 346 | |||
| 347 | // --------------------------------------------------------------------- | ||
| 348 | // Flat viewport readout | ||
| 349 | // --------------------------------------------------------------------- | ||
| 350 | |||
| 351 | /// cols*rows cells, 4 u32 each, row-major: | ||
| 352 | /// [0] codepoint (0 = empty cell) | ||
| 353 | /// [1] fg: kind << 24 | value (kind 0 none, 1 palette, 2 rgb; | ||
| 354 | /// value = palette index or 0xRRGGBB) | ||
| 355 | /// [2] bg: same packing | ||
| 356 | /// [3] flags: ghostty's u16 style flags (bit 0 bold, 1 italic, 2 faint, | ||
| 357 | /// 3 blink, 4 inverse, 5 invisible, 6 strikethrough, 7 overline, | ||
| 358 | /// bits 8-10 underline style) | wide << 16 | spacer << 17 | ||
| 359 | /// Valid until the next call that can grow memory — JS re-reads | ||
| 360 | /// memory.buffer every time (see the module header). | ||
| 361 | export fn mux_viewport_ptr() [*]const u32 { | ||
| 362 | const c = core orelse return &empty_viewport; | ||
| 363 | return c.viewport.ptr; | ||
| 364 | } | ||
| 365 | |||
| 366 | /// What an uninitialized core hands out instead of a null pointer JS | ||
| 367 | /// would happily index into. | ||
| 368 | var empty_viewport: [4]u32 = .{ 0, 0, 0, 0 }; | ||
| 369 | |||
| 370 | /// Repaint the DIRTY rows into the viewport buffer, clear the dirty set, | ||
| 371 | /// and return how many rows were repainted; mux_dirty_row(i) names them. | ||
| 372 | export fn mux_read_viewport() u32 { | ||
| 373 | const c = core orelse return 0; | ||
| 374 | c.dirty_count = 0; | ||
| 375 | for (c.dirty, 0..) |d, y| { | ||
| 376 | if (!d) continue; | ||
| 377 | paintRow(c, c.eng, @intCast(y)); | ||
| 378 | c.dirty_list[c.dirty_count] = @intCast(y); | ||
| 379 | c.dirty_count += 1; | ||
| 380 | } | ||
| 381 | @memset(c.dirty, false); | ||
| 382 | return c.dirty_count; | ||
| 383 | } | ||
| 384 | |||
| 385 | export fn mux_dirty_row(i: u32) u32 { | ||
| 386 | const c = core orelse return 0; | ||
| 387 | if (i >= c.dirty_count) return 0; | ||
| 388 | return c.dirty_list[i]; | ||
| 389 | } | ||
| 390 | |||
| 391 | fn paintRow(c: *Core, eng: *Engine, y: u16) void { | ||
| 392 | var x: u16 = 0; | ||
| 393 | while (x < c.cols) : (x += 1) { | ||
| 394 | const base = (@as(usize, y) * c.cols + x) * 4; | ||
| 395 | const cell = eng.term.screens.active.pages.getCell(.{ .viewport = .{ | ||
| 396 | .x = @intCast(x), | ||
| 397 | .y = @intCast(y), | ||
| 398 | } }) orelse { | ||
| 399 | c.viewport[base] = 0; | ||
| 400 | c.viewport[base + 1] = 0; | ||
| 401 | c.viewport[base + 2] = 0; | ||
| 402 | c.viewport[base + 3] = 0; | ||
| 403 | continue; | ||
| 404 | }; | ||
| 405 | c.viewport[base] = switch (cell.cell.content_tag) { | ||
| 406 | .codepoint, .codepoint_grapheme => cell.cell.content.codepoint, | ||
| 407 | .bg_color_palette, .bg_color_rgb => 0, | ||
| 408 | }; | ||
| 409 | const style = cell.style(); | ||
| 410 | c.viewport[base + 1] = packColor(style.fg_color); | ||
| 411 | c.viewport[base + 2] = packColor(style.bg_color); | ||
| 412 | const wide: u32 = switch (cell.cell.wide) { | ||
| 413 | .narrow => 0, | ||
| 414 | .wide => 1 << 16, | ||
| 415 | .spacer_tail, .spacer_head => 1 << 17, | ||
| 416 | }; | ||
| 417 | c.viewport[base + 3] = @as(u32, @as(u16, @bitCast(style.flags))) | wide; | ||
| 418 | } | ||
| 419 | } | ||
| 420 | |||
| 421 | fn packColor(col: anytype) u32 { | ||
| 422 | return switch (col) { | ||
| 423 | .none => 0, | ||
| 424 | .palette => |p| (1 << 24) | @as(u32, p), | ||
| 425 | .rgb => |rgb| (2 << 24) | | ||
| 426 | (@as(u32, rgb.r) << 16) | (@as(u32, rgb.g) << 8) | @as(u32, rgb.b), | ||
| 427 | }; | ||
| 428 | } | ||
| 429 | |||
| 430 | // --------------------------------------------------------------------- | ||
| 431 | // Scrollback view (a scratch terminal; the live replica is never touched) | ||
| 432 | // --------------------------------------------------------------------- | ||
| 433 | |||
| 434 | /// Feed `len` staged bytes (a scrollback_chunk's rows, echo header | ||
| 435 | /// already stripped by the host) into the scratch terminal, resetting it | ||
| 436 | /// first. The scratch is created lazily at the live grid size and follows | ||
| 437 | /// it. Returns 0, -1 uninit, -2 overflow. | ||
| 438 | export fn mux_scroll_feed(len: u32) i32 { | ||
| 439 | const c = core orelse return -1; | ||
| 440 | if (len > input_buf.len) return -2; | ||
| 441 | if (c.scroll_eng) |se| { | ||
| 442 | if (se.term.cols != c.cols or se.term.rows != c.rows) { | ||
| 443 | se.deinit(); | ||
| 444 | c.scroll_eng = null; | ||
| 445 | } | ||
| 446 | } | ||
| 447 | if (c.scroll_eng == null) { | ||
| 448 | c.scroll_eng = Engine.init(alloc, .{ | ||
| 449 | .cols = c.cols, | ||
| 450 | .rows = c.rows, | ||
| 451 | .max_scrollback = 0, | ||
| 452 | }) catch return -1; | ||
| 453 | } | ||
| 454 | const se = c.scroll_eng.?; | ||
| 455 | se.reset(); | ||
| 456 | se.feed(input_buf[0..len]); | ||
| 457 | return 0; | ||
| 458 | } | ||
| 459 | |||
| 460 | /// Paint the WHOLE scratch viewport into the shared viewport buffer and | ||
| 461 | /// mark everything dirty for the next live read (leaving scroll mode must | ||
| 462 | /// repaint from the replica). Returns rows painted. | ||
| 463 | export fn mux_read_scroll_viewport() u32 { | ||
| 464 | const c = core orelse return 0; | ||
| 465 | const se = c.scroll_eng orelse return 0; | ||
| 466 | var y: u16 = 0; | ||
| 467 | while (y < c.rows) : (y += 1) paintRow(c, se, y); | ||
| 468 | @memset(c.dirty, true); | ||
| 469 | return c.rows; | ||
| 470 | } | ||
| 471 | |||
| 472 | // --------------------------------------------------------------------- | ||
| 473 | // Diagnostics | ||
| 474 | // --------------------------------------------------------------------- | ||
| 475 | |||
| 476 | export fn mux_output_ptr() [*]const u8 { | ||
| 477 | return &output_buf; | ||
| 478 | } | ||
| 479 | |||
| 480 | export fn mux_output_len() u32 { | ||
| 481 | return output_len; | ||
| 482 | } | ||
| 483 | |||
| 484 | /// Plain-text dump of the live viewport (verify.js's referee; matches | ||
| 485 | /// Engine.dumpPlain, the same text muxd dump prints). | ||
| 486 | export fn mux_dump_plain() i32 { | ||
| 487 | const c = core orelse return -1; | ||
| 488 | output_len = 0; | ||
| 489 | const text = c.rep.eng.dumpPlain(alloc) catch return -2; | ||
| 490 | defer alloc.free(text); | ||
| 491 | if (text.len > output_buf.len) return -3; | ||
| 492 | @memcpy(output_buf[0..text.len], text); | ||
| 493 | output_len = @intCast(text.len); | ||
| 494 | return @intCast(text.len); | ||
| 495 | } | ||
| 496 | |||
| 497 | /// Replica-side terminal replies (DSR etc). DIAGNOSTIC ONLY: the daemon | ||
| 498 | /// is authoritative and answers the application itself; forwarding these | ||
| 499 | /// would double every reply. Cleared on read. | ||
| 500 | export fn mux_take_pty_output() i32 { | ||
| 501 | const c = core orelse return -1; | ||
| 502 | output_len = 0; | ||
| 503 | const data = c.rep.eng.ptyOutput(); | ||
| 504 | if (data.len > output_buf.len) return -3; | ||
| 505 | @memcpy(output_buf[0..data.len], data); | ||
| 506 | output_len = @intCast(data.len); | ||
| 507 | c.rep.eng.clearPtyOutput(); | ||
| 508 | return @intCast(data.len); | ||
| 509 | } | ||
| 510 | |||
| 511 | comptime { | ||
| 512 | if (!builtin.target.cpu.arch.isWasm()) { | ||
| 513 | @compileError("wasm_core.zig is the wasm32 target's root; build it via the mux_core step"); | ||
| 514 | } | ||
| 515 | } | ||
web/verify.js
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,220 @@ | |||
| 1 | #!/usr/bin/env node | ||
| 2 | // Fast non-browser smoke for the wasm core (M-web Task 4): drives | ||
| 3 | // mux_core.wasm through the page's real call sequence — attach payload, | ||
| 4 | // snapshot frame, delta frame, flat viewport readout, key encoding — | ||
| 5 | // and asserts styled cells, damage rows, and resume coordinates. | ||
| 6 | // | ||
| 7 | // THE DISCIPLINE THIS FILE DEMONSTRATES: every wasm call can grow linear | ||
| 8 | // memory, and growth detaches every cached ArrayBuffer view. Views are | ||
| 9 | // re-read from exports.memory.buffer at every access, never cached. | ||
| 10 | // | ||
| 11 | // Usage: node web/verify.js [path/to/mux_core.wasm] | ||
| 12 | |||
| 13 | 'use strict'; | ||
| 14 | const fs = require('fs'); | ||
| 15 | const path = require('path'); | ||
| 16 | |||
| 17 | const wasmPath = process.argv[2] || | ||
| 18 | path.join(__dirname, '..', 'zig-out', 'bin', 'mux_core.wasm'); | ||
| 19 | |||
| 20 | let passed = 0, failed = 0; | ||
| 21 | function check(name, got, want) { | ||
| 22 | const ok = typeof want === 'bigint' ? got === want : Object.is(got, want); | ||
| 23 | if (ok) { passed++; } | ||
| 24 | else { failed++; console.error(`FAIL ${name}: got ${got}, want ${want}`); } | ||
| 25 | } | ||
| 26 | |||
| 27 | // --- wire builders (layouts golden-pinned in protocol.zig) --- | ||
| 28 | function snapshotPayload({ seq, history, cols, rows, epoch }, state) { | ||
| 29 | const stateBytes = Buffer.from(state, 'utf8'); | ||
| 30 | const b = Buffer.alloc(24 + stateBytes.length); | ||
| 31 | b.writeBigUInt64LE(BigInt(seq), 0); | ||
| 32 | b.writeUInt32LE(history, 8); | ||
| 33 | b.writeUInt16LE(cols, 12); | ||
| 34 | b.writeUInt16LE(rows, 14); | ||
| 35 | b.writeBigUInt64LE(BigInt(epoch), 16); | ||
| 36 | stateBytes.copy(b, 24); | ||
| 37 | return b; | ||
| 38 | } | ||
| 39 | function deltaPayload({ seq, history, cx, cy }, rowEntries) { | ||
| 40 | const parts = []; | ||
| 41 | const hdr = Buffer.alloc(18); | ||
| 42 | hdr.writeBigUInt64LE(BigInt(seq), 0); | ||
| 43 | hdr.writeUInt32LE(history, 8); | ||
| 44 | hdr.writeUInt16LE(cx, 12); | ||
| 45 | hdr.writeUInt16LE(cy, 14); | ||
| 46 | hdr.writeUInt16LE(rowEntries.length, 16); | ||
| 47 | parts.push(hdr); | ||
| 48 | for (const [row, text] of rowEntries) { | ||
| 49 | const bytes = Buffer.from(text, 'utf8'); | ||
| 50 | const rh = Buffer.alloc(6); | ||
| 51 | rh.writeUInt16LE(row, 0); | ||
| 52 | rh.writeUInt32LE(bytes.length, 2); | ||
| 53 | parts.push(rh, bytes); | ||
| 54 | } | ||
| 55 | return Buffer.concat(parts); | ||
| 56 | } | ||
| 57 | |||
| 58 | async function main() { | ||
| 59 | const bin = fs.readFileSync(wasmPath); | ||
| 60 | const { instance } = await WebAssembly.instantiate(bin, {}); | ||
| 61 | const e = instance.exports; | ||
| 62 | const mem = () => Buffer.from(e.memory.buffer); // ALWAYS fresh | ||
| 63 | |||
| 64 | const stage = (buf) => { | ||
| 65 | if (buf.length > e.mux_input_cap()) throw new Error('over cap'); | ||
| 66 | buf.copy(mem(), e.mux_input_ptr()); | ||
| 67 | return buf.length; | ||
| 68 | }; | ||
| 69 | const outBytes = () => | ||
| 70 | Buffer.from(mem().subarray(e.mux_output_ptr(), e.mux_output_ptr() + e.mux_output_len())); | ||
| 71 | const cell = (x, y) => { | ||
| 72 | const cols = e.mux_cols(); | ||
| 73 | const base = e.mux_viewport_ptr() + (y * cols + x) * 16; | ||
| 74 | const m = mem(); | ||
| 75 | return { | ||
| 76 | cp: m.readUInt32LE(base), | ||
| 77 | fg: m.readUInt32LE(base + 4), | ||
| 78 | bg: m.readUInt32LE(base + 8), | ||
| 79 | flags: m.readUInt32LE(base + 12), | ||
| 80 | }; | ||
| 81 | }; | ||
| 82 | |||
| 83 | // --- init --- | ||
| 84 | check('init', e.mux_init(80, 24), 0); | ||
| 85 | check('cols', e.mux_cols(), 80); | ||
| 86 | check('rows', e.mux_rows(), 24); | ||
| 87 | |||
| 88 | // --- attach payload before any state: quotes (0,0); wall spelling 1x1 --- | ||
| 89 | check('attach len', e.mux_attach_payload(1, 1, 0), 20); | ||
| 90 | let att = outBytes(); | ||
| 91 | check('attach cols', att.readUInt16LE(0), 1); | ||
| 92 | check('attach rows', att.readUInt16LE(2), 1); | ||
| 93 | check('attach seq0', att.readBigUInt64LE(4), 0n); | ||
| 94 | check('attach epoch0', att.readBigUInt64LE(12), 0n); | ||
| 95 | |||
| 96 | // --- snapshot: styled text, adoption, full damage --- | ||
| 97 | const snap = snapshotPayload( | ||
| 98 | { seq: 7, history: 3, cols: 80, rows: 24, epoch: 0xabcdn }, | ||
| 99 | '\x1b[1;31mhello\x1b[0m world', | ||
| 100 | ); | ||
| 101 | check('apply snapshot', e.mux_apply_frame(0x81, stage(snap)), 0); | ||
| 102 | check('seq adopted', e.mux_attach_seq(), 7n); | ||
| 103 | check('epoch adopted', e.mux_attach_epoch(), 0xabcdn); | ||
| 104 | check('history', e.mux_history_rows(), 3); | ||
| 105 | check('snapshot dirties all', e.mux_read_viewport(), 24); | ||
| 106 | check('cell h', cell(0, 0).cp, 'h'.codePointAt(0)); | ||
| 107 | check('cell h bold', cell(0, 0).flags & 1, 1); | ||
| 108 | check('cell h fg palette red', cell(0, 0).fg, (1 << 24) | 1); | ||
| 109 | check('cell w plain', cell(6, 0).flags & 0xffff, 0); | ||
| 110 | check('cell w fg none', cell(6, 0).fg, 0); | ||
| 111 | check('cursor x', e.mux_cursor_x(), 11); // 11 visible cols; SGRs move nothing | ||
| 112 | |||
| 113 | // --- delta: rows 0 and 2 repainted, damage = their rows + cursor rows --- | ||
| 114 | const delta = deltaPayload( | ||
| 115 | { seq: 8, history: 4, cx: 2, cy: 2 }, | ||
| 116 | [[0, '\x1b[7myo\x1b[0m'], [2, 'row two']], | ||
| 117 | ); | ||
| 118 | check('apply delta', e.mux_apply_frame(0x87, stage(delta)), 0); | ||
| 119 | check('seq advanced', e.mux_attach_seq(), 8n); | ||
| 120 | check('history follows', e.mux_history_rows(), 4); | ||
| 121 | const ndirty = e.mux_read_viewport(); | ||
| 122 | const dirtyRows = []; | ||
| 123 | for (let i = 0; i < ndirty; i++) dirtyRows.push(e.mux_dirty_row(i)); | ||
| 124 | // rows 0 (content+old cursor row 0) and 2 (content+new cursor) — sorted. | ||
| 125 | check('delta dirty rows', dirtyRows.join(','), '0,2'); | ||
| 126 | check('cell yo inverse', cell(0, 0).flags & (1 << 4), 1 << 4); | ||
| 127 | check('cell row2', cell(0, 2).cp, 'r'.codePointAt(0)); | ||
| 128 | check('cursor y', e.mux_cursor_y(), 2); | ||
| 129 | |||
| 130 | // --- attach after state: quotes what we hold; fresh=1 re-quotes zero --- | ||
| 131 | e.mux_attach_payload(80, 24, 0); | ||
| 132 | att = outBytes(); | ||
| 133 | check('attach seq held', att.readBigUInt64LE(4), 8n); | ||
| 134 | check('attach epoch held', att.readBigUInt64LE(12), 0xabcdn); | ||
| 135 | e.mux_attach_payload(80, 24, 1); | ||
| 136 | att = outBytes(); | ||
| 137 | check('attach fresh seq', att.readBigUInt64LE(4), 0n); | ||
| 138 | |||
| 139 | // --- resync path: header lies about row count --- | ||
| 140 | const bad = deltaPayload({ seq: 9, history: 4, cx: 0, cy: 0 }, [[0, 'x']]); | ||
| 141 | bad.writeUInt16LE(2, 16); // row_count claims 2 | ||
| 142 | check('resync', e.mux_apply_frame(0x87, stage(bad)), 1); | ||
| 143 | check('resync holds seq', e.mux_attach_seq(), 8n); | ||
| 144 | |||
| 145 | // --- bad frames --- | ||
| 146 | check('not replay frame', e.mux_apply_frame(0x88, 1), -3); | ||
| 147 | check('unknown type', e.mux_apply_frame(0x40, 0), -3); | ||
| 148 | check('short snapshot', e.mux_apply_frame(0x81, 10), -3); | ||
| 149 | |||
| 150 | // --- grid move via snapshot: readout follows, all dirty --- | ||
| 151 | const wide = snapshotPayload( | ||
| 152 | { seq: 10, history: 0, cols: 100, rows: 30, epoch: 0xabcdn }, | ||
| 153 | 'wide', | ||
| 154 | ); | ||
| 155 | check('apply wide', e.mux_apply_frame(0x81, stage(wide)), 0); | ||
| 156 | check('cols follow', e.mux_cols(), 100); | ||
| 157 | check('rows follow', e.mux_rows(), 30); | ||
| 158 | check('grid move dirties all', e.mux_read_viewport(), 30); | ||
| 159 | check('cell after move', cell(0, 0).cp, 'w'.codePointAt(0)); | ||
| 160 | |||
| 161 | // --- wide CJK: wide flag + spacer --- | ||
| 162 | const cjk = snapshotPayload( | ||
| 163 | { seq: 11, history: 0, cols: 100, rows: 30, epoch: 0xabcdn }, | ||
| 164 | '漢字', | ||
| 165 | ); | ||
| 166 | e.mux_apply_frame(0x81, stage(cjk)); | ||
| 167 | e.mux_read_viewport(); | ||
| 168 | check('cjk cp', cell(0, 0).cp, 0x6f22); | ||
| 169 | check('cjk wide', cell(0, 0).flags & (1 << 16), 1 << 16); | ||
| 170 | check('cjk spacer', cell(1, 0).flags & (1 << 17), 1 << 17); | ||
| 171 | check('cjk second', cell(2, 0).cp, 0x5b57); | ||
| 172 | |||
| 173 | // --- key encoding round-trips two table rows --- | ||
| 174 | check('key a', e.mux_key_encode(0, 'a'.codePointAt(0), 0), 1); | ||
| 175 | check('key a byte', outBytes().toString('latin1'), 'a'); | ||
| 176 | check('key ctrl-up', e.mux_key_encode(5, 0, 4), 6); | ||
| 177 | check('key ctrl-up seq', outBytes().toString('latin1'), '\x1b[1;5A'); | ||
| 178 | check('key unknown', e.mux_key_encode(99, 0, 0), -3); | ||
| 179 | |||
| 180 | // --- paste wrap --- | ||
| 181 | stage(Buffer.from('two\nlines')); | ||
| 182 | check('paste len', e.mux_paste_encode(9), 9 + 12); | ||
| 183 | check('paste bytes', outBytes().toString('latin1'), '\x1b[200~two\nlines\x1b[201~'); | ||
| 184 | |||
| 185 | // --- scroll scratch: never touches the live replica --- | ||
| 186 | check('scroll start', e.mux_scroll_start(1, 30), 0); // history 0: saturates | ||
| 187 | stage(Buffer.from('old history line')); | ||
| 188 | check('scroll feed', e.mux_scroll_feed(16), 0); | ||
| 189 | check('scroll read', e.mux_read_scroll_viewport(), 30); | ||
| 190 | check('scroll cell', cell(0, 0).cp, 'o'.codePointAt(0)); | ||
| 191 | check('live untouched: full repaint queued', e.mux_read_viewport(), 30); | ||
| 192 | check('live cell back', cell(0, 0).cp, 0x6f22); | ||
| 193 | |||
| 194 | // --- diagnostics: dump matches, pty replies readable and cleared --- | ||
| 195 | e.mux_dump_plain(); | ||
| 196 | check('dump starts', outBytes().toString('utf8').startsWith('漢字'), true); | ||
| 197 | stage(Buffer.from('\x1b[6n')); // DSR: replica answers locally (diagnostic only) | ||
| 198 | e.mux_apply_frame(0x81, stage(snapshotPayload( | ||
| 199 | { seq: 12, history: 0, cols: 100, rows: 30, epoch: 0xabcdn }, '\x1b[6n'))); | ||
| 200 | const n = e.mux_take_pty_output(); | ||
| 201 | check('pty reply present', n > 0, true); | ||
| 202 | check('pty reply cleared', e.mux_take_pty_output(), 0); | ||
| 203 | |||
| 204 | // --- a real-sized snapshot fits the staging buffer --- | ||
| 205 | // 100x30 grid fully painted with styled cells is well under 256K; the | ||
| 206 | // pin here is the CAP ITSELF: input_cap must hold the biggest payload | ||
| 207 | // the daemon sends in one frame for the wall's grids. | ||
| 208 | check('input cap', e.mux_input_cap(), 256 * 1024); | ||
| 209 | |||
| 210 | // --- deinit / re-init --- | ||
| 211 | e.mux_deinit(); | ||
| 212 | check('apply after deinit', e.mux_apply_frame(0x81, 0), -1); | ||
| 213 | check('re-init', e.mux_init(80, 24), 0); | ||
| 214 | e.mux_deinit(); | ||
| 215 | |||
| 216 | console.log(`verify: ${passed} passed, ${failed} failed`); | ||
| 217 | process.exit(failed ? 1 : 0); | ||
| 218 | } | ||
| 219 | |||
| 220 | main().catch((err) => { console.error(err); process.exit(1); }); | ||