scsynth-sys 0.1.0

Raw FFI bindings to a statically-linked SuperCollider scsynth engine.
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
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
//! Builds and links SuperCollider's `libscsynth` and generates Rust bindings over the engine's
//! `extern "C"` surface.
//!
//! Two very different builds share one binding surface:
//!
//! * NATIVE ([`build_native`]): CMake builds a static `libscsynth` (with our host-pumped
//!   `AUDIOAPI=external` driver) against the system C/C++ toolchain, libsndfile and fftw.
//! * WASM ([`build_wasm`]): there is no system toolchain or libsndfile/fftw, so the engine's DSP
//!   core + static UGen plugins are compiled FROM SOURCE to `wasm32-unknown-unknown` against a
//!   from-source musl + libc++, driverless (`mRealTime=false`) via `csrc/SC_WasmPump.cpp`. Mirrors
//!   SuperCollider's own emscripten/webaudio build.
//!
//! Both paths resolve the SuperCollider source (env var or fetched at the `source.toml` revision;
//! see [`supercollider_source`]), copy it into `OUT_DIR` and patch the copy, and both generate
//! `bindings.rs` from `SC_WorldOptions.h` with the same allowlist so `crates/scsynth` sees one API.

use std::env;
use std::fs;
use std::path::{Path, PathBuf};

fn main() {
    // The wasm engine is compiled from source against the from-source musl/libc++ (no CMake,
    // libsndfile or fftw exist for this target); the native engine uses CMake + system libs.
    if env::var("CARGO_CFG_TARGET_ARCH").as_deref() == Ok("wasm32") {
        build_wasm();
    } else {
        build_native();
    }
}

/// Locate the SuperCollider source tree (asserted present).
///
/// `SCSYNTH_SYS_SUPERCOLLIDER_DIR` (set by the Nix build and the dev shell) takes precedence and
/// never fetches. Otherwise the revision pinned in `source.toml` is git-fetched into a cache (see
/// [`fetch_pinned_source`]) - so the published crate ships no vendored source yet the build still
/// gets the exact pinned tree.
fn supercollider_source() -> PathBuf {
    let dir = resolve_pinned_source("SCSYNTH_SYS_SUPERCOLLIDER_DIR", "supercollider", true);
    assert!(
        dir.join("CMakeLists.txt").is_file(),
        "SuperCollider source missing/incomplete at {} - set SCSYNTH_SYS_SUPERCOLLIDER_DIR to a \
         checkout, or allow the build to fetch it (needs `git` + network)",
        dir.display(),
    );
    dir
}

// ---------------------------------------------------------------------------
// Native build (CMake + system libsndfile/fftw + the host-pumped external driver).
// ---------------------------------------------------------------------------

