a73x

519410b1

fix: the run header is a delta against the previous run, as SGR is

a73x   2026-09-04 17:00

Commit message
fix: the run header is a delta against the previous run, as SGR is

The header carried flags, fg, bg and ul absolutely, 16 bytes on every run
whether or not the run changed any of them. That failed the design's size
gate on both style-dense screens: a syntax-highlighted source line or an
htop row changes style eight to ten times, so it paid 130 to 160 bytes of
header against 70 bytes of text, where VT spends four to seven on the same
change because SGR is a delta and this was not.

It is a delta now: u16 count, u8 mask, and only the fields the mask names,
in bit order. A colour spends a tag byte plus what that tag needs — none,
a palette index, or three. A run that turns bold on and keeps its colour
spends three bytes on style rather than twelve. The row is still the unit:
every row opens at the default style, so it stays self-contained and no
reader needs the row before it. Bits 4-6 of the mask are unassigned and a
set one is refused, as is a colour tag past RGB: their field lengths are
exactly what a reader of a newer format would not know.

Nothing changes in memory — CellStyle keeps its packed u32 colours.

The gate is now MET on all four screens: prose 1.02 against its 1.5, vim
0.69 and htop 0.75 against their 1.2. Cells are smaller than the VT they
replace on three of the four. Both tables are in decisions.md.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CsWfuJFQbTfGtKZLS5qw4q

