indicatrix 0.9.0

A physically-based spectral gemstone renderer that turns GemCAD-style cutting instructions into rendered output.
Documentation
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
//! Computes a stable content hash of this crate's own `src/**/*.rs` and `src/**/*.wgsl`
//! tree (plus `Cargo.toml`) and exposes it to `lib.rs` as `INDICATRIX_SOURCE_HASH`
//! (`indicatrix::SOURCE_HASH`), and a hash of the crate version as `INDICATRIX_BUILD_ID`
//! (`indicatrix::BUILD_ID`). Both feed the handshake identity (see
//! `indicatrix-net`'s `handshake` module): `BUILD_ID` is the primary, always-enforced
//! check; `SOURCE_HASH` travels in HELLO and WELCOME for diagnostics only and is never
//! compared, so builds of the same version from different machines pair normally.
//!
//! # Why this exists
//!
//! `indicatrix-net`'s wire protocol handshake refuses to pair a viewer and a remote
//! worker whose `indicatrix` builds disagree -- see `indicatrix-net`'s `handshake`
//! module. Mixing samples traced by two different physics implementations produces a
//! silently, plausibly wrong image: no crash, just numbers that look like a render and
//! aren't. `BUILD_ID` alone can't fully catch this on its own: it's a hash of the crate
//! VERSION, so it only catches a mismatch when the release rule below ("bump the
//! version whenever the physics changes") was actually followed. `SOURCE_HASH`, being a
//! content hash of the actual source tree, catches a physics-affecting edit that never
//! bumped the version at all. A plain "protocol
//! version" field could never catch either case (the wire format didn't change, the
//! *physics* did).
//!
//! # Why a content hash, not `git describe`
//!
//! This repository has a `.git` directory but no commits yet, so `git describe`/
//! `git rev-parse HEAD` both fail unconditionally. A content hash of the source works
//! regardless of VCS state, and (unlike a commit hash) also flags uncommitted local
//! edits that a `git`-based check would silently treat as whatever the last commit was.
//!
//! # Why not `DefaultHasher`
//!
//! `DefaultHasher`'s output is explicitly NOT guaranteed stable across Rust versions --
//! two machines building the same source on different toolchains could disagree and
//! refuse a compatible pairing. FNV-1a below is a fixed, fully-specified algorithm: same
//! input bytes always produce the same output, on any platform or Rust version, forever,
//! for about ten lines and zero dependencies.
//!
//! # Determinism
//!
//! - Files are visited in sorted, POSIX-normalized relative-path order (`/`, not `\`),
//!   so the hash does not depend on the host OS's directory-listing order or path
//!   separator.
//! - The relative path itself is hashed alongside each file's contents, so renaming a
//!   file changes the id even if no byte of any file's content changed.
//! - Line endings are normalized (`\r\n` -> `\n`) before hashing, so an identical
//!   checkout differing only by CRLF/LF (e.g. Windows author, Linux worker) hashes
//!   identically.

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

/// The 64-bit FNV-1a offset basis and prime -- see
/// <https://en.wikipedia.org/wiki/Fowler%E2%80%93Noll%E2%80%93Vo_hash_function>. Fully
/// specified, versioned nowhere, and will never change out from under this build.
const FNV_OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
const FNV_PRIME: u64 = 0x0000_0100_0000_01b3;

fn fnv1a_update(mut hash: u64, bytes: &[u8]) -> u64 {
    for &b in bytes {
        hash ^= u64::from(b);
        hash = hash.wrapping_mul(FNV_PRIME);
    }
    hash
}

/// Recursively collects every file under `dir`, returned as paths relative to `root`.
fn collect_files(dir: &Path, root: &Path, out: &mut Vec<PathBuf>) {
    let Ok(entries) = fs::read_dir(dir) else {
        return;
    };
    for entry in entries.flatten() {
        let path = entry.path();
        if path.is_dir() {
            collect_files(&path, root, out);
        } else if let Ok(rel) = path.strip_prefix(root) {
            out.push(rel.to_path_buf());
        }
    }
}