fn build_native() {
    let sc_src = supercollider_source();

    let out_dir = PathBuf::from(env::var("OUT_DIR").unwrap());
    let sc = out_dir.join("sc-src");

    // Copy the pinned source into a writable tree (once) and apply the AUDIOAPI=external patch.
    if !sc.join("CMakeLists.txt").is_file() {
        copy_tree(&sc_src, &sc);
        patch_audioapi_external(&sc);
    }
    // Keep our driver source in sync (only rewrite when it changes, to avoid needless rebuilds).
    sync_file(
        &PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap()).join("csrc/SC_ExternalDriver.cpp"),
        &sc.join("server/scsynth/SC_ExternalDriver.cpp"),
    );

    let inc_server = sc.join("include/server");
    let inc_common = sc.join("include/common");
    let inc_plugin = sc.join("include/plugin_interface");
    let inc_common_dir = sc.join("common");

    // --- 1. Build a static `libscsynth` (static plugins, host-pumped external driver). ---
    let dst = cmake::Config::new(&sc)
        .define("CMAKE_BUILD_TYPE", "Release")
        .define("LIBSCSYNTH", "OFF")
        .define("STATIC_PLUGINS", "ON")
        .define("SUPERNOVA", "OFF")
        .define("SC_QT", "OFF")
        .define("SC_IDE", "OFF")
        .define("SCLANG_SERVER", "OFF")
        .define("NO_X11", "ON")
        .define("NO_AVAHI", "ON")
        .define("SC_HIDAPI", "OFF")
        .define("SC_ABLETON_LINK", "OFF")
        .define("ENABLE_TESTSUITE", "OFF")
        .define("INSTALL_HELP", "OFF")
        .define("AUDIOAPI", "external")
        .build_target("libscsynth")
        .build();
    let build_dir = dst.join("build");

    // --- 2. Link the static libraries CMake produced (consumers before providers). ---
    let scsynth_a = find_file(&build_dir, "libscsynth.a")
        .unwrap_or_else(|| panic!("libscsynth.a not found under {}", build_dir.display()));
    let tlsf_a = find_file(&build_dir, "libtlsf.a")
        .unwrap_or_else(|| panic!("libtlsf.a not found under {}", build_dir.display()));
    link_search(scsynth_a.parent().unwrap());
    link_search(tlsf_a.parent().unwrap());
    println!("cargo:rustc-link-lib=static=scsynth");
    println!("cargo:rustc-link-lib=static=tlsf");

    // --- 3. Compile the shim (`WorldOptions` default + `ReplyAddress` accessor; a C++ consumer of
    // libstdc++). `SC_ReplyImpl.hpp` pulls `boost/asio.hpp` on native, so add the boost include dirs.
    cc::Build::new()
        .cpp(true)
        .std("c++17")
        .include(&inc_server)
        .include(&inc_common)
        .include(&inc_plugin)
        .include(&inc_common_dir)
        .include(sc.join("external_libraries/boost"))
        .include(sc.join("external_libraries/boost_sync/include"))
        .define("SC_MEMORY_ALIGNMENT", "32")
        .file("csrc/shim.cpp")
        .compile("scsynth_shim");

    // --- 4. System libraries `libscsynth` references (providers, after the static libs). ---
    pkg_config::probe_library("sndfile").expect("libsndfile (sndfile.pc)");
    pkg_config::probe_library("fftw3f").expect("fftw3f (fftw3f.pc)");
    for lib in ["pthread", "dl", "rt", "m"] {
        println!("cargo:rustc-link-lib=dylib={lib}");
    }
    // The C++ runtime must come last so it satisfies the static C++ archives above.
    println!("cargo:rustc-link-lib=dylib=stdc++");

    // --- 5. Generate bindings over the engine's `extern "C"` API. ---
    let bindings = bindgen::Builder::default()
        .header(inc_server.join("SC_WorldOptions.h").to_string_lossy())
        .clang_args(["-x", "c++", "-std=c++17"])
        .clang_arg(format!("-I{}", inc_server.display()))
        .clang_arg(format!("-I{}", inc_common.display()))
        .clang_arg(format!("-I{}", inc_plugin.display()))
        .clang_arg(format!("-I{}", inc_common_dir.display()))
        .clang_arg("-DSC_MEMORY_ALIGNMENT=32");
    write_bindings(allowlist(bindings));

    println!("cargo:rerun-if-changed=csrc/shim.cpp");
    println!("cargo:rerun-if-changed=csrc/SC_ExternalDriver.cpp");
    println!("cargo:rerun-if-changed=build.rs");
}

// ---------------------------------------------------------------------------
// Wasm build (from-source compile, driverless `mRealTime=false`, from-source musl + libc++).
//
// The vendored SuperCollider submodule is kept PRISTINE: sources are copied into `OUT_DIR/sc` and a
// handful of `#ifndef SC_WASM` guards are applied to the COPIES (see `patch_sc_sources`), each
// `assert!`-guarded so an upstream layout change fails loudly.
// ---------------------------------------------------------------------------

