summaryrefslogtreecommitdiff
path: root/src/board_memory.zig
blob: ac567b34568b0403cb3dfba8b555f50ffc82f7f9 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
//! The board's own address space and its pins, as text: the Peek, Poke, Hexdump and Gpio builtins'
//! whole implementation.
//!
//! THE P4 BUILD ONLY (`enabled` below), and the reason is not caution but honesty: with no OS there
//! is no MMU, no supervisor and no process - the editor IS the system software - so every one of the
//! 2^32 addresses is legitimately this program's to read and write, and a word that could name only
//! some of them would be lying about where it is running. Under an OS the same words would be either
//! a segfault or a syscall stub, so they are absent from those builds entirely rather than present
//! and refusing. Absent means not compiled, not hidden: nothing below is analysed for a build whose
//! platform is not `esp32p4`.
//!
//! Everything here goes through `*allowzero volatile` pointers. A peripheral
//! register is not memory: reading UART_STATUS twice is two reads and must not
//! be folded into one, a write to a write-only command register has no
//! observable value for the optimizer to keep, and address 0 is an ordinary
//! (unmapped) address on this bus rather than the null Zig assumes it is.
//!
//! The formatting side is a plain renderer over `Pardes.gpa`, so it lands in
//! an output buffer the same way Jumplist and Config do: an output buffer is a
//! file pane, so every motion, chord and Look works on a dump for free — you
//! can right-click an address in a hexdump row and Peek it.
const std = @import("std");
const builtin = @import("builtin");
const pardes = @import("pardes.zig");
const Pardes = pardes.Pardes;
const output_pane = @import("output_pane.zig");
const limits = @import("limits.zig");

/// THE ONE GATE, and it names the esp32p4 build, so `Peek`, `Poke`, `Hexdump` and `Gpio` are analysed
/// and emitted for that build and for no other. Nothing in this file reaches any other target's
/// binary: not the volatile accessors, not the JP1 pinout, not the parsers.
///
/// This used to be derived from the target - `os.tag == .freestanding and !isWasm()` - on the
/// argument that these words are a property of having no operating system rather than a product
/// configuration, and that a predicate spelled out of `builtin` cannot drift the way a
/// hand-maintained enum can. The argument was tidy and it answered the wrong question. A word
/// only exists if some SHELL offers it, and the shells are the platforms; `Gpio` settles it beyond
/// argument, because its whole content is one board's header, and a second freestanding port would
/// need its own pinout rather than inheriting this one. "Bare metal" was never the requirement,
/// "this board" was, and the two only looked identical because there is currently one of them.
///
/// The old predicate's real work was excluding wasm, which is `freestanding` too - inside the
/// browser's sandbox an address is an offset into a linear memory the engine owns, so a `Peek`
/// would read a number that means nothing about any machine and a `Poke` would corrupt the heap
/// this same editor runs out of. Naming `esp32p4` excludes it by construction rather than by a term
/// somebody has to keep remembering.
pub const enabled = pardes.platform == .esp32p4;

// The target is now the WITNESS rather than the gate: whatever else `esp32p4` means, it has to still be
// a machine whose addresses are the bus's, and a hosted or wasm build reaching this line means the
// platform and the target disagree about what the firmware is.
comptime {
    if (enabled and pardes.hosted) @compileError("an OS is not bare metal");
    if (enabled and builtin.os.tag != .freestanding) @compileError("the P4 firmware is freestanding");
    if (enabled and builtin.target.cpu.arch.isWasm()) @compileError("wasm addresses are not a bus");
}

/// How much of the address space ONE command may render.
///
/// The number is set by the console, not by the memory: UART0 runs at 115200
/// baud and measures ~11.9 KB/s on the wire, and a hexdump row is 76 bytes of
/// text per 16 bytes of memory. 4 KiB is therefore 256 rows and ~19.5 KiB of
/// text — under two seconds to paint the whole buffer, and ~4% of the 512 KiB
/// heap the firmware hands over. `Hexdump 0x0 0xffffffff` would otherwise wedge
/// the only console the board has for eleven hours, with no way to interrupt
/// it, which makes an unbounded dump not a slow command but a lost session.
///
/// Peek's cap is the same 4 KiB window expressed in words, so `Peek a 1024`
/// and `Hexdump a 4096` cover exactly the same bytes.
pub const max_bytes: u32 = 4096;
pub const max_words: u32 = max_bytes / 4;