/// POSIX-normalizes a relative path (`/` separators) so the hash is identical whether
/// computed on Windows or Linux.
fn normalize_path(rel: &Path) -> String {
    rel.components()
        .map(|c| c.as_os_str().to_string_lossy().into_owned())
        .collect::<Vec<_>>()
        .join("/")
}

/// `transport_physics.wgsl`, `transport_bounce.wgsl`, and `transport_functions.wgsl` were
/// each one oversized file; each is now a directory of ordered pieces (pure text
/// slices, split along real responsibility boundaries -- see each piece's own leading
/// comment) that must be concatenated in exactly this order to reproduce the original
/// file's text byte-for-byte. `read_concat` below joins them with NO separator (not even
/// a newline) for exactly that reason: every piece is a contiguous slice of the original
/// file, so plain concatenation reproduces it exactly, and each piece already ends with
/// its own trailing newline.
const TRANSPORT_PHYSICS_PIECES: &[&str] = &[
    "01_prelude_polarization.wgsl",
    "02_biaxial_eigenmodes.wgsl",
    "03_dispersion_absorption_frosted.wgsl",
    "04_scattering.wgsl",
    "05_nee_env_sampling.wgsl",
    "06_uniaxial_fullwave_complex.wgsl",
    "07_uniaxial_fullwave_solve.wgsl",
    "08_exit_splitting.wgsl",
    "09_lighting_presets.wgsl",
    "10_concave_tools.wgsl",
];

/// See [`TRANSPORT_PHYSICS_PIECES`]'s doc comment.
const TRANSPORT_BOUNCE_PIECES: &[&str] = &[
    "01_header_and_constants.wgsl",
    "02_scene_bindings.wgsl",
    "03_rng_and_spectral_tables.wgsl",
    "04_studio_and_hdr_environment.wgsl",
    "05_intersect_and_edge_rounding.wgsl",
    "06_nee_sampling.wgsl",
    "07_camera_and_mode_selection.wgsl",
    "07b_bounce_phases_types.wgsl",
    "08a_index_setup.wgsl",
    "08b_extend_scatter.wgsl",
    "08c_surface.wgsl",
    "08d_arms_frosted_tir_uni_entry.wgsl",
    "08e_arms_uni_internal_generic.wgsl",
    "08f_dispatch_split_tail_step.wgsl",
    "09_finalize_and_ray_gen.wgsl",
];

/// See [`TRANSPORT_PHYSICS_PIECES`]'s doc comment.
const TRANSPORT_FUNCTIONS_PIECES: &[&str] = &[
    "01_polarization_fresnel_tir.wgsl",
    "02_dispersion_absorption_uniaxial_index.wgsl",
    "03_frosted_and_scattering.wgsl",
    "04_biaxial_and_scatter_extinguish.wgsl",
    "05_uniaxial_fullwave_and_exit_splitting.wgsl",
    "06_assigned_mode_and_balance.wgsl",
    "07_env_distribution_sampling.wgsl",
    "08_nee_and_intersect.wgsl",
];

/// The zoned-absorption units under `shaders/zoning/`, in concatenation order. They are
/// appended to the shared bounce text of every generated transport shader.
const ZONING_PIECES: &[&str] = &[
    "01_zone_table.wgsl",
    "02_zone_kernel.wgsl",
    "03_zone_absorption.wgsl",
];

/// The event wavefront kernels under `shaders/event_wavefront/`, in dispatch order.
const EW_KERNELS: &[(&str, &str)] = &[
    ("Generate", "ew_generate.wgsl"),
    ("Extend", "ew_extend.wgsl"),
    ("Scatter", "ew_scatter.wgsl"),
    ("NeeHg", "ew_nee_hg.wgsl"),
    ("Surface", "ew_surface.wgsl"),
    ("ArmFrosted", "ew_arm_frosted.wgsl"),
    ("ArmTir", "ew_arm_tir.wgsl"),
    ("ArmUniEntry", "ew_arm_uni_entry.wgsl"),
    ("ArmUniInternal", "ew_arm_uni_internal.wgsl"),
    ("ArmGeneric", "ew_arm_generic.wgsl"),
    ("SplitResolve", "ew_split_resolve.wgsl"),
    ("Tail", "ew_tail.wgsl"),
    ("Finalize", "ew_finalize.wgsl"),
];