fn build_wasm() {
    let sc_src = supercollider_source()
        .canonicalize()
        .expect("SuperCollider source not found");
    let out_dir = PathBuf::from(env::var("OUT_DIR").unwrap());

    // Copy the pinned source into a writable scratch tree (once) and apply the SC_WASM guards.
    let sc = out_dir.join("sc");
    if !sc.join("server/scsynth/SC_World.cpp").is_file() {
        copy_tree(&sc_src, &sc);
        patch_sc_sources(&sc);
        generate_version_header(&sc);
    }

    // --- Toolchain include dirs exported by the from-source musl + libc++ deps. ---
    let libc_includes: Vec<String> = env::var("DEP_WASM32_LIBC_INCLUDE")
        .unwrap_or_default()
        .split(':')
        .filter(|s| !s.is_empty())
        .map(str::to_string)
        .collect();
    let libcxx_include = env::var("DEP_WASM32_LIBCXX_INCLUDE").unwrap_or_default();
    assert!(
        !libc_includes.is_empty() && !libcxx_include.is_empty(),
        "DEP_WASM32_LIBC_INCLUDE / DEP_WASM32_LIBCXX_INCLUDE not set - the from-source libc/libc++ \
         deps must export them via their own build scripts",
    );

    // --- SuperCollider include roots. ---
    let inc = [
        sc.join("include/server"),
        sc.join("include/common"),
        sc.join("include/plugin_interface"),
        sc.join("common"),
        sc.join("server/scsynth"),
        sc.join("server/plugins"),
        sc.join("external_libraries/boost"),
        sc.join("external_libraries/boost_sync/include"),
        sc.join("external_libraries/nova-simd"),
        sc.join("external_libraries/nova-tt"),
        sc.join("external_libraries/TLSF-2.4.6/src"),
    ];

    // --- The scsynth engine + plugin sources (relative to the copied tree). ---
    let core = [
        "server/scsynth/SC_BufGen.cpp",
        "server/scsynth/SC_Graph.cpp",
        "server/scsynth/SC_GraphDef.cpp",
        "server/scsynth/SC_Group.cpp",
        "server/scsynth/SC_Lib_Cintf.cpp",
        "server/scsynth/SC_Lib.cpp",
        "server/scsynth/SC_MiscCmds.cpp",
        "server/scsynth/SC_Node.cpp",
        "server/scsynth/SC_Rate.cpp",
        "server/scsynth/SC_SequencedCommand.cpp",
        "server/scsynth/SC_Str4.cpp",
        "server/scsynth/SC_Unit.cpp",
        "server/scsynth/SC_UnitDef.cpp",
        "server/scsynth/SC_World.cpp",
        "server/scsynth/SC_CoreAudio.cpp",
    ];
    // SC_Reply.cpp is intentionally EXCLUDED, mirroring SuperCollider's own webaudio build
    // (server/scsynth/CMakeLists.txt REMOVE_ITEMs it): its ReplyAddress operators dereference the
    // boost::asio `mAddress` field, which we drop on wasm. The pump shim provides those symbols.
    let common = [
        "common/SC_fftlib.cpp",
        "common/SC_AllocPool.cpp",
        "common/SC_Errors.cpp",
        "common/SC_StringBuffer.cpp",
        "common/SC_StringParser.cpp",
        "common/Samp.cpp",
    ];
    // C sources: the green FFT (fftlib.c provides rffts/riffts, included extern "C" by SC_fftlib.cpp)
    // and TLSF (scope_buffer.hpp's pool, referenced by the unused shared-memory creator). These MUST
    // compile as C so their symbols are unmangled to match the extern "C" call sites.
    let c_sources = [
        "common/fftlib.c",
        "external_libraries/TLSF-2.4.6/src/tlsf.c",
    ];
    // Exactly the plugin TUs whose `*_Load` is called by SC_Lib_Cintf.cpp's STATIC_PLUGINS block,
    // plus the three FFT module TUs reached transitively by FFT_UGens_Load (FFTInterfaceTable.cpp ->
    // initFFT/initPV/initPartConv). Derived from server/plugins/CMakeLists.txt. This is the same
    // UGen set the native CMake build registers (which additionally registers DiskIO via libsndfile);
    // UGens SuperCollider ships only as separately-loaded plugins (ChaosUGens, the MIR/analysis set,
    // PV third-party onset detectors, UnpackFFTUGens, DemoUGens) are absent from the static load list
    // and so are unavailable on both targets. See the crate README's "Plugins (UGens)" section.
    let plugins = [
        "server/plugins/IOUGens.cpp",
        "server/plugins/OscUGens.cpp",
        "server/plugins/DelayUGens.cpp",
        "server/plugins/BinaryOpUGens.cpp",
        "server/plugins/FilterUGens.cpp",
        "server/plugins/GendynUGens.cpp",
        "server/plugins/LFUGens.cpp",
        "server/plugins/NoiseUGens.cpp",
        "server/plugins/MulAddUGens.cpp",
        "server/plugins/GrainUGens.cpp",
        "server/plugins/PanUGens.cpp",
        "server/plugins/ReverbUGens.cpp",
        "server/plugins/TriggerUGens.cpp",
        "server/plugins/UnaryOpUGens.cpp",
        "server/plugins/PhysicalModelingUGens.cpp",
        "server/plugins/TestUGens.cpp",
        "server/plugins/DemandUGens.cpp",
        "server/plugins/DynNoiseUGens.cpp",
        "server/plugins/FFTInterfaceTable.cpp",
        "server/plugins/FFT_UGens.cpp",
        "server/plugins/PV_UGens.cpp",
        "server/plugins/PartitionedConvolution.cpp",
    ];

    // --- C gap symbols (aligned alloc, math, pthread/libc shims the minimal libc lacks). ---
    let mut c = cc::Build::new();
    for d in &libc_includes {
        c.include(d);
    }
    // The gap intentionally redeclares libc built-ins (fprintf/vfprintf/fwrite) with our opaque
    // `GapFile` instead of pulling <stdio.h>'s FILE; silence clang's -Wbuiltin-requires-header.
    c.flag("-Wno-builtin-requires-header")
        .file("csrc/libc_gap.c")
        .compile("libc_gap");

    // --- SuperCollider C sources (green FFT + TLSF), compiled as C for unmangled linkage. ---
    let mut sc_c = cc::Build::new();
    sc_c.std("c11")
        .flag("-w")
        .define("SC_FFT_GREEN", None)
        .define("SC_WASM", None)
        .define("NDEBUG", None);
    for d in &libc_includes {
        sc_c.include(d);
    }
    for d in &inc {
        sc_c.include(d);
    }
    for f in &c_sources {
        sc_c.file(sc.join(f));
    }
    sc_c.compile("scsynth_wasm_c");

    // --- The scsynth engine: one big archive built with wasm-native exceptions + RTTI. ---
    let mut sc_build = cc::Build::new();
    sc_build
        .cpp(true)
        .std("c++17")
        .cpp_link_stdlib(None)
        .flag("-fwasm-exceptions")
        .flag("-w") // SC emits many warnings; quiet them during bring-up.
        .define("NO_LIBSNDFILE", None)
        .define("NO_X11", None) // no UI plugins; drops the UIUGens_Unload reference in SC_Lib_Cintf.
        .define("STATIC_PLUGINS", None)
        .define("SC_MEMORY_ALIGNMENT", "32")
        .define("SC_FFT_GREEN", None)
        .define("SC_WASM", None)
        // Pick the webaudio audio-API id so SC_CoreAudio.h compiles without pulling a device backend
        // header. No driver is constructed under mRealTime=false; the pump shim stubs
        // SC_NewAudioDriver (referenced, never called) to keep the link satisfied.
        .define("SC_AUDIO_API", "SC_AUDIO_API_WEBAUDIO")
        .define("NDEBUG", None);
    sc_build.include(&libcxx_include);
    for d in &libc_includes {
        sc_build.include(d);
    }
    for d in &inc {
        sc_build.include(d);
    }
    for f in core.iter().chain(common.iter()).chain(plugins.iter()) {
        sc_build.file(sc.join(f));
    }
    // The driverless pump shim + SC_WebAudio.cpp-style fake implementations, and the `WorldOptions`
    // default-constructor shim (shared with native; consumes libc++ here).
    sc_build.file("csrc/SC_WasmPump.cpp");
    sc_build.file("csrc/shim.cpp");
    sc_build.compile("scsynth_wasm");

    // Force-link the Rust-based libc (malloc/free/etc.) and the from-source libc++.
    println!("cargo::rustc-link-lib=wasm32-libcxx");
    println!("cargo::rustc-link-lib=wasm32-libc");

    // --- Generate bindings over the engine's `extern "C"` API, for the wasm32 target/ABI. ---
    let mut bindings = bindgen::Builder::default()
        .header(
            sc.join("include/server/SC_WorldOptions.h")
                .to_string_lossy(),
        )
        .clang_args(["-x", "c++", "-std=c++17"])
        .clang_arg("-target")
        .clang_arg("wasm32-unknown-unknown")
        .clang_arg("-DSC_MEMORY_ALIGNMENT=32")
        // Match the engine compile so the patched SC_WASM header branches are taken (e.g.
        // SC_Endian.h / SC_ReplyImpl.hpp) and the struct layouts agree with the compiled objects.
        .clang_arg("-DSC_WASM")
        .clang_arg("-DSC_FFT_GREEN")
        .clang_arg("-DSC_AUDIO_API=SC_AUDIO_API_WEBAUDIO");
    for d in &inc {
        bindings = bindings.clang_arg(format!("-I{}", d.display()));
    }
    // libc++ first, then libc: clang resolves system headers (<cstdint>, <cstddef>, ...) the SC
    // headers pull in transitively for the wasm32 target.
    bindings = bindings.clang_arg(format!("-I{libcxx_include}"));
    for d in &libc_includes {
        bindings = bindings.clang_arg(format!("-I{d}"));
    }
    write_bindings(allowlist(bindings));

    println!("cargo::rerun-if-changed=csrc/SC_WasmPump.cpp");
    println!("cargo::rerun-if-changed=csrc/shim.cpp");
    println!("cargo::rerun-if-changed=csrc/libc_gap.c");
    println!("cargo::rerun-if-changed=build.rs");
}