/// One address past the last: the reads below are bounded by this rather than
/// wrapping, because `Hexdump 0xfffffff0 256` wrapping to 0 would silently
/// show you the bottom of the space labelled with top-of-space addresses.
const space: u64 = 1 << 32;

pub const Error = error{
    MissingAddress,
    BadAddress,
    BadCount,
    MissingValue,
    BadValue,
    /// the ONE fault this file exists to prevent by hand: the RISC-V core
    /// traps an unaligned 32-bit access, and a trap in firmware with no
    /// handler is a watchdog reset that takes the session with it. Reported on
    /// the message row instead.
    MisalignedAddress,
    ExtraArgument,
    /// not a number, or a number the part does not have a pad for
    BadPin,
    /// the host brought no pads: every build but the firmware, where the word
    /// is not registered at all, and a firmware too old to pass the hook
    NoPads,
};

/// EVERY literal these three words take is HEX, with or without an `0x`, and there is no way to
/// write a decimal one.
///
/// This replaces base-0 parsing, which accepted `0x4ff40000` and `1341390848` and refused a bare
/// `4ff40000` on the grounds that guessing between hex and decimal would make one typo address
/// somewhere else entirely. That reasoning was sound and the conclusion was still wrong: the
/// ambiguity it protected against is not a real one. Every address anybody has ever typed at these
/// three words is hex - it came off a datasheet, a linker map, or a previous dump's own output, all
/// of which print hex - so the base was never in doubt, and demanding `0x` on every one of them was
/// a toll on the common case to guard a case that does not arise.
///
/// The COUNTS go with them, and that is the part worth stating out loud rather than leaving as a
/// surprise: `Hexdump 4ff40000 100` shows 0x100 bytes, which is 256, not one hundred. One rule for
/// every literal in the word is worth more than two rules that each fit their argument better,
/// because the second kind is the sort of thing you have to remember at the moment you are already
/// concentrating on something else. Everything these words PRINT is hex too, including the clamp
/// notes, so a number can go back in where it came out.
fn parseHex(comptime T: type, tok: []const u8, bad: Error) Error!T {
    // `parseInt` only honours an `0x` when its base is 0, so with base 16 the prefix has to come off
    // here. A bare `0x` leaves nothing behind and `parseInt` rejects the empty string, which is the
    // answer that wants giving.
    const body = if (tok.len > 2 and tok[0] == '0' and (tok[1] | 0x20) == 'x') tok[2..] else tok;
    return std.fmt.parseInt(T, body, 16) catch bad;
}

fn parseAddr(tok: []const u8) Error!u32 {
    return parseHex(u32, tok, Error.BadAddress);
}

fn parseCount(tok: []const u8) Error!u64 {
    return parseHex(u64, tok, Error.BadCount);
}

fn parseValue(tok: []const u8) Error!u32 {
    return parseHex(u32, tok, Error.BadValue);
}

/// A 32-bit peripheral or RAM read that the compiler may neither elide,
/// duplicate, reorder past another access, nor narrow.
fn readWord(addr: u32) u32 {
    const cell: *allowzero const volatile u32 = @ptrFromInt(@as(usize, addr));
    return cell.*;
}

fn writeWord(addr: u32, value: u32) void {
    const cell: *allowzero volatile u32 = @ptrFromInt(@as(usize, addr));
    cell.* = value;
}

fn readByte(addr: u32) u8 {
    const cell: *allowzero const volatile u8 = @ptrFromInt(@as(usize, addr));
    return cell.*;
}

const Limit = enum {
    /// the 4 KiB console cap above
    console,
    /// the end of the 32-bit address space
    space,
};

/// How many units this command will actually show, and WHY that is fewer than
/// you asked for when it is. Never silent: the note below becomes the buffer's
/// FIRST line, which is the one place a clamp cannot be missed — a trailing
/// note on a 256-row dump is a note you scroll past.
const Extent = struct {
    count: u32,
    /// the tighter of the two bounds, or null when neither applied
    limit: ?Limit,
};

fn extent(addr: u32, requested: u64, unit: u32, cap: u32) Extent {
    var count = requested;
    var limit: ?Limit = null;
    if (count > cap) {
        count = cap;
        limit = .console;
    }
    const fits = (space - addr) / unit;
    if (count > fits) {
        count = fits;
        limit = .space;
    }
    return .{ .count = @intCast(count), .limit = limit };
}

