test: differential fuzzing of the terminal core against Ghostty's Zig code

Adds tools/difftest: a Zig driver built against a copy of Ghostty
1.3.1's own src/terminal (Zig 0.15.2, set up under target/ by
tools/difftest/setup.sh) and a Rust twin on ghostty-terminal, both
printing a canonical dump of every scrollback/active row (cells,
graphemes, styles, hyperlinks, row flags), cursor, modes, regions,
tabstops and colors. A seeded generator produces escape-heavy streams
(UTF-8/wide/combining/emoji, C0, CSI with edge params, SGR, modes, ESC,
OSC, DCS, margins, insert/delete); `just difftest` runs it with Zig
assertions on (ReleaseSafe vs debug Rust) and off (ReleaseFast vs
production Rust), with minimizers and a triage script.

Divergences found and fixed in Rust (each with a regression test):
- ill-formed UTF-8 decoding follows the SIMD path a default Ghostty
  build uses (not Zig's scalar fallback)
- PageList::erase_rows keeps Zig's row-flag behavior on regrow
- grapheme move on wrap with left/right margins uses the original cell

Upstream bugs found and deliberately NOT replicated (per the project
rule): a stale cell pointer after page growth in Terminal.zig:482/539
that drops a grapheme, plus several Zig assertion failures/segfaults;
documented with minimized repros in docs/rewrite/notes/difftest-DF.md.

Final run: 390k cases across many geometries, 0 divergences (Zig-abort
cases, ~0.1-0.5%, are not compared).

Test Plan:
- just difftest 20000 "1 2 3"
- cargo test -p ghostty-terminal (regression tests)
This commit is contained in:
ddidderr committed 2026-10-12 13:24:12 +02:00
1 parent 580ed4d52d
commit d13dcc8a7f
19 files changed
+2033 -165

No files matched your search

+21 -3
View File
@@ -1094,10 +1094,28 @@ impl PageList {
page.size.rows = 0;
} else {
let page = &mut self.node_mut(id).ok_or(Error::InvalidId)?.data;
for y in a..b {
page.clear_cells(y, 0, usize::from(page.size.cols), false)?;
if a == 0 {
// Zig (`eraseRows`) SWAPS the surviving rows into place
// rather than rotating, then clears the cells of the
// rows left over past the new size. Row metadata (e.g.
// the `wrap` flag) of those leftover rows is NOT reset
// and becomes visible again when the page regrows into
// them, so the exact arrangement must match.
let cols = usize::from(page.size.cols);
let scroll_amount = rows - b;
for i in 0..scroll_amount {
page.rows.swap(i, i + b);
page.rows[i].set_dirty(true);
}
for y in scroll_amount..rows {
page.clear_cells(y, 0, cols, false)?;
}
} else {
for y in a..b {
page.clear_cells(y, 0, usize::from(page.size.cols), false)?;
}
page.rows[a..rows].rotate_left(b - a);
}
page.rows[a..rows].rotate_left(b - a);
page.size.rows -= (b - a) as u16;
for row in &mut page.rows[a..usize::from(page.size.rows)] {
row.set_dirty(true);