// ---------------------------------------------------------------------------
// Shared bindgen configuration.
// ---------------------------------------------------------------------------

/// Apply the allowlist/opaque configuration shared by both targets. The header + include args
/// (which differ per target) are configured by the caller.
fn allowlist(builder: bindgen::Builder) -> bindgen::Builder {
    builder
        .allowlist_function("World_.*")
        .allowlist_function("scprintf")
        .allowlist_function("SetPrintFunc")
        .allowlist_type("WorldOptions")
        .allowlist_type("ReplyFunc")
        .allowlist_type("PrintFunc")
        // Allowlist the opaque engine types directly (not just via the functions that reference
        // them). On native they are pulled in by the `World_*` prototypes anyway; on wasm32, libclang
        // does not surface the `extern "C"` function declarations to bindgen (a known cross-target
        // libclang quirk - the `WorldOptions` struct still comes out, with the correct 32-bit
        // layout), so `crates/scsynth-sys` declares the few `World_*` it needs by hand. Allowlisting
        // the type here makes the opaque `World` available on both targets regardless.
        .allowlist_type("World")
        .allowlist_type("HiddenWorld")
        // Keep the engine internals opaque - we only ever hold pointers to these.
        .opaque_type("World")
        .opaque_type("HiddenWorld")
        .opaque_type("ReplyAddress")
        .opaque_type("SndBuf")
        .blocklist_type("std::.*")
        .blocklist_type("boost::.*")
}

