summaryrefslogtreecommitdiff
path: root/build.zig
blob: a4d530f1720ff46ab4afe442cf23a8c21d9ba31f (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
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
const std = @import("std");

pub const Platform = enum { tty, gui, web };
pub const TreeSitterGrammars = enum { disabled, zig, minimal, full };
const GhosttyArtifact = enum { ghostty, lib };

pub fn build(b: *std.Build) void {
    // Default target is the Steam Deck (deckcap's trick): x86_64 linux-gnu
    // with the glibc version pinned low, so a binary built on a rolling-
    // release host runs on SteamOS — a native build references the host's
    // newer versioned libm/libc symbols and dies with "GLIBC_2.4x not found"
    // on the deck. Override with -Dtarget= as usual.
    const target = b.standardTargetOptions(.{ .default_target = .{
        .cpu_arch = .x86_64,
        .os_tag = .linux,
        .abi = .gnu,
        .glibc_version = .{ .major = 2, .minor = 38, .patch = 0 },
    } });
    const requested_optimize = b.standardOptimizeOption(.{});
    const platform = b.option(Platform, "platform", "which shell to build (tty, gui, web)") orelse .tty;
    const static = b.option(bool, "static", "statically link") orelse false;
    const dump_path = b.option([]const u8, "dump", "dump .zon embedded into the web shell (-Dplatform=web)");
    const emcc_path = b.option([]const u8, "emcc", "emcc for the web shell's final link") orelse "/usr/lib/emscripten/emcc";
    const is_emscripten = target.result.os.tag == .emscripten;
    // wasm: size is the budget
    const optimize = if (is_emscripten) .ReleaseSmall else requested_optimize;
    const default_grammars: TreeSitterGrammars = if (is_emscripten) .zig else .full;
    const tree_sitter_grammars = b.option(TreeSitterGrammars, "tree-sitter", "tree-sitter grammar set: disabled, zig, minimal (c/c++/zig), full") orelse default_grammars;
    const tracy = b.option([]const u8, "tracy", "enable Tracy profiling; supply the path to a Tracy source checkout");

    // the browser shell: zig object + emcc link + html, installed to zig-out/web
    const web_step = b.step("web", "build the browser shell into zig-out/web (-Dplatform=web -Dtarget=wasm32-emscripten -Ddump=<dump.zon>)");

    // Web LOOK has a tiny read-only source filesystem. Generate one Zig module
    // on the host from every tracked .zig path + its current working-tree
    // contents. The generator prefers Git as the tracked-file filter and has a
    // conservative filesystem fallback for source archives without `.git`.
    const source_gen = b.addExecutable(.{
        .name = "pardes-embed-zig-sources",
        .root_module = b.createModule(.{
            .target = b.graph.host,
            .optimize = .ReleaseSafe,
            .root_source_file = b.path("tools/embed_zig_sources.zig"),
        }),
    });
    const run_source_gen = b.addRunArtifact(source_gen);
    run_source_gen.addDirectoryArg(b.path("."));
    const embedded_sources = run_source_gen.addOutputFileArg("embedded_sources.zig");
    // The tracked set can change without any already-known input changing, so
    // discovery intentionally runs whenever a consumer builds it.
    run_source_gen.has_side_effects = true;
    const inspect_sources = b.addInstallFileWithDir(embedded_sources, .{ .custom = "web" }, "embedded_sources.zig");
    b.step("embed-web-sources", "generate zig-out/web/embedded_sources.zig from tracked .zig files").dependOn(&inspect_sources.step);

    // Browser regression suite: make an isolated web build from the real TTY
    // dump captured after running `git ls-files '*.zig'`, with the Zig-only
    // tree-sitter tier. The dependency-free CDP harness then drives
    // its .snap script through real Chromium touch events. This recursive build
    // selects `web`, not `web-snap`, so it bottoms out after one level.
    const build_web_snap = b.addSystemCommand(&.{
        b.graph.zig_exe,
        "build",
        "web",
        "-Dplatform=web",
        "-Dtarget=wasm32-emscripten",
        "-Dtree-sitter=zig",
        "-Ddump=test/web-snapshots/source-list.dump.zon",
        "-p",
        "zig-out/web-snap-test",
    });
    build_web_snap.setCwd(b.path("."));
    const run_web_snap = b.addSystemCommand(&.{
        "node",
        "test/web_snapshot.mjs",
        "zig-out/web-snap-test/web",
        "test/web-snapshots/source-list.snap",
    });
    run_web_snap.setCwd(b.path("."));
    run_web_snap.step.dependOn(&build_web_snap.step);
    if (b.args) |args| run_web_snap.addArgs(args);
    b.step("web-snap", "run browser touch/LOOK snapshots (-- --update regenerates goldens)").dependOn(&run_web_snap.step);

    const emscripten_sysroot = if (is_emscripten) detectEmscriptenSysroot(b) else null;
    if (platform == .web and !is_emscripten) return failBuild(b, web_step, "-Dplatform=web requires -Dtarget=wasm32-emscripten");
    if (platform != .web and is_emscripten) return failBuild(b, web_step, "-Dtarget=wasm32-emscripten requires -Dplatform=web");
    if (platform == .web and dump_path == null) return failBuild(b, web_step, "-Dplatform=web requires -Ddump=<dump.zon> (the browser has no ptys; state replays from an embedded dump)");
    if (platform == .web and emscripten_sysroot == null) return failBuild(b, web_step, "no emscripten sysroot found; pass --sysroot <emscripten-cache>/sysroot or set EM_CACHE/EMSDK");

    // emscripten "libc": point zig at the sysroot headers/crt via a libc.txt
    // (the prototype's WriteFiles trick) — applied to EVERY compile step.
    const libc_file: ?std.Build.LazyPath = if (emscripten_sysroot) |sysroot| blk: {
        const files = b.addWriteFiles();
        break :blk files.add("emscripten-libc.txt", b.fmt(
            \\include_dir={s}/include
            \\sys_include_dir={s}/include
            \\crt_dir={s}/lib/wasm32-emscripten
            \\msvc_lib_dir=
            \\kernel32_lib_dir=
            \\gcc_dir=
            \\
        , .{ sysroot, sysroot, sysroot }));
    } else null;
    const emscripten_include: ?std.Build.LazyPath = if (emscripten_sysroot) |sysroot|
        .{ .cwd_relative = b.pathJoin(&.{ sysroot, "include" }) }
    else
        null;

    // build-time IO, only used to slurp the grammars' highlights.scm queries
    var threaded: std.Io.Threaded = .init(b.allocator, .{});
    defer threaded.deinit();
    const io = threaded.io();

    const root_mod = b.createModule(.{
        .target = target,
        .optimize = optimize,
        .root_source_file = b.path("src/main.zig"),
        .link_libc = true,
    });
    // helix differential harness (test/hxdiff.zig): a second compilation of
    // the sans-IO core, driven headlessly. Mirrors root_mod's wiring for
    // everything src/pardes.zig pulls in (ghostty-vt, tree-sitter, zstbi, the
    // option modules); imported as "pardes" by the harness exe below.
    const hx_core_mod = b.createModule(.{
        .target = target,
        .optimize = optimize,
        .root_source_file = b.path("src/pardes.zig"),
        .link_libc = true,
    });

    // static archives the emcc link needs beyond the zig object (grammars, SDL)
    var wasm_libs: std.ArrayList(std.Build.LazyPath) = .empty;

    // forkpty: in libc proper on glibc>=2.34 and darwin; the BSDs keep it in libutil
    if (target.result.os.tag == .freebsd or target.result.os.tag == .netbsd or target.result.os.tag == .openbsd)
        root_mod.linkSystemLibrary("util", .{});

    const opts = b.addOptions();
    opts.addOption(Platform, "platform", platform);
    opts.addOption(bool, "syntax_highlighting", tree_sitter_grammars != .disabled);
    opts.addOption(bool, "syntax_zig_grammar", tree_sitter_grammars != .disabled);
    opts.addOption(bool, "syntax_minimal_grammars", tree_sitter_grammars == .minimal or tree_sitter_grammars == .full);
    opts.addOption(bool, "syntax_full_grammars", tree_sitter_grammars == .full);
    opts.addOption(bool, "enable_tracy", tracy != null);
    root_mod.addOptions("pardes_config", opts);
    hx_core_mod.addOptions("pardes_config", opts);

    // Tracy zones (src/tracy.zig): compile the client into the binary only when
    // -Dtracy=<path> names a Tracy checkout; otherwise every zone is a no-op.
    // Sampling/callstacks/system tracing stay off: tracy's symbol worker
    // SIGSEGVs on this binary's debug info, and its crash handler then parks
    // every thread — the app wedges before the first frame. Zones don't need
    // any of it.
    if (tracy) |tracy_path| {
        for ([_]*std.Build.Module{ root_mod, hx_core_mod }) |mod| {
            mod.addIncludePath(.{ .cwd_relative = tracy_path });
            mod.addCSourceFile(.{
                .file = .{ .cwd_relative = b.pathJoin(&.{ tracy_path, "public", "TracyClient.cpp" }) },
                .flags = &.{
                    "-DTRACY_ENABLE=1",
                    "-DTRACY_NO_SAMPLING",
                    "-DTRACY_NO_CALLSTACK",
                    "-DTRACY_NO_SYSTEM_TRACING",
                    "-DTRACY_NO_CRASH_HANDLER",
                    "-DTRACY_NO_CODE_TRANSFER",
                    "-fno-sanitize=undefined",
                },
            });
            mod.link_libcpp = true;
        }
    }

    // tree-sitter: the zig bindings + C runtime, one static lib per grammar,
    // and each grammar's highlights.scm slurped at build time into the
    // ts_queries options module (codegen consumed by comptime in syntax.zig).
    if (tree_sitter_grammars != .disabled) {
        const tree_sitter_mod = b.dependency("tree_sitter", .{ .target = target, .optimize = optimize }).module("tree_sitter");
        // the C runtime rides the module as a static lib; give it the wasm
        // libc and hand its archive to the emcc link
        if (is_emscripten) {
            for (tree_sitter_mod.link_objects.items) |link_object| switch (link_object) {
                .other_step => |lib| {
                    lib.setLibCFile(libc_file.?);
                    wasm_libs.append(b.allocator, lib.getEmittedBin()) catch @panic("OOM");
                },
                else => {},
            };
        }
        root_mod.addImport("tree-sitter", tree_sitter_mod);
        hx_core_mod.addImport("tree-sitter", tree_sitter_mod);

        const Grammar = struct {
            name: []const u8,
            dep: []const u8,
            src: []const u8 = "src",
            scanner: bool = false,
            query: []const u8 = "queries/highlights.scm",
            minimal: bool = false,
        };
        const grammars = [_]Grammar{
            .{ .name = "ada", .dep = "ts_ada" },
            .{ .name = "bash", .dep = "ts_bash", .scanner = true },
            .{ .name = "c", .dep = "ts_c", .minimal = true },
            .{ .name = "c_sharp", .dep = "ts_c_sharp", .scanner = true },
            .{ .name = "clojure", .dep = "ts_clojure" },
            .{ .name = "cpp", .dep = "ts_cpp", .scanner = true, .minimal = true },
            .{ .name = "css", .dep = "ts_css", .scanner = true },
            .{ .name = "elixir", .dep = "ts_elixir", .scanner = true },
            .{ .name = "erlang", .dep = "ts_erlang", .scanner = true },
            .{ .name = "fortran", .dep = "ts_fortran", .scanner = true },
            .{ .name = "go", .dep = "ts_go" },
            .{ .name = "haskell", .dep = "ts_haskell", .scanner = true },
            .{ .name = "html", .dep = "ts_html", .scanner = true },
            .{ .name = "java", .dep = "ts_java" },
            .{ .name = "javascript", .dep = "ts_javascript", .scanner = true },
            .{ .name = "json", .dep = "ts_json" },
            .{ .name = "kotlin", .dep = "ts_kotlin", .scanner = true },
            .{ .name = "ocaml", .dep = "ts_ocaml", .src = "grammars/ocaml/src", .scanner = true },
            .{ .name = "pascal", .dep = "ts_pascal" },
            .{ .name = "php", .dep = "ts_php", .src = "php/src", .scanner = true },
            .{ .name = "powershell", .dep = "ts_powershell", .scanner = true },
            .{ .name = "python", .dep = "ts_python", .scanner = true },
            .{ .name = "ruby", .dep = "ts_ruby", .scanner = true },
            .{ .name = "rust", .dep = "ts_rust", .scanner = true },
            .{ .name = "scala", .dep = "ts_scala", .scanner = true },
            .{ .name = "zig", .dep = "ts_zig", .minimal = true },
        };
        const ts_queries = b.addOptions();
        inline for (grammars) |g| {
            if (tree_sitter_grammars == .full or
                (tree_sitter_grammars == .minimal and g.minimal) or
                (tree_sitter_grammars == .zig and std.mem.eql(u8, g.name, "zig")))
            {
                const dep = b.dependency(g.dep, .{});
                const query_path = dep.path(g.query);
                const query = std.Io.Dir.cwd().readFileAlloc(io, query_path.getPath(b), b.allocator, .limited(0x100000)) catch @panic("read " ++ g.name ++ " highlights");
                ts_queries.addOption([]const u8, g.name ++ "_highlights", query);

                const lib = b.addLibrary(.{
                    .name = "tree-sitter-" ++ g.name,
                    .root_module = b.createModule(.{ .target = target, .optimize = optimize, .link_libc = true }),
                    .linkage = .static,
                });
                if (libc_file) |f| lib.setLibCFile(f);
                // grammars declare external_scanner_create() with EMPTY PARENS
                // (a K&R non-prototype, not (void)): under clang's
                // -fsanitize=function the callee's type hash differs from the
                // runtime's void*(*)(void) call through the pointer, so the
                // first scanner call of ANY parse traps (function_type_mismatch,
                // an ud1 blamed on a random inlined line). Uninstrumented
                // callees make the runtime's call-site checks skip; the rest
                // of UBSan stays live for the grammar code.
                const ts_cflags = [_][]const u8{"-fno-sanitize=function"};
                lib.root_module.addCSourceFile(.{ .file = dep.path(g.src ++ "/parser.c"), .flags = &ts_cflags });
                if (g.scanner) lib.root_module.addCSourceFile(.{ .file = dep.path(g.src ++ "/scanner.c"), .flags = &ts_cflags });
                lib.root_module.addIncludePath(dep.path(g.src));
                root_mod.linkLibrary(lib);
                hx_core_mod.linkLibrary(lib);
                if (is_emscripten) wasm_libs.append(b.allocator, lib.getEmittedBin()) catch @panic("OOM");
            }
        }
        root_mod.addOptions("ts_queries", ts_queries);
        hx_core_mod.addOptions("ts_queries", ts_queries);
    }

    // zstbi (C stb_image): image pane decode. zigimg crashes the 0.16 compiler.
    const zstbi_dep = b.dependency("zstbi", .{ .target = target, .optimize = optimize });
    root_mod.addImport("zstbi", zstbi_dep.module("root"));
    hx_core_mod.addImport("zstbi", zstbi_dep.module("root"));

    // ghostty-vt and vaxis both depend on uucode, but Zig forbids one source
    // file in two modules — build a single uucode (our config, unpacked tables)
    // and hand it to both. Same trick as the prototype; see uucode_config.zig.
    const uucode_config = b.path("uucode_config.zig");
    const uucode_tables = b.dependency("uucode", .{
        .target = target,
        .optimize = optimize,
        .build_config_path = uucode_config,
    }).namedLazyPath("tables.zig");
    const uucode_mod = b.dependency("uucode", .{
        .target = target,
        .optimize = optimize,
        .build_config_path = uucode_config,
        .tables_path = uucode_tables,
    }).module("uucode");

    const vaxis_mod = b.dependency("vaxis", .{
        .target = target,
        .optimize = optimize,
        .external_uucode = true,
    }).module("vaxis");

    var ghostty_vt_for_snap: ?*std.Build.Module = null;
    // ghostty's simd libs (simdutf/highway, C++) locate the Apple SDK via
    // xcrun on darwin targets, so cross-compiling to macOS from elsewhere
    // uses the scalar fallback (the same configuration the web shell ships).
    // Native/cross builds hand ghostty the real target (it defaults to the
    // host otherwise); the web path keeps its original no-target fetch, whose
    // zig object never uses ghostty's artifacts.
    // NOTE: darwin targets also need two one-line zig-0.16 fixes in the
    // pinned ghostty (applied in the zig-pkg cache; re-apply after a fresh
    // fetch, or bump the pin once upstream carries them):
    //   src/os/mach.zig — std.heap.next_mmap_addr_hint is gone (make the hint
    //     var module-local) and posix.mmap prot is now a packed struct
    //     (.{ .READ = true, .WRITE = true });
    //   src/terminal/kitty/graphics_image.zig:185 — shm_open's variadic mode
    //     literal 0 must be @as(std.c.mode_t, 0).
    // Darwin HOSTS need a third: src/build/GhosttyDist.zig:28 — the dist
    // tarball's GTK resources run pkg-config for libadwaita eagerly (panics
    // without it); gate that block on `b.graph.host.result.os.tag == .linux`.
    // Ghostty's pinned translate-c tarball also 404s now (codeberg dropped
    // the archive endpoint) — seed zig-pkg/ from a machine that has it.
    const ghostty_simd = !target.result.os.tag.isDarwin() or b.graph.host.result.os.tag.isDarwin();
    // app-runtime none + emit-xcframework off: only the ghostty-vt module is
    // consumed, and the defaults otherwise drag ghostty's app graph into the
    // build — gtk4 header translation via host pkg-config for linux targets,
    // the Xcode app graph (iOS SDK, xcodebuild) on darwin hosts.
    // NEVER hand ghostty `.Debug`: that flips its `slow_runtime_safety`, which
    // walks the whole PageList after every mutation and PANICS the app on a
    // transient state its own next lines repair — `PageList.resizeCols` grows
    // rows BEFORE it moves a history viewport pin back into the active area,
    // so widening a window whose scrollback holds wrapped lines dies with
    // "PageList integrity check failed: ViewportPinInsufficientRows". Those
    // checks are a ghostty-development tool (upstream ships them off); Zig's
    // own safety checks come from OUR optimize mode and are unaffected, since
    // ghostty-vt is a module compiled into this binary. See reflow.snap.
    const ghostty_optimize: std.builtin.OptimizeMode = if (optimize == .Debug) .ReleaseSafe else optimize;
    const ghostty_dep = if (is_emscripten)
        b.lazyDependency("ghostty", .{ .simd = false })
    else
        b.lazyDependency("ghostty", .{ .target = target, .optimize = ghostty_optimize, .simd = ghostty_simd, .@"app-runtime" = .none, .@"emit-xcframework" = false });
    if (ghostty_dep) |dep| {
        const ghostty_vt = dep.module("ghostty-vt");
        ghostty_vt_for_snap = ghostty_vt;
        ghostty_vt.addImport("uucode", uucode_mod);
        // emscripten: hand ghostty-vt the terminal_options module its build()
        // would otherwise provide (copied verbatim from the prototype build)
        if (is_emscripten) {
            const term_opts = b.addOptions();
            term_opts.addOption(GhosttyArtifact, "artifact", .lib);
            term_opts.addOption(bool, "c_abi", false);
            term_opts.addOption(bool, "oniguruma", false);
            term_opts.addOption(bool, "simd", false);
            term_opts.addOption(bool, "slow_runtime_safety", false);
            term_opts.addOption(bool, "kitty_graphics", false);
            term_opts.addOption(bool, "tmux_control_mode", false);
            term_opts.addOption([]const u8, "version_string", "0.0.0");
            term_opts.addOption(usize, "version_major", 0);
            term_opts.addOption(usize, "version_minor", 0);
            term_opts.addOption(usize, "version_patch", 0);
            term_opts.addOption(?[]const u8, "version_pre", null);
            term_opts.addOption(?[]const u8, "version_build", null);
            ghostty_vt.addOptions("terminal_options", term_opts);
        }
        root_mod.addImport("ghostty-vt", ghostty_vt);
        hx_core_mod.addImport("ghostty-vt", ghostty_vt);
    }
    // override uucode on vaxis LAST so it wins over ghostty's build() wiring.
    vaxis_mod.addImport("uucode", uucode_mod);
    root_mod.addImport("vaxis", vaxis_mod);
    root_mod.addImport("uucode", uucode_mod);
    hx_core_mod.addImport("uucode", uucode_mod);

    // SDL3 shell wiring (-Dplatform=gui and web). The sdl package builds SDL3
    // from source as a static lib + emits the SDL3/ headers; gui.zig @cImports
    // those, plus a small stb_truetype C shim for the glyph atlas, and embeds
    // the SPIR-V compiled at build time from shaders/*.glsl — or, on web, the
    // GL ES shader sources compiled at runtime by WebGL.
    if (platform == .gui or platform == .web) {
        // sanitize_c MUST stay off: zig cc's UBSan (on for C in Debug AND
        // ReleaseSafe) traps hidapi's mismatched fn-pointer calls the moment
        // a HID gamepad is enumerated — SDL_Init SIGILLs on the deck itself
        // (ud2 in SDL_hid_set_nonblocking_REAL); headless boxes never see it.
        // SDL3 always statically linked (like deckcap): the gui binary must
        // only need system libc/libm — never a shared libSDL3.
        const sdl_dep = if (emscripten_include) |include|
            b.lazyDependency("sdl", .{ .target = target, .optimize = optimize, .sanitize_c = .off, .preferred_linkage = .static, .system_include_path = include })
        else
            b.lazyDependency("sdl", .{ .target = target, .optimize = optimize, .sanitize_c = .off, .preferred_linkage = .static });
        if (sdl_dep) |dep| {
            const sdl_lib = dep.artifact("SDL3");
            if (libc_file) |f| sdl_lib.setLibCFile(f);
            root_mod.linkLibrary(sdl_lib);
            root_mod.addIncludePath(dep.path("include"));
            if (emscripten_include) |include| root_mod.addSystemIncludePath(include);
            if (is_emscripten) wasm_libs.append(b.allocator, sdl_lib.getEmittedBin()) catch @panic("OOM");
        }
        // darwin: SDL_GPU only speaks SPIRV here (shaders/*.glsl -> glslc), so
        // it must pick its vulkan backend via the Vulkan SDK's loader +
        // MoltenVK in /usr/local/lib — a path dyld no longer searches for
        // bare dlopen names. The rpath restores that lookup.
        if (target.result.os.tag.isDarwin())
            root_mod.addRPath(.{ .cwd_relative = "/usr/local/lib" });
        root_mod.addIncludePath(b.path("vendor/stb"));
        root_mod.addCSourceFile(.{ .file = b.path("vendor/stb/stb_truetype_impl.c") });
        root_mod.addCSourceFile(.{ .file = b.path("vendor/stb/font.c") });
        // the embedded UI font: provided as a build import since assets/ lives
        // outside the src/ module root (@embedFile can't escape it)
        root_mod.addAnonymousImport("AdwaitaMono-Regular.ttf", .{
            .root_source_file = b.path("assets/AdwaitaMono-Regular.ttf"),
        });
        if (platform == .web) {
            root_mod.addAnonymousImport("ui.vert.es.glsl", .{ .root_source_file = b.path("shaders/ui.vert.es.glsl") });
            root_mod.addAnonymousImport("ui.frag.es.glsl", .{ .root_source_file = b.path("shaders/ui.frag.es.glsl") });
            root_mod.addAnonymousImport("overlay.vert.es.glsl", .{ .root_source_file = b.path("shaders/overlay.vert.es.glsl") });
            root_mod.addAnonymousImport("overlay.frag.es.glsl", .{ .root_source_file = b.path("shaders/overlay.frag.es.glsl") });
            const dp = dump_path.?;
            root_mod.addAnonymousImport("embedded.dump.zon", .{
                .root_source_file = if (std.fs.path.isAbsolute(dp)) .{ .cwd_relative = dp } else b.path(dp),
            });
            root_mod.addAnonymousImport("embedded_sources", .{ .root_source_file = embedded_sources });
        } else {
            root_mod.addAnonymousImport("ui.vert.spv", .{
                .root_source_file = compileGlsl(b, "shaders/ui.vert.glsl", "vertex", "ui.vert.spv"),
            });
            root_mod.addAnonymousImport("ui.frag.spv", .{
                .root_source_file = compileGlsl(b, "shaders/ui.frag.glsl", "fragment", "ui.frag.spv"),
            });
            root_mod.addAnonymousImport("overlay.vert.spv", .{
                .root_source_file = compileGlsl(b, "shaders/overlay.vert.glsl", "vertex", "overlay.vert.spv"),
            });
            root_mod.addAnonymousImport("overlay.frag.spv", .{
                .root_source_file = compileGlsl(b, "shaders/overlay.frag.glsl", "fragment", "overlay.frag.spv"),
            });
            root_mod.addAnonymousImport("crt.vert.spv", .{
                .root_source_file = compileGlsl(b, "shaders/crt.vert.glsl", "vertex", "crt.vert.spv"),
            });
            root_mod.addAnonymousImport("crt.frag.spv", .{
                .root_source_file = compileGlsl(b, "shaders/crt.frag.glsl", "fragment", "crt.frag.spv"),
            });
        }
    }

    if (platform == .web) {
        // zig emits an object; emcc does the final link (wasm + js glue),
        // compiling the C shims itself and pulling in every static archive.
        const obj = b.addObject(.{ .name = "pardes", .root_module = root_mod });
        obj.setLibCFile(libc_file.?);

        const emcc = b.addSystemCommand(&.{emcc_path});
        emcc.addFileArg(obj.getEmittedBinDirectory().path(b, "pardes_zcu.o"));
        emcc.addFileArg(obj.getEmittedBin());
        emcc.addFileArg(b.path("vendor/stb/stb_truetype_impl.c"));
        emcc.addFileArg(b.path("vendor/stb/font.c"));
        emcc.addFileArg(zstbi_dep.path("src/zstbi.c"));
        for (wasm_libs.items) |lib| emcc.addFileArg(lib);
        emcc.addPrefixedDirectoryArg("-I", b.path("vendor/stb"));
        emcc.addPrefixedDirectoryArg("-I", zstbi_dep.path("libs/stbi"));
        emcc.addArgs(&.{
            "-std=c99",
            "-fno-sanitize=undefined",
            "-Oz",
            "-sINITIAL_MEMORY=1073741824", // the 512 MiB web heap + code/stack
            "-sEXIT_RUNTIME=0", // run() returns; the rAF loop keeps living
            "-sFULL_ES3=1",
            "-sMIN_WEBGL_VERSION=2",
            "-sMAX_WEBGL_VERSION=2",
            "-sINCOMING_MODULE_JS_API=canvas",
            "-sEXPORTED_FUNCTIONS=['_main','_pardes_web_test_cols','_pardes_web_test_rows','_pardes_web_test_cursor_x','_pardes_web_test_cursor_y','_pardes_web_test_cell','_pardes_web_test_cell_fg','_pardes_web_test_cell_bg','_pardes_web_test_cell_attrs','_pardes_web_test_cell_x','_pardes_web_test_cell_y']",
            "-o",
        });
        const js = emcc.addOutputFileArg("pardes.js");
        web_step.dependOn(&b.addInstallFileWithDir(js, .{ .custom = "web" }, "pardes.js").step);
        web_step.dependOn(&b.addInstallFileWithDir(js.dirname().path(b, "pardes.wasm"), .{ .custom = "web" }, "pardes.wasm").step);
        const html = b.addWriteFiles().add("index.html", html_shell);
        web_step.dependOn(&b.addInstallFileWithDir(html, .{ .custom = "web" }, "index.html").step);
        b.getInstallStep().dependOn(web_step);
    } else {
        // binary name: the native-shaped linux-x86_64 tty build stays `pardes`
        // (the snap suite and e2e harness drive zig-out/bin/pardes); the gui
        // shell gets its own name so it no longer clobbers the tty binary,
        // and any non linux-x86_64 target carries os-arch in the name.
        var exe_name: []const u8 = if (platform == .gui) "pardes-gui" else "pardes";
        if (target.result.os.tag != .linux or target.result.cpu.arch != .x86_64)
            exe_name = b.fmt("{s}-{s}-{s}", .{ exe_name, @tagName(target.result.os.tag), @tagName(target.result.cpu.arch) });
        const exe = b.addExecutable(.{
            .name = exe_name,
            .linkage = if (static) .static else null,
            .root_module = root_mod,
        });
        b.installArtifact(exe);
        const run = b.addRunArtifact(exe);
        b.step("run", "descend to the pardes").dependOn(&run.step);
        web_step.dependOn(&b.addFail("web needs -Dplatform=web -Dtarget=wasm32-emscripten -Ddump=<dump.zon>").step);

        // snapshot parity suite: scripted input traces in a pty, captured grids
        // diffed against the goldens in snapshots/ — the frozen record of parity
        // with the prototype this program replaced. `-- --update` regenerates.
        const snap = b.addExecutable(.{
            .name = "pardes-snap",
            .root_module = b.createModule(.{
                .target = target,
                .optimize = optimize,
                .root_source_file = b.path("test/snapshot.zig"),
                .link_libc = true,
            }),
        });
        if (ghostty_vt_for_snap) |vt| snap.root_module.addImport("ghostty-vt", vt);
        if (target.result.os.tag == .freebsd or target.result.os.tag == .netbsd or target.result.os.tag == .openbsd)
            snap.root_module.linkSystemLibrary("util", .{});
        b.installArtifact(snap);
        const run_snap = b.addRunArtifact(snap);
        run_snap.addArtifactArg(exe);
        if (b.args) |args| run_snap.addArgs(args);
        b.step("snap", "run the snapshot parity suite (-- --update regenerates goldens)").dependOn(&run_snap.step);

        // helix differential harness: drive the sans-IO core headlessly over
        // JSON-Lines cases, one contract result line per case on stdout —
        // the pardes half of the pardes-vs-helix diff (helix's hx-harness on
        // its pardes-harness branch speaks the same contract).
        const hxdiff = b.addExecutable(.{
            .name = "pardes-hxdiff",
            .root_module = b.createModule(.{
                .target = target,
                .optimize = optimize,
                .root_source_file = b.path("test/hxdiff.zig"),
                .link_libc = true,
            }),
        });
        hxdiff.root_module.addImport("pardes", hx_core_mod);
        b.installArtifact(hxdiff);
        const run_hxdiff = b.addRunArtifact(hxdiff);
        if (b.args) |args| run_hxdiff.addArgs(args) else {
            // no args: run the checked-in differential suite offline —
            // cases vs the helix goldens, waivers exempting the documented
            // pardes-isms. Nonzero exit on any unwaivered mismatch.
            run_hxdiff.addArgs(&.{
                "test/hxcases/cases.jsonl",
                "test/hxcases/goldens.jsonl",
                "test/hxcases/waivers.jsonl",
            });
            run_hxdiff.setCwd(b.path("."));
        }
        b.step("hxdiff", "run the helix differential suite (-- <cases.jsonl> [goldens.jsonl [waivers.jsonl]])").dependOn(&run_hxdiff.step);

        // file-vs-pty parity: the SAME harness binary, run in --parity mode.
        // Each case runs twice over the same text and keys, once in a file
        // pane and once in a pty pane, and the two result lines must be
        // identical — the file pane is the oracle, so editing a shell pane
        // cannot drift away from editing a document.
        const run_hxparity = b.addRunArtifact(hxdiff);
        run_hxparity.addArg("--parity");
        if (b.args) |args| run_hxparity.addArgs(args) else {
            // the WHOLE helix corpus plus the editing extras: a parity gate
            // that only ran the cases its author wrote could not catch the
            // next regression. parity-waivers names the divergences that are
            // not editing (pardes bindings, pty viewport geometry, a tab the
            // emulator expands), each with its reason.
            run_hxparity.addArgs(&.{
                "--waivers",                "test/hxcases/parity-waivers.jsonl",
                "test/hxcases/cases.jsonl", "test/hxcases/parity.jsonl",
            });
            run_hxparity.setCwd(b.path("."));
        }
        b.step("hxparity", "run the file-vs-pty editing parity suite (-- [--waivers w.jsonl] <cases.jsonl>...)").dependOn(&run_hxparity.step);

        // modal.zig is pure std — its inline unit tests run here
        const unit = b.addTest(.{ .root_module = b.createModule(.{
            .target = target,
            .optimize = optimize,
            .root_source_file = b.path("src/modal.zig"),
        }) });
        const unit_step = b.step("unit-test", "run the modal.zig + crt.zig + deck replay unit tests");
        unit_step.dependOn(&b.addRunArtifact(unit).step);
        // crt.zig is pure std — the mouse-mapping-vs-shader-formula test
        const crt_test = b.addTest(.{ .root_module = b.createModule(.{
            .target = target,
            .optimize = optimize,
            .root_source_file = b.path("src/crt.zig"),
        }) });
        unit_step.dependOn(&b.addRunArtifact(crt_test).step);
        // deck.zig is pure std too; replay.zig drives it with slices of real
        // Steam Deck recordings (test/deck/*.zon, deckcap capture format)
        const deck_test = b.addTest(.{ .root_module = b.createModule(.{
            .target = target,
            .optimize = optimize,
            .root_source_file = b.path("test/deck/replay.zig"),
        }) });
        deck_test.root_module.addImport("deck", b.createModule(.{
            .target = target,
            .optimize = optimize,
            .root_source_file = b.path("src/deck.zig"),
        }));
        unit_step.dependOn(&b.addRunArtifact(deck_test).step);
    }
}

// the html shell: a full-viewport canvas wired into Module (from the prototype)
const html_shell =
    \\<!doctype html>
    \\<html>
    \\<head>
    \\  <meta charset="utf-8">
    \\  <meta name="viewport" content="width=device-width, initial-scale=1">
    \\  <title>pardes</title>
    \\  <style>
    \\    html, body { margin: 0; width: 100%; height: 100%; background: #121212; }
    \\    body { overflow: hidden; }
    \\    canvas { display: block; width: 100vw; height: 100vh; outline: none;
    \\      image-rendering: crisp-edges; image-rendering: pixelated;
    \\      -ms-interpolation-mode: nearest-neighbor;
    \\      transform: translateZ(0); backface-visibility: hidden;
    \\      touch-action: none; }
    \\  </style>
    \\</head>
    \\<body>
    \\  <canvas id="canvas" tabindex="0"></canvas>
    \\  <script>
    \\    const canvas = document.getElementById('canvas');
    \\    canvas.addEventListener('contextmenu', (event) => event.preventDefault());
    \\    canvas.addEventListener('pointerdown', (event) => {
    \\      canvas.focus({ preventScroll: true });
    \\      canvas.setPointerCapture?.(event.pointerId);
    \\    });
    \\    const releasePointer = (event) => {
    \\      if (canvas.hasPointerCapture?.(event.pointerId)) canvas.releasePointerCapture(event.pointerId);
    \\    };
    \\    canvas.addEventListener('pointerup', releasePointer);
    \\    canvas.addEventListener('pointercancel', releasePointer);
    \\    window.Module = { canvas };
    \\  </script>
    \\  <script src="pardes.js"></script>
    \\</body>
    \\</html>
;

// a bad -D combination: fail both `zig build` and `zig build web` with the why
fn failBuild(b: *std.Build, web_step: *std.Build.Step, msg: []const u8) void {
    const fail = &b.addFail(msg).step;
    web_step.dependOn(fail);
    b.getInstallStep().dependOn(fail);
}

// glslc <src> -fshader-stage=<stage> -o <out> -> .spv, returned as a LazyPath
// for @embedFile. Only used by the SDL3 GPU shell (-Dplatform=gui).
fn compileGlsl(b: *std.Build, src: []const u8, stage: []const u8, out: []const u8) std.Build.LazyPath {
    const cmd = b.addSystemCommand(&.{ "glslc", b.fmt("-fshader-stage={s}", .{stage}) });
    cmd.addFileArg(b.path(src));
    cmd.addArg("-o");
    return cmd.addOutputFileArg(out);
}

fn detectEmscriptenSysroot(b: *std.Build) ?[]const u8 {
    if (b.option([]const u8, "emscripten-sysroot", "path to the Emscripten sysroot")) |path| return path;
    if (b.sysroot) |path| return path;
    if (envPath(b, "EMSCRIPTEN_SYSROOT")) |path| return path;
    if (envPath(b, "EM_CACHE")) |cache| return b.pathJoin(&.{ cache, "sysroot" });
    if (envPath(b, "EMSDK")) |emsdk| return b.pathJoin(&.{ emsdk, "upstream", "emscripten", "cache", "sysroot" });
    if (envPath(b, "HOME")) |home| return b.pathJoin(&.{ home, ".cache", "emscripten", "sysroot" });
    return null;
}

fn envPath(b: *std.Build, name: []const u8) ?[]const u8 {
    return b.graph.environ_map.get(name);
}