fn writeNote(w: *std.Io.Writer, e: Extent, requested: u64, unit_name: []const u8) !void {
    switch (e.limit orelse return) {
        // Hex, like everything else these words read and print, so the number in a clamp note can go
        // straight back into the command that produced it.
        .console => try w.print(
            "clamped: 0x{x} {s} requested, 0x{x} shown (0x{x}-byte cap, one 115200-baud console)\n",
            .{ requested, unit_name, e.count, max_bytes },
        ),
        .space => try w.print(
            "clamped: 0x{x} {s} requested, 0x{x} shown (the 32-bit address space ends at 0x100000000)\n",
            .{ requested, unit_name, e.count },
        ),
    }
}

// The two bounds and their reporting, on the one part of this file that is
// pure arithmetic and therefore testable on any target — the accesses
// themselves are only meaningful on the board.
test "the clamp reports the tighter bound and never wraps the address space" {
    const eq = std.testing.expectEqual;
    // neither bound applied: what you asked for, and nothing to report
    try eq(Extent{ .count = 3, .limit = null }, extent(0x4ff40000, 3, 4, max_words));
    // the console cap, in words and in bytes
    try eq(Extent{ .count = max_words, .limit = .console }, extent(0x4ff40000, 99_999, 4, max_words));
    try eq(Extent{ .count = max_bytes, .limit = .console }, extent(0, 100_000, 1, max_bytes));
    // sixteen bytes left above 0xfffffff0 — the whole point, because wrapping
    // would show the BOTTOM of the space under top-of-space addresses
    try eq(Extent{ .count = 16, .limit = .space }, extent(0xfffffff0, 64, 1, max_bytes));
    try eq(Extent{ .count = 4, .limit = .space }, extent(0xfffffff0, 64, 4, max_words));
    // ...including the row that has no whole word left in it
    try eq(Extent{ .count = 0, .limit = .space }, extent(0xffffffff, 1, 4, max_words));
    // both bounds at once: the tighter one is the one reported
    try eq(Extent{ .count = max_bytes, .limit = .console }, extent(0xffff0000, 1 << 20, 1, max_bytes));
}

test "a clamp note is written exactly when something was clamped" {
    var buf: [256]u8 = undefined;
    var w: std.Io.Writer = .fixed(&buf);

    try writeNote(&w, extent(0x4ff40000, 3, 4, max_words), 3, "words");
    try std.testing.expectEqualStrings("", w.buffered());

    try writeNote(&w, extent(0x4ff40000, 99_999, 4, max_words), 99_999, "words");
    try std.testing.expectEqualStrings(
        "clamped: 0x1869f words requested, 0x400 shown (0x1000-byte cap, one 115200-baud console)\n",
        w.buffered(),
    );

    w = .fixed(&buf);
    try writeNote(&w, extent(0xfffffff0, 64, 1, max_bytes), 64, "bytes");
    try std.testing.expectEqualStrings(
        "clamped: 0x40 bytes requested, 0x10 shown (the 32-bit address space ends at 0x100000000)\n",
        w.buffered(),
    );
}

test "every literal is hex, with or without the prefix" {
    const eq = std.testing.expectEqual;
    // the prefix is optional, never required, and never changes the answer
    try eq(0x4ff40000, parseAddr("0x4ff40000"));
    try eq(0x4ff40000, parseAddr("4ff40000"));
    try eq(0x4ff40000, parseAddr("0X4FF40000"));
    try eq(0x4ff40000, parseAddr("4FF40000"));
    // a token that looks decimal is hex too - the whole point, and the thing to remember
    try eq(0x100, parseCount("100"));
    try eq(0x256, parseCount("256"));
    try eq(0xdeadbeef, parseValue("deadbeef"));
    // and the refusals still refuse
    try std.testing.expectError(Error.BadAddress, parseAddr("0x100000000"));
    try std.testing.expectError(Error.BadAddress, parseAddr("0x"));
    try std.testing.expectError(Error.BadAddress, parseAddr("nope"));
    try std.testing.expectError(Error.BadAddress, parseAddr("12g4"));
    try std.testing.expectError(Error.BadCount, parseCount("-1"));
    try std.testing.expectError(Error.BadValue, parseValue("0x1_0000_0000"));
}