/// Generate and write `bindings.rs` to `OUT_DIR`.
fn write_bindings(builder: bindgen::Builder) {
    let bindings = builder.generate().expect("failed to generate bindings");
    let out = PathBuf::from(env::var("OUT_DIR").unwrap());
    bindings
        .write_to_file(out.join("bindings.rs"))
        .expect("failed to write bindings");
}

// ---------------------------------------------------------------------------
// Pinned-source resolution: env-var override, else git-fetch the pinned revision.
//
// We fetch the upstream source at build time (rather than vendoring it in the crate) because
// SuperCollider is far larger than the crates.io package-size limit; this keeps the published crate
// tiny. Nix and the dev shell set the env var to a pre-fetched checkout, so they never fetch. The
// revision lives in `source.toml`, which the flake reads too - so the two cannot drift.
// ---------------------------------------------------------------------------

/// Resolve a pinned upstream source tree: the `env_var` override if set (Nix / dev shell), else the
/// revision in `source.toml` git-fetched into a rev-keyed cache.
fn resolve_pinned_source(env_var: &str, name: &str, recurse_submodules: bool) -> PathBuf {
    println!("cargo::rerun-if-env-changed={env_var}");
    if let Some(dir) = env::var_os(env_var) {
        return PathBuf::from(dir);
    }
    let (url, rev) = read_pin();
    let dir = source_cache_dir(name, &rev);
    if !dir.join(".fetched").is_file() {
        fetch_pinned_source(&url, &rev, &dir, recurse_submodules);
        fs::write(dir.join(".fetched"), &rev).unwrap();
    }
    dir
}

/// Read `{ url, rev }` from `source.toml` next to this crate. Hand-parsed (two `key = "value"` lines)
/// to avoid a build dependency; it ships inside the published crate so the fetch is self-contained.
fn read_pin() -> (String, String) {
    let path = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap()).join("source.toml");
    println!("cargo::rerun-if-changed={}", path.display());
    let text = fs::read_to_string(&path).unwrap_or_else(|e| panic!("read {}: {e}", path.display()));
    let field = |key: &str| -> String {
        for line in text.lines() {
            let line = line.trim();
            if line.starts_with('#') {
                continue;
            }
            if let Some((k, v)) = line.split_once('=')
                && k.trim() == key
            {
                return v.trim().trim_matches('"').to_string();
            }
        }
        panic!("`{key}` not found in {}", path.display());
    };
    (field("url"), field("rev"))
}

/// A persistent, rev-keyed cache dir for fetched sources, so `cargo clean` doesn't force a re-fetch.
/// Overridable with `SCSYNTH_SRC_CACHE_DIR`; falls back to `OUT_DIR` when no cache home is available.
fn source_cache_dir(name: &str, rev: &str) -> PathBuf {
    let base = env::var_os("SCSYNTH_SRC_CACHE_DIR")
        .map(PathBuf::from)
        .or_else(|| env::var_os("XDG_CACHE_HOME").map(|c| PathBuf::from(c).join("scsynth-rs")))
        .or_else(|| env::var_os("HOME").map(|h| PathBuf::from(h).join(".cache/scsynth-rs")))
        .unwrap_or_else(|| PathBuf::from(env::var("OUT_DIR").unwrap()).join("scsynth-rs-src"));
    base.join(name).join(rev)
}

/// Git-fetch `rev` from `url` into `dir`. Prefers a shallow fetch of the exact rev (works on GitHub);
/// falls back to a full fetch for hosts that disallow fetching an arbitrary SHA.
fn fetch_pinned_source(url: &str, rev: &str, dir: &Path, recurse_submodules: bool) {
    let _ = fs::remove_dir_all(dir);
    fs::create_dir_all(dir).unwrap();
    git(dir, &["init", "-q"]);
    git(dir, &["remote", "add", "origin", url]);
    let target = if try_git(dir, &["fetch", "-q", "--depth", "1", "origin", rev]) {
        "FETCH_HEAD".to_string()
    } else {
        git(dir, &["fetch", "-q", "origin"]);
        rev.to_string()
    };
    git(dir, &["checkout", "-q", "--detach", &target]);
    if recurse_submodules {
        git(dir, &["submodule", "update", "--init", "--recursive"]);
    }
}

/// Run `git -C dir <args>`, panicking on failure.
fn git(dir: &Path, args: &[&str]) {
    assert!(
        try_git(dir, args),
        "git {args:?} failed in {}",
        dir.display()
    );
}

/// Run `git -C dir <args>`, returning whether it succeeded.
fn try_git(dir: &Path, args: &[&str]) -> bool {
    std::process::Command::new("git")
        .arg("-C")
        .arg(dir)
        .args(args)
        .status()
        .map(|s| s.success())
        .unwrap_or(false)
}