const WHITTED_PIECES: &[&str] = &[
    "00_bindings.wgsl",
    "10_exp.wgsl",
    "20_studio.wgsl",
    "30_environment.wgsl",
    "40_intersect.wgsl",
    "50_chain.wgsl",
    "60_frame.wgsl",
];

/// Replaces the single occurrence of the one-line `anchor` in `text` with `replacement`.
///
/// Panics when `anchor` is absent or ambiguous, so a later edit to the default shader text
/// that moves an anchor fails the build loudly instead of silently dropping the
/// zoned absorption.
fn patch_once(text: &str, anchor: &str, replacement: &str, what: &str) -> String {
    let count = text.matches(anchor).count();
    assert!(
        count == 1,
        "zoning shader patch `{what}`: the anchor {anchor:?} occurs {count} times in the \
         transport_bounce pieces (expected exactly 1); the default shader text moved, update \
         build.rs"
    );
    text.replacen(anchor, replacement, 1)
}

/// The zoned variant of the shared bounce text (`transport_bounce/*` concatenated): two
/// anchored patches plus the zoned units appended. Everything else is the default text.
///
/// 1. the `GpuGemMaterial` struct gains its trailing `zones: GpuZoneTable` field (the Rust
///    twin appends the table at offset 576, `renderer::buffers::GpuGemMaterial::zones`);
/// 2. the homogeneous interior-absorption branch of `transport_bounce_step` becomes
///    `if (zoned) { zoned_interior_absorption(..) } else if (<the original condition>) {`.
///
/// WGSL allows module-scope declarations in any order, so the units are simply appended.
fn zoned_bounce_shared(shaders_dir: &Path, bounce_shared: &str) -> String {
    let with_table = patch_once(
        bounce_shared,
        "extraordinary_param_b: vec4<f32>,",
        "extraordinary_param_b: vec4<f32>,\n    // Zone table, appended at byte offset 576.\n    zones: GpuZoneTable,",
        "GpuGemMaterial.zones",
    );
    let with_call = patch_once(
        &with_table,
        "if (material.scattering_sigma_s <= 0.0) {",
        "if (material.zones.header.zone_count > 0u && material.scattering_sigma_s <= 0.0) {\n                \
         // Zoned stone: per-zone absorption of this segment.\n                \
         zoned_interior_absorption(\n                    \
         hit.t, (*current_origin), (*current_dir), wave_dir_at_bounce, lambdas,\n                    \
         is_anisotropic, is_biaxial, c_axis, n_alpha_hero, n_beta_hero, n_gamma_hero,\n                    \
         biax_ax0, biax_ax1, biax_ax2, n_o_hero_seed, n_e_hero_seed, (*is_extraordinary),\n                    \
         alpha_o_hoisted, alpha_e_hoisted, alpha_beta_hoisted, stokes,\n                \
         );\n            \
         } else if (material.scattering_sigma_s <= 0.0) {",
        "zoned interior absorption branch",
    );
    let mut out = with_call;
    out.push_str("\n// ---- zoning units (shaders/zoning/*.wgsl) ----\n");
    for piece in ZONING_PIECES {
        let text = fs::read_to_string(shaders_dir.join("zoning").join(piece))
            .unwrap_or_else(|e| panic!("failed to read shaders/zoning/{piece}: {e}"));
        out.push_str(&text);
        out.push('\n');
    }
    out
}

