concinnity_toolchain/lib.rs
1//! Shared build-script support for the workspace.
2//!
3//! Besides the Metal shader precompilation in `metal_shaders` and the source
4//! hashing in `source_hash`, two responsibilities, both previously copy-pasted
5//! between the runtime crate's build script and the editor crate's build script
6//! (and missing entirely from the example binaries, which is why they failed to
7//! link against the runtime's DLSS code on Windows):
8//!
9//! 1. Resolve the rendering backend once and emit it as a single cfg
10//! (`backend_metal` / `backend_dx` / `backend_vk`) the source gates on.
11//!
12//! 2. Detect the optional graphics SDKs and emit the cfgs the renderer gates on
13//! (`agility_sdk_configured`, `ffx_sdk_bundled`, `xess_sdk_bundled`,
14//! `ngx_sdk_bundled`, `dxc_bundled`). For a package that produces final
15//! binaries this also copies the runtime DLLs next to the .exe and links the
16//! NGX import lib; for a package that produces only an rlib and its own test
17//! binaries just the NGX link is needed. Which of the two, and where the
18//! binaries land, is read off the calling package by `targets` -- a build
19//! script declares nothing about its own target list.
20//!
21//! The public entry points emit `cargo::` directives on stdout, which Cargo
22//! attributes to the build script of whichever package called in. That is what
23//! lets an example binary's build script pick up the same NGX link and DLL
24//! bundling the CLI's does, without duplicating any of this logic.
25//!
26//! This file is the thin environment-reading layer: it snapshots everything the
27//! setup needs from the process environment into an `SdkEnv` and prints the
28//! directives. The probe/copy/directive logic itself lives in the `sdks`
29//! module, which never touches the environment or stdout.
30
31use std::path::{Path, PathBuf};
32
33mod metal_shaders;
34mod sdks;
35mod slang_artifacts;
36mod source_hash;
37mod targets;
38
39pub use metal_shaders::{SlangLibSpec, SlangShaders, precompile_metal_shaders};
40use sdks::SdkEnv;
41pub use slang_artifacts::{SlangArtifact, precompile_slang_artifacts, watch_shader_dir};
42
43#[derive(Clone, Copy, Debug, PartialEq, Eq)]
44/// The graphics backend a build targets.
45pub enum Backend {
46 /// Metal, on macOS.
47 Metal,
48 /// DirectX 12, on Windows.
49 Dx,
50 /// Vulkan, on Windows and Linux.
51 Vk,
52}
53
54impl Backend {
55 pub(crate) fn cfg_name(self) -> &'static str {
56 match self {
57 Backend::Metal => "backend_metal",
58 Backend::Dx => "backend_dx",
59 Backend::Vk => "backend_vk",
60 }
61 }
62}
63
64// Which of the calling package's targets are the final binaries the graphics
65// SDKs serve. Cargo scopes a linker argument by target kind and places each kind
66// in its own directory, so this picks both the `cargo::rustc-link-arg-*` key the
67// Agility exports go out under and the directory the runtime DLLs are copied
68// into -- which has to be the one holding the .exe, since that is where Windows
69// looks for them. Discovered per package by `targets`, never named by a caller.
70#[derive(Clone, Copy, Debug, PartialEq, Eq)]
71pub(crate) enum BinaryTargets {
72 // The package links no binary of its own. Its test and bench executables
73 // still resolve the NGX symbols through the plain `rustc-link-arg`, but
74 // nothing is placed beside them.
75 None,
76 // `src/main.rs` and `src/bin/`, which land in `<target>/<profile>/`.
77 Bins,
78 // `examples/`, which land in `<target>/<profile>/examples/`.
79 Examples,
80}
81
82impl BinaryTargets {
83 pub(crate) fn bundles(self) -> bool {
84 self != BinaryTargets::None
85 }
86
87 // The `cargo::rustc-link-arg-*` key covering these targets. Cargo rejects
88 // `rustc-link-arg-bins` outright from a package with no bin target, and has
89 // no per-example form at all, so an argument emitted for `Examples` reaches
90 // every example the package builds.
91 pub(crate) fn link_arg_key(self) -> Option<&'static str> {
92 match self {
93 BinaryTargets::None => None,
94 BinaryTargets::Bins => Some("cargo::rustc-link-arg-bins"),
95 BinaryTargets::Examples => Some("cargo::rustc-link-arg-examples"),
96 }
97 }
98
99 // Subdirectory of `<target>/<profile>/` Cargo writes these binaries to.
100 pub(crate) fn exe_subdir(self) -> Option<&'static str> {
101 matches!(self, BinaryTargets::Examples).then_some("examples")
102 }
103}
104
105// Resolve the backend from the target OS and whether the `vulkan` feature is on.
106// macOS defaults to Metal and Windows to DirectX; both opt into Vulkan with the
107// feature. Everything else (Linux) is Vulkan regardless. macOS Vulkan runs over
108// MoltenVK and exists for cross-backend testing, not for shipping.
109pub(crate) fn resolve_backend(target_os: &str, vulkan: bool) -> Backend {
110 match (target_os, vulkan) {
111 ("macos", false) => Backend::Metal,
112 ("windows", false) => Backend::Dx,
113 _ => Backend::Vk,
114 }
115}
116
117/// Declare every cfg the renderer source gates on so `--check-cfg` does not warn.
118/// A package only needs this if its own source references one of these cfgs.
119pub fn emit_check_cfgs() {
120 for line in sdks::check_cfg_directives() {
121 println!("{line}");
122 }
123}
124
125/// Resolve the backend from the Cargo-provided environment, emitting nothing.
126/// For a package that needs the backend only to pick its SDK setup and never
127/// gates its own source on one, so has no reason to carry the cfg.
128pub fn backend_from_cargo() -> Backend {
129 let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default();
130 let vulkan = std::env::var("CARGO_FEATURE_VULKAN").is_ok();
131 resolve_backend(&target_os, vulkan)
132}
133
134/// Resolve the backend from the Cargo-provided environment and emit the
135/// `rustc-cfg` for it, returning the choice so the caller can branch.
136pub fn emit_backend_cfg() -> Backend {
137 let backend = backend_from_cargo();
138 println!("{}", sdks::backend_cfg_directive(backend));
139 backend
140}
141
142/// Set up the optional graphics SDKs for the given backend. On a non-Windows
143/// target (or the Metal backend) this is a no-op: none of these SDKs apply.
144///
145/// Which kinds of final binary the calling package builds is read from that
146/// package, not passed in: a package can build both bins and examples, and each
147/// kind takes its own linker-argument key and its own directory for the bundled
148/// DLLs, so the setup runs once per kind. A directive both kinds produce --
149/// every cfg, every warning -- is emitted once.
150pub fn setup_graphics_sdks(backend: Backend) {
151 let env = sdk_env_from_cargo();
152 if let Some(dir) = manifest_dir() {
153 for path in targets::watched_inputs(&dir) {
154 println!("cargo::rerun-if-changed={}", path.display());
155 }
156 }
157 for line in sdks::graphics_sdk_directives(backend, &binary_targets_from_cargo(), &env) {
158 println!("{line}");
159 }
160}
161
162// The calling package's binary kinds, from the manifest Cargo is building it
163// from. A package Cargo hands no manifest (nothing does outside a build script)
164// falls back to the kind that scopes nothing.
165fn binary_targets_from_cargo() -> Vec<BinaryTargets> {
166 let Some(dir) = manifest_dir() else {
167 return vec![BinaryTargets::None];
168 };
169 let manifest = std::fs::read_to_string(dir.join("Cargo.toml")).unwrap_or_default();
170 targets::binary_targets(&manifest, &dir)
171}
172
173fn manifest_dir() -> Option<PathBuf> {
174 std::env::var("CARGO_MANIFEST_DIR").ok().map(PathBuf::from)
175}
176
177/// Hash the Rust sources under `roots`, and emit the rerun directives that
178/// re-run the calling build script when any of them change. Each root is either
179/// a directory tree (every `.rs` under it participates) or a single file.
180///
181/// The hash is what a content-addressed cache folds in so that a change to the
182/// code producing its stored bytes evicts entries whose other inputs did not
183/// move. See `source_hash` for the shape of the guarantee.
184pub fn hash_sources(roots: &[PathBuf]) -> u32 {
185 let package =
186 PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").expect("build script runs under cargo"));
187 let package = package.canonicalize().unwrap_or(package);
188 let mut named = Vec::new();
189 for root in roots {
190 // Directory-level rerun directives catch added and removed files.
191 println!("cargo:rerun-if-changed={}", root.display());
192 let mut files = Vec::new();
193 source_hash::collect(root, &mut files);
194 named.extend(
195 files
196 .into_iter()
197 .map(|file| (source_hash::relative_name(&package, &file), file)),
198 );
199 }
200 source_hash::hash_named(&mut named)
201}
202
203// Default SDK install roots, used when the matching env var is unset.
204const DEFAULT_AGILITY_SDK_ROOT: &str = "C:\\microsoft.direct3d.d3d12.1.619.3";
205const DEFAULT_FIDELITYFX_SDK_ROOT: &str = "C:\\FidelityFX-SDK-v1.1.4";
206const DEFAULT_XESS_SDK_ROOT: &str = "C:\\XeSS_SDK_3.0.1";
207const DEFAULT_STREAMLINE_SDK_ROOT: &str = "C:\\streamline-sdk-v2.11.1";
208const DEFAULT_WINDOWS_SDK_BIN: &str = "C:\\Program Files (x86)\\Windows Kits\\10\\bin";
209
210// Whether an opt-in variable's value asks for the feature, for the one SDK that
211// is off by default. Bundling Agility links `D3D12SDKVersion` / `D3D12SDKPath`
212// into the binary, and `d3d12.dll` reads those before any engine code runs: a
213// binary carrying them starts only where the staged `D3D12/` directory sits
214// beside it, so an executable copied anywhere else -- every `cargo install` --
215// reaches no adapter at all. That makes bundling a decision about how the
216// artifact is distributed rather than about which SDKs the build machine
217// happens to have, so it has to be asked for. `0` keeps meaning off, as it
218// always has for these variables.
219fn opted_in(value: Option<&str>) -> bool {
220 matches!(value, Some("1" | "true" | "TRUE"))
221}
222
223// Snapshot every environment input the SDK setup reads. This is the only place
224// the setup consults the process environment; everything downstream works on
225// the returned struct.
226fn sdk_env_from_cargo() -> SdkEnv {
227 let var = |name: &str| std::env::var(name).ok();
228 let root = |name: &str, default: &str| {
229 var(name)
230 .map(PathBuf::from)
231 .unwrap_or_else(|| PathBuf::from(default))
232 };
233 // The `LoadLibrary`-at-runtime SDKs default to ON and are opted out of with
234 // `<VAR>=0`: an absent DLL costs the feature and nothing else.
235 let enabled = |name: &str| var(name).as_deref() != Some("0");
236 // Agility is the exception and defaults to OFF; see `opted_in`.
237 let opted_in = |name: &str| self::opted_in(var(name).as_deref());
238 SdkEnv {
239 target_os: var("CARGO_CFG_TARGET_OS").unwrap_or_default(),
240 out_dir: var("OUT_DIR").map(PathBuf::from),
241 workspace_root: workspace_root(),
242 agility_root: root("AGILITY_SDK_ROOT", DEFAULT_AGILITY_SDK_ROOT),
243 fidelityfx_root: root("FIDELITYFX_SDK_ROOT", DEFAULT_FIDELITYFX_SDK_ROOT),
244 xess_root: root("XESS_SDK_ROOT", DEFAULT_XESS_SDK_ROOT),
245 streamline_root: root("STREAMLINE_SDK_ROOT", DEFAULT_STREAMLINE_SDK_ROOT),
246 dxc_root: var("DXC_SDK_ROOT").map(PathBuf::from),
247 windows_sdk_bin: PathBuf::from(DEFAULT_WINDOWS_SDK_BIN),
248 agility_enabled: opted_in("CN_ENABLE_AGILITY_SDK"),
249 ffx_enabled: enabled("CN_ENABLE_FFX_FSR3"),
250 xess_enabled: enabled("CN_ENABLE_XESS"),
251 dlss_enabled: enabled("CN_ENABLE_DLSS"),
252 dxc_enabled: enabled("CN_ENABLE_DXC"),
253 }
254}
255
256// Locate the workspace root by walking up from the caller's manifest until a
257// `Cargo.toml` declaring `[workspace]` is found.
258fn workspace_root() -> Option<PathBuf> {
259 let start = std::env::var("CARGO_MANIFEST_DIR").ok()?;
260 find_ancestor_with(Path::new(&start), |dir| {
261 std::fs::read_to_string(dir.join("Cargo.toml"))
262 .map(|c| c.contains("[workspace]"))
263 .unwrap_or(false)
264 })
265}
266
267// Walk `start` and its ancestors, returning the first that satisfies `pred`.
268fn find_ancestor_with(start: &Path, pred: impl Fn(&Path) -> bool) -> Option<PathBuf> {
269 let mut dir = start.to_path_buf();
270 loop {
271 if pred(&dir) {
272 return Some(dir);
273 }
274 if !dir.pop() {
275 return None;
276 }
277 }
278}
279
280#[cfg(test)]
281mod tests {
282 use super::*;
283
284 #[test]
285 fn backend_resolution_covers_every_target() {
286 assert_eq!(resolve_backend("macos", false), Backend::Metal);
287 assert_eq!(resolve_backend("macos", true), Backend::Vk);
288 assert_eq!(resolve_backend("windows", false), Backend::Dx);
289 assert_eq!(resolve_backend("windows", true), Backend::Vk);
290 assert_eq!(resolve_backend("linux", false), Backend::Vk);
291 assert_eq!(resolve_backend("linux", true), Backend::Vk);
292 }
293
294 #[test]
295 fn backend_cfg_names_are_stable() {
296 assert_eq!(Backend::Metal.cfg_name(), "backend_metal");
297 assert_eq!(Backend::Dx.cfg_name(), "backend_dx");
298 assert_eq!(Backend::Vk.cfg_name(), "backend_vk");
299 }
300
301 #[test]
302 fn ancestor_search_finds_marked_dir() {
303 let start = Path::new("/a/b/c/d");
304 let hit = find_ancestor_with(start, |p| p == Path::new("/a/b"));
305 assert_eq!(hit, Some(PathBuf::from("/a/b")));
306
307 let miss = find_ancestor_with(start, |p| p == Path::new("/x"));
308 assert_eq!(miss, None);
309 }
310
311 #[test]
312 fn workspace_root_finds_the_workspace_manifest() {
313 // Cargo sets CARGO_MANIFEST_DIR for test binaries, so the walk starts
314 // at this crate and must land on the workspace's own Cargo.toml.
315 let root = workspace_root().expect("workspace root");
316 let manifest = std::fs::read_to_string(root.join("Cargo.toml")).expect("read manifest");
317 assert!(manifest.contains("[workspace]"));
318 }
319
320 #[test]
321 fn sdk_env_snapshot_defaults_the_loadlibrary_probes_on() {
322 let env = sdk_env_from_cargo();
323 // The `LoadLibrary` probes default ON when their opt-out variable is
324 // unset. Only assert for variables the surrounding environment leaves
325 // unset, so a local `<VAR>=0` opt-out does not fail the test.
326 for (var, flag) in [
327 ("CN_ENABLE_FFX_FSR3", env.ffx_enabled),
328 ("CN_ENABLE_XESS", env.xess_enabled),
329 ("CN_ENABLE_DLSS", env.dlss_enabled),
330 ("CN_ENABLE_DXC", env.dxc_enabled),
331 ] {
332 if std::env::var(var).is_err() {
333 assert!(flag, "{var} should default on");
334 }
335 }
336 // Roots fall back to the hardcoded defaults when unset.
337 if std::env::var("XESS_SDK_ROOT").is_err() {
338 assert_eq!(env.xess_root, PathBuf::from(DEFAULT_XESS_SDK_ROOT));
339 }
340 }
341
342 // Agility is the one that binds the executable to a directory beside it, so
343 // an unset variable must leave it OFF: the default has to be the artifact
344 // that runs anywhere, not the one that runs only where it was built.
345 #[test]
346 fn sdk_env_snapshot_defaults_agility_off() {
347 if std::env::var("CN_ENABLE_AGILITY_SDK").is_err() {
348 assert!(!sdk_env_from_cargo().agility_enabled);
349 }
350 }
351
352 // Only an affirmative value opts in. `0` has always meant off and keeps
353 // meaning off, so an environment carrying the old opt-out is unaffected.
354 #[test]
355 fn only_an_affirmative_value_opts_into_agility() {
356 for on in ["1", "true", "TRUE"] {
357 assert!(opted_in(Some(on)), "{on}");
358 }
359 for off in ["0", "", "no", "yes", "2", "false"] {
360 assert!(!opted_in(Some(off)), "{off}");
361 }
362 assert!(!opted_in(None));
363 }
364
365 #[test]
366 fn graphics_sdk_setup_is_a_noop_off_windows_targets() {
367 // Metal never has SDKs to set up, and the Vulkan arm is gated on a
368 // Windows target OS (CARGO_CFG_TARGET_OS is unset outside build
369 // scripts), so neither requires any SDK to be present.
370 let env = sdk_env_from_cargo();
371 for targets in [
372 &[BinaryTargets::None][..],
373 &[BinaryTargets::Bins],
374 &[BinaryTargets::Examples],
375 &[BinaryTargets::Bins, BinaryTargets::Examples],
376 ] {
377 for backend in [Backend::Metal, Backend::Vk] {
378 assert!(sdks::graphics_sdk_directives(backend, targets, &env).is_empty());
379 }
380 }
381 setup_graphics_sdks(Backend::Metal);
382 setup_graphics_sdks(Backend::Vk);
383 // The check-cfg list is emitted unconditionally and must not panic.
384 emit_check_cfgs();
385 }
386
387 #[test]
388 fn this_crate_builds_no_final_binary() {
389 // concinnity-toolchain is a lib with no bin and no example, so the
390 // discovery run from its own test binary has to say so.
391 assert_eq!(binary_targets_from_cargo(), vec![BinaryTargets::None]);
392 }
393}