// ---------------------------------------------------------------------------
// Shared helpers.
// ---------------------------------------------------------------------------

/// Emit a native link-search directory.
fn link_search(dir: &Path) {
    println!("cargo:rustc-link-search=native={}", dir.display());
}

/// Recursively copy `src` into `dst`, skipping `.git` entries.
fn copy_tree(src: &Path, dst: &Path) {
    fs::create_dir_all(dst).unwrap();
    for entry in fs::read_dir(src).unwrap() {
        let entry = entry.unwrap();
        if entry.file_name() == ".git" {
            continue;
        }
        let from = entry.path();
        let to = dst.join(entry.file_name());
        let file_type = entry.file_type().unwrap();
        if file_type.is_dir() {
            copy_tree(&from, &to);
        } else if file_type.is_symlink() {
            let target = fs::read_link(&from).unwrap();
            let _ = std::os::unix::fs::symlink(target, &to);
        } else {
            fs::copy(&from, &to).unwrap();
            // `fs::copy` preserves source permissions; a source from the read-only Nix store (or a
            // read-only env-var checkout) would make the copy read-only too, but the build patches
            // some of these files in place - so add the owner-write bit.
            use std::os::unix::fs::PermissionsExt;
            let perms = fs::metadata(&to).unwrap().permissions();
            let mode = perms.mode();
            if mode & 0o200 == 0 {
                fs::set_permissions(&to, fs::Permissions::from_mode(mode | 0o200)).unwrap();
            }
        }
    }
}

/// Write `src` to `dst`, but only if the content differs (preserves mtime to avoid rebuilds).
fn sync_file(src: &Path, dst: &Path) {
    let new = fs::read(src).unwrap();
    if fs::read(dst).is_ok_and(|old| old == new) {
        return;
    }
    fs::create_dir_all(dst.parent().unwrap()).unwrap();
    fs::write(dst, new).unwrap();
}

/// Recursively find the first file named `name` under `root`.
fn find_file(root: &Path, name: &str) -> Option<PathBuf> {
    let mut stack = vec![root.to_path_buf()];
    while let Some(dir) = stack.pop() {
        let entries = fs::read_dir(&dir).ok()?;
        for entry in entries.flatten() {
            let path = entry.path();
            if path.is_dir() {
                stack.push(path);
            } else if path.file_name().is_some_and(|n| n == name) {
                return Some(path);
            }
        }
    }
    None
}

// ---------------------------------------------------------------------------
// Native source patch: add an `AUDIOAPI=external` branch compiling our host-pumped driver.
// ---------------------------------------------------------------------------

/// Patch `server/scsynth/CMakeLists.txt` to add an `AUDIOAPI=external` branch that compiles our
/// host-pumped `SC_ExternalDriver.cpp`. Asserts on each edit so an upstream layout change fails
/// loudly rather than silently producing the wrong build.
fn patch_audioapi_external(sc: &Path) {
    // Both scsynth and (the still-configured) sclang validate AUDIOAPI; allow `external` in each.
    for rel in ["server/scsynth/CMakeLists.txt", "lang/CMakeLists.txt"] {
        let path = sc.join(rel);
        let regex_old = "^(jack|coreaudio|portaudio|bela|webaudio)$";
        let regex_new = "^(jack|coreaudio|portaudio|bela|webaudio|external)$";
        let mut text = fs::read_to_string(&path).unwrap();
        assert!(
            text.contains(regex_old),
            "AUDIOAPI validation regex not found in {rel} - SuperCollider CMake layout changed"
        );
        text = text.replace(regex_old, regex_new);
        fs::write(&path, text).unwrap();
    }

    // Add the `external` driver branch to scsynth's audio-API selection.
    let path = sc.join("server/scsynth/CMakeLists.txt");
    let mut text = fs::read_to_string(&path).unwrap();
    let anchor = "add_definitions(\"-DSC_AUDIO_API=SC_AUDIO_API_JACK\")";
    let injected = concat!(
        "add_definitions(\"-DSC_AUDIO_API=SC_AUDIO_API_JACK\")\n",
        "elseif(AUDIOAPI STREQUAL external)\n",
        "\tlist(APPEND scsynth_sources ${CMAKE_SOURCE_DIR}/server/scsynth/SC_ExternalDriver.cpp)\n",
        "\tadd_definitions(\"-DSC_AUDIO_API_EXTERNAL=8\" \"-DSC_AUDIO_API=SC_AUDIO_API_EXTERNAL\")",
    );
    assert!(
        text.contains(anchor),
        "jack AUDIOAPI branch not found - SuperCollider CMake layout changed"
    );
    text = text.replace(anchor, injected);

    fs::write(&path, text).unwrap();
}

// ---------------------------------------------------------------------------
// Wasm source patches: minimal `#ifndef SC_WASM` guards so the engine links without the excluded
// filesystem / shared-memory / device-driver / networking sources. Each edit is `assert!`-guarded
// so an upstream layout change fails loudly rather than silently mis-building.
// ---------------------------------------------------------------------------