/// Concatenates `shaders/transport_physics.wgsl` (the single shared source of the
/// transport physics functions) ahead of each of `spectral_transport.wgsl`,
/// `transport_functions.wgsl`, and `wavefront_transport.wgsl`, and writes each result
/// into `$OUT_DIR`, since WGSL has no `#include` and every one of those files assumes
/// the prelude's symbols are already in scope.
///
/// `shaders/transport_bounce.wgsl` -- the scene bindings plus the
/// per-bounce `transport_bounce_step`/`transport_finalize_xyz` functions shared between
/// the megakernel and the wavefront pipeline (see that file's own header comment) -- is
/// ALSO concatenated in, for `spectral_transport.wgsl` and `wavefront_transport.wgsl`
/// only: `transport_functions.wgsl` (Tier 2's standalone per-function kernels) never
/// calls either shared function or needs their scene bindings, so it is not
/// concatenated there.
///
/// Deliberately writes into `$OUT_DIR` (under `target/`), never under `src/`: the
/// content hash below walks `src/**/*.wgsl` on disk, and a generated file leaked into
/// `src/` would either double-count as a redundant hash input or, if `.gitignore`d, go
/// missing on a clean checkout and make the hash nondeterministic across `cargo clean`.
///
/// `include_str!(concat!(env!("OUT_DIR"), "/..."))` in `renderer::gpu::estimator_check`,
/// `renderer::gpu::transport_check`, and `renderer::gpu::frame` reads these generated
/// files back at compile time.
fn generate_transport_shaders(manifest_dir: &Path, out_dir: &Path) {
    let shaders_dir = manifest_dir.join("src").join("renderer").join("shaders");
    let read = |name: &str| -> String {
        fs::read_to_string(shaders_dir.join(name))
            .unwrap_or_else(|e| panic!("failed to read shaders/{name}: {e}"))
    };
    // Reads every named piece under `shaders/<subdir>/` in order and concatenates them
    // with no separator -- see `TRANSPORT_PHYSICS_PIECES`'s doc comment above for why
    // that reproduces the pre-split file exactly.
    let read_concat = |subdir: &str, pieces: &[&str]| -> String {
        pieces
            .iter()
            .map(|piece| {
                let rel = format!("{subdir}/{piece}");
                fs::read_to_string(shaders_dir.join(subdir).join(piece))
                    .unwrap_or_else(|e| panic!("failed to read shaders/{rel}: {e}"))
            })
            .collect()
    };
    let prelude = read_concat("transport_physics", TRANSPORT_PHYSICS_PIECES);
    // Independent unit and admission hash: event-wavefront text/hash stay unchanged.
    let mut whitted_tools = read("transport_physics/10_concave_tools.wgsl");
    // The shared tool prelude predates the small-kernel explicit-initializer rule.
    // Initialize its fully assigned records in this unit alone; old shaders/hash
    // must remain byte-identical.
    for (name, ty) in [
        ("t", "CtTool"),
        ("r", "CtInterval"),
        ("p", "CtPiece"),
        ("out", "CtSd"),
        ("s", "CtScan"),
        ("st", "CtCarve"),
    ] {
        whitted_tools = whitted_tools.replace(
            &format!("var {name}: {ty};"),
            &format!("var {name}: {ty} = {ty}();"),
        );
    }
    let whitted = format!(
        "{}\n{}",
        whitted_tools,
        read_concat("whitted", WHITTED_PIECES)
    );
    fs::write(out_dir.join("whitted.generated.wgsl"), &whitted)
        .expect("write whitted generated shader");
    let whitted_hash = fnv1a_update(FNV_OFFSET_BASIS, whitted.replace("\r\n", "\n").as_bytes());
    fs::write(
        out_dir.join("whitted_kernel_hash.rs"),
        format!("pub const WHITTED_KERNEL_HASH: &str = \"{whitted_hash:016x}\";\n"),
    )
    .expect("write whitted kernel hash");
    let bounce_shared = read_concat("transport_bounce", TRANSPORT_BOUNCE_PIECES);
    // The shared bounce text is its zoned variant (see `zoned_bounce_shared`).
    let bounce_shared = zoned_bounce_shared(&shaders_dir, &bounce_shared);

    // (body file, whether `transport_bounce.wgsl` is concatenated in between the
    // prelude and the body) -- see this function's own doc comment.
    let units: [(&str, bool); 3] = [
        ("spectral_transport.wgsl", true),
        ("transport_functions.wgsl", false),
        ("wavefront_transport.wgsl", true),
    ];

    for (name, needs_bounce_shared) in units {
        // `transport_functions.wgsl` is likewise now a directory of ordered pieces (see
        // `TRANSPORT_FUNCTIONS_PIECES`'s doc comment above); `spectral_transport.wgsl`
        // and `wavefront_transport.wgsl` are still single files, read as before.
        let body = if name == "transport_functions.wgsl" {
            read_concat("transport_functions", TRANSPORT_FUNCTIONS_PIECES)
        } else {
            read(name)
        };
        let generated = if needs_bounce_shared {
            format!(
                "// GENERATED by build.rs: shaders/transport_physics.wgsl + \
                 shaders/transport_bounce.wgsl + shaders/{name}.\n\
                 // Do not edit this file -- edit the sources above instead.\n\n\
                 {prelude}\n{bounce_shared}\n{body}"
            )
        } else {
            format!(
                "// GENERATED by build.rs: shaders/transport_physics.wgsl + shaders/{name}.\n\
                 // Do not edit this file -- edit the two sources above instead.\n\n{prelude}\n{body}"
            )
        };
        let out_name = name.replace(".wgsl", ".generated.wgsl");
        fs::write(out_dir.join(&out_name), generated)
            .unwrap_or_else(|e| panic!("failed to write {out_name} to OUT_DIR: {e}"));
    }

    let common = fs::read_to_string(shaders_dir.join("event_wavefront").join("00_common.wgsl"))
        .unwrap_or_else(|e| panic!("failed to read shaders/event_wavefront/00_common.wgsl: {e}"));

    let mut ew_hash = FNV_OFFSET_BASIS;
    for &(_name, file) in EW_KERNELS {
        let kernel_body = fs::read_to_string(shaders_dir.join("event_wavefront").join(file))
            .unwrap_or_else(|e| panic!("failed to read shaders/event_wavefront/{file}: {e}"));
        let generated = format!(
            "// GENERATED by build.rs: shaders/transport_physics.wgsl + \
             shaders/transport_bounce.wgsl + shaders/event_wavefront/00_common.wgsl + \
             shaders/event_wavefront/{file}.\n\
             // Do not edit this file -- edit the sources above instead.\n\n\
             {prelude}\n{bounce_shared}\n{common}\n{kernel_body}"
        );
        let out_name = file.replace(".wgsl", ".generated.wgsl");
        fs::write(out_dir.join(&out_name), &generated)
            .unwrap_or_else(|e| panic!("failed to write {out_name} to OUT_DIR: {e}"));

        let normalized = generated.replace("\r\n", "\n");
        ew_hash = fnv1a_update(ew_hash, normalized.as_bytes());
    }

    let hash_file_content = format!("pub const KERNEL_SET_HASH: &str = \"{ew_hash:016x}\";\n");
    fs::write(out_dir.join("ew_kernel_set_hash.rs"), hash_file_content)
        .unwrap_or_else(|e| panic!("failed to write ew_kernel_set_hash.rs to OUT_DIR: {e}"));
}

