Files
ghostty-rs/docs/rewrite/notes/visual-parity-VP.md
T
ddidderr 64aa8b6793 feat: visual parity with the original Ghostty (pixel-diff pipeline)
Adds scripts/visual-parity.sh (`just visual-parity`): launches the
installed Ghostty 1.3.1 and the Rust build in an isolated headless
nested weston (GL renderer, Mesa forced, weston-screenshooter via
--debug), with identical isolated config, a deterministic test page
(ASCII, ligatures, all SGR styles and underline kinds, 16/256/truecolor,
box/block/braille/powerline/nerd icons, CJK, emoji, wide chars,
cursor) and a tabs+splits scenario driven through Xwayland, then diffs
the captures (terminal area and chrome separately, heatmaps).

Fixes found by comparing against the original:
- A pane stayed blank after a nested split: the GTK render path called
  draw_frame(false) where Ghostty uses drawFrame(true) (a re-realized
  GLArea has a new swap chain that nothing else redraws).
- Sprite glyph antialiasing now ports z2d's default 4x4 multisample
  rasterizer instead of tiny-skia's analytic coverage (pixels differing
  from Ghostty's reference sprite sheets: 5.76% -> 1.20%, none off by
  more than 96 levels); the sheet test tolerance is tightened to 0.1%.

Result vs Ghostty 1.3.1: test page 0.020% of pixels differ (196 px at
curly-underline ends and diagonal box strokes, max delta 86 levels);
tabs+splits and headerbar/tab bar are bit-identical. Details in
docs/rewrite/notes/visual-parity-VP.md.

Test Plan:
- just visual-parity (needs weston, weston-screenshooter, xdotool)
- cargo test -p ghostty-font (sprite sheet comparison)
2026-10-12 13:52:22 +02:00

4.4 KiB

Visual parity vs original Ghostty 1.3.1 (agent VP)

Method (scripts/visual-parity.sh, just visual-parity [scenario...])

  • A private headless weston (--backend=headless --renderer=gl, 1200x800, desktop-shell, no panel) per run, on its own socket in $XDG_RUNTIME_DIR=/tmp/vp-runtime-$UID (socket paths must be <108 bytes). The user's desktop is never touched. Mesa is forced for weston (__EGL_VENDOR_LIBRARY_FILENAMES -> 50_mesa.json); the NVIDIA EGL crashes Xwayland's glamor. XWAYLAND_NO_GLAMOR=1.
  • weston --debug is what authorizes weston-screenshooter (without it: "Output capture error: unauthorized").
  • Both apps run with env -i, GSETTINGS_BACKEND=memory, no D-Bus, isolated XDG_CONFIG_HOME with the same explicit config (JetBrains Mono 13, bg/fg, padding 8, maximize = true so the window geometry is deterministic, cursor-style-blink = false, title = Ghostty, no shell integration) and LANG=C.UTF-8 (otherwise printf '\U..' in the test page prints literally).
  • Scenarios: page (block cursor), page-bar (bar cursor), tabs (new tab + split right + split down, driven with xdotool through Xwayland since the original Ghostty has no input replay; Rust is driven the same way so both apps are treated identically). Test page: scripts/visual-parity/testpage.sh (ASCII, ligatures, bold/italic/faint/inverse/strike/ overline, all underline styles + colored, 16/256/truecolor, box drawing, blocks, braille, powerline, nerd font icons, CJK, emoji, wide chars, math, cursor). Not covered: mouse selection (no input injection into the original on Wayland; could be added through the Xwayland path).
  • scripts/visual-parity/compare.py: per-pixel max-channel delta, % differing, % > 8 / > 32 levels, max, mean, red heat-map PNG; run on the full frame, the terminal area and the headerbar strip. scripts/visual-parity/zoom.py: ref | rust | diff crops, upscaled.
  • Output (not committed): target/visual-parity/<scenario>/{ref,rust,diff,diff-term,diff-chrome}.png, target/visual-parity/stats.txt. RUST_BIN defaults to target/production/ghostty. Iteration build: CARGO_TARGET_DIR=target/vp CARGO_PROFILE_PRODUCTION_LTO=off CARGO_PROFILE_PRODUCTION_CODEGEN_UNITS=16 CARGO_PROFILE_PRODUCTION_INCREMENTAL=true cargo build --profile production.

Results (% of pixels differing at all; max channel delta)

scenario before after
page (full frame) 0.130% (1250 px, max 93) 0.020% (196 px, max 86; 0.001% > 32 levels)
page-bar same as page 0.020%
tabs + splits 44.6% (a whole pane blank, bug) 0.000% (bit-identical)
chrome (headerbar, tab bar) 0.000% 0.000%

Sprite sheets (ghostty-font tests, Zig reference PNGs): differing pixels 5.76% -> 1.20%, pixels off by more than 96 levels 74 -> 0 (test tolerance tightened from 4% to 0.1% big diffs).

Font metrics, cell size, baseline, glyph rasterization, colors, cursor, underline positions, padding, GTK CSS/headerbar/tab bar were already pixel-identical; no change was needed there.

Fixes

  1. Split rebuild blanked an unchanged pane (ghostty-core/src/surface/mod.rs, draw_frame): the GTK render handler called renderer.draw_frame(false); Zig's glRender uses drawFrame(true). With sync = false and nothing dirty the renderer only re-presents the last target, which is cleared when the GLArea is re-realized (nested split = tree rebuild = unrealize/realize of every surface whose size did not change), so that pane stayed empty until something changed. Now true like Zig.
  2. Sprite antialiasing (ghostty-font/src/sprite/canvas.rs): replaced tiny-skia's coverage with a port of z2d 0.10's default multisample_4x rasterizer (4x4 samples, scanline spans with edge x rounded at sample row centers, non-zero filter, alpha = coverage * 16 - 1, full = 255, z2d's Bezier flattening with tolerance 0.1). Stroke outlines still come from tiny-skia's stroker.

Remaining differences (page: 196 px, all <= 86 levels)

  • Curly underline: first/last pixels and peaks differ slightly. z2d builds its own stroke polygon (pen/joins) for curves; tiny-skia's stroker offsets the Bezier differently.
  • Diagonal box glyphs (U+2571..2573) have a few edge pixels off by up to ~38 levels (same cause).
  • Porting z2d's stroker (tess/stroke_plotter, Pen) would remove the rest; judged not worth it (0.02% of pixels, imperceptible).