summaryrefslogtreecommitdiff
path: root/tools/bench_main.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-08-26 01:42:35 -0300
committerGabriel Schneider <[email protected]>2026-08-26 01:42:35 -0300
commitcea01b8c08c30fa68fcbf18266937274a080507e (patch)
treec0170b8a418bde3962ee1d3931d72bf37478b538 /tools/bench_main.zig
parente1526395233aad15dc9e2fdb49d5842de3e7e77b (diff)
downloadesp32p4-cea01b8c08c30fa68fcbf18266937274a080507e.tar.gz
esp32p4-cea01b8c08c30fa68fcbf18266937274a080507e.zip
Send mouse events over the wire, thinned, and check the two things only hardware can
## The console thins mouse reports The board asks for DEC 1002, so the terminal reports presses, releases and motion while a button is held. A press is one report; a DRAG is one report per cell crossed, each about a dozen bytes. A hand crosses forty cells in a tenth of a second, which is ~500 bytes, which is 43 ms of a 115200 line - and every one of those bytes is input the board must parse while it is trying to paint the result of the previous one. Unthinned, a drag makes the editor unusable for as long as the drag lasts and for a while after. `MouseFilter` holds the newest motion report and drops the ones it supersedes, on a 40 ms window: ~25 reports a second, about 2.6% of the line. Newest-wins is right for motion and only for motion - where the pointer PASSED THROUGH is not information the editor can use, since a selection is defined by where the drag began and where it is now, so an intermediate report already stale by the time it reaches the wire is pure cost. Presses, releases and wheel notches are never held: each one means something different and dropping one loses a click. Ordering is the part worth testing. A held report is released before any non-motion byte that follows it, so a release cannot overtake the motion it ends, and a drag that stops moving still delivers its final position when the window expires. The seven tests cover those, a report split across a read boundary, a non-mouse escape sequence passing through untouched, and - the one that would hurt most - a lone ESC not being swallowed, because that is how you leave insert mode. ## Two hardware checks: p4-bench --check Both are regressions that no host test can see and no latency number can show. A lone Escape still leaves insert mode. The shell now holds a solitary ESC for 10 ms because on this wire the first byte of every sequence arrives alone; if that hold ever stops expiring, Escape stops working and the editor is unusable. A click split byte-by-byte lands at the column clicked. This is the bug that made the mouse look unimplemented, and it only appears when the bytes arrive separately - which the wire does anyway, 87 us apart. The check sends them as ten separate writes with no gap, because a gap longer than the hold would expire it and the check would be exercising nothing. Two things this file deliberately does NOT check, both because a check that cannot fail honestly is worse than no check. Input loss during transmit belongs to the deterministic host test in `src/pardes/input_rescue.zig`, which loses 67 bytes with the fix removed and needs no board. And every hardware oracle for it that was tried here was worse: the cursor stops being reported past 160 characters because the wrapped line outgrows the viewport, and a screen reconstruction cannot be rebuilt mid-session because the board only sends what changed. Both false starts are recorded in the file so the next person does not repeat them. The check also found its own bugs before it found any of the firmware's: 12 ms gaps between the click's bytes expired the very hold it meant to test, `$` produces no frame when the cursor is already at the end of the line, and reading the cursor after a press alone measures the revert rather than the click.
Diffstat (limited to 'tools/bench_main.zig')
-rw-r--r--tools/bench_main.zig158
1 files changed, 151 insertions, 7 deletions
diff --git a/tools/bench_main.zig b/tools/bench_main.zig
index fbaea8a..6e453a8 100644
--- a/tools/bench_main.zig
+++ b/tools/bench_main.zig
@@ -30,6 +30,8 @@ const Mode = enum {
editor,
/// A named experiment: one controlled variable, many trials, machine-readable.
sweep,
+ /// Behaviour rather than speed: things that were broken on this transport and must stay fixed.
+ check,
};
/// Which variable an experiment varies. One per run, because the point is a controlled variable and
@@ -100,6 +102,10 @@ pub fn main(init: std.process.Init.Minimal) void {
o.mode = .editor;
continue;
}
+ if (eql(a, "--check")) {
+ o.mode = .check;
+ continue;
+ }
if (eql(a, "--csv")) {
o.csv = true;
continue;
@@ -258,6 +264,7 @@ fn run(o: Options) !void {
.link => try link(&port, o, &r),
.editor => try editor(&port, o, &r),
.sweep => try sweep(&port, o, &r),
+ .check => try check(&port, o, &r),
}
}
@@ -400,7 +407,7 @@ fn trials(port: *serial.Port, o: Options, r: *Report, c: Condition) !void {
last_bytes = v.bytes;
last_settle = v.settle_us;
if (o.csv) r.print("{s},{s},{d},{d},{d},{s},{d},{d},{d},{d}\n", .{
- o.label, @tagName(o.which), c.cols, c.rows, c.length, c.op,
+ o.label, @tagName(o.which), c.cols, c.rows, c.length, c.op,
rep, pos(v.rtt_us), pos(v.settle_us), v.bytes,
});
} else lost += 1;
@@ -413,7 +420,7 @@ fn trials(port: *serial.Port, o: Options, r: *Report, c: Condition) !void {
}
std.mem.sort(i64, rtts[0..got], {}, std.sort.asc(i64));
r.print(" {s:<12} {d:>3}x{d:<3} len {d:>4} median {d:>7} us spread {d:>6} us {d:>5} B\n", .{
- c.op, c.cols, c.rows, c.length,
+ c.op, c.cols, c.rows, c.length,
pos(rtts[got / 2]), pos(rtts[got - 1] - rtts[0]), last_bytes,
});
r.flush();
@@ -486,7 +493,7 @@ fn link(port: *serial.Port, o: Options, r: *Report) !void {
const up_ok = up_stat.bytes == sent and up_stat.crc == want_crc;
r.print(" uplink host -> board\n", .{});
r.print(" {d} B in {d} us = {d} B/s ({d}% of wire)\n", .{
- sent, up_us, @divTrunc(@as(i64, sent) * 1_000_000, up_us),
+ sent, up_us, @divTrunc(@as(i64, sent) * 1_000_000, up_us),
@divTrunc(@as(i64, sent) * 1_000_000 * 100, up_us * @as(i64, port.capacity())),
});
if (up_ok) {
@@ -529,7 +536,7 @@ fn link(port: *serial.Port, o: Options, r: *Report) !void {
r.print("\n downlink board -> host\n", .{});
r.print(" {d} B in {d} us = {d} B/s ({d}% of wire)\n", .{
- got, down_us, @divTrunc(@as(i64, got) * 1_000_000, down_us),
+ got, down_us, @divTrunc(@as(i64, got) * 1_000_000, down_us),
@divTrunc(@as(i64, got) * 1_000_000 * 100, down_us * @as(i64, port.capacity())),
});
if (down_stat) |s| {
@@ -618,8 +625,8 @@ fn editor(port: *serial.Port, o: Options, r: *Report) !void {
const pass = s.lost == 0 and s.median_us >= 0 and s.median_us <= ceiling;
if (pass) best = rate;
r.print(" {d:>7} {d:>9} us {d:>9} us {d:>7} {d:>4}% {d:>5} {s}\n", .{
- pos(rate), pos(s.median_us), pos(s.median_settle_us),
- s.median_bytes, s.wire_percent, s.lost,
+ pos(rate), pos(s.median_us), pos(s.median_settle_us),
+ s.median_bytes, s.wire_percent, s.lost,
if (s.lost > 0) "LOST INPUT" else if (pass) "ok" else "behind",
});
r.flush();
@@ -704,7 +711,7 @@ fn usage() void {
out(
\\p4-bench - measure the board's serial link, verified with a checksum
\\
- \\ p4-bench [--link | --editor] [--port <path>] [--baud <rate>]
+ \\ p4-bench [--link | --editor | --check] [--port <path>] [--baud <rate>]
\\ [--bulk <bytes>] [--samples <n>] [--no-reset]
\\
\\ --link (default) bulk throughput each way plus round-trip latency, every byte
@@ -735,3 +742,140 @@ fn fatal(msg: []const u8) noreturn {
fn eql(a: []const u8, b: []const u8) bool {
return std.mem.eql(u8, a, b);
}
+
+// -------------------------------------------------------------------------------- behaviour checks
+//
+// Three things were broken on this transport in ways no latency number could show, and each is
+// checked here because each was found by hand and would otherwise be found by hand again.
+
+/// Read until the wire has been quiet for `quiet_ms`, or `cap_ms` has passed, and return what
+/// arrived. "Quiet" rather than "for a fixed time" because a burst of six hundred characters is many
+/// frames and an unknown number of milliseconds; a reply read before those have finished is the
+/// previous action's output, which is how the first version of these checks fooled itself.
+fn soak(port: *serial.Port, buf: []u8, quiet_ms: i64, cap_ms: i64) []u8 {
+ var n: usize = 0;
+ const cap = port.nowMs() + cap_ms;
+ var last = port.nowMs();
+ while (port.nowMs() < cap and n < buf.len) {
+ const got = port.readTimeout(buf[n..], 20) catch 0;
+ if (got > 0) {
+ n += got;
+ last = port.nowMs();
+ } else if (port.nowMs() - last >= quiet_ms) break;
+ }
+ return buf[0..n];
+}
+
+/// The last absolute cursor position in `bytes`, as 1-based (row, col).
+///
+/// The board's frames end by positioning the cursor and then padding with repeats of that same
+/// sequence, so the final CUP in a reply is where the editor put the cursor. That makes the cursor
+/// an observable: it is how these checks see what the editor did without reconstructing a screen.
+const Cursor = struct { row: u32, col: u32 };
+
+fn lastCup(bytes: []const u8) ?Cursor {
+ var found: ?Cursor = null;
+ var i: usize = 0;
+ while (i + 3 < bytes.len) : (i += 1) {
+ if (bytes[i] != 0x1b or bytes[i + 1] != '[') continue;
+ var j = i + 2;
+ var row: u32 = 0;
+ var col: u32 = 0;
+ var digits: usize = 0;
+ while (j < bytes.len and bytes[j] >= '0' and bytes[j] <= '9') : (j += 1) {
+ row = row * 10 + (bytes[j] - '0');
+ digits += 1;
+ }
+ if (digits == 0 or j >= bytes.len or bytes[j] != ';') continue;
+ j += 1;
+ digits = 0;
+ while (j < bytes.len and bytes[j] >= '0' and bytes[j] <= '9') : (j += 1) {
+ col = col * 10 + (bytes[j] - '0');
+ digits += 1;
+ }
+ if (digits == 0 or j >= bytes.len or bytes[j] != 'H') continue;
+ found = .{ .row = row, .col = col };
+ i = j;
+ }
+ return found;
+}
+
+fn check(port: *serial.Port, o: Options, r: *Report) !void {
+ try ready(port, o);
+ var buf: [8192]u8 = undefined;
+ var failures: u32 = 0;
+
+ // 1. A LONE ESCAPE IS STILL THE ESCAPE KEY.
+ //
+ // The shell holds a solitary ESC for a few milliseconds because on this wire the first byte of
+ // every escape sequence arrives alone. The hold must expire, or Escape stops working and the
+ // editor is unusable. Typing `abc`, pressing Escape, then `x` deletes a character in normal
+ // mode; if Escape had been swallowed, the `x` would be inserted instead and show up on the wire.
+ try port.write("abc");
+ _ = soak(port, &buf, 150, 1500);
+ try port.write("\x1b");
+ _ = soak(port, &buf, 150, 1500);
+ try port.write("x");
+ const after_x = soak(port, &buf, 200, 1500);
+ const escaped = std.mem.indexOfScalar(u8, after_x, 'x') == null;
+ r.print(" lone Escape still leaves insert mode {s}\n", .{if (escaped) "ok" else "FAILED"});
+ if (!escaped) failures += 1;
+
+ // 2. AN ESCAPE SEQUENCE SPLIT ACROSS READS PARSES AS ONE EVENT.
+ //
+ // This is the bug that made the mouse look unimplemented. At 115200 the bytes of a report are
+ // 87 us apart, so the board reads them one at a time; a parser that resolves a lone ESC turns
+ // one click into ten key presses, and the `0` among them is "go to column zero" in normal mode,
+ // which is exactly where the cursor kept landing.
+ //
+ // The line has to be long enough to contain the clicked column, or the editor correctly clamps
+ // to the end of the line and the check measures the clamp instead of the parse.
+ try port.write("\x1b");
+ _ = soak(port, &buf, 150, 1500);
+ try port.write("dd");
+ _ = soak(port, &buf, 200, 2000);
+ try port.write("i");
+ _ = soak(port, &buf, 150, 1500);
+ try port.write("abcdefghijklmnopqrstuvwxyz0123");
+ _ = soak(port, &buf, 250, 3000);
+ try port.write("\x1b");
+ _ = soak(port, &buf, 200, 2000);
+
+ const want_col: u32 = 18;
+ var report: [16]u8 = undefined;
+ const click = std.fmt.bufPrint(&report, "\x1b[<0;{d};3M", .{want_col}) catch unreachable;
+ // NO gap between the bytes. The wire already provides one - 87 us - and anything longer than the
+ // shell's hold would expire it, which is the check defeating itself rather than exercising the
+ // fix. One write per byte is what stops them arriving as a single read.
+ for (click) |b| try port.write(&[_]u8{b});
+ _ = soak(port, &buf, 250, 2000);
+ // The RELEASE is what commits it. A press alone paints the new position and then reverts, because
+ // a press is the start of a drag and the caret does not move until the gesture ends - so a check
+ // that reads the cursor after the press alone measures the revert and calls a working click
+ // broken. This cost an hour.
+ const up = std.fmt.bufPrint(&report, "\x1b[<0;{d};3m", .{want_col}) catch unreachable;
+ for (up) |b| try port.write(&[_]u8{b});
+ const reply = soak(port, &buf, 250, 2000);
+ const landed = lastCup(reply);
+ const at_col = landed != null and landed.?.col == want_col;
+ if (landed) |at| {
+ r.print(" a click split byte-by-byte lands at {d} {s} (cursor {d},{d})\n", .{
+ want_col, if (at_col) "ok" else "FAILED", at.row, at.col,
+ });
+ } else r.print(" a click split byte-by-byte lands at {d} FAILED (no reply)\n", .{want_col});
+ if (!at_col) failures += 1;
+
+ // A BURST IS NOT CHECKED HERE, deliberately. The bug it would cover - input lost while the
+ // transmitter was full - has a deterministic host test in `src/pardes/input_rescue.zig` that
+ // loses 67 bytes with the rescue removed and needs no board at all. Every hardware oracle for it
+ // that was tried here was worse than that: the cursor stops being reported past 160 characters
+ // because the wrapped line outgrows the viewport, and a screen reconstruction cannot be rebuilt
+ // mid-session because the board only ever sends what CHANGED. A check that cannot fail honestly
+ // is worse than no check, so this file keeps only the two things hardware alone can answer.
+
+ r.print("\n {d} failure(s)\n", .{failures});
+ // Flushed here, not by the caller: these lines ARE the result, and returning an error would
+ // otherwise discard the very output that says which check failed.
+ r.flush();
+ if (failures > 0) return error.CheckFailed;
+}