// The pinout is the one thing here whose CORRECTNESS IS ITS SHAPE: a header drawn in two columns
// stops being a header the moment a row wraps, and it wraps on the board rather than on a
// developer's terminal, which is the worst place to find out. So the width is asserted against the
// grid the board is actually built with, and the alignment is asserted against the column the pin
// numbers are supposed to share.
test "the pinout fits the board's own grid, in two aligned columns" {
    const cols: usize = @import("pardes_config").esp32p4_cols;
    // Seven columns of the shell's grid go to the line-number gutter before a pane's text starts.
    const usable = cols - 7;

    var rows: usize = 0;
    var pins: usize = 0;
    var first_bar: ?usize = null;
    var it = std.mem.splitScalar(u8, pinout, '\n');
    while (it.next()) |line| {
        try std.testing.expect(line.len <= usable);
        rows += 1;
        // A pin row is one with two numbers in it; every one must put its bars in the same place,
        // which is what "aligned in two columns" means when the check is mechanical.
        const bar = std.mem.indexOfScalar(u8, line, '|') orelse continue;
        if (line[line.len - 1] == '+') continue;
        pins += 1;
        if (first_bar) |b| try std.testing.expectEqual(b, bar) else first_bar = bar;
    }
    try std.testing.expectEqual(@as(usize, 13), pins);
    try std.testing.expect(rows > 15);

    // Two independent facts about the board, each with a witness outside this file: GPIO20 is
    // `05-zig-p4/build.zig`'s documented `-Dled` default ("JP1 pin 17"), and pin 8 is the one
    // header pin the vendor schematic leaves unconnected.
    try std.testing.expect(std.mem.indexOf(u8, pinout, "GPIO 20 | 17 |") != null);
    try std.testing.expect(std.mem.indexOf(u8, pinout, "|  8 | --") != null);
}

// The exception to the file's own rule, so it is written down as a test rather than only as a
// comment: a pin number is part of a name and is read as decimal, while every address beside it is
// hex. `Gpio 20` must mean the pin the schematic calls GPIO20, not 0x20.
test "a pin number is decimal, unlike every address in this file" {
    try std.testing.expectEqual(@as(u16, 20), try std.fmt.parseInt(u16, "20", 10));
    try std.testing.expectEqual(@as(u32, 0x20), try parseAddr("20"));
    try std.testing.expect(20 != 0x20);
}

/// `Peek <addr> [count]` — count 32-bit words at addr, one `addr: value` row
/// each. One word per row rather than four so that every row carries its own
/// address: the rows are then ordinary Look targets, and `Peek` or `Poke`
/// chorded onto one re-reads or writes exactly that word.
pub fn peek(p: *Pardes, id: usize, argument: []const u8) !void {
    var it = std.mem.tokenizeAny(u8, argument, " \t\r\n");
    const addr = try parseAddr(it.next() orelse return Error.MissingAddress);
    const requested = if (it.next()) |tok| try parseCount(tok) else 0x1;
    if (it.next() != null) return Error.ExtraArgument;
    if (addr % 4 != 0) return Error.MisalignedAddress;

    const e = extent(addr, requested, 4, max_words);
    var out: std.Io.Writer.Allocating = .init(p.gpa);
    errdefer out.deinit();
    try writeNote(&out.writer, e, requested, "words");
    for (0..e.count) |i| {
        const at = addr + @as(u32, @intCast(i * 4));
        try out.writer.print("{x:0>8}: {x:0>8}\n", .{ at, readWord(at) });
    }
    const content = try out.toOwnedSlice();
    try fill(p, id, .{ .cmd = .Peek }, content);
}

/// `Poke <addr> <value>` — one 32-bit store, then one load back, both reported
/// on the message row.
///
/// The READ-BACK is the whole point of the word and not a confirmation: on RAM
/// it always equals what you wrote and tells you nothing, and on MMIO it
/// almost never does — a write-only command register reads as 0, a W1C status
/// bit reads back cleared, a reserved field reads back masked, and a register
/// behind a gated clock reads back whatever the bus returns for nothing at
/// all. Printing only the value written would show you your own argument.
pub fn poke(p: *Pardes, id: usize, argument: []const u8) !void {
    var it = std.mem.tokenizeAny(u8, argument, " \t\r\n");
    const addr = try parseAddr(it.next() orelse return Error.MissingAddress);
    const value = try parseValue(it.next() orelse return Error.MissingValue);
    if (it.next() != null) return Error.ExtraArgument;
    if (addr % 4 != 0) return Error.MisalignedAddress;

    writeWord(addr, value);
    const back = readWord(addr);
    var buf: [96]u8 = undefined;
    p.setMessage(id, std.fmt.bufPrint(
        &buf,
        "{x:0>8}: wrote {x:0>8}, reads {x:0>8}",
        .{ addr, value, back },
    ) catch unreachable);
}

