a73x

2e414911

feat: wasm core — replica+keymap on wasm32-freestanding, flat damage-hinted viewport ABI

a73x   2026-08-13 10:27

Commit message
feat: wasm core — replica+keymap on wasm32-freestanding, flat damage-hinted viewport ABI

The browser replica core (M-web Task 4): frame-driven (mux_apply_frame
routes through replica.zig, so the browser replays exactly what the CLI
replays), with damage tracked from delta row headers plus the cursor's
old and new rows; snapshots and grid moves dirty everything. Readout is
one flat pass — 4 u32 per cell (codepoint, fg, bg, flags|wide|spacer) —
because upstream documents the per-cell accessors as non-shipping. A
scratch terminal renders scrollback pages without touching the live
replica. Attach payloads are built core-side so JS never assembles a
u64.

The canary PASSED as wired: protocol.zig's six posix references
lazy-analyze away under wasm32 — no protocol_io split needed. One shared
-source fix: engine.zig's @fieldParentPtr needs @alignCast on wasm32
(4-byte default pointer alignment vs Engine's 8; sound, every Engine
comes from alloc.create — the spike's obstacle 3).

Build: second target instantiation of the four wasm-clean modules +
ghostty, always ReleaseSmall (345KB, 109KB gzipped), entry disabled,
rdynamic, and deliberately no use_llvm/use_lld. web/verify.js drives
the page's real call sequence under node: 62/62.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

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); });