fn patch_sc_sources(sc: &Path) {
    // SC_Endian.h: there is no platform branch for bare wasm (it would `#error cannot find
    // endianess`). wasm is little-endian and has no <netinet/in.h>; add an SC_WASM branch that
    // defines the endian macros and SC_NO_ENDIAN_FUNCTIONS (so byte-swaps use the pure-C path),
    // mirroring the existing _WIN32 branch.
    patch(
        sc,
        "include/common/SC_Endian.h",
        &[(
            "#elif defined(__EMSCRIPTEN__)\n\n#    include <endian.h>\n#    include <netinet/in.h>\n",
            "#elif defined(__EMSCRIPTEN__)\n\n#    include <endian.h>\n#    include <netinet/in.h>\n\n\
             #elif defined(SC_WASM) // SC_WASM: wasm is little-endian; no <netinet/in.h>.\n\n\
             #    define LITTLE_ENDIAN 1234\n#    define BIG_ENDIAN 4321\n\
             #    define BYTE_ORDER LITTLE_ENDIAN\n#    define SC_NO_ENDIAN_FUNCTIONS\n",
        )],
    );

    // SC_ReplyImpl.hpp: the ReplyAddress struct embeds a boost::asio ip::address (and includes the
    // whole of <boost/asio.hpp>) unless __EMSCRIPTEN__ is defined. boost::asio pulls networking /
    // threads / OS headers that do not build for bare wasm. Extend the existing emscripten guards to
    // also fire under SC_WASM, exactly as the emscripten web build relies on them.
    patch(
        sc,
        "common/SC_ReplyImpl.hpp",
        &[
            (
                "#ifndef __EMSCRIPTEN__\n#    include <boost/asio.hpp>\n#endif",
                "#if !defined(__EMSCRIPTEN__) && !defined(SC_WASM)\n#    include <boost/asio.hpp>\n#endif",
            ),
            (
                "#ifndef __EMSCRIPTEN__\n    boost::asio::ip::address mAddress;\n#endif",
                "#if !defined(__EMSCRIPTEN__) && !defined(SC_WASM)\n    boost::asio::ip::address mAddress;\n#endif",
            ),
        ],
    );

    // SC_World.cpp: <filesystem> is only needed for World_LoadGraphDefs (mLoadGraphDefs=false, never
    // called). Guard it so we don't link libc++ <filesystem>. (server_shm.hpp / boost::interprocess
    // is pulled in unconditionally via SC_HiddenWorld.h and DOES compile for wasm; the unused
    // shared-memory creator is never instantiated because mSharedMemoryID is forced 0, so its
    // mmap/shm/TLSF symbols only need to LINK - see the syscall stubs in libc_gap.c and TLSF below.)
    patch(
        sc,
        "server/scsynth/SC_World.cpp",
        &[(
            "#include <filesystem>\n\nnamespace fs = std::filesystem;",
            "#ifndef SC_WASM // SC_WASM: <filesystem>/World_LoadGraphDefs unused \
             (mLoadGraphDefs=false).\n#include <filesystem>\n\nnamespace fs = std::filesystem;\n#endif",
        )],
    );
    // SC_World.cpp: guard the body of World_LoadGraphDefs (uses SC_Filesystem + fs::, both excluded).
    // It is only reached when mLoadGraphDefs is true, which we never set.
    patch(
        sc,
        "server/scsynth/SC_World.cpp",
        &[(
            "void World_LoadGraphDefs(World* world) {\n    GraphDef* list = nullptr;",
            "void World_LoadGraphDefs(World* world) {\n#ifndef SC_WASM // SC_WASM: never called \
             (mLoadGraphDefs=false); body uses excluded SC_Filesystem.\n    GraphDef* list = nullptr;",
        )],
    );
    patch(
        sc,
        "server/scsynth/SC_World.cpp",
        &[(
            "        list = GraphDef_LoadDir(world, path, list);\n        GraphDef_Define(world, list);\n    }\n}",
            "        list = GraphDef_LoadDir(world, path, list);\n        GraphDef_Define(world, list);\n    }\n#endif\n}",
        )],
    );

    // SC_GraphDef.cpp: the file-loading GraphDef_Load{,Dir,Glob} + load_file use SC_Filesystem and
    // libc++ <filesystem> directory iteration (the SC_Filesystem.cpp TU is excluded). They are only
    // reached by /d_load and /d_loadDir, which we never send. Replace their bodies with no-ops that
    // return the list unchanged so the symbols still link for the (unsent) command handlers.
    // GraphDef_Recv (the /d_recv path) reads from a memory buffer and is left intact.
    patch(
        sc,
        "server/scsynth/SC_GraphDef.cpp",
        &[(
            "#include \"SC_Filesystem.hpp\"",
            "#ifndef SC_WASM // SC_WASM: SC_Filesystem.cpp TU excluded; file loaders stubbed below.\n\
             #include \"SC_Filesystem.hpp\"\n#endif",
        )],
    );
    // Guard the include of <filesystem>/<fstream> too (the no-op loaders below don't need them,
    // and the signatures only reference std::filesystem::path which <filesystem> in the header
    // SC_GraphDef.h still provides for declarations).
    patch(
        sc,
        "server/scsynth/SC_GraphDef.cpp",
        &[(
            "#include <filesystem>\n#include <fstream>",
            "#include <filesystem>\n#ifndef SC_WASM\n#include <fstream>\n#endif",
        )],
    );
    // Replace the three filesystem loaders + load_file with SC_WASM no-ops (return list unchanged).
    let loaders_old =
        "GraphDef* GraphDef_LoadGlob(World* inWorld, const char* pattern, GraphDef* inList) {";
    let loaders_guard_open =
        "#ifndef SC_WASM // SC_WASM: filesystem loaders excluded (no /d_load on wasm).\n";
    patch(
        sc,
        "server/scsynth/SC_GraphDef.cpp",
        &[(loaders_old, &format!("{loaders_guard_open}{loaders_old}"))],
    );
    // Close the guard after GraphDef_LoadDir and supply no-op definitions for the three symbols.
    let after_loaders = "void UnitSpec_Free(UnitSpec* inUnitSpec);";
    let noops = concat!(
        "#else\n",
        "// SC_WASM no-op loaders: /d_load and /d_loadDir are never sent on wasm, but the command\n",
        "// handlers reference these symbols, so provide list-passthrough definitions.\n",
        "GraphDef* GraphDef_LoadGlob(World*, const char*, GraphDef* inList) { return inList; }\n",
        "GraphDef* GraphDef_Load(World*, const std::filesystem::path&, GraphDef* inList) { return inList; }\n",
        "GraphDef* GraphDef_LoadDir(World*, const std::filesystem::path&, GraphDef* inList) { return inList; }\n",
        "#endif\n",
        "void UnitSpec_Free(UnitSpec* inUnitSpec);",
    );
    patch(
        sc,
        "server/scsynth/SC_GraphDef.cpp",
        &[(after_loaders, noops)],
    );
}

