summaryrefslogtreecommitdiff
path: root/src/pardes.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-08-26 13:27:46 -0300
committerGabriel Schneider <[email protected]>2026-08-27 09:47:39 -0300
commit11f380f6d7222f2cad93c2cdf13701ea1f903d47 (patch)
tree803194ee5853a6b4cda93f90a95e28d1f02e69ae /src/pardes.zig
parentfbc194068687e49a8490c85c9f1257a2f2bb9079 (diff)
downloadpardes-11f380f6d7222f2cad93c2cdf13701ea1f903d47.tar.gz
pardes-11f380f6d7222f2cad93c2cdf13701ea1f903d47.zip
One core behind N frontends, the board's own runner moved in, and every board cap on one screen
## The wire is the effect stream, not a new protocol `pardes --detach` leaves a core running with no terminal; `pardes --attach` is a frontend that owns a terminal and a socket and nothing else. N frontends on one core all look at the same screen — `screen -x`, not N sessions. The codec (`src/detached/wire.zig`) carries exactly one `Event` or one `Host.VTable` call per message. That is not a coincidence and it is why there is no third vocabulary to keep in step: the core's IO seam was already a struct of function pointers with plain-data arguments, so a socket is a legal implementation of it. `nested.zig`'s socket could not be reused — it carries a builtin command line, and a command line cannot carry a frame. ARCHITECTURE-NEUTRAL on purpose, not as decoration. The frontend on the far end may be riscv32-freestanding on the ESP32-P4 while the core is x86_64 Linux, so every field is an explicit little-endian fixed width and no message is a blit of a native struct. A protocol that only works between two builds of the same compiler would have thrown away the one frontend that motivated it. ## The board comes in; its toolchain stays out `src/p4.zig` becomes `src/esp32p4.zig`, and the pardes half of `../05-zig-p4` — the vaxis-over- serial runner, the UART editor terminal, the keystroke rescue ring, the on-die test suite — moves into `src/esp32p4/`. `build.zig.zon` gains `.zig_p4 = .{ .path = "../05-zig-p4" }`, so `zig build -Dplatform=esp32p4 -Desp32p4-firmware` builds, flashes, monitors and self-tests the board from this repo's `build.zig`. The DIVISION is the point. What moved is what only pardes wants: the runner that drives a pardes core over a serial line. What stayed is everything a second project would also want — the HAL, the register/radio/oracle layers, the linker script, `_start`. `zig_p4` declares no dependencies of its own and its `build()` early-returns when it is not the root package, so this costs the package graph exactly zero packages and the editor's own builds nothing at all. ## limits.zig: nine forgettable places become one budget Nine `platform == .esp32p4` capacity tests lived in nine files. They were never nine decisions — they are ONE decision, how much memory this build may spend, taken nine times where no reader could see the total. `src/limits.zig` puts the whole budget on one screen with every cap named against what it is measured against, derived from two booleans. The payoff is testability on a machine that is not the board: the caps are ordinary comptime values, so a host build can be compiled against the board's numbers and the parking, eviction and clamping paths a 240 KiB core takes get exercised by the normal test suite instead of only over a UART. ## A bare `zig build` `zig build` with no arguments now builds the tty and GUI binaries and installs them into `~/.local/bin`, and says so once on stdout with the flag that overrides it. The old default built one binary into `zig-out` — a path nothing on a `PATH` ever looks at, which made "build it" and "use it" two different commands for no reason.
Diffstat (limited to 'src/pardes.zig')
-rw-r--r--src/pardes.zig361
1 files changed, 316 insertions, 45 deletions
diff --git a/src/pardes.zig b/src/pardes.zig
index 4da64c3f..db20ff32 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -37,6 +37,9 @@ pub const pdf_pane = @import("pdf_pane.zig");
const output_pane = @import("output_pane.zig");
const builtins = @import("builtins.zig");
const runtime_cfg = @import("runtime_config.zig");
+/// Every board-shaped capacity, in one table keyed on a profile rather than on
+/// the platform. See src/limits.zig.
+const limits = @import("limits.zig");
const selection_pipe = @import("selection_pipe.zig");
/// acme's control filesystem, as a pure transaction over this core: the FILES
/// a script opens (`body`, `ctl`, `event`, ...) and what they mean. The
@@ -65,7 +68,7 @@ pub const frameMark = tracy.frameMark;
/// `p4` is ESP32-P4 firmware: a riscv32-freestanding core whose whole host is
/// a serial line. It joins `web` in having no filesystem, no ptys and no
/// config directory, which is what `hosted` below is for.
-pub const Platform = enum { tty, gui, web, macos, p4 };
+pub const Platform = enum { tty, gui, web, macos, esp32p4 };
pub const platform: Platform = @field(Platform, @tagName(@import("pardes_config").platform));
/// Whether a theme change FADES the anchored chrome palette or replaces it. Ten display frames
@@ -74,7 +77,7 @@ pub const platform: Platform = @field(Platform, @tagName(@import("pardes_config"
///
/// The exception is a screen reached through a UART. Each of the ten steps recolors every anchored
/// cell, so the diff finds the whole chrome dirty and spends a frame's worth of wire on it, ten times
-/// over, for a fade nobody can see arrive gradually anyway. Off by default for `p4` and settable
+/// over, for a fade nobody can see arrive gradually anyway. Off by default for `esp32p4` and settable
/// either way from the build, because the thing that makes it wrong is the transport rather than the
/// target - see `build.zig`.
pub const theme_animation = @import("pardes_config").theme_animation;
@@ -112,7 +115,7 @@ pub const hosted = platform == .tty or platform == .gui or platform == .macos;
/// this one name, and src/term_pane.zig re-exports it as `enabled` and owns
/// the whole seam — the two Pane slots included — so ghostty-vt ends up
/// imported by exactly one file.
-pub const terminal_panes = platform != .p4;
+pub const terminal_panes = platform != .esp32p4;
/// ...and the one fact about that face the core keeps: the name `Font` last
/// resolved, which the Debug overlay prints. Behind the same comptime shim
@@ -142,7 +145,7 @@ pub const pdf_raster_policy: PdfRasterPolicy = switch (platform) {
.gui, .macos => sdl_pdf_raster_policy,
// Neither hostless platform rasterizes a PDF at all (mupdf is compiled
// out), so the cheaper policy is the honest placeholder.
- .web, .p4 => kitty_pdf_raster_policy,
+ .web, .esp32p4 => kitty_pdf_raster_policy,
};
// The capacities and the two heights that are STRUCTURE, not taste: the
@@ -2281,40 +2284,9 @@ fn mix(a: [3]u8, b: [3]u8) [3]u8 {
return out;
}
-const TAG_TAIL_CAP = dump.max_tag_tail; // one editable command line; extra input is refused
+const TAG_TAIL_CAP = limits.max_tag_tail; // one editable command line; extra input is refused
const TTY_REPLAY_CAP = 1024 * 1024; // oldest bytes are evicted from the dump/replay record
-/// How many effects the ring holds. SHRUNK, not moved to the heap, on the
-/// board: `pump` drains this to empty on every iteration with an
-/// unconditional `while (nextEffect())` — including effects `perform` itself
-/// queues — so no capacity can deadlock the drain, and the only question a
-/// capacity answers is how big a single-pump BURST may be before `emit`
-/// refuses the overflow. The one producer that can burst is `emitWrite`,
-/// which chunks arbitrary bytes into 64-byte `.write` effects for a pty, and
-/// a build with `terminal_panes == false` has no pty to write to. Everything
-/// else queues O(1) effects per event, and `in_q` holds at most 64 events per
-/// pump, so 128 leaves two effects per queued event.
-///
-/// A 1.0625 MiB inline ring cannot live in the board's 384 KiB heap at all;
-/// 128 entries is 34 KiB. NOTE THE BEHAVIOUR CHANGE: `emit` has always
-/// refused (not evicted) once full, so on p4 a burst larger than 128 effects
-/// now drops its tail where 4096 would have held it — reachable only through
-/// `emitWrite`, i.e. only if a pty ever appears on this platform.
-const EFFECT_CAP = if (platform == .p4) 128 else 4096;
-
-/// Rows the per-pane soft-wrap map covers. `wrapWidth` refuses to wrap a pane
-/// taller than this (it reads the array's own length), so shrinking it cannot
-/// truncate a map — a taller pane renders unwrapped, exactly as documented on
-/// `Pane.wrap_line`. A serial console is not 128 rows tall.
-const WRAP_ROWS = if (platform == .p4) 128 else 256;
-
-/// A shell's reported working directory, owned inline by the pane. Zero-sized
-/// where there are no processes to report one: the `PdfSlot` rule, applied to
-/// a capacity whose sole producer (`Pardes.setCwd`, fed by a pty's prompt
-/// report) does not exist without terminal panes. `setOwnedCwd` clamps, so a
-/// zero cap reads as "no directory known" — which is the truth here.
-const CWD_BUF_CAP = if (terminal_panes) 1024 else 0;
-
/// Everything a theme repaints. `bg`/`fg` null = leave the host terminal's own
/// default cell showing (the native-dark shape); `palette` null = let a child's
/// ANSI indices reach the host untranslated until that terminal enables its
@@ -2462,7 +2434,7 @@ const boot_buffer =
// pinning the exact gutter width would make this test a restatement of the renderer instead of a
// statement about the text.
test "every line of the board's boot buffer renders whole" {
- const cols: usize = @import("pardes_config").p4_cols;
+ const cols: usize = @import("pardes_config").esp32p4_cols;
var it = std.mem.splitScalar(u8, boot_buffer, '\n');
while (it.next()) |line| {
std.testing.expect(line.len + 8 <= cols) catch |err| {
@@ -3085,6 +3057,133 @@ test "surface print keeps combining and wide graphemes in their display cells" {
try std.testing.expectEqualStrings("A", cells[7].grapheme());
}
+test "the ASCII fast path in surface print paints what the general arm paints" {
+ // The fast path skips a UTF-8 length, a decode, a grapheme iterator, a slice validation and a
+ // width lookup, so it can only be judged against those: the reference below is `print` with the
+ // fast-path block deleted and nothing else changed. Both routes paint into equally sized
+ // surfaces and every cell plus the returned column must match.
+ const ref = struct {
+ fn print(s: *Surface, x: u16, y: u16, w: u16, text: []const u8, style: CellStyle) u16 {
+ var col = x;
+ const end = x + w;
+ var i: usize = 0;
+ while (i < text.len) {
+ if (col >= end) break;
+ const n = std.unicode.utf8ByteSequenceLength(text[i]) catch 0;
+ const decoded: ?u21 = if (n > 0 and i + n <= text.len)
+ (std.unicode.utf8Decode(text[i .. i + n]) catch null)
+ else
+ null;
+ var cp_slice: []const u8 = "\u{FFFD}";
+ var consumed: usize = 1;
+ if (decoded != null) {
+ var git = uucode.grapheme.utf8Iterator(text[i..]);
+ if (git.nextGrapheme()) |g| {
+ const candidate = text[i .. i + g.end];
+ if (std.unicode.utf8ValidateSlice(candidate)) {
+ cp_slice = candidate;
+ consumed = candidate.len;
+ } else {
+ cp_slice = text[i .. i + n];
+ consumed = n;
+ }
+ }
+ }
+ i += consumed;
+ var cp = decoded orelse 0xFFFD;
+ if (cp == '\r') continue;
+ if (cp == '\t') {
+ const spaces = @min(config.tab_width, end - col);
+ s.fill(col, y, spaces, 1, style);
+ col += spaces;
+ continue;
+ }
+ if (cp < ' ' or cp == 0x7f or (cp >= 0x80 and cp <= 0x9f)) {
+ cp = 0xFFFD;
+ cp_slice = "\u{FFFD}";
+ }
+ const width: u16 = if (decoded == null or (cp < 0x80 and cp_slice.len == 1))
+ 1
+ else
+ @max(1, vaxis.gwidth.gwidth(cp_slice, .unicode));
+ if (width == 2 and col + 1 >= end) break;
+ var shown = cp_slice;
+ if (shown.len > @typeInfo(@FieldType(Cell, "text")).array.len) {
+ const cap = @typeInfo(@FieldType(Cell, "text")).array.len;
+ var prefix: usize = 0;
+ while (prefix < shown.len) {
+ const cp_len = std.unicode.utf8ByteSequenceLength(shown[prefix]) catch break;
+ if (prefix + cp_len > cap) break;
+ prefix += cp_len;
+ }
+ shown = if (prefix > 0) shown[0..prefix] else "\u{FFFD}";
+ }
+ s.set(col, y, shown, style);
+ if (width == 2 and col + 1 < end) s.set(col + 1, y, "", style);
+ col += width;
+ }
+ return col;
+ }
+ }.print;
+
+ const H = struct {
+ fn check(text: []const u8) !void {
+ // Every width from 0 past the end, because clipping interacts with the fast path: the
+ // wide-glyph bail at the right edge is only reachable from the general arm.
+ var w: u16 = 0;
+ while (w <= text.len + 4) : (w += 1) {
+ var fast_cells: [64]Cell = @splat(.{});
+ var slow_cells: [64]Cell = @splat(.{});
+ var fast = Surface{ .cols = fast_cells.len, .rows = 1, .cells = &fast_cells };
+ var slow = Surface{ .cols = slow_cells.len, .rows = 1, .cells = &slow_cells };
+ const got = fast.print(0, 0, w, text, .{});
+ const want = ref(&slow, 0, 0, w, text, .{});
+ std.testing.expectEqual(want, got) catch |e| {
+ std.debug.print("print end column: {any} w={d}\n", .{ text, w });
+ return e;
+ };
+ for (fast_cells, slow_cells, 0..) |f, sc, col| {
+ std.testing.expectEqualStrings(sc.grapheme(), f.grapheme()) catch |e| {
+ std.debug.print("print cell {d}: {any} w={d}\n", .{ col, text, w });
+ return e;
+ };
+ try std.testing.expectEqual(sc.default, f.default);
+ }
+ }
+ }
+ };
+
+ // The scalars that extend an ASCII base into ONE cluster - a combining mark, a ZWJ sequence, a
+ // spacing mark, a variation selector - are exactly what the guard on the next byte exists for.
+ // Wide glyphs check the two-cell accounting either side of the fast path, and the three invalid
+ // sequences check that a bad start byte, a truncated tail and a bad continuation each still
+ // become one U+FFFD per undecodable byte instead of being swallowed.
+ const neighbours = [_][]const u8{
+ "", "x", "\u{301}", "\u{200d}\u{1f680}",
+ "\u{903}", "\u{fe0f}", "\u{20e3}", "\u{4e16}",
+ "\u{1f642}", "\xff", "\xe4\xb8", "\xe4\x28\xb8",
+ "\u{1f1e6}\u{1f1e7}",
+ };
+ // Every byte 0x20..0x7e takes the fast path; \t, \r, the rest of the C0 controls and DEL are
+ // excluded by its range test and must keep the general arm's tab expansion and U+FFFD.
+ var buf: [16]u8 = undefined;
+ var b: u8 = 0;
+ while (b < 0x80) : (b += 1) {
+ buf[0] = b;
+ for (neighbours) |tail| {
+ @memcpy(buf[1..][0..tail.len], tail);
+ try H.check(buf[0 .. 1 + tail.len]);
+ @memcpy(buf[0..tail.len], tail);
+ buf[tail.len] = b;
+ try H.check(buf[0 .. tail.len + 1]);
+ }
+ }
+
+ // Runs long enough that the fast path starts, hands over and restarts inside one call.
+ try H.check("ascii \u{4e16}\u{754c} e\u{301} \xff ok\t\r!");
+ try H.check("plain");
+}
+
test "insert and normal modes edit complete Unicode graphemes" {
const gpa = std.testing.allocator;
const p = try Pardes.init(gpa, .{ .cols = 60, .rows = 12 });
@@ -3936,7 +4035,7 @@ pub const Pane = struct {
/// link to the pane it was opened from (.inherited), or unknown (.none).
/// The inherited pointer is kept valid by deferred pane teardown + fixup.
cwd: Cwd = .none,
- cwd_buf: [CWD_BUF_CAP]u8 = undefined,
+ cwd_buf: [limits.cwd_buf_cap]u8 = undefined,
/// modal cursor, at ABSOLUTE body rows of the pane's SURFACE (file lines,
/// or the terminal's shell rows with its edit buffer standing in). Tracks
/// the shell cursor until pinned by a click or a key.
@@ -3964,14 +4063,14 @@ pub const Pane = struct {
/// renderPane call. Nothing else may write it; a second writer is a second
/// truth, and the first click on a stale row is how you find out.
///
- /// ponytail: a fixed `WRAP_ROWS` rows (256; 128 on the board). A pane
+ /// ponytail: a fixed `limits.wrap_rows` rows (256; 128 on the board). A pane
/// taller than that does not wrap at all — bodyText leaves wrap_n at 0 and
/// clips the way it always did — rather than half-recording a mapping
/// every site here would then have to distrust. `wrapWidth` derives that
/// refusal from `wrap_line.len` itself, so the bound follows the array.
/// Grow the arrays the day a taller window turns up.
- wrap_line: [WRAP_ROWS]i32 = undefined,
- wrap_col: [WRAP_ROWS]i32 = undefined,
+ wrap_line: [limits.wrap_rows]i32 = undefined,
+ wrap_col: [limits.wrap_rows]i32 = undefined,
wrap_n: u16 = 0,
sel: [3]Sel = @splat(.{}),
/// terminals only: the typed-text buffer standing in for shell rows
@@ -5613,11 +5712,11 @@ pub const Pardes = struct {
/// request stale as soon as another ThemeFile/Theme/NextColor command wins.
custom_theme: ?Theme = null,
custom_theme_active: bool = false,
- /// Sized by `runtime_cfg.host_path_cap`, which is 0 where the platform has
+ /// Sized by `limits.host_path_cap`, which is 0 where the platform has
/// no filesystem to hold a theme file: `set` then refuses every non-empty
/// path and `themeFileRequest` answers `PathTooLong`, which is the honest
/// answer on a board whose only IO is a UART.
- theme_file_path: runtime_cfg.Text(runtime_cfg.host_path_cap) = .{},
+ theme_file_path: runtime_cfg.Text(limits.host_path_cap) = .{},
theme_file_generation: u32 = 0,
theme_file_pane: u8 = 0,
chrome_animation: ChromeAnimation = ChromeAnimation.init(initial_chrome),
@@ -5731,7 +5830,7 @@ pub const Pardes = struct {
/// Pending effects, drained by the shell after each update. The bounded
/// ring preserves byte order; once full, later effects are refused so no
/// already-queued write can be reordered or silently evicted.
- effects: [EFFECT_CAP]Effect = undefined,
+ effects: [limits.effect_cap]Effect = undefined,
effects_head: usize = 0,
effects_len: usize = 0,
@@ -5809,7 +5908,7 @@ pub const Pardes = struct {
p.ncol = 1;
p.col_n[0] = 1;
p.col_terms[0][0] = 0;
- } else if (comptime platform == .p4) {
+ } else if (comptime platform == .esp32p4) {
// BARE METAL BOOTS AN EMPTY OUTPUT BUFFER, and a shell is not a layout preference
// here but an impossibility: there is no operating system under this, so there is
// nothing to fork and no pty to give a terminal pane. Booting one anyway produced
@@ -16038,3 +16137,175 @@ test "entering tty walks the shell cursor to the column clicked past the prompt"
try std.testing.expectEqual(@as(usize, 0), rights);
try std.testing.expectEqual(@as(usize, 7), lefts);
}
+
+// THE BOARD'S MEMORY PRESSURE, REPRODUCED ON AN ORDINARY NATIVE TARGET.
+//
+// These live in pardes.zig and not in limits.zig because every one of them
+// drives `Pardes.init`: the table alone cannot say what a boot costs. The one
+// test that needs nothing but the numbers — the desktop-capacity regression
+// guard — stays in src/limits.zig.
+//
+// All three use the FixedBufferAllocator (or the accounting FailingAllocator)
+// as the CORE'S OWN gpa and hand the same allocator to every `Options` arena,
+// mirroring src/esp32p4.zig's `allocators.init(a)`: on the board every tier is a
+// `StackFallbackAllocator` with a 4 KiB or zero buffer, so effectively all of
+// it spills onto the single heap. Calling `allocators.init` here instead would
+// hand a desktop build its 32 MiB static `.bss` tier and serve every request
+// out of that, making a 384 KiB budget mean nothing.
+
+/// The board grid. These mirror `pardes_config.esp32p4_cols/esp32p4_rows` (build.zig
+/// defaults, read at src/esp32p4.zig:235) rather than reading them, because a
+/// native build does not set the P4 options at all.
+const board_cols: u16 = 56;
+const board_rows: u16 = 14;
+
+fn boardBudgetOptions(gpa: std.mem.Allocator, cols: u16, rows: u16) Options {
+ return .{
+ .cols = cols,
+ .rows = rows,
+ // No pty is spawned by a test host, but `tty_only` is the one-pane boot
+ // and therefore the closest a hosted build gets to the board's
+ // single-output-buffer boot.
+ .tty_only = true,
+ .frame_allocator = gpa,
+ .image_allocator = gpa,
+ .pdf_allocator = gpa,
+ .tree_sitter_allocator = gpa,
+ };
+}
+
+/// A boot and its teardown as one `!void` call. `checkAllAllocationFailures`
+/// requires exactly that shape and `Pardes.init` returns `*Pardes` with a
+/// `deinit` obligation, so the harness cannot call it directly.
+fn bootAndTearDown(gpa: std.mem.Allocator, cols: u16, rows: u16) !void {
+ const p = try Pardes.init(gpa, boardBudgetOptions(gpa, cols, rows));
+ p.deinit();
+}
+
+// WHAT THE BOARD'S 384 KiB BUYS, CHECKED FROM A DESKTOP.
+//
+// What a hosted build CANNOT do is boot in 384 KiB, and the reason is not a
+// capacity: `@sizeOf(Pane)` carries the ghostty-vt Terminal, 1.1 MiB of it, and
+// what removes that on the board is `terminal_panes` — a CAPABILITY keyed on
+// the platform (src/limits.zig says why it is not in the table). So there is no
+// build option that turns a desktop into the board, and a test that pretended
+// otherwise would be asserting a FixedBufferAllocator refuses a 2.2 MiB
+// request. That was written, it asserted nothing, and it is gone.
+//
+// What a hosted build CAN check is every product the board's budget is spent
+// on, because both factors are visible here: the board's CAPS are literals in
+// src/limits.zig, and the ELEMENT SIZES are the same structs this target
+// compiles (`Effect`, `Snapshot`, `Cell` and `PanelCellDiff` hold no pointers,
+// so riscv32 and x86_64 agree about all four). That is the product a code
+// change actually moves: nobody shrinks the board's heap, but somebody adds a
+// `Buf(512)` arm to `Effect` and costs it 49 KiB it does not have.
+//
+// The caps are spelled as LITERALS rather than read from `limits`, because on
+// this build `limits` holds the desktop numbers; these are the board's, they
+// are its contract, and a derivation would agree with itself.
+//
+// MEASURED on x86_64-linux Debug at this commit: `@sizeOf(Effect)` 272,
+// `@sizeOf(term_pane.Snapshot)` 56, `@sizeOf(Cell)` 26, `@sizeOf(PanelCellDiff)` 3 —
+// so the three products are 34,816 + 1,792 + 43,120 = 79,728 bytes, a fifth of
+// the heap, against bounds of 49,152 / 12,288 / 49,152 and a total of 196,608.
+test "board heap: every inline ring the board pays for still fits its budget" {
+ const heap = limits.board_heap_bytes;
+
+ // THE EFFECT RING, the largest single inline cost in `Pardes` — 4096
+ // entries on a desktop is 1.09 MiB of the 1.14 MiB the struct occupies.
+ // The board holds 128 (src/limits.zig `effect_cap`).
+ const effect_ring = 128 * @sizeOf(Effect);
+ // ...and the undo history, the largest in `Pane` once the terminal is out:
+ // 16 snapshots on the board against 256 on a desktop.
+ const undo_history = 2 * 16 * @sizeOf(term_pane.Snapshot);
+ // ...and the three per-cell arrays the core owns at the board's own grid,
+ // which the next test pins the SHAPE of; this one pins the COST.
+ const grid = @as(usize, board_cols) * board_rows * (2 * @sizeOf(Cell) + @sizeOf(PanelCellDiff));
+
+ // Each of the three separately, so a failure names the one that grew
+ // instead of reporting a total nobody can attribute.
+ try std.testing.expect(effect_ring <= heap / 8);
+ try std.testing.expect(undo_history <= heap / 32);
+ try std.testing.expect(grid <= heap / 8);
+ // ...and together, against the half of the heap the firmware measured as
+ // available after its own .bss, stack and vaxis's two grids. Three eighths
+ // is what the individual bounds already allow; asserting the sum as well is
+ // what catches two of them growing a little each.
+ try std.testing.expect(effect_ring + undo_history + grid <= heap / 2);
+}
+
+// EVERY CELL IS PAID FOR FOUR TIMES on the board — vaxis's `Screen` and
+// `InternalScreen`, and the core's `Surface.cells` and `presented_cells` — plus
+// the core's per-cell diff classification. Two of those four are vaxis's and
+// invisible from here; what this pins is the three buffers the CORE owns, so
+// that adding a fourth core-owned per-cell array fails loudly instead of
+// quietly costing the board another 20 KiB.
+//
+// MEASURED at this commit: `@sizeOf(Cell)` is 26 and `@sizeOf(PanelCellDiff)`
+// is 3, so the core spends 55 bytes per cell — 43,120 bytes at the board's
+// 56x14 grid, an eighth of the whole heap and the largest single grid-scaled
+// cost in the program.
+test "board heap: the core owns exactly three per-cell arrays" {
+ const per_cell = 2 * @sizeOf(Cell) + @sizeOf(PanelCellDiff);
+
+ // Five NAMES, three BUFFERS: `Surface.previous_cells` and
+ // `Surface.cell_diffs` are views published onto the two the core owns (see
+ // `render`), so they cost nothing. Counted by reflection because a fifth
+ // name is exactly the change this test exists to catch.
+ const grid_slices = comptime blk: {
+ var n: usize = 0;
+ for (@typeInfo(Pardes).@"struct".fields ++ @typeInfo(Surface).@"struct".fields) |f| {
+ const info = @typeInfo(f.type);
+ if (info != .pointer or info.pointer.size != .slice) continue;
+ if (info.pointer.child == Cell or info.pointer.child == PanelCellDiff) n += 1;
+ }
+ break :blk n;
+ };
+ try std.testing.expectEqual(@as(usize, 5), grid_slices);
+
+ // The diff array is only allocated for a transition that needs the previous
+ // grid, so the sequence here is the shortest one that makes all three real:
+ // boot, acknowledge, split with `vertical` armed, render.
+ const p = try Pardes.init(std.testing.allocator, .{ .cols = 60, .rows = 16, .tty_only = true });
+ defer p.deinit();
+ var frame: std.heap.ArenaAllocator = .init(std.testing.allocator);
+ defer frame.deinit();
+ const boot = try p.render(frame.allocator());
+ p.acknowledgePanelPresentation(boot.panelTracks());
+ p.settings.panel_transition = .vertical;
+ _ = try p.newShell(1, "");
+ try std.testing.expect(p.layoutSplitColumn(0, 1, false));
+ p.sync();
+ _ = frame.reset(.retain_capacity);
+ _ = try p.render(frame.allocator());
+
+ const cells = @as(usize, p.screen_w) * p.screen_h;
+ try std.testing.expectEqual(cells, p.surface.cells.len);
+ try std.testing.expectEqual(cells, p.presented_cells.len);
+ try std.testing.expectEqual(cells, p.panel_cell_diffs.len);
+ const owned = p.surface.cells.len * @sizeOf(Cell) +
+ p.presented_cells.len * @sizeOf(Cell) +
+ p.panel_cell_diffs.len * @sizeOf(PanelCellDiff);
+ try std.testing.expectEqual(cells * per_cell, owned);
+
+ // ...and the board's own grid has to leave the other seven eighths of the
+ // heap for everything else. 43,120 of 49,152 at the numbers above; a cell
+ // that grew by two bytes would spend the margin.
+ try std.testing.expect(@as(usize, board_cols) * board_rows * per_cell <=
+ limits.board_heap_bytes / 8);
+}
+
+// EVERY ALLOCATION IN A BOOT, FAILED IN TURN. Eight of them at this commit, so
+// the sweep is eight boots and costs milliseconds. `checkAllAllocationFailures`
+// is the whole test because it asserts precisely the three things that matter:
+// a failed allocation surfaces `error.OutOfMemory` rather than being swallowed
+// into a half-built instance, `allocated_bytes == freed_bytes` at that point
+// (so the failure path needs no `deinit` and leaves nothing dangling), and the
+// allocation count is deterministic.
+test "board heap: every allocation failure during boot is a clean OutOfMemory" {
+ try std.testing.checkAllAllocationFailures(
+ std.testing.allocator,
+ bootAndTearDown,
+ .{ board_cols, board_rows },
+ );
+}