docs/decisions.md
Old New
@@ -8906,31 +8906,53 @@ four 80x24 screens fed real output shapes, comparing `dumpVtRow` against
8906 `encodeViewportRow` over the whole viewport: 8906 `encodeViewportRow` over the whole viewport:
8907 8907
8908 ``` 8908 ```
8909 cells-measure prose: vt=988 cells=1009 ratio=1.02
8910 cells-measure vim: vt=3132 cells=2164 ratio=0.69
8911 cells-measure htop: vt=4167 cells=3130 ratio=0.75
8912 cells-measure shell: vt=128 cells=88 ratio=0.69
8913 ```
8914
8915 **Gate MET, with room.** The gate asked for prose <= 1.5 and vim/htop <= 1.2.
8916 Prose is 1.02 and the two style-dense screens are 0.69 and 0.75 — cells are
8917 SMALLER than the VT they replace on every screen but prose, where they are
8918 level. Cells go on the wire for terminal clients too; the spec's alternative
8919 (VT frames for terminal clients, cells for native and browser) is not needed.
8920
8921 ### First cut, 16-byte absolute header (refused)
8922
8923 ```
8909 cells-measure prose: vt=988 cells=1308 ratio=1.32 8924 cells-measure prose: vt=988 cells=1308 ratio=1.32
8910 cells-measure vim: vt=3132 cells=4533 ratio=1.45 8925 cells-measure vim: vt=3132 cells=4533 ratio=1.45
8911 cells-measure htop: vt=4167 cells=6488 ratio=1.56 8926 cells-measure htop: vt=4167 cells=6488 ratio=1.56
8912 cells-measure shell: vt=128 cells=127 ratio=0.99 8927 cells-measure shell: vt=128 cells=127 ratio=0.99
8913 ``` 8928 ```
8914 8929
8915 **Gate verdict: NOT met.** The gate asked for prose <= 1.5 and vim/htop 8930 The first run header was absolute: 16 bytes of `flags, fg, bg, ul` on every
8916 <= 1.2. Prose passes at 1.32 and the shell screen is free at 0.99, but the 8931 run whether or not the run changed them. That failed the gate on both
8917 two style-dense screens are 1.45 and 1.56, well over their 1.2. The cost is 8932 style-dense screens. A syntax-highlighted source line or an htop row changes
8918 the run header: 16 bytes of style per run, and a syntax-highlighted source 8933 style eight to ten times, so such a row paid 130 to 160 bytes of header
8919 line or an htop row changes style eight to ten times, so a row pays 130-160 8934 against roughly 70 bytes of text, where VT spends four to seven bytes on the
8920 bytes of header for 70 bytes of text. VT spends four to seven bytes on the 8935 same change — because SGR is a DELTA against the previous style and that
8921 same style change because SGR is a delta against the previous style and a 8936 header was not.
8922 run header is absolute. Whether that is worth paying for is the design's 8937
8923 call, not the encoder's — the alternative the spec names is VT frames for 8938 So the header became a delta too: `u16 count` ++ `u8 mask` ++ only the fields
8924 terminal clients and cells for native and browser ones. 8939 the mask names. A run that turns bold on and keeps its colour spends three
8925 8940 bytes on style rather than twelve, and a colour spends a tag byte plus only
8926 The first measurement of the same encoder read 2.08 / 1.67 / 1.80 / 1.17. 8941 the bytes that tag needs — none, a palette index, or three. The row is still
8927 The difference was one line: the encoder sent a space cell as the EMPTY text 8942 the unit: every row starts again from the default style, so a row remains
8928 a blank cell carries, on the reasoning that both paint the same and the empty 8943 self-contained and no reader needs the row before it. That one change is the
8929 form is shorter. It is shorter for one cell and much longer for the row. 8944 whole difference between the two tables above.
8930 Only a one-byte narrow cell qualifies for the run's ascii form, in which a 8945
8931 cell is its single byte and nothing else, so one empty cell drops the whole 8946 ### The other measured line: a space is a space
8932 run out of that form and every remaining cell then pays a head byte as well 8947
8933 as its text. Prose is a third spaces, so blanking them cost 36% of the 8948 An earlier cut of the encoder sent a space cell as the EMPTY text a blank
8934 screen. Sending the space as a space is also the better answer for copy: 8949 cell carries, on the reasoning that both paint the same and the empty form is
8935 an interior space is a space. Trailing blanks were never the question — 8950 shorter. It is shorter for one cell and much longer for the row. Only a
8936 the row already ends at the last cell that is not one. 8951 one-byte narrow cell qualifies for the run's ascii form, in which a cell is
8952 its single byte and nothing else, so one empty cell drops the whole run out
8953 of that form and every remaining cell then pays a head byte as well as its
8954 text. Prose is a third spaces, so blanking them cost 36% of the screen
8955 (2.08 rather than 1.32 under the absolute header). Sending the space as a
8956 space is also the better answer for copy: an interior space is a space.
8957 Trailing blanks were never the question — the row already ends at the last
8958 cell that is not one.
src/engine/engine.zig
Old New
@@ -1847,7 +1847,7 @@ test "encodeViewportRow: an interior space keeps the row in one ascii run" {
1847 // form and cost a head byte on every cell of it — 36% of a prose screen. 1847 // form and cost a head byte on every cell of it — 36% of a prose screen.
1848 try std.testing.expectEqualSlices(u8, &[_]u8{ 1848 try std.testing.expectEqualSlices(u8, &[_]u8{
1849 3, 0, // ncells 1849 3, 0, // ncells
1850 3, 0, 0, 0x80, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // run header, ascii 1850 3, 0, 0x80, // run header: default style, ascii
1851 'a', ' ', 'b', 1851 'a', ' ', 'b',
1852 }, row); 1852 }, row);
1853 } 1853 }
src/engine/protocol.zig
Old New
@@ -1298,10 +1298,29 @@ pub const CellStyle = struct {
1298 } 1298 }
1299 }; 1299 };
1300 1300
1301 /// Run flag bit: every cell in the run is one byte 0x20..0x7E, narrow, and 1301 /// A run header is `u16 count` ++ `u8 mask` ++ the fields the mask names, in
1302 /// carries no head byte. Dense text is what a wire pays for most. 1302 /// bit order. The mask says what CHANGED since the previous run of the same
1303 pub const run_ascii: u16 = 1 << 15; 1303 /// row, the way SGR is a delta against the previous style: a run that only
1304 pub const run_header_len = 16; 1304 /// turns on bold spends three bytes on style, not twelve. Every row starts
1305 /// again from the default style, so a row stays self-contained and no reader
1306 /// needs the row before it.
1307 pub const mask_flags: u8 = 1 << 0;
1308 pub const mask_fg: u8 = 1 << 1;
1309 pub const mask_bg: u8 = 1 << 2;
1310 pub const mask_ul: u8 = 1 << 3;
1311 /// Every cell in the run is one byte 0x20..0x7E, narrow, and carries no head
1312 /// byte. Dense text is what a wire pays for most.
1313 pub const mask_ascii: u8 = 1 << 7;
1314 /// Unassigned. A writer that sets one is speaking a format this reader does
1315 /// not know, so a set bit is refused rather than ignored.
1316 const mask_reserved: u8 = 0b0111_0000;
1317 /// Bit 15 of the style flags. ghostty's `Style.Flags` reaches bit 10 and pads
1318 /// the rest, and the wire writes the flags as a whole u16 — so nothing may
1319 /// set it, and `CellRowWriter.cell` asserts as much.
1320 pub const flags_reserved: u16 = 1 << 15;
1321
1322 /// `count` and `mask`; the style fields after them are what the mask names.
1323 pub const run_header_min_len = 3;
1305 pub const cell_row_prefix_len = 2; 1324 pub const cell_row_prefix_len = 2;
1306 pub const cell_text_max = 63; 1325 pub const cell_text_max = 63;
1307 1326
@@ -1327,6 +1346,9 @@ pub const CellRowWriter = struct {
1327 /// The open run's cell texts, back to back. 1346 /// The open run's cell texts, back to back.
1328 text: std.ArrayListUnmanaged(u8) = .empty, 1347 text: std.ArrayListUnmanaged(u8) = .empty,
1329 run_style: CellStyle = .{}, 1348 run_style: CellStyle = .{},
1349 /// The style the last run WRITTEN for this row carried, which the next
1350 /// run's mask is a delta against. A row opens at the default style.
1351 prev_style: CellStyle = .{},
1330 1352
1331 pub fn begin(list: *std.ArrayList(u8), alloc: std.mem.Allocator) !CellRowWriter { 1353 pub fn begin(list: *std.ArrayList(u8), alloc: std.mem.Allocator) !CellRowWriter {
1332 const at = list.items.len; 1354 const at = list.items.len;
@@ -1336,9 +1358,7 @@ pub const CellRowWriter = struct {
1336 1358
1337 pub fn cell(self: *CellRowWriter, style: CellStyle, wide: Wide, text: []const u8) !void { 1359 pub fn cell(self: *CellRowWriter, style: CellStyle, wide: Wide, text: []const u8) !void {
1338 std.debug.assert(text.len <= cell_text_max); 1360 std.debug.assert(text.len <= cell_text_max);
1339 // Bit 15 is the run's own ascii marker on the wire, so a style may not 1361 std.debug.assert(style.flags & flags_reserved == 0);
1340 // carry it: ghostty's Style.Flags only reaches bit 10.
1341 std.debug.assert(style.flags & run_ascii == 0);
1342 if (self.run.items.len > 0 and !style.eql(self.run_style)) try self.flush(); 1362 if (self.run.items.len > 0 and !style.eql(self.run_style)) try self.flush();
1343 if (self.run.items.len == 0) self.run_style = style; 1363 if (self.run.items.len == 0) self.run_style = style;
1344 const off: u32 = @intCast(self.text.items.len); 1364 const off: u32 = @intCast(self.text.items.len);
@@ -1351,6 +1371,27 @@ pub const CellRowWriter = struct {
1351 return self.text.items[c.text_off..][0..c.text_len]; 1371 return self.text.items[c.text_off..][0..c.text_len];
1352 } 1372 }
1353 1373
1374 /// A colour on the wire is its tag byte and only the bytes that tag
1375 /// needs: nothing for none, an index for a palette entry, three for RGB.
1376 /// The packed u32 the rest of mux carries has the tag in its top byte
1377 /// already, which is what makes the shift below the whole conversion.
1378 fn writeColor(self: *CellRowWriter, col: u32) !void {
1379 const code: u8 = @intCast(col >> 24);
1380 try self.list.append(self.alloc, code);
1381 switch (code) {
1382 0 => {},
1383 1 => try self.list.append(self.alloc, @truncate(col)),
1384 2 => try self.list.appendSlice(self.alloc, &[_]u8{
1385 @truncate(col >> 16),
1386 @truncate(col >> 8),
1387 @truncate(col),
1388 }),
1389 // colorPalette and colorRgb mint every colour mux holds, and
1390 // color_none is zero: there is no fourth tag to write.
1391 else => unreachable,
1392 }
1393 }
1394
1354 fn flush(self: *CellRowWriter) !void { 1395 fn flush(self: *CellRowWriter) !void {
1355 const cells = self.run.items; 1396 const cells = self.run.items;
1356 if (cells.len == 0) return; 1397 if (cells.len == 0) return;
@@ -1361,13 +1402,29 @@ pub const CellRowWriter = struct {
1361 break; 1402 break;
1362 } 1403 }
1363 } 1404 }
1364 var hdr: [run_header_len]u8 = undefined; 1405 const s = self.run_style;
1406 const prev = self.prev_style;
1407 var mask: u8 = 0;
1408 if (s.flags != prev.flags) mask |= mask_flags;
1409 if (s.fg != prev.fg) mask |= mask_fg;
1410 if (s.bg != prev.bg) mask |= mask_bg;
1411 if (s.ul != prev.ul) mask |= mask_ul;
1412 if (ascii) mask |= mask_ascii;
1413
1414 var hdr: [run_header_min_len]u8 = undefined;
1365 std.mem.writeInt(u16, hdr[0..2], @intCast(cells.len), .little); 1415 std.mem.writeInt(u16, hdr[0..2], @intCast(cells.len), .little);
1366 std.mem.writeInt(u16, hdr[2..4], self.run_style.flags | (if (ascii) run_ascii else 0), .little); 1416 hdr[2] = mask;
1367 std.mem.writeInt(u32, hdr[4..8], self.run_style.fg, .little);
1368 std.mem.writeInt(u32, hdr[8..12], self.run_style.bg, .little);
1369 std.mem.writeInt(u32, hdr[12..16], self.run_style.ul, .little);
1370 try self.list.appendSlice(self.alloc, &hdr); 1417 try self.list.appendSlice(self.alloc, &hdr);
1418 if (mask & mask_flags != 0) {
1419 var fb: [2]u8 = undefined;
1420 std.mem.writeInt(u16, &fb, s.flags, .little);
1421 try self.list.appendSlice(self.alloc, &fb);
1422 }
1423 if (mask & mask_fg != 0) try self.writeColor(s.fg);
1424 if (mask & mask_bg != 0) try self.writeColor(s.bg);
1425 if (mask & mask_ul != 0) try self.writeColor(s.ul);
1426 self.prev_style = s;
1427
1371 for (cells) |c| { 1428 for (cells) |c| {
1372 const text = self.cellText(c); 1429 const text = self.cellText(c);
1373 if (ascii) { 1430 if (ascii) {
@@ -1406,7 +1463,9 @@ pub const CellRowReader = struct {
1406 ncells: u16, 1463 ncells: u16,
1407 read: u16 = 0, 1464 read: u16 = 0,
1408 run_left: u16 = 0, 1465 run_left: u16 = 0,
1409 run_style: CellStyle = .{}, 1466 /// The style in force, carried across runs of this row: a header names
1467 /// only the fields that changed, so the rest stand.
1468 cur: CellStyle = .{},
1410 run_ascii: bool = false, 1469 run_ascii: bool = false,
1411 1470
1412 pub fn init(bytes: []const u8) !CellRowReader { 1471 pub fn init(bytes: []const u8) !CellRowReader {
@@ -1417,23 +1476,51 @@ pub const CellRowReader = struct {
1417 }; 1476 };
1418 } 1477 }
1419 1478
1479 /// The inverse of `CellRowWriter.writeColor`: a tag byte and only the
1480 /// bytes that tag needs. An unknown tag is refused, not skipped — its
1481 /// length is exactly what this reader would not know.
1482 fn readColor(self: *CellRowReader) !u32 {
1483 if (self.rest.len < 1) return error.BadPayload;
1484 const code = self.rest[0];
1485 self.rest = self.rest[1..];
1486 switch (code) {
1487 0 => return color_none,
1488 1 => {
1489 if (self.rest.len < 1) return error.BadPayload;
1490 const idx = self.rest[0];
1491 self.rest = self.rest[1..];
1492 return colorPalette(idx);
1493 },
1494 2 => {
1495 if (self.rest.len < 3) return error.BadPayload;
1496 const col = colorRgb(self.rest[0], self.rest[1], self.rest[2]);
1497 self.rest = self.rest[3..];
1498 return col;
1499 },
1500 else => return error.BadPayload,
1501 }
1502 }
1503
1420 pub fn next(self: *CellRowReader) !?DecodedCell { 1504 pub fn next(self: *CellRowReader) !?DecodedCell {
1421 if (self.read == self.ncells) return null; 1505 if (self.read == self.ncells) return null;
1422 if (self.run_left == 0) { 1506 if (self.run_left == 0) {
1423 if (self.rest.len < run_header_len) return error.BadPayload; 1507 if (self.rest.len < run_header_min_len) return error.BadPayload;
1424 const count = std.mem.readInt(u16, self.rest[0..2], .little); 1508 const count = std.mem.readInt(u16, self.rest[0..2], .little);
1425 const flags = std.mem.readInt(u16, self.rest[2..4], .little); 1509 const mask = self.rest[2];
1426 // A run may not claim cells the row does not have. 1510 // A run may not claim cells the row does not have.
1427 if (count == 0 or count > self.ncells - self.read) return error.BadPayload; 1511 if (count == 0 or count > self.ncells - self.read) return error.BadPayload;
1512 if (mask & mask_reserved != 0) return error.BadPayload;
1513 self.rest = self.rest[run_header_min_len..];
1514 if (mask & mask_flags != 0) {
1515 if (self.rest.len < 2) return error.BadPayload;
1516 self.cur.flags = std.mem.readInt(u16, self.rest[0..2], .little);
1517 self.rest = self.rest[2..];
1518 }
1519 if (mask & mask_fg != 0) self.cur.fg = try self.readColor();
1520 if (mask & mask_bg != 0) self.cur.bg = try self.readColor();
1521 if (mask & mask_ul != 0) self.cur.ul = try self.readColor();
1428 self.run_left = count; 1522 self.run_left = count;
1429 self.run_ascii = flags & run_ascii != 0; 1523 self.run_ascii = mask & mask_ascii != 0;
1430 self.run_style = .{
1431 .flags = flags & ~run_ascii,
1432 .fg = std.mem.readInt(u32, self.rest[4..8], .little),
1433 .bg = std.mem.readInt(u32, self.rest[8..12], .little),
1434 .ul = std.mem.readInt(u32, self.rest[12..16], .little),
1435 };
1436 self.rest = self.rest[run_header_len..];
1437 } 1524 }
1438 self.run_left -= 1; 1525 self.run_left -= 1;
1439 self.read += 1; 1526 self.read += 1;
@@ -1441,7 +1528,7 @@ pub const CellRowReader = struct {
1441 if (self.rest.len < 1) return error.BadPayload; 1528 if (self.rest.len < 1) return error.BadPayload;
1442 const text = self.rest[0..1]; 1529 const text = self.rest[0..1];
1443 self.rest = self.rest[1..]; 1530 self.rest = self.rest[1..];
1444 return .{ .style = self.run_style, .wide = .narrow, .text = text }; 1531 return .{ .style = self.cur, .wide = .narrow, .text = text };
1445 } 1532 }
1446 if (self.rest.len < 1) return error.BadPayload; 1533 if (self.rest.len < 1) return error.BadPayload;
1447 const head = self.rest[0]; 1534 const head = self.rest[0];
@@ -1449,7 +1536,7 @@ pub const CellRowReader = struct {
1449 if (self.rest.len < 1 + len) return error.BadPayload; 1536 if (self.rest.len < 1 + len) return error.BadPayload;
1450 const text = self.rest[1 .. 1 + len]; 1537 const text = self.rest[1 .. 1 + len];
1451 self.rest = self.rest[1 + len ..]; 1538 self.rest = self.rest[1 + len ..];
1452 return .{ .style = self.run_style, .wide = @enumFromInt(head >> 6), .text = text }; 1539 return .{ .style = self.cur, .wide = @enumFromInt(head >> 6), .text = text };
1453 } 1540 }
1454 1541
1455 /// The bytes after this row — the next row of a dense run, or nothing. 1542 /// The bytes after this row — the next row of a dense run, or nothing.
@@ -2813,10 +2900,11 @@ test "cellrow: two default ascii cells are one ascii run" {
2813 try w.cell(.{}, .narrow, "a"); 2900 try w.cell(.{}, .narrow, "a");
2814 try w.cell(.{}, .narrow, "b"); 2901 try w.cell(.{}, .narrow, "b");
2815 w.finish(); 2902 w.finish();
2816 // ncells=2; run: count=2, flags=ascii, fg=bg=ul=0; then "ab". 2903 // ncells=2; run: count=2, mask=ascii and nothing else, since the row
2904 // opens at the default style and this run is it; then "ab".
2817 try std.testing.expectEqualSlices(u8, &[_]u8{ 2905 try std.testing.expectEqualSlices(u8, &[_]u8{
2818 2, 0, // ncells 2906 2, 0, // ncells
2819 2, 0, 0x00, 0x80, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // run header 2907 2, 0, 0x80, // run header
2820 'a', 'b', 2908 'a', 'b',
2821 }, list.items); 2909 }, list.items);
2822 } 2910 }
@@ -2882,12 +2970,55 @@ test "cellrow: an ascii run is only taken when every cell qualifies" {
2882 try w.cell(.{}, .narrow, "a"); 2970 try w.cell(.{}, .narrow, "a");
2883 try w.cell(.{}, .narrow, "é"); // 2 bytes: breaks the ascii form for the whole run 2971 try w.cell(.{}, .narrow, "é"); // 2 bytes: breaks the ascii form for the whole run
2884 w.finish(); 2972 w.finish();
2885 // ncells=2; run: count=2, flags=0; cells: head 1 'a', head 2 0xC3 0xA9 2973 // ncells=2; run: count=2, mask=0 (default style, not ascii); cells:
2974 // head 1 'a', head 2 0xC3 0xA9
2886 try std.testing.expectEqualSlices(u8, &[_]u8{ 2975 try std.testing.expectEqualSlices(u8, &[_]u8{
2887 2, 0, // ncells 2976 2, 0, // ncells
2888 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // run header 2977 2, 0, 0, // run header
2889 1, 'a', 2, 0xC3, 0xA9, 2978 1, 'a', 2,
2979 0xC3, 0xA9,
2980 }, list.items);
2981 }
2982
2983 test "cellrow: a run header carries only what changed since the run before it" {
2984 const alloc = std.testing.allocator;
2985 var list: std.ArrayList(u8) = .empty;
2986 defer list.deinit(alloc);
2987 const red: CellStyle = .{ .fg = colorPalette(1) };
2988 const bold_red: CellStyle = .{ .fg = colorPalette(1), .flags = 1 };
2989 const on_rgb: CellStyle = .{ .fg = colorPalette(1), .flags = 1, .bg = colorRgb(1, 2, 3) };
2990 var w = try CellRowWriter.begin(&list, alloc);
2991 try w.cell(.{}, .narrow, "a");
2992 try w.cell(.{}, .narrow, "b");
2993 try w.cell(red, .narrow, "c");
2994 try w.cell(bold_red, .narrow, "d");
2995 try w.cell(on_rgb, .narrow, "e");
2996 w.finish();
2997 // The third run turns bold on and keeps the red: its header names the
2998 // flags and NOT the foreground, which is the whole point of the mask.
2999 // The fourth adds a background and repeats neither of them.
3000 try std.testing.expectEqualSlices(u8, &[_]u8{
3001 5, 0, // ncells
3002 2, 0, 0x80, // run 1: default style, ascii
3003 'a', 'b',
3004 1, 0, 0x82, // run 2: ascii, fg changed
3005 0x01, 0x01, // fg: palette, index 1
3006 'c',
3007 1, 0, 0x81, // run 3: ascii, flags changed — fg is unsaid
3008 0x01, 0x00, // flags: bold
3009 'd',
3010 1, 0, 0x84, // run 4: ascii, bg changed
3011 0x02, 0x01, 0x02, 0x03, // bg: rgb 1,2,3
3012 'e',
2890 }, list.items); 3013 }, list.items);
3014
3015 var r = try CellRowReader.init(list.items);
3016 _ = try r.next();
3017 _ = try r.next();
3018 try std.testing.expect((try r.next()).?.style.eql(red));
3019 // The reader carried the red across the header that never repeated it.
3020 try std.testing.expect((try r.next()).?.style.eql(bold_red));
3021 try std.testing.expect((try r.next()).?.style.eql(on_rgb));
2891 } 3022 }
2892 3023
2893 test "cellrow: the reader leaves the bytes after the row alone" { 3024 test "cellrow: the reader leaves the bytes after the row alone" {
@@ -2907,14 +3038,24 @@ test "cellrow: the reader leaves the bytes after the row alone" {
2907 test "cellrow: malformed rows are BadPayload, never a read past the end" { 3038 test "cellrow: malformed rows are BadPayload, never a read past the end" {
2908 // A run that claims more cells than the row's ncells: refused at the 3039 // A run that claims more cells than the row's ncells: refused at the
2909 // run header, before a cell is read. 3040 // run header, before a cell is read.
2910 var over = try CellRowReader.init(&[_]u8{ 1, 0, 2, 0, 0x00, 0x80, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 'a', 'b' }); 3041 var over = try CellRowReader.init(&[_]u8{ 1, 0, 2, 0, 0x80, 'a', 'b' });
2911 try std.testing.expectError(error.BadPayload, over.next()); 3042 try std.testing.expectError(error.BadPayload, over.next());
2912 // A cell whose text_len runs past the payload. 3043 // A cell whose text_len runs past the payload.
2913 var short_text = try CellRowReader.init(&[_]u8{ 1, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 5, 'a' }); 3044 var short_text = try CellRowReader.init(&[_]u8{ 1, 0, 1, 0, 0x00, 5, 'a' });
2914 try std.testing.expectError(error.BadPayload, short_text.next()); 3045 try std.testing.expectError(error.BadPayload, short_text.next());
2915 // A row shorter than its own prefix. 3046 // A row shorter than its own prefix.
2916 try std.testing.expectError(error.BadPayload, CellRowReader.init(&[_]u8{1})); 3047 try std.testing.expectError(error.BadPayload, CellRowReader.init(&[_]u8{1}));
2917 // A row that ends mid-run header. 3048 // A row that ends mid-run header.
2918 var mid_header = try CellRowReader.init(&[_]u8{ 1, 0, 1, 0, 0 }); 3049 var mid_header = try CellRowReader.init(&[_]u8{ 1, 0, 1, 0 });
2919 try std.testing.expectError(error.BadPayload, mid_header.next()); 3050 try std.testing.expectError(error.BadPayload, mid_header.next());
3051 // A mask bit this format has not assigned: the fields it would name are
3052 // of a length this reader cannot know, so it stops rather than guesses.
3053 var reserved = try CellRowReader.init(&[_]u8{ 1, 0, 1, 0, 0x10, 'a' });
3054 try std.testing.expectError(error.BadPayload, reserved.next());
3055 // A colour tag past RGB, for the same reason.
3056 var bad_color = try CellRowReader.init(&[_]u8{ 1, 0, 1, 0, 0x02, 3, 0, 0, 0, 'a' });
3057 try std.testing.expectError(error.BadPayload, bad_color.next());
3058 // A header whose mask names a colour the payload does not carry.
3059 var truncated_color = try CellRowReader.init(&[_]u8{ 1, 0, 1, 0, 0x02, 1 });
3060 try std.testing.expectError(error.BadPayload, truncated_color.next());
2920 } 3061 }