diff options
Diffstat (limited to 'src/p4.zig')
| -rw-r--r-- | src/p4.zig | 578 |
1 files changed, 578 insertions, 0 deletions
diff --git a/src/p4.zig b/src/p4.zig new file mode 100644 index 00000000..7356dc73 --- /dev/null +++ b/src/p4.zig @@ -0,0 +1,578 @@ +//! The ESP32-P4 firmware shell: pardes as one freestanding object, bytes in and bytes out. +//! +//! This is the fourth platform, and the only one that is not an executable. `zig build +//! -Dplatform=p4 -Dtarget=riscv32-freestanding` emits this file as a single object exporting the C +//! ABI below; the `zig-p4` package links it beside its own `_start`, its generated linker script, +//! and its UART driver. Nothing here knows what a UART is. +//! +//! **Why an object and not a module.** The obvious arrangement was for zig-p4 to declare this +//! package in its `build.zig.zon` and import `pardes_p4`. That was built, and it broke every build +//! in that repo: nesting this package's ~30-package graph under one whose own claim is "host +//! dependencies: Zig, that is the whole list" made `std/Build.zig:2091` exceed its 1000-branch +//! comptime quota (through ghostty's `SharedDeps.zig:874` `lazyImport`), dragged in seven cached +//! tree-sitter versions whose `build.zig` uses APIs removed in 0.16, and materialised 2.6 GB across +//! 42,736 files into that repo's working copy. A linked object has none of those properties and one +//! extra virtue: the seam is bytes, so neither side can accidentally depend on the other's types. +//! +//! **Where the terminal is.** On the host. The board writes ANSI and reads ANSI; the terminal +//! emulator at the far end of the serial line does the font rendering, and answers this program's +//! own capability queries. That is why `vaxis` works here unmodified: `Vaxis.render`, +//! `queryTerminalSend` and `enableDetectedFeatures` all take a bare `*std.Io.Writer` +//! (`Vaxis.zig:375,278,329`), so the transport is a parameter. `vaxis.Tty` and `vaxis.Loop` are +//! termios/ioctl/SIGWINCH bound and are not used. +//! +//! **Where the memory is.** Not here either. The firmware measured its own RAM (240 KiB low, +//! 384 KiB high, and a 128 KiB region that turned out to be L2 cache) and owns the allocator; this +//! file receives four function pointers and rebuilds a `std.mem.Allocator` from them. Everything +//! the editor allocates comes from there. +//! +//! **Window size** arrives as DEC mode 2048 in-band resize reports, parsed by `vaxis.Parser` like +//! any other input. Firmware has no `TIOCGWINSZ`, so the host-side bridge synthesises the first one. + +const std = @import("std"); +const pardes = @import("pardes.zig"); +const vaxis = @import("vaxis"); + +// ------------------------------------------------------------------ what a freestanding root owes +// +// These are ROOT-module declarations: std reads them off whichever file is the compilation root, and +// as of the build change that emits this file as the object, that is this file. They are not +// ceremony - each one was discovered by the build failing without it. + +/// The board has no MMU and no pages, but std derives allocator alignment from these two. 4 KiB is +/// the ESP32-P4's cache and DMA granularity. Without them: "riscv32-freestanding has unknown +/// page_size_min" from std/heap.zig:48. +/// +/// `logFn` is the load-bearing one. std's DEFAULT log implementation reaches `std.debug_io`, which +/// instantiates `std.Io.Threaded` - a thread pool, `getrandom`, `IOV_MAX`, `mremap` - none of which +/// exist on this target, and ONE `log.warn` anywhere in the core or in vaxis is enough to drag the +/// whole thing in and fail the build with "no member named 'getrandom'". +pub const std_options: std.Options = .{ + .page_size_min = 4096, + .page_size_max = 4096, + .logFn = logFn, +}; + +/// Logs go out the same byte sink as the frames, which is the only sink there is. Truncated rather +/// than allocated: a log line is never worth an allocation on a 384 KiB heap, and a logger that can +/// fail on OOM is a logger that disappears exactly when it is needed. +fn logFn( + comptime level: std.log.Level, + comptime scope: @EnumLiteral(), + comptime fmt: []const u8, + args: anytype, +) void { + if (out_ctx == null and @intFromPtr(out_write) == 0) return; + var buf: [256]u8 = undefined; + const line = std.fmt.bufPrint( + &buf, + "\r\n[" ++ level.asText() ++ "/" ++ @tagName(scope) ++ "] " ++ fmt ++ "\r\n", + args, + ) catch "\r\n[log truncated]\r\n"; + out_write(out_ctx, line.ptr, line.len); +} + +pub const panic = std.debug.FullPanic(panicImpl); + +/// A panic here cannot unwind and has nowhere to go, so it reports through the write callback and +/// stops. `@trap` and not a spin: the firmware's own panic handler prints through the mask ROM, +/// which shares nothing with this path but the FIFO, so a trap leaves that diagnostic route intact. +fn panicImpl(msg: []const u8, _: ?usize) noreturn { + const prefix = "\r\nMARK PARDES_CORE_PANIC "; + out_write(out_ctx, prefix.ptr, prefix.len); + out_write(out_ctx, msg.ptr, msg.len); + out_write(out_ctx, "\r\n", 2); + @trap(); +} + +// ---------------------------------------------------------------------------------- the C ABI +// +// Deliberately tiny, and versioned. Linkers do not type-check C symbols, so a signature that drifts +// on one side of this seam links cleanly and then corrupts the stack. `pardes_p4_abi_version` is the +// cheapest possible defence: the firmware calls it first and refuses to continue on a mismatch. + +/// Bumped whenever any signature below changes, including a type. +const abi_version: u32 = 1; + +export fn pardes_p4_abi_version() callconv(.c) u32 { + return abi_version; +} + +/// The firmware's allocator, as C function pointers. `alignment` is a log2 value, matching +/// `std.mem.Alignment`'s own representation, so no translation table is needed. +/// +/// `remap` is absent on purpose: this allocator cannot move a block without copying it, so +/// `std.mem.Allocator`'s remap is implemented locally as "resize in place, or fail" and the caller's +/// own alloc/copy/free path handles the rest. +pub const Allocator = extern struct { + ctx: ?*anyopaque, + alloc: *const fn (ctx: ?*anyopaque, len: usize, log2_align: u8) callconv(.c) ?[*]u8, + resize: *const fn (ctx: ?*anyopaque, ptr: [*]u8, len: usize, log2_align: u8, new_len: usize) callconv(.c) bool, + free: *const fn (ctx: ?*anyopaque, ptr: [*]u8, len: usize, log2_align: u8) callconv(.c) void, +}; + +/// How finished runs of ANSI leave this object. +pub const WriteFn = *const fn (ctx: ?*anyopaque, ptr: [*]const u8, len: usize) callconv(.c) void; + +// ------------------------------------------------------------------- the allocator, rebuilt +// One `std.mem.Allocator` whose vtable forwards to the four pointers above. The indirection is the +// price of the seam and it is paid once per allocation, which on a first-fit heap is already the +// cheap part (measured on the die: 8,229 cycles for one allocation across 257 free blocks). + +var host_alloc: Allocator = undefined; + +fn hostAlloc(_: *anyopaque, len: usize, alignment: std.mem.Alignment, _: usize) ?[*]u8 { + return host_alloc.alloc(host_alloc.ctx, len, @intFromEnum(alignment)); +} + +fn hostResize(_: *anyopaque, mem: []u8, alignment: std.mem.Alignment, new_len: usize, _: usize) bool { + return host_alloc.resize(host_alloc.ctx, mem.ptr, mem.len, @intFromEnum(alignment), new_len); +} + +fn hostRemap(_: *anyopaque, mem: []u8, alignment: std.mem.Alignment, new_len: usize, _: usize) ?[*]u8 { + return if (host_alloc.resize(host_alloc.ctx, mem.ptr, mem.len, @intFromEnum(alignment), new_len)) mem.ptr else null; +} + +fn hostFree(_: *anyopaque, mem: []u8, alignment: std.mem.Alignment, _: usize) void { + host_alloc.free(host_alloc.ctx, mem.ptr, mem.len, @intFromEnum(alignment)); +} + +const host_vtable: std.mem.Allocator.VTable = .{ + .alloc = hostAlloc, + .resize = hostResize, + .remap = hostRemap, + .free = hostFree, +}; + +/// `ptr` is never dereferenced - the four forwarders read the file-scope `host_alloc` - but +/// `std.mem.Allocator` requires a non-null context, so it points at the record itself. +fn gpa() std.mem.Allocator { + return .{ .ptr = @ptrCast(&host_alloc), .vtable = &host_vtable }; +} + +// ------------------------------------------------------------------------------- the ANSI sink +// A `std.Io.Writer` over the firmware's write callback. Buffered, because vaxis emits a frame as a +// long run of small writes - cursor move, SGR run, grapheme, repeat - and an unbuffered writer would +// make a C call per fragment. + +var out_write: WriteFn = undefined; +var out_ctx: ?*anyopaque = null; +var out_buf: [8192]u8 = undefined; +var out: std.Io.Writer = undefined; + +fn drain(w: *std.Io.Writer, data: []const []const u8, splat: usize) std.Io.Writer.Error!usize { + // The shape std documents at Io/Writer.zig:46-63: buffer first, then every slice of `data`, with + // the LAST slice repeated `splat` times, and the count returned excluding the buffered bytes. + if (w.end > 0) { + out_write(out_ctx, w.buffer.ptr, w.end); + w.end = 0; + } + const head = data[0 .. data.len - 1]; + const pattern = data[head.len]; + var written: usize = 0; + for (head) |bytes| { + if (bytes.len > 0) out_write(out_ctx, bytes.ptr, bytes.len); + written += bytes.len; + } + var i: usize = 0; + while (i < splat) : (i += 1) { + if (pattern.len > 0) out_write(out_ctx, pattern.ptr, pattern.len); + } + return written + pattern.len * splat; +} + +// ------------------------------------------------------------------------------------ the state + +var core: ?*pardes.Pardes = null; +var vx: vaxis.Vaxis = undefined; +var parser: vaxis.Parser = .{}; + +/// vaxis wants an environment map. There is no environment; an empty one is the honest answer and +/// the only thing vaxis reads it for is TERM-derived heuristics, which the capability queries +/// supersede. +var env_map: std.process.Environ.Map = undefined; + +/// Input that arrived mid-sequence. An escape sequence can be split across UART reads, and the +/// parser reports "incomplete" by consuming nothing, so the tail has to survive until more arrives. +var in_buf: [1024]u8 = undefined; +var in_len: usize = 0; + +/// Bracketed paste: between the markers, keys are DATA and never commands. +var paste_buf: std.ArrayListUnmanaged(u8) = .empty; +var in_paste: bool = false; + +/// Set by anything that could change the screen; cleared by a render. The firmware asks before +/// rendering, because on a 115200-baud link an unconditional repaint per loop saturates the wire and +/// starves input. +var dirty: bool = true; + +/// The largest grid this board can render, and the reason it is not the host's terminal size. +/// +/// Every cell is paid for four times over: vaxis keeps a `Screen` and an `InternalScreen`, pardes +/// keeps its own `Surface` and `previous_cells`. Against a 384 KiB heap that puts a hard ceiling on +/// the geometry, and it was measured rather than guessed - 40x12 initialises with room to spare, +/// 80x24 exhausts the heap and `Pardes.init` returns OutOfMemory with 9,128 bytes left. +/// +/// Raising these is what PSRAM would buy: this board has 32 MB fitted and untrained. +pub const max_cols: u16 = 40; +pub const max_rows: u16 = 12; + +var cur_winsize: vaxis.Winsize = .{ .rows = max_rows, .cols = max_cols, .x_pixel = 0, .y_pixel = 0 }; + + +// -------------------------------------------------------------------------------------- exports + +/// Hand over the allocator and the output sink, state the initial window size, and bring the editor +/// up. Returns 0, or a small non-zero code the firmware can only report. +export fn pardes_p4_init( + alloc: *const Allocator, + write: WriteFn, + ctx: ?*anyopaque, + cols: u16, + rows: u16, +) callconv(.c) u32 { + host_alloc = alloc.*; + out_write = write; + out_ctx = ctx; + out = .{ .vtable = &.{ .drain = drain }, .buffer = &out_buf }; + + const a = gpa(); + env_map = .{ .array_hash_map = .empty, .allocator = a }; + // Clamped, so a firmware asking for more than the heap affords still starts. See `max_cols`. + cur_winsize = .{ + .rows = @min(rows, max_rows), + .cols = @min(cols, max_cols), + .x_pixel = 0, + .y_pixel = 0, + }; + + const allocs = pardes.allocators.init(a); + // `std.Io.failing` and not a real Io: every path in the core that would perform I/O is behind + // the Host vtable, and the ones that are not are the ones this platform does not have. + pardes.image.start(std.Io.failing, allocs.image); + pardes.syntax.start(allocs.tree_sitter); + + vx = vaxis.init(std.Io.failing, a, &env_map, .{}) catch |err| return errCode(err); + vx.resize(a, &out, cur_winsize) catch |err| return errCode(err); + + // Ask the terminal what it is. Both halves are pure byte writers, which is the whole reason this + // works over a serial line: the replies arrive as ordinary input and are parsed like any key. + vx.enterAltScreen(&out) catch |err| return errCode(err); + vx.queryTerminalSend(&out) catch |err| return errCode(err); + out.flush() catch |err| return errCode(err); + + // The CLAMPED geometry, because the core and vaxis must agree on the grid and vaxis was just + // sized to `cur_winsize`. + core = pardes.Pardes.init(allocs.pardes, .{ + .cols = cur_winsize.cols, + .rows = cur_winsize.rows, + .frame_allocator = allocs.frame, + .image_allocator = allocs.image, + .tree_sitter_allocator = allocs.tree_sitter, + }) catch |err| return errCode(err); + + dirty = true; + return 0; +} + +/// Raw bytes off the wire: keystrokes, capability replies, and in-band resize reports. All three are +/// the same kind of thing to `vaxis.Parser`, and this function does not distinguish them. +export fn pardes_p4_input(ptr: [*]const u8, len: usize) callconv(.c) void { + const c = core orelse return; + + // Append, dropping the oldest on overflow: a full buffer means the parser is stuck on a + // malformed sequence, and keeping the tail is what lets it resynchronise. + const room = in_buf.len - in_len; + const take = @min(room, len); + if (take < len) { + in_len = 0; + @memcpy(in_buf[0..@min(len, in_buf.len)], ptr[0..@min(len, in_buf.len)]); + in_len = @min(len, in_buf.len); + } else { + @memcpy(in_buf[in_len..][0..take], ptr[0..take]); + in_len += take; + } + + var off: usize = 0; + while (off < in_len) { + const res = parser.parse(in_buf[off..in_len], gpa()) catch break; + if (res.n == 0) break; // incomplete: wait for more bytes + off += res.n; + if (res.event) |ev| apply(c, ev); + } + // Keep whatever was not consumed: the tail of a split escape sequence. + if (off > 0) { + std.mem.copyForwards(u8, in_buf[0 .. in_len - off], in_buf[off..in_len]); + in_len -= off; + } +} + +/// One parsed vaxis event applied to the core. Mirrors the tty shell's `apply` +/// (`src/tty/tty.zig:926-985`), minus everything that needs an OS. +fn apply(c: *pardes.Pardes, ev: vaxis.Event) void { + switch (ev) { + .key_press => |key| if (in_paste) { + // Between the brackets a key is DATA, never a command. vaxis gives control bytes no + // text at all, so a line break inside a paste arrives as a bare CR (Key.enter) or, from + // a terminal that does not translate them, as ctrl+j. + const text = key.text orelse ""; + const cp = mapKey(effCp(key)); + const bytes: []const u8 = if (text.len > 0) + text + else if (cp == pardes.Key.tab) + "\t" + else if (cp == pardes.Key.enter or (key.mods.ctrl and cp == 'j')) + "\n" + else + ""; + if (bytes.len > 0) paste_buf.appendSlice(gpa(), bytes) catch {}; + } else { + c.update(.{ .key = .{ + .cp = mapKey(effCp(key)), + .text = key.text orelse "", + .ctrl = key.mods.ctrl, + .alt = key.mods.alt, + .shift = key.mods.shift, + } }); + dirty = true; + }, + .paste_start => { + paste_buf.clearRetainingCapacity(); + in_paste = true; + }, + .paste_end => { + in_paste = false; + if (paste_buf.items.len > 0) { + c.update(.{ .paste = paste_buf.items }); + dirty = true; + } + paste_buf.clearRetainingCapacity(); + }, + // OSC 52. The bytes are the parser's, allocated from our own allocator, so they are freed + // here rather than leaked - the core copies whatever it keeps. + .paste => |text| { + c.update(.{ .paste = text }); + gpa().free(text); + dirty = true; + }, + .mouse => |m| { + const button: ?pardes.Mouse.Button = switch (m.button) { + .left => .left, + .middle => .middle, + .right => .right, + .wheel_up => .wheel_up, + .wheel_down => .wheel_down, + .wheel_left => .wheel_left, + .wheel_right => .wheel_right, + .none => .none, + else => null, + }; + if (button) |b| { + c.update(.{ .mouse = .{ + .button = b, + .kind = switch (m.type) { + .press => .press, + .release => .release, + .motion => .motion, + .drag => .drag, + }, + .col = @intCast(m.col), + .row = @intCast(m.row), + .ctrl = m.mods.ctrl, + } }); + dirty = true; + } + }, + // The only way this platform learns its size, and the one place a 384 KiB heap shows through + // to the user. Two things happen here that the tty shell does not need. + // + // CLAMPED, because the grids do not fit an arbitrary terminal: vaxis keeps a `Screen` and an + // `InternalScreen`, pardes keeps its own `Surface` and `previous_cells`, so every cell is + // paid for four times. Measured on the die - 40x12 initialises with room to spare, 80x24 + // exhausts the heap and `Pardes.init` returns OutOfMemory with 9,128 bytes left. The host's + // terminal is normally larger than the board can render, so the editor takes a corner of it + // instead of refusing to start. + // + // ATOMIC, because `Vaxis.resize` deinits both screens BEFORE allocating the replacements + // (Vaxis.zig:194-206), so a failed resize leaves vaxis with freed screens and renders + // nothing at all. That is exactly how this was found: the host bridge injects a size report + // on attach, the 80x24 it reported could not be allocated, and an editor that had just drawn + // its interface went silent. A failure now puts the previous geometry back. + .winsize => |ws| { + const want: vaxis.Winsize = .{ + .rows = @min(ws.rows, max_rows), + .cols = @min(ws.cols, max_cols), + .x_pixel = ws.x_pixel, + .y_pixel = ws.y_pixel, + }; + if (want.cols == cur_winsize.cols and want.rows == cur_winsize.rows) return; + const previous = cur_winsize; + vx.resize(gpa(), &out, want) catch { + vx.resize(gpa(), &out, previous) catch {}; + return; + }; + cur_winsize = want; + c.update(.{ .resize = .{ .cols = want.cols, .rows = want.rows } }); + dirty = true; + }, + // A TTY cannot report a pointer leaving its grid, so losing focus is the only reliable + // pointer-leave signal there is. + .focus_out => { + c.update(.pointer_leave); + dirty = true; + }, + .focus_in, .mouse_leave => {}, + // Capability replies. vaxis's own Loop sets these fields directly (`Loop.zig:377-403`); + // with no Loop, this is where they land. DA1 is the terminator: every terminal answers it + // last, so it is the signal that the whole handshake is in and the detected features can be + // switched on. + .cap_kitty_keyboard => vx.caps.kitty_keyboard = true, + .cap_kitty_graphics => vx.caps.kitty_graphics = true, + .cap_rgb => vx.caps.rgb = true, + .cap_unicode => { + vx.caps.unicode = .unicode; + vx.screen.width_method = .unicode; + }, + .cap_sgr_pixels => vx.caps.sgr_pixels = true, + .cap_color_scheme_updates => vx.caps.color_scheme_updates = true, + .cap_multi_cursor => vx.caps.multi_cursor = true, + .cap_da1 => { + vx.enableDetectedFeatures(&out) catch {}; + out.flush() catch {}; + dirty = true; + }, + .color_report, .color_scheme => {}, + .key_release => {}, + } +} + +/// The effective codepoint the way vaxis's own `Key.matches` sees it: a single-character `text` +/// wins, because the terminal has already resolved shift; otherwise the shifted codepoint. +fn effCp(key: vaxis.Key) u21 { + if (key.text) |t| { + const view = std.unicode.Utf8View.init(t) catch return key.codepoint; + var it = view.iterator(); + if (it.nextCodepoint()) |cp| { + if (it.nextCodepoint() == null) return cp; + } + } + return key.shifted_codepoint orelse key.codepoint; +} + +/// vaxis functional-key codepoints -> core constants. The ASCII ones already coincide, so +/// enter/tab/escape/backspace pass straight through. +fn mapKey(cp: u21) u21 { + return switch (cp) { + vaxis.Key.up => pardes.Key.up, + vaxis.Key.down => pardes.Key.down, + vaxis.Key.left => pardes.Key.left, + vaxis.Key.right => pardes.Key.right, + vaxis.Key.home => pardes.Key.home, + vaxis.Key.end => pardes.Key.end, + vaxis.Key.page_up => pardes.Key.page_up, + vaxis.Key.page_down => pardes.Key.page_down, + vaxis.Key.delete => pardes.Key.delete, + else => cp, + }; +} + +export fn pardes_p4_tick(now_ms: u64) callconv(.c) void { + const c = core orelse return; + _ = now_ms; + if (c.animationActive()) { + c.update(.tick); + dirty = true; + } +} + +export fn pardes_p4_wants_frame() callconv(.c) bool { + const c = core orelse return false; + return dirty or c.animationActive(); +} + +export fn pardes_p4_render() callconv(.c) u32 { + const c = core orelse return 0; + c.pump(.{ .ctx = null, .vtable = &pardes_host }) catch |err| return errCode(err); + dirty = false; + return 0; +} + +export fn pardes_p4_quit() callconv(.c) bool { + const c = core orelse return true; + return c.quit; +} + +// ------------------------------------------------------------------------------------ the host + +const pardes_host: pardes.Host.VTable = .{ .push_present = present }; + +/// The canonical surface -> vaxis, cell for cell, then one render. Same shape as the tty shell's +/// (`src/tty/tty.zig:1096`) minus the panel compositor and the kitty image path: neither has a +/// reason to exist on a board with no pixels. +fn present(_: ?*anyopaque, surface: *const pardes.Surface) void { + const win = vx.window(); + win.clear(); + var y: u16 = 0; + while (y < surface.rows) : (y += 1) { + var x: u16 = 0; + while (x < surface.cols) : (x += 1) { + // `at` takes a mutable Surface but only reads; the tty shell does the same const-cast + // for the same reason (src/tty/tty.zig:1105). + const cell = @constCast(surface).at(x, y); + if (cell.default) continue; + win.writeCell(x, y, .{ + .char = .{ .grapheme = cell.grapheme() }, + .style = vaxisStyle(cell.style), + }); + } + } + if (surface.cursor) |cur| { + win.showCursor(cur.x, cur.y); + } else win.hideCursor(); + + // vaxis diffs against its own shadow grid, so this writes only what changed - which is what + // makes an editor usable at 11.9 KB/s. + vx.render(&out) catch return; + out.flush() catch return; +} + +fn vaxisStyle(s: pardes.CellStyle) vaxis.Style { + return .{ + .fg = vaxisColor(s.fg), + .bg = vaxisColor(s.bg), + .bold = s.bold, + .dim = s.dim, + .italic = s.italic, + .blink = s.blink, + .reverse = s.reverse, + .invisible = s.invisible, + .strikethrough = s.strikethrough, + .ul_style = switch (s.ul) { + .off => .off, + .single => .single, + .double => .double, + .curly => .curly, + .dotted => .dotted, + .dashed => .dashed, + }, + }; +} + +fn vaxisColor(c: pardes.Color) vaxis.Color { + return switch (c) { + .default => .default, + .index => |i| .{ .index = i }, + .rgb => |rgb| .{ .rgb = rgb }, + }; +} + +/// Errors cross the ABI as small non-zero integers. `@intFromError` is not stable across builds, so +/// it is not used: the firmware only reports the number, and a stable-looking value that silently +/// changed meaning would be worse than an opaque one. +fn errCode(err: anyerror) u32 { + return switch (err) { + error.OutOfMemory => 1, + error.WriteFailed => 2, + else => 255, + }; +} |