/// JP1, the 26-pin header down the left edge of the JC-ESP32P4-M3-DEV, as the board wears it: two
/// columns, odd pins on the left, even on the right, pin 1 at the top.
///
/// READ OFF THE VENDOR SCHEMATIC, sheet 2 "Expand IO"
/// (`01-esp32p4-m3/docs/schematics/2_EXPAND_IO&BAT.png`), which is the only document that carries
/// this mapping - the specification PDF's "Interface Description" page is a marketing render, and
/// there is no board user guide. The sheet is a 872x1168 raster, so the assignment was taken from
/// the drawing's own geometry rather than by eye: thirteen wires leave each side of the symbol, a
/// net wire runs ~100 px to its label and a power stub ~21 px, which is what identifies pin 8 as
/// unconnected rather than as the first of the GPIO4x labels. Cross-checked against a second,
/// independent source: `05-zig-p4/build.zig` has always documented `-Dled=20` as "JP1 pin 17", and
/// GPIO20 lands on pin 17 here.
///
/// `--` is a pin the header brings out with nothing behind it. `C6_*` are the ESP32-C6 companion's
/// pads, not the P4's, and toggling a P4 GPIO cannot reach them. `ES_I2C_*` is the audio codec's
/// bus, shared - driving either one by hand while the codec is live is a collision, which is a
/// reason to know the pin is there rather than a reason to hide it.
const pinout =
    \\JP1 header - 26 pins, pin 1 top left.
    \\Every number here is DECIMAL.
    \\
    \\           +---------+
    \\       3V3 |  1 |  2 | 5V
    \\       3V3 |  3 |  4 | 5V
    \\       GND |  5 |  6 | GND
    \\    GPIO 1 |  7 |  8 | --
    \\    GPIO 2 |  9 | 10 | GPIO 47
    \\    GPIO 3 | 11 | 12 | GPIO 46
    \\    GPIO 4 | 13 | 14 | GPIO 45
    \\    GPIO 5 | 15 | 16 | GND
    \\   GPIO 20 | 17 | 18 | 3V3
    \\   GPIO 32 | 19 | 20 | C6_U0RXD
    \\   GPIO 33 | 21 | 22 | C6_U0TXD
    \\ES_I2C_SDA | 23 | 24 | C6_IO9
    \\ES_I2C_SCL | 25 | 26 | C6_CHIP_PU
    \\           +---------+
    \\
    \\Gpio <pin> flips one: 0->1 or 1->0.
    \\
;

/// `Gpio <pin>` flips one pad and says what it did; `Gpio` alone draws JP1.
///
/// THE PIN NUMBER IS DECIMAL, and it is the one literal in this file that is. Every other one is
/// hex because every other one is an address, and addresses come off datasheets and linker maps
/// that print hex. A GPIO number is not an address - it is part of a NAME. The schematic says
/// `GPIO47`, the silkscreen says 47, the datasheet's pin table says 47, and `Gpio 20` meaning pin
/// 32 would be a trap laid for the one argument a person types from memory. One rule per KIND of
/// literal beats one rule per file when the kinds are this different.
///
/// The toggle is the host's to perform (`Host.VTable.pull_gpio_toggle`) even though `Poke` two
/// functions up would happily write GPIO_OUT_REG directly. Writing that register is not the job:
/// a pad has to be pointed at the GPIO peripheral in the IO MUX, routed in the GPIO matrix, have
/// its driver and input buffer enabled, and only then be driven - and getting that wrong on a pin
/// that boots as something else is how you lose the console you are typing on.
///
/// Reported levels are the OUTPUT bits, before and after, because that is what a toggle means: the
/// level this board is DRIVING. A pad's input buffer on an unconnected header pin reads whatever
/// the air says.
pub fn gpio(p: *Pardes, id: usize, argument: []const u8) !void {
    var it = std.mem.tokenizeAny(u8, argument, " \t\r\n");
    const tok = it.next() orelse {
        // No argument is not an error and not inert: it is the question "which pins are there",
        // and the answer is a picture of the header.
        const content = try p.gpa.dupe(u8, pinout);
        errdefer p.gpa.free(content);
        return fill(p, id, .{ .cmd = .Gpio }, content);
    };
    if (it.next() != null) return Error.ExtraArgument;
    const pin = std.fmt.parseInt(u16, tok, 10) catch return Error.BadPin;

    const toggle = p.host.vtable.pull_gpio_toggle orelse return Error.NoPads;
    var was: u8 = 0;
    var now: u8 = 0;
    if (!toggle(p.host.ctx, pin, &was, &now)) return Error.BadPin;

    var buf: [48]u8 = undefined;
    p.setMessage(id, std.fmt.bufPrint(&buf, "GPIO {d}: {d}->{d}", .{ pin, was, now }) catch unreachable);
}