/// Generate `common/SC_Version.hpp` from `SC_Version.hpp.in` (normally done by CMake configure).
/// The version triple comes from the submodule's SCVersion.txt; git ref fields are not used by the
/// engine's runtime, only by /version replies, so fixed placeholders are fine.
fn generate_version_header(sc: &Path) {
    let scver = fs::read_to_string(sc.join("SCVersion.txt")).expect("read SCVersion.txt");
    let field = |name: &str| -> String {
        let needle = format!("set({name} ");
        let line = scver
            .lines()
            .find(|l| l.trim_start().starts_with(&needle))
            .unwrap_or_else(|| panic!("{name} not found in SCVersion.txt"));
        // `set(NAME value)` or `set(NAME "value")`
        let val = line.trim().trim_start_matches(&needle);
        val.trim_end_matches(')')
            .trim()
            .trim_matches('"')
            .to_string()
    };
    let template = sc.join("common/SC_Version.hpp.in");
    let mut text = fs::read_to_string(&template).expect("read SC_Version.hpp.in");
    for (ph, val) in [
        ("@SC_VERSION_MAJOR@", field("SC_VERSION_MAJOR")),
        ("@SC_VERSION_MINOR@", field("SC_VERSION_MINOR")),
        ("@SC_VERSION_PATCH@", field("SC_VERSION_PATCH")),
        ("@SC_VERSION_TWEAK@", field("SC_VERSION_TWEAK")),
        ("@GIT_REF_TYPE@", "na".to_string()),
        ("@GIT_BRANCH_OR_TAG@", "na".to_string()),
        ("@GIT_COMMIT_HASH@", "na".to_string()),
    ] {
        assert!(
            text.contains(ph),
            "version placeholder {ph} missing - template changed"
        );
        text = text.replace(ph, &val);
    }
    fs::write(sc.join("common/SC_Version.hpp"), &text).unwrap();
    // SC_MiscCmds.cpp includes "SC_Version.hpp"; the common/ dir is on the include path.
}

/// Apply a list of `(old, new)` text replacements to `sc/rel`, asserting each `old` is present
/// (so an upstream layout change is caught immediately).
fn patch(sc: &Path, rel: &str, edits: &[(&str, &str)]) {
    let path = sc.join(rel);
    let mut text = fs::read_to_string(&path).unwrap_or_else(|e| panic!("read {rel}: {e}"));
    for (old, new) in edits {
        assert!(
            text.contains(old),
            "SC_WASM guard anchor not found in {rel} (upstream layout changed):\n{old}",
        );
        text = text.replace(old, new);
    }
    fs::write(&path, text).unwrap();
}