Skip to main content

khora_editor/
build_game.rs

1// Copyright 2025 eraflo
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! "Build Game" — packs the project's assets and stages a runnable binary.
16//!
17//! Two strategies, picked deterministically from the project's contents:
18//!
19//! - **Runtime stamp** (default, project has no `Cargo.toml`):
20//!   1. `khora_io::PackBuilder` produces `index.bin` + `data.pack` from
21//!      `<project>/assets/`.
22//!   2. The pre-built `khora-runtime` binary for the chosen target is
23//!      copied into the output directory and renamed to the project name.
24//!   3. A `runtime.json` companion file is written so the runtime knows
25//!      which scene to auto-load.
26//!
27//!   This path is **cross-platform trivial** — the runtime binary already
28//!   exists for every target (built by `release.yml`), so building Linux
29//!   from a Windows host is just a file copy.
30//!
31//! - **Cargo build** (project has `Cargo.toml` — opted in via the hub's
32//!   "Add Native Code" button):
33//!   1. Same `PackBuilder` step.
34//!   2. `cargo build --release --manifest-path <project>/Cargo.toml`
35//!      compiles the user's binary (which depends on `khora-sdk` and
36//!      registers their custom components / agents / lanes).
37//!   3. The compiled binary is copied into the output directory.
38//!   4. Same `runtime.json` companion is written.
39//!
40//!   This path is **host-only in v1** because Rust cross-compilation needs
41//!   per-target toolchains; running the editor on each target is the
42//!   simplest workaround. Future work can integrate `cross` for
43//!   Docker-based cross-compilation.
44//!
45//! Output layout (identical between the two strategies):
46//! ```text
47//! <project>/dist/<target>/
48//! ├── <project_name>{.exe}   # renamed runtime OR compiled user binary
49//! ├── data.pack              # asset blobs
50//! ├── index.bin              # asset metadata (UUIDs → packed offsets)
51//! └── runtime.json           # project name + default scene rel path
52//! ```
53//!
54//! The user can therefore start data-only, ship cross-platform via the
55//! stamp strategy, and "graduate" to native Rust without changing how
56//! Build Game is invoked — the editor switches strategies automatically
57//! based on `Cargo.toml`'s presence.
58
59use crate::project_vfs::ProjectVfs;
60use anyhow::{anyhow, Context, Result};
61use khora_sdk::khora_core::asset::CompressionKind;
62use khora_sdk::PackBuilder;
63use serde::Serialize;
64use std::path::{Path, PathBuf};
65
66/// Build profile selecting compression / manifest / runtime-validation
67/// trade-offs in one place. Callers pick the preset; the build pipeline
68/// reads the resolved settings off it.
69///
70/// `Debug` / `Shipping` are reserved for the upcoming "Build…" dialog
71/// (Phase 6) — `Release` is the default until that lands.
72#[allow(dead_code)]
73#[derive(Debug, Clone, Copy, PartialEq, Eq)]
74pub enum BuildPreset {
75    /// Fast iteration. No compression, no manifest, runtime validation
76    /// stays on so corruption is loud.
77    Debug,
78    /// Production-bound. LZ4 per-entry compression, manifest emitted,
79    /// runtime validation on (catches broken downloads before crashes).
80    Release,
81    /// Performance-critical shipping. Same on-disk format as Release but
82    /// the runtime *skips* integrity verification at load time — used
83    /// when QA has already validated the pack and you want maximum FPS.
84    Shipping,
85}
86
87impl BuildPreset {
88    pub fn label(self) -> &'static str {
89        match self {
90            Self::Debug => "debug",
91            Self::Release => "release",
92            Self::Shipping => "shipping",
93        }
94    }
95
96    /// Compression scheme applied at pack time.
97    pub fn compression(self) -> CompressionKind {
98        match self {
99            Self::Debug => CompressionKind::None,
100            Self::Release | Self::Shipping => CompressionKind::Lz4,
101        }
102    }
103
104    /// Whether to emit a `manifest.bin` BLAKE3 sidecar.
105    pub fn emit_manifest(self) -> bool {
106        match self {
107            Self::Debug => false,
108            Self::Release | Self::Shipping => true,
109        }
110    }
111
112    /// Whether the staged runtime should hash assets on load and bail
113    /// on mismatch.
114    pub fn verify_integrity(self) -> bool {
115        match self {
116            Self::Debug | Self::Release => true,
117            Self::Shipping => false,
118        }
119    }
120}
121
122/// One of the platforms the editor knows how to stage a build for.
123#[derive(Debug, Clone, Copy, PartialEq, Eq)]
124pub enum BuildTarget {
125    Windows,
126    Linux,
127    Macos,
128}
129
130impl BuildTarget {
131    /// The build target matching the editor's host OS. Used by the
132    /// "Build Game…" menu (v1) and as a sane default in any future UI.
133    pub fn host() -> Self {
134        if cfg!(target_os = "windows") {
135            Self::Windows
136        } else if cfg!(target_os = "macos") {
137            Self::Macos
138        } else {
139            // Treat every other unix-ish target as Linux for staging
140            // purposes — the runtime binary suffix is the same.
141            Self::Linux
142        }
143    }
144
145    /// Sub-directory name for the staged build (e.g. `windows` →
146    /// `<project>/dist/windows/`).
147    pub fn dir_name(self) -> &'static str {
148        match self {
149            Self::Windows => "windows",
150            Self::Linux => "linux",
151            Self::Macos => "macos",
152        }
153    }
154
155    /// Filename suffix appended to the runtime binary on this target.
156    pub fn exe_suffix(self) -> &'static str {
157        match self {
158            Self::Windows => ".exe",
159            Self::Linux | Self::Macos => "",
160        }
161    }
162}
163
164/// What we write next to the staged runtime so it knows which scene to
165/// auto-load. Mirrors the schema `khora_runtime::RuntimeConfig` reads.
166#[derive(Debug, Serialize)]
167struct RuntimeConfig<'a> {
168    project_name: &'a str,
169    default_scene: &'a str,
170    /// Build preset label (debug/release/shipping). Runtime uses it to
171    /// decide whether to verify pack integrity on load.
172    preset: &'a str,
173    /// Whether the runtime should re-hash assets against `manifest.bin`
174    /// on load. Independent of `preset` so runtimes shipped before the
175    /// preset concept existed can still toggle it.
176    verify_integrity: bool,
177}
178
179/// Which build strategy was used to produce a [`BuildOutcome`].
180#[derive(Debug, Clone, Copy, PartialEq, Eq)]
181pub enum BuildStrategy {
182    /// The pre-built `khora-runtime` binary was stamped next to the
183    /// project's pack. Cross-platform trivial.
184    RuntimeStamp,
185    /// `cargo build --release` was invoked on the project's `Cargo.toml`
186    /// to produce a custom binary that links the user's native Rust.
187    /// Host-only.
188    CargoBuild,
189}
190
191impl BuildStrategy {
192    pub fn label(self) -> &'static str {
193        match self {
194            Self::RuntimeStamp => "runtime stamp",
195            Self::CargoBuild => "cargo build",
196        }
197    }
198}
199
200/// Result of a successful build, returned to the caller (the editor's
201/// menu dispatcher logs + banners off these fields).
202#[derive(Debug, Clone)]
203pub struct BuildOutcome {
204    pub strategy: BuildStrategy,
205    pub output_dir: PathBuf,
206    /// Absolute path of the staged executable. Used by future UI affordances
207    /// like a "Reveal in Explorer" button — currently the menu dispatcher
208    /// only reads `output_dir` and the asset/byte counts.
209    #[allow(dead_code)]
210    pub binary_path: PathBuf,
211    pub asset_count: usize,
212    pub pack_bytes: u64,
213}
214
215/// Stages a build for the host OS using the **Release** preset by
216/// default. Convenience wrapper for the menu's "Build Game…" entry.
217pub fn build_for_host(pvfs: &ProjectVfs, project_name: &str) -> Result<BuildOutcome> {
218    let target = BuildTarget::host();
219    build_for_target(pvfs, project_name, target, BuildPreset::Release)
220}
221
222/// Stages a build for any target with an explicit preset. v1 only
223/// invokes this with the host; non-host targets fail at the
224/// runtime-binary lookup (or, for the cargo path, are explicitly
225/// refused — see [`stage_with_cargo_build`]) until cross-compile lands.
226pub fn build_for_target(
227    pvfs: &ProjectVfs,
228    project_name: &str,
229    target: BuildTarget,
230    preset: BuildPreset,
231) -> Result<BuildOutcome> {
232    if project_name.trim().is_empty() {
233        anyhow::bail!("Build Game: project_name is empty");
234    }
235
236    let output_dir = pvfs.root.join("dist").join(target.dir_name());
237    std::fs::create_dir_all(&output_dir).with_context(|| {
238        format!(
239            "Failed to create build output directory {}",
240            output_dir.display()
241        )
242    })?;
243
244    let has_cargo = pvfs.root.join("Cargo.toml").is_file();
245    let strategy = if has_cargo {
246        BuildStrategy::CargoBuild
247    } else {
248        BuildStrategy::RuntimeStamp
249    };
250
251    log::info!(
252        "Build Game: target={:?}, preset={}, strategy={}, output={}",
253        target,
254        preset.label(),
255        strategy.label(),
256        output_dir.display()
257    );
258
259    // Pack assets directly into the output dir — index.bin + data.pack
260    // (and optionally manifest.bin) sit alongside the staged binary
261    // regardless of strategy. Compression / manifest are driven by the
262    // build preset.
263    let pack_out = PackBuilder::new(&pvfs.assets_root, &output_dir)
264        .with_compression(preset.compression())
265        .with_manifest(preset.emit_manifest())
266        .build()
267        .context("PackBuilder failed")?;
268
269    let bin_dst = match strategy {
270        BuildStrategy::RuntimeStamp => stage_with_runtime_stamp(&output_dir, project_name, target)?,
271        BuildStrategy::CargoBuild => {
272            stage_with_cargo_build(&pvfs.root, &output_dir, project_name, target)?
273        }
274    };
275
276    write_runtime_config(&output_dir, project_name, preset)?;
277
278    log::info!(
279        "Build Game: staged {} ({} assets, {} bytes packed) via {}",
280        bin_dst.display(),
281        pack_out.asset_count,
282        pack_out.pack_bytes(),
283        strategy.label()
284    );
285
286    Ok(BuildOutcome {
287        strategy,
288        output_dir,
289        binary_path: bin_dst,
290        asset_count: pack_out.asset_count,
291        pack_bytes: pack_out.pack_bytes(),
292    })
293}
294
295/// Stamps the pre-built `khora-runtime` into `output_dir`, renamed to the
296/// project name. Returns the absolute path of the staged binary.
297fn stage_with_runtime_stamp(
298    output_dir: &Path,
299    project_name: &str,
300    target: BuildTarget,
301) -> Result<PathBuf> {
302    let runtime_src = locate_runtime_binary(target)
303        .with_context(|| format!("Could not locate khora-runtime for {:?}", target))?;
304    let safe_name = sanitize_binary_name(project_name);
305    let bin_filename = format!("{}{}", safe_name, target.exe_suffix());
306    let bin_dst = output_dir.join(&bin_filename);
307    std::fs::copy(&runtime_src, &bin_dst).with_context(|| {
308        format!(
309            "Failed to copy runtime {} → {}",
310            runtime_src.display(),
311            bin_dst.display()
312        )
313    })?;
314    set_executable_bit(&bin_dst);
315    Ok(bin_dst)
316}
317
318/// Invokes `cargo build --release` against `<project>/Cargo.toml`,
319/// streams stdout+stderr into the editor log, and copies the resulting
320/// binary into `output_dir` renamed to the project name.
321///
322/// Refuses non-host targets with a clear error — Rust cross-compilation
323/// is host-only in v1.
324fn stage_with_cargo_build(
325    project_root: &Path,
326    output_dir: &Path,
327    project_name: &str,
328    target: BuildTarget,
329) -> Result<PathBuf> {
330    if target != BuildTarget::host() {
331        anyhow::bail!(
332            "Build Game (cargo strategy): target {:?} is not the host \
333             ({:?}). Native-Rust projects can only be built for the host \
334             OS in v1 — run the editor on the desired target.",
335            target,
336            BuildTarget::host()
337        );
338    }
339
340    let manifest = project_root.join("Cargo.toml");
341    if !manifest.is_file() {
342        anyhow::bail!(
343            "Build Game (cargo strategy): no Cargo.toml at {}",
344            manifest.display()
345        );
346    }
347
348    log::info!(
349        "Build Game: invoking `cargo build --release --manifest-path {}`",
350        manifest.display()
351    );
352
353    let output = std::process::Command::new("cargo")
354        .arg("build")
355        .arg("--release")
356        .arg("--manifest-path")
357        .arg(&manifest)
358        .output()
359        .with_context(|| {
360            format!(
361                "Failed to launch cargo (is it installed and on PATH?). \
362                 Manifest: {}",
363                manifest.display()
364            )
365        })?;
366
367    // Stream cargo's stdout/stderr through the editor's logger so the
368    // user sees compile errors in the console panel.
369    if !output.stdout.is_empty() {
370        log::info!("cargo stdout:\n{}", String::from_utf8_lossy(&output.stdout));
371    }
372    if !output.stderr.is_empty() {
373        // Cargo writes "Compiling foo v0.1.0…" to stderr by design — log
374        // at info, not warn.
375        log::info!("cargo stderr:\n{}", String::from_utf8_lossy(&output.stderr));
376    }
377    if !output.status.success() {
378        anyhow::bail!(
379            "cargo build failed with exit code {:?}",
380            output.status.code()
381        );
382    }
383
384    // Discover the produced binary in target/release/. The Cargo package
385    // name is whatever the user set in their generated Cargo.toml — read
386    // it back out of the manifest. We fall back to scanning the directory
387    // when the [package].name TOML extraction stumbles on hand-edits.
388    let target_release = project_root.join("target").join("release");
389    let pkg_bin = find_compiled_binary(&target_release, target).with_context(|| {
390        format!(
391            "Could not find a compiled executable in {} (cargo build \
392                 succeeded but the binary is missing — is `[[bin]]` set?)",
393            target_release.display()
394        )
395    })?;
396
397    let safe_name = sanitize_binary_name(project_name);
398    let bin_filename = format!("{}{}", safe_name, target.exe_suffix());
399    let bin_dst = output_dir.join(&bin_filename);
400    std::fs::copy(&pkg_bin, &bin_dst).with_context(|| {
401        format!(
402            "Failed to copy compiled binary {} → {}",
403            pkg_bin.display(),
404            bin_dst.display()
405        )
406    })?;
407    set_executable_bit(&bin_dst);
408    Ok(bin_dst)
409}
410
411/// Walks `target/release/` for the first regular file with the host's
412/// executable suffix, ignoring cargo metadata files (`.d`, `.rlib`, etc).
413fn find_compiled_binary(target_release: &Path, target: BuildTarget) -> Result<PathBuf> {
414    let suffix = target.exe_suffix();
415    let entries = std::fs::read_dir(target_release).with_context(|| {
416        format!(
417            "Failed to read target/release directory {}",
418            target_release.display()
419        )
420    })?;
421    for entry in entries.flatten() {
422        let path = entry.path();
423        if !path.is_file() {
424            continue;
425        }
426        let name = path
427            .file_name()
428            .and_then(|s| s.to_str())
429            .unwrap_or_default();
430        // Skip cargo metadata files.
431        if name.ends_with(".d") || name.ends_with(".rlib") || name.ends_with(".pdb") {
432            continue;
433        }
434        // On Windows, only files ending in .exe are the binary. On Unix,
435        // the binary has no extension.
436        if !suffix.is_empty() {
437            if path.extension().and_then(|s| s.to_str()) == Some(suffix.trim_start_matches('.')) {
438                return Ok(path);
439            }
440        } else if path.extension().is_none() {
441            return Ok(path);
442        }
443    }
444    Err(anyhow!(
445        "no compiled executable found in {}",
446        target_release.display()
447    ))
448}
449
450fn write_runtime_config(output_dir: &Path, project_name: &str, preset: BuildPreset) -> Result<()> {
451    let cfg = RuntimeConfig {
452        project_name,
453        default_scene: crate::scene_io::DEFAULT_SCENE_REL,
454        preset: preset.label(),
455        verify_integrity: preset.verify_integrity(),
456    };
457    let cfg_text = serde_json::to_string_pretty(&cfg).context("serialize runtime.json")?;
458    std::fs::write(output_dir.join("runtime.json"), cfg_text)
459        .context("Failed to write runtime.json")
460}
461
462#[cfg_attr(not(unix), allow(unused_variables))]
463fn set_executable_bit(path: &Path) {
464    #[cfg(unix)]
465    {
466        use std::os::unix::fs::PermissionsExt;
467        if let Ok(meta) = std::fs::metadata(path) {
468            let mut perms = meta.permissions();
469            perms.set_mode(0o755);
470            let _ = std::fs::set_permissions(path, perms);
471        }
472    }
473}
474
475/// Replaces filesystem-unsafe characters in the project name with
476/// underscores so we can use it as a binary filename.
477fn sanitize_binary_name(name: &str) -> String {
478    let cleaned: String = name
479        .chars()
480        .map(|c| {
481            if c.is_alphanumeric() || c == '-' || c == '_' {
482                c
483            } else {
484                '_'
485            }
486        })
487        .collect();
488    if cleaned.is_empty() {
489        "game".to_owned()
490    } else {
491        cleaned
492    }
493}
494
495/// Looks for the `khora-runtime` binary for `target` in:
496///
497/// 1. **Sibling of the editor binary** — the canonical layout once the
498///    engine release archive is unpacked, and what `cargo build` produces
499///    in `target/<profile>/`.
500/// 2. **Sibling profile fallback** — when the editor runs from
501///    `target/release/`, also check `target/debug/` (and vice-versa).
502///    Lets a contributor running `cargo run -p khora-hub --release` find
503///    a runtime that was built in debug by `cargo build` or `cargo
504///    hub-dev`.
505/// 3. **Hub engine cache** — `~/.khora/engines/<version>/runtime/` is the
506///    layout produced by `hub::download::start_download` when the matching
507///    `khora-runtime-<host>` artifact is present in the GitHub release.
508///
509/// Non-host targets always fall through today: the hub only fetches its
510/// own host architecture from a release, and dev builds only produce the
511/// host runtime. Cross-target build-template caching is a future expansion
512/// that re-uses this same lookup once the hub fetches all three runtime
513/// archives.
514fn locate_runtime_binary(target: BuildTarget) -> Result<PathBuf> {
515    let bin_name = format!("khora-runtime{}", target.exe_suffix());
516
517    // (1) Sibling of the editor binary — release-archive layout, also what
518    //     `cargo build -p khora-runtime` produces in `target/<profile>/`.
519    let sibling_dir = if target == BuildTarget::host() {
520        let editor_exe =
521            std::env::current_exe().context("locate_runtime_binary: current_exe failed")?;
522        editor_exe.parent().map(|p| p.to_path_buf())
523    } else {
524        None
525    };
526    if let Some(dir) = sibling_dir.as_ref() {
527        let candidate = dir.join(&bin_name);
528        if candidate.is_file() {
529            return Ok(candidate);
530        }
531    }
532
533    // (2) Sibling profile fallback — for the common dev workflow where the
534    //     hub launches an editor in `target/debug/` while the contributor
535    //     ran the hub itself in `--release`. Same layout, just the other
536    //     profile.
537    if let Some(dir) = sibling_dir.as_ref() {
538        if let Some(workspace_root) = workspace_root_from_target_dir(dir) {
539            for profile in ["release", "debug"] {
540                let candidate = workspace_root.join("target").join(profile).join(&bin_name);
541                if candidate.is_file() {
542                    return Ok(candidate);
543                }
544            }
545        }
546    }
547
548    // (3) Hub engine cache — matches the layout produced by
549    //     `hub::download::start_download`: <cache>/<version>/runtime/<bin>.
550    if target == BuildTarget::host() {
551        if let Some(cache) = engine_cache_dir() {
552            if let Ok(entries) = std::fs::read_dir(&cache) {
553                let mut versions: Vec<PathBuf> = entries
554                    .flatten()
555                    .filter(|e| e.file_type().map(|t| t.is_dir()).unwrap_or(false))
556                    .map(|e| e.path())
557                    .collect();
558                versions.sort();
559                for ver in versions.iter().rev() {
560                    let canonical = ver.join("runtime").join(&bin_name);
561                    if canonical.is_file() {
562                        return Ok(canonical);
563                    }
564                    if let Some(found) = find_file_recursive(ver, &bin_name) {
565                        return Ok(found);
566                    }
567                }
568            }
569        }
570    }
571
572    // Pedagogical error: the contributor most likely just hasn't built
573    // the runtime yet. Point them at the exact command (cargo hub-dev
574    // does it transparently as part of the dev workflow).
575    let workspace_hint = sibling_dir
576        .as_ref()
577        .and_then(|d| workspace_root_from_target_dir(d))
578        .map(|w| {
579            format!(
580                " Run `cargo hub-dev` (or `cargo build -p khora-runtime`) from {}.",
581                w.display()
582            )
583        })
584        .unwrap_or_default();
585    Err(anyhow!(
586        "khora-runtime binary not found for {:?}. \
587         Expected '{}' next to the editor binary or under \
588         '~/.khora/engines/<version>/runtime/'.{}",
589        target,
590        bin_name,
591        workspace_hint
592    ))
593}
594
595/// Returns the workspace root if `target_dir` is a `target/<profile>/`
596/// subdirectory of one. Detection: parent must be named `target`, and
597/// the grandparent must contain a `Cargo.toml`.
598fn workspace_root_from_target_dir(target_dir: &Path) -> Option<PathBuf> {
599    let target_dir_name = target_dir.parent()?.file_name()?.to_str()?;
600    if target_dir_name != "target" {
601        return None;
602    }
603    let candidate = target_dir.parent()?.parent()?;
604    if candidate.join("Cargo.toml").is_file() {
605        Some(candidate.to_path_buf())
606    } else {
607        None
608    }
609}
610
611/// Returns `~/.khora/engines/` if accessible. The hub manages this
612/// directory; the editor reads from it.
613fn engine_cache_dir() -> Option<PathBuf> {
614    let home = dirs_home()?;
615    let p = home.join(".khora").join("engines");
616    if p.is_dir() {
617        Some(p)
618    } else {
619        None
620    }
621}
622
623/// Recursive file search. Returns `Some(path)` for the first hit.
624fn find_file_recursive(dir: &Path, filename: &str) -> Option<PathBuf> {
625    let direct = dir.join(filename);
626    if direct.is_file() {
627        return Some(direct);
628    }
629    let entries = std::fs::read_dir(dir).ok()?;
630    for entry in entries.flatten() {
631        let p = entry.path();
632        if p.is_dir() {
633            if let Some(found) = find_file_recursive(&p, filename) {
634                return Some(found);
635            }
636        } else if p.file_name().and_then(|n| n.to_str()) == Some(filename) {
637            return Some(p);
638        }
639    }
640    None
641}
642
643fn dirs_home() -> Option<PathBuf> {
644    // Avoid a hard dep on `dirs`; fall back to OS-standard env vars.
645    if let Some(h) = std::env::var_os("HOME") {
646        return Some(PathBuf::from(h));
647    }
648    if let Some(p) = std::env::var_os("USERPROFILE") {
649        return Some(PathBuf::from(p));
650    }
651    None
652}
653
654#[cfg(test)]
655mod tests {
656    use super::*;
657
658    #[test]
659    fn host_target_matches_cfg() {
660        let host = BuildTarget::host();
661        if cfg!(target_os = "windows") {
662            assert_eq!(host, BuildTarget::Windows);
663        } else if cfg!(target_os = "macos") {
664            assert_eq!(host, BuildTarget::Macos);
665        } else {
666            assert_eq!(host, BuildTarget::Linux);
667        }
668    }
669
670    #[test]
671    fn target_suffixes_are_correct() {
672        assert_eq!(BuildTarget::Windows.exe_suffix(), ".exe");
673        assert_eq!(BuildTarget::Linux.exe_suffix(), "");
674        assert_eq!(BuildTarget::Macos.exe_suffix(), "");
675    }
676
677    #[test]
678    fn sanitize_binary_name_strips_unsafe_chars() {
679        assert_eq!(sanitize_binary_name("My Game!"), "My_Game_");
680        assert_eq!(sanitize_binary_name("ok-name_1"), "ok-name_1");
681        assert_eq!(sanitize_binary_name(""), "game");
682        assert_eq!(sanitize_binary_name("/etc/passwd"), "_etc_passwd");
683    }
684}