fn main() {
    let manifest_dir = PathBuf::from(
        std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR is set by cargo"),
    );
    let src_dir = manifest_dir.join("src");
    let cargo_toml = manifest_dir.join("Cargo.toml");
    let out_dir =
        PathBuf::from(std::env::var("OUT_DIR").expect("OUT_DIR is set by cargo for build scripts"));

    generate_transport_shaders(&manifest_dir, &out_dir);
    println!(
        "cargo:rerun-if-changed={}",
        src_dir.join("renderer/shaders/whitted").display()
    );

    let ew_shaders_dir = manifest_dir
        .join("src")
        .join("renderer")
        .join("shaders")
        .join("event_wavefront");
    println!("cargo:rerun-if-changed={}", ew_shaders_dir.display());
    println!(
        "cargo:rerun-if-changed={}",
        ew_shaders_dir.join("00_common.wgsl").display()
    );
    for &(_, file) in EW_KERNELS {
        println!(
            "cargo:rerun-if-changed={}",
            ew_shaders_dir.join(file).display()
        );
    }

    let mut rel_paths = Vec::new();
    collect_files(&src_dir, &manifest_dir, &mut rel_paths);
    // .rs and .wgsl both carry physics this hash must fingerprint: a worker running a
    // GPU backend executes WGSL as its physics, not as decoration on top of it. Two
    // workers with byte-identical Rust but divergent WGSL would otherwise handshake as
    // identical, which is exactly the silent-divergence failure this hash exists to
    // catch. Deliberately still ONE hash, not two: verify_compatible has no "close
    // enough" tier, so any change to either language is a build-identity change.
    rel_paths.retain(|p| {
        p.extension()
            .is_some_and(|ext| ext == "rs" || ext == "wgsl")
    });

    // Pathological case: no source files found under src/ at all. Rather than silently
    // emit a hash computed from Cargo.toml alone, fall back to an explicit sentinel --
    // parse_build_id maps any non-16-hex-char string (this included) to
    // UNKNOWN_BUILD_HASH, which verify_compatible refuses unconditionally.
    if rel_paths.is_empty() {
        println!("cargo:rerun-if-changed={}", src_dir.display());
        println!("cargo:rustc-env=INDICATRIX_SOURCE_HASH=unknown");
        println!("cargo:rustc-env=INDICATRIX_BUILD_ID=unknown");
        return;
    }

    rel_paths.push(PathBuf::from("Cargo.toml"));
    rel_paths.sort();

    let mut hash = FNV_OFFSET_BASIS;
    for rel in &rel_paths {
        let abs = manifest_dir.join(rel);
        let Ok(contents) = fs::read(&abs) else {
            continue;
        };
        // Normalize CRLF -> LF before hashing so a Windows checkout and a Linux
        // checkout of byte-identical source hash identically.
        let normalized: Vec<u8> = {
            let mut out = Vec::with_capacity(contents.len());
            let mut i = 0;
            while i < contents.len() {
                if contents[i] == b'\r' && contents.get(i + 1) == Some(&b'\n') {
                    // Drop the \r; the loop picks up the \n on the next iteration.
                } else {
                    out.push(contents[i]);
                }
                i += 1;
            }
            out
        };

        let rel_str = normalize_path(rel);
        hash = fnv1a_update(hash, rel_str.as_bytes());
        hash = fnv1a_update(hash, &[0u8]); // separator between path and contents
        hash = fnv1a_update(hash, &normalized);

        println!("cargo:rerun-if-changed={}", abs.display());
    }
    // Also react to files being added/removed (rerun-if-changed on the directory
    // itself catches that on most platforms/cargo versions).
    println!("cargo:rerun-if-changed={}", src_dir.display());
    println!("cargo:rerun-if-changed={}", cargo_toml.display());

    let source_hash = format!("{hash:016x}");
    println!("cargo:rustc-env=INDICATRIX_SOURCE_HASH={source_hash}");

    // The PRIMARY handshake identity is keyed on the crate VERSION, not on the source
    // hash: a worker built on Linux and a viewer built on Windows from the same release
    // must pair even when their checkouts differ in metadata, docs, or platform-only
    // code. That guarantee rests on the release rule "bump the version whenever
    // anything under src/ that affects a traced sample changes" -- a promise nothing
    // enforces. The source hash above (`indicatrix::SOURCE_HASH`) is carried in the
    // handshake for diagnostics only and is never compared (see
    // `indicatrix-net::handshake`'s module doc comment).
    // `CARGO_PKG_VERSION` is the package's resolved version: cargo substitutes a
    // `version = { workspace = true }` inheritance before running this script, and
    // `cargo package` writes the literal version into the published manifest, so a
    // workspace build and a crates.io build of the same release agree. An empty or
    // missing value must not hash into a plausible id, so it falls back to the
    // `unknown` sentinel the handshake refuses.
    let build_id = match std::env::var("CARGO_PKG_VERSION") {
        Ok(version) if !version.trim().is_empty() => {
            format!(
                "{:016x}",
                fnv1a_update(FNV_OFFSET_BASIS, version.trim().as_bytes())
            )
        }
        _ => "unknown".to_string(),
    };
    println!("cargo:rustc-env=INDICATRIX_BUILD_ID={build_id}");
}