/// Bytes per dumped row, and it is a different number on the board — see
/// `limits.hexdump_row_bytes`, which is where that number and its reasoning
/// live now.
const row_bytes: u32 = limits.hexdump_row_bytes;

/// `Hexdump <addr> [len]` — len bytes, `row_bytes` to a row, hex columns and an ASCII gutter, in
/// `hexdump -C`'s layout because that is the one everyone can already read. BYTE reads, so a partial
/// row at the end of the space is a short row rather than a refusal, and no alignment is required:
/// this is the word you reach for when you do not yet know what is there.
pub fn hexdump(p: *Pardes, id: usize, argument: []const u8) !void {
    var it = std.mem.tokenizeAny(u8, argument, " \t\r\n");
    const addr = try parseAddr(it.next() orelse return Error.MissingAddress);
    const requested = if (it.next()) |tok| try parseCount(tok) else 0x100;
    if (it.next() != null) return Error.ExtraArgument;

    const e = extent(addr, requested, 1, max_bytes);
    var out: std.Io.Writer.Allocating = .init(p.gpa);
    errdefer out.deinit();
    try writeNote(&out.writer, e, requested, "bytes");
    var row: u32 = 0;
    while (row < e.count) : (row += row_bytes) {
        const n = @min(row_bytes, e.count - row);
        var bytes: [row_bytes]u8 = undefined;
        for (0..n) |i| bytes[i] = readByte(addr + row + @as(u32, @intCast(i)));
        try out.writer.print("{x:0>8} ", .{addr + row});
        for (0..row_bytes) |i| {
            // The gap at the halfway mark: the eye counts to four or eight, not to sixteen.
            if (i == row_bytes / 2) try out.writer.writeByte(' ');
            if (i < n)
                try out.writer.print(" {x:0>2}", .{bytes[i]})
            else
                try out.writer.writeAll("   ");
        }
        try out.writer.writeAll("  |");
        for (0..n) |i| try out.writer.writeByte(
            if (bytes[i] >= 0x20 and bytes[i] < 0x7f) bytes[i] else '.',
        );
        try out.writer.writeAll("|\n");
    }
    const content = try out.toOwnedSlice();
    try fill(p, id, .{ .cmd = .Hexdump }, content);
}

/// The shared tail. `fillResults` is the one public entry that REFILLS the
/// buffer a command already opened instead of stacking a twin beside it, which
/// is what a dump wants: peeking twenty addresses in a row is twenty renders
/// of one window on memory, not twenty panes. The empty argument is what makes
/// it one window — a dump is identified by the command, never by the address,
/// so a second Peek replaces the first rather than opening a buffer per
/// address and exhausting the pane slots.
///
/// Neither buffer `steps`, so nothing is armed on n/N and focus stays in the
/// pane you typed the command in. `content` is gpa-owned and adopted there.
fn fill(p: *Pardes, id: usize, from: output_pane.Origin, content: []u8) !void {
    const pane = p.panes[id] orelse {
        p.gpa.free(content);
        return error.MissingPane;
    };
    const dir = if (pane.file) |f| (std.fs.path.dirname(f.path) orelse "/") else pane.cwdSlice();
    try output_pane.fillResults(p, id, dir, from, "", content, null);
}