Skip to main content

pite_export/
lib.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! `pite-export`: desktop export. Copies the engine binary plus only the
3//! referenced game content into a runnable directory, together with a pinned
4//! CPython unless `--no-bundle-python` keeps the system-Python requirement.
5
6pub mod player;
7pub mod python_bundle;
8
9pub use player::{candidate_player_names, player_bin_name};
10
11use std::collections::HashSet;
12use std::path::{Path, PathBuf};
13
14use anyhow::{Context, Result};
15use pite_render::atlas::{sheet_for_sidecar, SIDECAR_EXT};
16
17pub const SUPPORTED_PLATFORMS: &[&str] = &["linux", "windows", "macos"];
18
19#[derive(Debug, Clone)]
20pub struct ExportOptions {
21    pub platform: String,
22    pub out_dir: Option<PathBuf>,
23    pub binary: Option<PathBuf>,
24    pub skip_python_check: bool,
25    /// Bundle a pinned CPython so the export runs without system Python.
26    pub bundle_python: bool,
27}
28
29impl Default for ExportOptions {
30    fn default() -> Self {
31        Self {
32            platform: String::new(),
33            out_dir: None,
34            binary: None,
35            skip_python_check: false,
36            bundle_python: true,
37        }
38    }
39}
40
41/// How the exported launcher finds its interpreter.
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub enum PythonChoice {
44    /// A pinned CPython staged into `<out>/python`.
45    Bundled { zip: PathBuf },
46    /// Fall back to whatever Python 3.12 the machine has.
47    System,
48}
49
50#[derive(Debug)]
51pub struct ExportReport {
52    pub out_dir: PathBuf,
53    pub binary: PathBuf,
54    pub files: Vec<PathBuf>,
55    pub main_scene: String,
56    pub python: PythonChoice,
57}
58
59pub fn find_python() -> Option<PathBuf> {
60    if let Some(p) = std::env::var_os("PITE_PYTHON") {
61        let p = PathBuf::from(p);
62        return is_python312(&p).then_some(p);
63    }
64    let path = std::env::var_os("PATH")?;
65    for dir in std::env::split_paths(&path) {
66        for name in ["python3.12", "python3.12.exe"] {
67            let p = dir.join(name);
68            if is_python312(&p) {
69                return Some(p);
70            }
71        }
72    }
73    None
74}
75
76fn is_python312(p: &Path) -> bool {
77    if !p.is_file() {
78        return false;
79    }
80    let Ok(out) = std::process::Command::new(p).arg("--version").output() else {
81        return false;
82    };
83    let text = format!(
84        "{}{}",
85        String::from_utf8_lossy(&out.stdout),
86        String::from_utf8_lossy(&out.stderr)
87    );
88    text.contains("Python 3.12")
89}
90
91/// Owns the export directory for the length of an export. Every failure path is
92/// a `?`, so without this a run that dies partway — a wrong archive layout once
93/// left 136 MB of unpacked CPython behind — also wedges the next attempt,
94/// because the exists-guard refuses to overwrite what the failed run left.
95struct ExportDir {
96    path: PathBuf,
97    complete: bool,
98}
99
100impl ExportDir {
101    fn commit(&mut self) {
102        self.complete = true;
103    }
104}
105
106impl Drop for ExportDir {
107    fn drop(&mut self) {
108        if !self.complete {
109            let _ = std::fs::remove_dir_all(&self.path);
110        }
111    }
112}
113
114pub fn export_project(root: &Path, opts: &ExportOptions) -> Result<ExportReport> {
115    if !SUPPORTED_PLATFORMS.contains(&opts.platform.as_str()) {
116        anyhow::bail!(
117            "unsupported export platform {:?} (supported: linux, windows, macos)",
118            opts.platform
119        );
120    }
121    let manifest = pite_project::load_manifest(root)?;
122    let game_name = manifest
123        .export
124        .binary_name
125        .clone()
126        .unwrap_or_else(|| manifest.project.name.clone());
127    let python = if opts.bundle_python {
128        let spec = python_bundle::pinned(&opts.platform).with_context(|| {
129            format!(
130                "no pinned Python build for {}; pass --no-bundle-python to require \
131                 system Python 3.12 instead",
132                opts.platform
133            )
134        })?;
135        let archive = python_bundle::ensure_archive(&spec)?;
136        PythonChoice::Bundled {
137            zip: archive.clone(),
138        }
139    } else {
140        if !opts.skip_python_check && find_python().is_none() {
141            anyhow::bail!(
142                "export needs system Python 3.12 on PATH (or PITE_PYTHON); \
143                 the target machine must have it too. Pass --skip-python-check \
144                 for cross-machine builds, or drop --no-bundle-python to ship a \
145                 pinned interpreter."
146            );
147        }
148        PythonChoice::System
149    };
150    let binary_src = select_binary(opts)?;
151
152    let out = opts.out_dir.clone().unwrap_or_else(|| {
153        root.join("dist")
154            .join(format!("{game_name}-{}", opts.platform))
155    });
156    if out.exists() {
157        anyhow::bail!("{} exists; remove it or pass --out <dir>", out.display());
158    }
159    let mut owned = ExportDir {
160        path: out.clone(),
161        complete: false,
162    };
163    let bin_name = if opts.platform == "windows" {
164        format!("{game_name}.exe")
165    } else {
166        game_name.clone()
167    };
168    let game_dir = out.join("game");
169    let bin_dir = out.join("bin");
170    std::fs::create_dir_all(&game_dir)?;
171    std::fs::create_dir_all(&bin_dir)?;
172
173    let mut files = collect_referenced(root, &manifest)?;
174    files.sort();
175    for abs in &files {
176        let rel = abs.strip_prefix(root).with_context(|| {
177            format!("export cannot reach outside the project: {}", abs.display())
178        })?;
179        let dest = game_dir.join(rel);
180        if let Some(parent) = dest.parent() {
181            std::fs::create_dir_all(parent)?;
182        }
183        std::fs::copy(abs, &dest)?;
184    }
185    std::fs::copy(
186        root.join(pite_project::MANIFEST_FILE),
187        game_dir.join(pite_project::MANIFEST_FILE),
188    )?;
189
190    let bin_dest = bin_dir.join(&bin_name);
191    std::fs::copy(&binary_src, &bin_dest)?;
192    python_bundle::strip_debug(&bin_dest);
193    #[cfg(unix)]
194    {
195        use std::os::unix::fs::PermissionsExt;
196        std::fs::set_permissions(&bin_dest, std::fs::Permissions::from_mode(0o755))?;
197    }
198
199    let staged = match &python {
200        PythonChoice::Bundled { zip } => {
201            let spec = python_bundle::pinned(&opts.platform).expect("checked above");
202            let mut staged = python_bundle::stage(&spec, zip, &out.join("python"))
203                .context("cannot stage the bundled Python")?;
204            if opts.platform == "windows" {
205                // Windows resolves DLLs from the exe's own directory before
206                // PATH, so the interpreter DLL lives next to the binary and
207                // the staged copy is dropped instead of shipped twice.
208                let dll = staged
209                    .lib
210                    .file_name()
211                    .expect("lib file has a name")
212                    .to_string_lossy()
213                    .into_owned();
214                let dest = bin_dir.join(&dll);
215                std::fs::copy(&staged.lib, &dest)
216                    .context("cannot place the interpreter DLL next to the binary")?;
217                std::fs::remove_file(&staged.lib)?;
218                staged.lib = dest;
219            }
220            if let Some(pth) = spec.pth_name {
221                let pth_body = windows_pth(&bin_dir, &staged)?;
222                std::fs::write(bin_dir.join(pth), pth_body)
223                    .context("cannot write the interpreter loader config")?;
224            }
225            Some(staged)
226        }
227        PythonChoice::System => None,
228    };
229
230    let main_rel = manifest
231        .project
232        .main_scene
233        .strip_prefix("res://")
234        .map(str::to_string)
235        .unwrap_or_else(|| manifest.project.main_scene.clone());
236    if opts.platform == "windows" {
237        std::fs::write(
238            out.join("run.bat"),
239            windows_launcher(&game_name, &bin_name, &main_rel, staged.as_ref()),
240        )?;
241    } else {
242        let sh = out.join("run.sh");
243        std::fs::write(
244            &sh,
245            unix_launcher(&game_name, &bin_name, &main_rel, &out, staged.as_ref()),
246        )?;
247        #[cfg(unix)]
248        {
249            use std::os::unix::fs::PermissionsExt;
250            std::fs::set_permissions(&sh, std::fs::Permissions::from_mode(0o755))?;
251        }
252    }
253    std::fs::write(
254        out.join("README.txt"),
255        readme(&game_name, &opts.platform, &python),
256    )?;
257
258    let python = match staged {
259        Some(staged) => PythonChoice::Bundled {
260            zip: staged.stdlib_zip,
261        },
262        None => PythonChoice::System,
263    };
264    owned.commit();
265    Ok(ExportReport {
266        out_dir: out,
267        binary: bin_dest,
268        files,
269        main_scene: manifest.project.main_scene.clone(),
270        python,
271    })
272}
273
274/// Bundled mode needs no Python on PATH: the loader finds `libpython` through
275/// `LD_LIBRARY_PATH` / `DYLD_LIBRARY_PATH` (or a `$ORIGIN` RUNPATH when the binary was built with one)
276/// and the stdlib comes from the zip. Proved on Linux and macOS.
277fn unix_launcher(
278    game: &str,
279    bin: &str,
280    main_rel: &str,
281    out: &Path,
282    bundle: Option<&python_bundle::StagedBundle>,
283) -> String {
284    match bundle {
285        Some(bundle) => {
286            let lib_dir = bundle.lib.parent().unwrap_or(&bundle.root);
287            format!(
288                "#!/bin/sh\n\
289                 set -e\n\
290                 HERE=\"$(cd \"$(dirname \"$0\")\" && pwd)\"\n\
291                 LD_LIBRARY_PATH=\"$HERE/{lib_rel}\"${{LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}}\n\
292                 export LD_LIBRARY_PATH\n\
293                 DYLD_LIBRARY_PATH=\"$HERE/{lib_rel}\"${{DYLD_LIBRARY_PATH:+:$DYLD_LIBRARY_PATH}}\n\
294                 export DYLD_LIBRARY_PATH\n\
295                 PYTHONPATH=\"$HERE/{zip_rel}\"\n\
296                 export PYTHONPATH\n\
297                 exec \"$HERE/bin/{bin}\" \"$HERE/game/{main_rel}\" \"$@\"\n",
298                lib_rel = rel_to(out, lib_dir),
299                zip_rel = rel_to(out, &bundle.stdlib_zip),
300            )
301        }
302        None => format!(
303            "#!/bin/sh\n\
304             set -e\n\
305             HERE=\"$(cd \"$(dirname \"$0\")\" && pwd)\"\n\
306             if [ -n \"$PITE_PYTHON\" ]; then PY=\"$PITE_PYTHON\"; else PY=\"python3.12\"; fi\n\
307             if ! command -v \"$PY\" >/dev/null 2>&1; then\n\
308             echo \"error: {game} needs system Python 3.12 (tried $PY).\" >&2\n\
309             echo \"This export has no bundled interpreter; install CPython 3.12 or set PITE_PYTHON.\" >&2\n\
310             exit 1\n\
311             fi\n\
312              exec \"$HERE/bin/{bin}\" \"$HERE/game/{main_rel}\" \"$@\"\n"
313        ),
314    }
315}
316
317/// Body of the Windows loader config. The interpreter reads this from beside its
318/// own DLL and ignores `PYTHONPATH` entirely once it exists, so it is the only
319/// thing that points it at the stdlib zip. Entries resolve against the DLL's
320/// directory, and must stay relative: an absolute path would break the export as
321/// soon as the folder is moved or copied to another machine.
322fn windows_pth(bin_dir: &Path, bundle: &python_bundle::StagedBundle) -> Result<String> {
323    let python_dir = bundle
324        .root
325        .file_name()
326        .context("staged python directory has a name")?
327        .to_string_lossy();
328    let zip = bundle
329        .stdlib_zip
330        .file_name()
331        .context("staged stdlib zip has a name")?
332        .to_string_lossy();
333    let parent = bin_dir.parent().context("bin directory has a parent")?;
334    if bundle.root.parent() != Some(parent) {
335        anyhow::bail!(
336            "staged python directory {} is not a sibling of {}; the loader config \
337             cannot name a relative path to the stdlib",
338            bundle.root.display(),
339            bin_dir.display()
340        );
341    }
342    Ok(format!("..\\{python_dir}\\{zip}\r\n.\r\n"))
343}
344
345/// Windows resolves DLLs from the exe's own directory first, so the staged
346/// interpreter sits next to `bin/<game>.exe`, and the `_pth` file beside it
347/// carries the stdlib zip. No Python install, no PATH juggling.
348fn windows_launcher(
349    game: &str,
350    bin: &str,
351    main_rel: &str,
352    bundle: Option<&python_bundle::StagedBundle>,
353) -> String {
354    match bundle {
355        Some(_) => format!(
356            "@echo off\r\n\
357             set HERE=%~dp0\r\n\
358             \"%HERE%bin\\{bin}\" \"%HERE%game/{main_rel}\" %*\r\n"
359        ),
360        None => format!(
361            "@echo off\r\n\
362             set HERE=%~dp0\r\n\
363             if defined PITE_PYTHON (set PY=%PITE_PYTHON%) else (set PY=python3.12)\r\n\
364             where %PY% >nul 2>nul\r\n\
365             if errorlevel 1 (\r\n\
366             echo error: {game} needs system Python 3.12 on PATH. 1>&2\r\n\
367             echo This export has no bundled interpreter; install CPython 3.12 or set PITE_PYTHON. 1>&2\r\n\
368             exit /b 1\r\n\
369             )\r\n\
370             \"%HERE%bin\\{bin}\" \"%HERE%game/{main_rel}\" %*\r\n"
371        ),
372    }
373}
374
375/// Which binary an export ships: an explicit `--binary`, else a `pite-player`
376/// sibling named for the target platform. Engine bundles ship player binaries
377/// for all platforms. If no sibling binary is found beside the engine, export
378/// automatically downloads and caches the target platform's player binary.
379/// The full `pite` binary is never shipped: it carries the editor.
380fn select_binary(opts: &ExportOptions) -> Result<PathBuf> {
381    select_binary_from(opts, std::env::current_exe().ok())
382}
383
384fn select_binary_from(opts: &ExportOptions, current_exe: Option<PathBuf>) -> Result<PathBuf> {
385    if let Some(p) = &opts.binary {
386        if !p.is_file() {
387            anyhow::bail!("engine binary not found: {}", p.display());
388        }
389        return Ok(p.clone());
390    }
391    if let Some(dir) = current_exe.as_ref().and_then(|e| e.parent()) {
392        for name in candidate_player_names(&opts.platform, std::env::consts::OS) {
393            let sibling = dir.join(name);
394            if sibling.is_file() {
395                return Ok(sibling);
396            }
397        }
398    }
399    match player::ensure_player_binary(&opts.platform) {
400        Ok(path) => Ok(path),
401        Err(e) => {
402            anyhow::bail!(
403                "export needs a pite-player binary for {} next to {} (engine bundles ship player \
404                 binaries for all platforms), or pass --binary with a player binary; \
405                 download fallback failed: {e:#}",
406                opts.platform,
407                current_exe
408                    .as_deref()
409                    .unwrap_or(Path::new("pite"))
410                    .display()
411            )
412        }
413    }
414}
415
416/// Path of `target` relative to the export root, with `/` separators.
417fn rel_to(root: &Path, target: &Path) -> String {
418    target
419        .strip_prefix(root)
420        .map(|rel| rel.to_string_lossy().replace('\\', "/"))
421        .unwrap_or_else(|_| target.to_string_lossy().into_owned())
422}
423
424fn readme(game: &str, platform: &str, python: &PythonChoice) -> String {
425    let python_line = match python {
426        PythonChoice::Bundled { .. } => format!(
427            "Python: bundled CPython {} (pinned build {})
428",
429            python_bundle::PINNED_PYTHON,
430            python_bundle::PINNED_BUILD
431        ),
432        PythonChoice::System => {
433            "Requires system Python 3.12 on PATH (PITE_PYTHON overrides detection).\n".to_string()
434        }
435    };
436    format!(
437        "{game} ({platform} export)\n\
438         \n\
439         Run: ./run.sh   (or run.bat on Windows)\n\
440         {python_line}\
441         Exported runs never watch files: hot reload is a dev-only feature.\n"
442    )
443}
444
445/// Every file under `dir`, recursively.
446fn stage_tree(dir: &Path, out: &mut std::collections::HashSet<PathBuf>) -> Result<()> {
447    for entry in std::fs::read_dir(dir).with_context(|| format!("cannot read {}", dir.display()))? {
448        let path = entry
449            .with_context(|| format!("cannot list entry in {}", dir.display()))?
450            .path();
451        if path.is_dir() {
452            stage_tree(&path, out)?;
453        } else if path.is_file() {
454            out.insert(path);
455        }
456    }
457    Ok(())
458}
459
460fn collect_referenced(root: &Path, manifest: &pite_project::PiteManifest) -> Result<Vec<PathBuf>> {
461    let mut files = HashSet::new();
462    let mut scenes = vec![manifest.project.main_scene.clone()];
463    let mut visited: HashSet<String> = HashSet::new();
464    while let Some(scene_ref) = scenes.pop() {
465        if !visited.insert(scene_ref.clone()) {
466            continue;
467        }
468        let scene_path = resolve_ref(root, None, &scene_ref)
469            .with_context(|| format!("export cannot resolve scene {scene_ref:?}"))?;
470        files.insert(scene_path.clone());
471        let scene_dir = scene_path
472            .parent()
473            .map(Path::to_path_buf)
474            .unwrap_or_else(|| root.to_path_buf());
475        let doc = pite_scene::load_scene(&scene_path)
476            .with_context(|| format!("export cannot parse {}", scene_path.display()))?;
477        for node in &doc.node {
478            if let Some(script) = &node.script {
479                let p = resolve_ref(root, Some(&scene_dir), &script.path)
480                    .with_context(|| format!("export cannot resolve script {:?}", script.path))?;
481                files.insert(p.clone());
482                for audio in played_assets(
483                    &std::fs::read_to_string(&p)
484                        .with_context(|| format!("export cannot read {}", p.display()))?,
485                ) {
486                    files.insert(
487                        resolve_ref(root, Some(&scene_dir), audio)
488                            .with_context(|| format!("export cannot resolve audio {audio:?}"))?,
489                    );
490                }
491            }
492            for value in node.props.values() {
493                if let toml::Value::String(s) = value {
494                    if s.starts_with("res://") {
495                        let asset = resolve_ref(root, Some(&scene_dir), s)
496                            .with_context(|| format!("export cannot resolve asset {s:?}"))?;
497                        if let Some(sheet) = atlas_sheet(&asset) {
498                            files.insert(sheet);
499                        }
500                        files.insert(asset);
501                    }
502                }
503            }
504        }
505        for inst in &doc.instance {
506            scenes.push(inst.scene.clone());
507        }
508    }
509    for extra in &manifest.export.include {
510        files.insert(root.join(extra));
511    }
512    // Vendored third-party packages ship whole: the lock pins them, the
513    // runtime puts them on sys.path in dev and export alike.
514    let vendor_dir = root.join("vendor");
515    if vendor_dir.is_dir() {
516        stage_tree(&vendor_dir, &mut files)?;
517    }
518    let mut files: Vec<PathBuf> = files.into_iter().collect();
519    for f in &files {
520        if !f.is_file() {
521            anyhow::bail!("export references missing file: {}", f.display());
522        }
523    }
524    files.sort();
525    Ok(files)
526}
527
528fn resolve_ref(root: &Path, scene_dir: Option<&Path>, r: &str) -> Option<PathBuf> {
529    if let Some(rel) = r.strip_prefix("res://") {
530        return Some(root.join(rel));
531    }
532    let p = PathBuf::from(r);
533    if p.is_absolute() {
534        return Some(p);
535    }
536    scene_dir.map(|d| d.join(p))
537}
538
539/// An atlas sidecar is only half an asset: without its sheet the runtime draws
540/// its magenta fallback. Resolved through the same helper `pite_runtime` uses,
541/// so an export can never ship a different file than the game looks for.
542fn atlas_sheet(atlas: &Path) -> Option<PathBuf> {
543    atlas
544        .file_name()?
545        .to_str()?
546        .ends_with(SIDECAR_EXT)
547        .then(|| sheet_for_sidecar(atlas))
548}
549
550fn played_assets(text: &str) -> Vec<&str> {
551    let mut out = Vec::new();
552    for line in text.lines() {
553        let line = line.split('#').next().unwrap_or("");
554        for marker in ["play(\"", "play('"] {
555            let mut rest = line;
556            while let Some(pos) = rest.find(marker) {
557                let before = &rest[..pos];
558                if before.ends_with(|c: char| c.is_alphanumeric() || c == '_') {
559                    rest = &rest[pos + marker.len()..];
560                    continue;
561                }
562                let quote = marker.as_bytes()[marker.len() - 1] as char;
563                rest = &rest[pos + marker.len()..];
564                if let Some(end) = rest.find(quote) {
565                    out.push(&rest[..end]);
566                    rest = &rest[end + 1..];
567                } else {
568                    break;
569                }
570            }
571        }
572    }
573    out
574}
575
576#[cfg(test)]
577pub(crate) static SERIAL: std::sync::Mutex<()> = std::sync::Mutex::new(());
578
579#[cfg(test)]
580mod tests {
581    use super::*;
582    use crate::SERIAL;
583
584    const MAIN: &str = r#"format_version = 1
585root = "root"
586
587[[node]]
588id = "root"
589type = "Node2D"
590name = "Main"
591
592[[node]]
593id = "hero"
594type = "Sprite2D"
595name = "Hero"
596parent = "root"
597
598[node.props]
599atlas = "res://assets/sheet.atlas.json"
600frame = "player"
601
602[[node]]
603id = "spr"
604type = "Sprite2D"
605name = "Spr"
606parent = "root"
607
608[node.props]
609texture = "res://assets/a.png"
610
611[node.script]
612path = "res://scripts/m.py"
613class = "M"
614
615[[instance]]
616scene = "res://scenes/sub.ptscn"
617parent = "root"
618prefix = "s_"
619"#;
620
621    const SUB: &str = r#"format_version = 1
622root = "subroot"
623
624[[node]]
625id = "subroot"
626type = "Node"
627name = "Sub"
628
629[[node]]
630id = "pic"
631type = "Sprite2D"
632name = "Pic"
633parent = "subroot"
634
635[node.props]
636texture = "res://assets/b.png"
637"#;
638
639    const SCRIPT: &str = r#"import pite
640
641class M(pite.Node):
642    pass
643
644def _once():
645    pite.play("res://sfx/hit.wav")
646    # play("res://sfx/comment.wav")
647    display("res://sfx/nope.wav")
648"#;
649
650    const ATLAS: &str = r#"{
651  "texture": "sheet.png",
652  "size": [64, 32],
653  "frames": { "player": { "x": 0, "y": 0, "w": 32, "h": 32 } }
654}
655"#;
656
657    fn fixture(tag: &str) -> (PathBuf, PathBuf) {
658        let dir = std::env::temp_dir().join(format!("pite-exp-{tag}-{}", std::process::id()));
659        std::fs::create_dir_all(dir.join("scenes")).unwrap();
660        std::fs::create_dir_all(dir.join("scripts")).unwrap();
661        std::fs::create_dir_all(dir.join("assets")).unwrap();
662        std::fs::create_dir_all(dir.join("sfx")).unwrap();
663        std::fs::write(dir.join("pite.toml"), "[project]\nname = \"fixt\"\n").unwrap();
664        std::fs::write(dir.join("scenes").join("main.ptscn"), MAIN).unwrap();
665        std::fs::write(dir.join("scenes").join("sub.ptscn"), SUB).unwrap();
666        std::fs::write(dir.join("scripts").join("m.py"), SCRIPT).unwrap();
667        std::fs::write(dir.join("assets").join("a.png"), "A").unwrap();
668        std::fs::write(dir.join("assets").join("b.png"), "B").unwrap();
669        std::fs::write(dir.join("assets").join("unused.png"), "U").unwrap();
670        std::fs::write(dir.join("assets").join("sheet.atlas.json"), ATLAS).unwrap();
671        std::fs::write(dir.join("assets").join("sheet.png"), "S").unwrap();
672        std::fs::write(dir.join("assets").join("orphan.png"), "O").unwrap();
673        std::fs::write(dir.join("sfx").join("hit.wav"), "W").unwrap();
674        let fake_bin = dir.join("fakebin");
675        std::fs::write(&fake_bin, "FAKEBIN").unwrap();
676        (dir, fake_bin)
677    }
678
679    fn opts(bin: &Path) -> ExportOptions {
680        ExportOptions {
681            platform: "linux".to_string(),
682            out_dir: None,
683            binary: Some(bin.to_path_buf()),
684            skip_python_check: true,
685            bundle_python: false,
686        }
687    }
688
689    fn game_has(out: &Path, rel: &str) -> bool {
690        out.join("game").join(rel).is_file()
691    }
692
693    #[test]
694    fn layout_copies_only_referenced_files() {
695        let (dir, bin) = fixture("layout");
696        let report = export_project(&dir, &opts(&bin)).unwrap();
697        let out = &report.out_dir;
698        for rel in [
699            "pite.toml",
700            "scenes/main.ptscn",
701            "scenes/sub.ptscn",
702            "scripts/m.py",
703            "assets/a.png",
704            "assets/b.png",
705            "assets/sheet.atlas.json",
706            "assets/sheet.png",
707            "sfx/hit.wav",
708        ] {
709            assert!(game_has(out, rel), "missing {rel}");
710        }
711        assert!(!game_has(out, "assets/unused.png"));
712        assert!(!game_has(out, "assets/orphan.png"));
713        assert!(out.join("bin").join("fixt").is_file());
714        let sh = std::fs::read_to_string(out.join("run.sh")).unwrap();
715        assert!(sh.contains("bin/fixt"));
716        assert!(!sh.contains(" run "));
717        assert!(sh.contains("game/scenes/main.ptscn"));
718        assert!(out.join("README.txt").is_file());
719        std::fs::remove_dir_all(&dir).ok();
720        std::fs::remove_dir_all(out).ok();
721    }
722
723    #[test]
724    fn layout_stages_vendor_whole() {
725        let (dir, bin) = fixture("vendor-stage");
726        std::fs::create_dir_all(dir.join("vendor").join("vpkg")).unwrap();
727        std::fs::write(dir.join("vendor").join("vpkg").join("__init__.py"), b"x=1").unwrap();
728        std::fs::write(
729            dir.join("vendor").join("pure-1.0-py3-none-any.whl"),
730            b"fake-wheel",
731        )
732        .unwrap();
733        let report = export_project(&dir, &opts(&bin)).unwrap();
734        let out = &report.out_dir;
735        assert!(game_has(out, "vendor/vpkg/__init__.py"), "missing tree");
736        assert!(
737            game_has(out, "vendor/pure-1.0-py3-none-any.whl"),
738            "missing wheel"
739        );
740        std::fs::remove_dir_all(&dir).ok();
741        std::fs::remove_dir_all(out).ok();
742    }
743
744    #[test]
745    fn missing_python_fails_loudly() {
746        let _guard = SERIAL.lock().unwrap();
747        let prev = std::env::var_os("PITE_PYTHON");
748        std::env::set_var("PITE_PYTHON", "/nonexistent/python3.12");
749        let (dir, bin) = fixture("nopy");
750        let err = export_project(
751            &dir,
752            &ExportOptions {
753                skip_python_check: false,
754                ..opts(&bin)
755            },
756        )
757        .unwrap_err();
758        assert!(err.to_string().contains("Python 3.12"), "{err:#}");
759        match prev {
760            Some(v) => std::env::set_var("PITE_PYTHON", v),
761            None => std::env::remove_var("PITE_PYTHON"),
762        }
763        std::fs::remove_dir_all(&dir).ok();
764    }
765
766    #[test]
767    fn python_override_passes_gate() {
768        let _guard = SERIAL.lock().unwrap();
769        let prev = std::env::var_os("PITE_PYTHON");
770        let (dir, bin) = fixture("pyok");
771        let fake = dir.join("fake312");
772        std::fs::write(&fake, "#!/bin/sh\necho \"Python 3.12.99\"\n").unwrap();
773        #[cfg(unix)]
774        {
775            use std::os::unix::fs::PermissionsExt;
776            std::fs::set_permissions(&fake, std::fs::Permissions::from_mode(0o755)).unwrap();
777        }
778        std::env::set_var("PITE_PYTHON", &fake);
779        let report = export_project(
780            &dir,
781            &ExportOptions {
782                skip_python_check: false,
783                ..opts(&bin)
784            },
785        )
786        .unwrap();
787        match prev {
788            Some(v) => std::env::set_var("PITE_PYTHON", v),
789            None => std::env::remove_var("PITE_PYTHON"),
790        }
791        std::fs::remove_dir_all(&report.out_dir).ok();
792        std::fs::remove_dir_all(&dir).ok();
793    }
794
795    #[test]
796    fn bundling_stages_runtime_and_stdlib_next_to_the_game() {
797        let Some(spec) = python_bundle::pinned("linux") else {
798            panic!("linux must have a pinned bundle");
799        };
800        let dir = std::env::temp_dir().join(format!("pite-stage-{}", std::process::id()));
801        let _ = std::fs::remove_dir_all(&dir);
802        std::fs::create_dir_all(dir.join("python/lib/python3.12/site-packages")).unwrap();
803        std::fs::write(
804            dir.join("python/lib").join("libpython3.12.so.1.0"),
805            b"fake-so",
806        )
807        .unwrap();
808        std::fs::write(
809            dir.join("python/lib/python3.12").join("os.py"),
810            b"import sys\n",
811        )
812        .unwrap();
813        std::fs::write(
814            dir.join("python/lib/python3.12/site-packages")
815                .join("pip.py"),
816            b"raise SystemExit\n",
817        )
818        .unwrap();
819        let archive = dir.join("archive.tar.gz");
820        let tar = std::process::Command::new("tar")
821            .arg("-czf")
822            .arg(&archive)
823            .arg("-C")
824            .arg(&dir)
825            .arg("python")
826            .output();
827        if !tar.as_ref().is_ok_and(|o| o.status.success()) {
828            eprintln!("skipped: tar unavailable");
829            return;
830        }
831        let out = dir.join("out");
832        let staged = python_bundle::stage(&spec, &archive, &out).unwrap();
833        assert!(staged.lib.ends_with("libpython3.12.so.1.0"), "{staged:?}");
834        assert!(staged.lib.is_file());
835        assert!(staged.stdlib_zip.is_file());
836        let listing = std::process::Command::new("python3")
837            .arg("-c")
838            .arg("import zipfile,sys; print(sorted(zipfile.ZipFile(sys.argv[1]).namelist()))")
839            .arg(&staged.stdlib_zip)
840            .output()
841            .expect("python3 lists the zip");
842        let listing = String::from_utf8_lossy(&listing.stdout).into_owned();
843        assert!(listing.contains("os.py"), "{listing}");
844        assert!(!listing.contains("site-packages"), "{listing}");
845        assert!(
846            !out.join(".staging").exists(),
847            "staging dir must be cleaned up"
848        );
849        std::fs::remove_dir_all(&dir).ok();
850    }
851
852    #[test]
853    fn macos_bundling_stages_runtime_and_stdlib_next_to_the_game() {
854        let Some(spec) = python_bundle::pinned("macos") else {
855            panic!("macos must have a pinned bundle");
856        };
857        let dir = std::env::temp_dir().join(format!("pite-stage-mac-{}", std::process::id()));
858        let _ = std::fs::remove_dir_all(&dir);
859        std::fs::create_dir_all(dir.join("python/lib/python3.12/site-packages")).unwrap();
860        std::fs::write(
861            dir.join("python/lib").join("libpython3.12.dylib"),
862            b"fake-dylib",
863        )
864        .unwrap();
865        std::fs::write(
866            dir.join("python/lib/python3.12").join("os.py"),
867            b"import sys\n",
868        )
869        .unwrap();
870        std::fs::write(
871            dir.join("python/lib/python3.12/site-packages")
872                .join("pip.py"),
873            b"raise SystemExit\n",
874        )
875        .unwrap();
876        let archive = dir.join("archive.tar.gz");
877        let tar = std::process::Command::new("tar")
878            .arg("-czf")
879            .arg(&archive)
880            .arg("-C")
881            .arg(&dir)
882            .arg("python")
883            .output();
884        if !tar.as_ref().is_ok_and(|o| o.status.success()) {
885            eprintln!("skipped: tar unavailable");
886            return;
887        }
888        let out = dir.join("out");
889        let staged = python_bundle::stage(&spec, &archive, &out).unwrap();
890        assert!(staged.lib.ends_with("libpython3.12.dylib"), "{staged:?}");
891        assert!(staged.lib.is_file());
892        assert!(staged.stdlib_zip.is_file());
893        let listing = std::process::Command::new("python3")
894            .arg("-c")
895            .arg("import zipfile,sys; print(sorted(zipfile.ZipFile(sys.argv[1]).namelist()))")
896            .arg(&staged.stdlib_zip)
897            .output()
898            .expect("python3 lists the zip");
899        let listing = String::from_utf8_lossy(&listing.stdout).into_owned();
900        assert!(listing.contains("os.py"), "{listing}");
901        assert!(!listing.contains("site-packages"), "{listing}");
902        assert!(
903            !out.join(".staging").exists(),
904            "staging dir must be cleaned up"
905        );
906        std::fs::remove_dir_all(&dir).ok();
907    }
908
909    /// Real download + export layout. Gated: it fetches a pinned 60 MB archive
910    /// the first time, then runs from the local toolchain cache.
911    #[test]
912    fn bundled_export_layout_has_runtime_and_stdlib() {
913        if std::env::var("PITE_BUNDLE_SMOKE").is_err() {
914            eprintln!("skipped: set PITE_BUNDLE_SMOKE=1 (downloads the pinned Python once)");
915            return;
916        }
917        let _guard = SERIAL.lock().unwrap();
918        let (dir, bin) = fixture("bundle");
919        let report = export_project(
920            &dir,
921            &ExportOptions {
922                platform: "linux".to_string(),
923                out_dir: None,
924                binary: Some(bin.clone()),
925                skip_python_check: false,
926                bundle_python: true,
927            },
928        )
929        .expect("bundled export must succeed");
930        match &report.python {
931            PythonChoice::Bundled { zip } => assert!(zip.is_file(), "{zip:?}"),
932            other => panic!("expected a bundled interpreter, got {other:?}"),
933        }
934        let out = &report.out_dir;
935        assert!(
936            out.join("bin").join("fixt").is_file(),
937            "engine binary missing"
938        );
939        assert!(
940            python_bundle::pinned("linux").is_some_and(|spec| {
941                let staged = spec.lib.rsplit('/').next().unwrap_or(spec.lib);
942                out.join("python/lib").join(staged).is_file()
943            }),
944            "bundled libpython missing under python/lib"
945        );
946        assert!(
947            out.join("python").join("python312.zip").is_file(),
948            "stdlib zip missing"
949        );
950        let sh = std::fs::read_to_string(out.join("run.sh")).unwrap();
951        assert!(sh.contains("LD_LIBRARY_PATH"), "{sh}");
952        assert!(sh.contains("PYTHONPATH"), "{sh}");
953        assert!(
954            !sh.contains("needs system Python"),
955            "bundled launcher must not gate"
956        );
957        let readme = std::fs::read_to_string(out.join("README.txt")).unwrap();
958        assert!(readme.contains("bundled CPython"), "{readme}");
959        std::fs::remove_dir_all(&report.out_dir).ok();
960        std::fs::remove_dir_all(&dir).ok();
961    }
962
963    #[test]
964    fn bundling_without_network_support_points_at_the_opt_out() {
965        let _guard = SERIAL.lock().unwrap();
966        let prev = std::env::var_os("PITE_TOOLCHAIN_DIR");
967        let dir = std::env::temp_dir().join(format!("pite-offline-{}", std::process::id()));
968        std::env::set_var("PITE_TOOLCHAIN_DIR", &dir);
969        let err = export_project(
970            &dir,
971            &ExportOptions {
972                platform: "ps5".to_string(),
973                ..opts(Path::new("unused"))
974            },
975        )
976        .unwrap_err();
977        assert!(
978            err.to_string().contains("unsupported export platform"),
979            "{err:#}"
980        );
981        match prev {
982            Some(v) => std::env::set_var("PITE_TOOLCHAIN_DIR", v),
983            None => std::env::remove_var("PITE_TOOLCHAIN_DIR"),
984        }
985        std::fs::remove_dir_all(&dir).ok();
986    }
987
988    #[test]
989    fn exported_manifest_runs_round_trip() {
990        let (dir, bin) = fixture("roundtrip");
991        let report = export_project(&dir, &opts(&bin)).unwrap();
992        let scene = report
993            .out_dir
994            .join("game")
995            .join("scenes")
996            .join("main.ptscn");
997        let manifest = pite_project::load_manifest(&report.out_dir.join("game")).unwrap();
998        assert_eq!(manifest.project.name, "fixt");
999        let session =
1000            pite_runtime::GameSession::open(&scene, true).expect("exported scene must open");
1001        assert_eq!(session.tree_len(), 5);
1002        assert_eq!(session.script_count(), 1);
1003        std::fs::remove_dir_all(&report.out_dir).ok();
1004        std::fs::remove_dir_all(&dir).ok();
1005    }
1006
1007    /// A run that dies after creating its output must not leave that directory
1008    /// behind: the next attempt refuses to overwrite it, so the failure becomes
1009    /// sticky. Unreadable engine binary fails the copy step, which runs after
1010    /// the game and bin directories already exist.
1011    #[cfg(unix)]
1012    #[test]
1013    fn failed_export_leaves_no_directory_behind() {
1014        use std::os::unix::fs::PermissionsExt;
1015        let _guard = SERIAL.lock().unwrap();
1016        let (dir, bin) = fixture("rollback");
1017        let out = dir.join("rollback-out");
1018        std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o000)).unwrap();
1019        if std::fs::read(&bin).is_ok() {
1020            eprintln!("skipped: cannot make the binary unreadable as this user");
1021            std::fs::remove_dir_all(&dir).ok();
1022            return;
1023        }
1024        let err = export_project(
1025            &dir,
1026            &ExportOptions {
1027                out_dir: Some(out.clone()),
1028                ..opts(&bin)
1029            },
1030        )
1031        .unwrap_err();
1032        assert!(
1033            err.to_string().to_lowercase().contains("permission")
1034                || err.to_string().to_lowercase().contains("denied"),
1035            "expected the copy to fail on permissions, got: {err:#}"
1036        );
1037        assert!(!out.exists(), "failed export left {} behind", out.display());
1038        std::fs::remove_dir_all(&dir).ok();
1039    }
1040
1041    /// The loader config is the only thing pointing the interpreter at the stdlib
1042    /// once it exists, and its entries resolve against the DLL's directory. An
1043    /// absolute path would work on the build machine and break the moment the
1044    /// export is copied, so pin the exact shape.
1045    #[test]
1046    fn windows_loader_config_names_the_stdlib_relatively() {
1047        let bundle = python_bundle::StagedBundle {
1048            root: PathBuf::from("/game/python"),
1049            lib: PathBuf::from("/game/python/lib/python312.dll"),
1050            stdlib_zip: PathBuf::from("/game/python/python312.zip"),
1051        };
1052        let pth = windows_pth(Path::new("/game/bin"), &bundle).unwrap();
1053        assert_eq!(pth, "..\\python\\python312.zip\r\n.\r\n");
1054        assert!(
1055            !pth.contains("/game"),
1056            "loader config must stay relative: {pth:?}"
1057        );
1058    }
1059
1060    /// A staged `python/` that is not a sibling of `bin/` has no nameable
1061    /// relative path: error rather than write an entry that cannot resolve.
1062    #[test]
1063    fn windows_loader_config_rejects_an_unreachable_stdlib() {
1064        let bundle = python_bundle::StagedBundle {
1065            root: PathBuf::from("/elsewhere/python"),
1066            lib: PathBuf::from("/elsewhere/python/lib/python312.dll"),
1067            stdlib_zip: PathBuf::from("/elsewhere/python/python312.zip"),
1068        };
1069        let err = windows_pth(Path::new("/game/bin"), &bundle)
1070            .unwrap_err()
1071            .to_string();
1072        assert!(err.contains("not a sibling"), "{err}");
1073    }
1074
1075    /// Windows export layout, end to end against the real pinned archive. Skips
1076    /// when that archive is not cached, so CI stays offline.
1077    #[test]
1078    fn windows_export_ships_the_interpreter_beside_the_binary() {
1079        let Some(spec) = python_bundle::pinned("windows") else {
1080            panic!("windows must have a pinned bundle");
1081        };
1082        if !python_bundle::archive_path(&spec).is_file() {
1083            eprintln!("skipped: windows archive not cached (export once to populate it)");
1084            return;
1085        }
1086        let _guard = SERIAL.lock().unwrap();
1087        let (dir, bin) = fixture("winbundle");
1088        let report = export_project(
1089            &dir,
1090            &ExportOptions {
1091                platform: "windows".to_string(),
1092                out_dir: None,
1093                binary: Some(bin),
1094                skip_python_check: false,
1095                bundle_python: true,
1096            },
1097        )
1098        .expect("windows bundled export must succeed");
1099        let out = &report.out_dir;
1100        let dll = spec.lib.rsplit('/').next().unwrap_or(spec.lib);
1101        assert!(
1102            out.join("bin").join(dll).is_file(),
1103            "interpreter must sit next to the exe, not in python/lib"
1104        );
1105        assert!(
1106            !out.join("python/lib").join(dll).exists(),
1107            "interpreter must not ship twice"
1108        );
1109        let pth = out
1110            .join("bin")
1111            .join(spec.pth_name.expect("windows needs a loader config"));
1112        let body = std::fs::read_to_string(&pth).unwrap();
1113        assert!(
1114            body.contains("python312.zip") && !body.contains(&out.display().to_string()),
1115            "loader config must name the zip relatively: {body:?}"
1116        );
1117        let bat = std::fs::read_to_string(out.join("run.bat")).unwrap();
1118        assert!(!bat.contains("PYTHONPATH"), "{bat}");
1119        assert!(
1120            !bat.contains("needs system Python"),
1121            "bundled launcher must not gate"
1122        );
1123        assert!(
1124            !out.join("python/.staging").exists(),
1125            "staging dir must be cleaned up"
1126        );
1127        std::fs::remove_dir_all(&report.out_dir).ok();
1128        std::fs::remove_dir_all(&dir).ok();
1129    }
1130
1131    fn bin_opts(platform: &str, binary: Option<PathBuf>) -> ExportOptions {
1132        ExportOptions {
1133            platform: platform.to_string(),
1134            out_dir: None,
1135            binary,
1136            skip_python_check: true,
1137            bundle_python: false,
1138        }
1139    }
1140
1141    fn sel_dir(tag: &str) -> PathBuf {
1142        let dir = std::env::temp_dir().join(format!(
1143            "pite-sel-{tag}-{}-{}",
1144            std::process::id(),
1145            std::time::SystemTime::now()
1146                .duration_since(std::time::UNIX_EPOCH)
1147                .map(|d| d.subsec_nanos())
1148                .unwrap_or(0)
1149        ));
1150        std::fs::create_dir_all(&dir).unwrap();
1151        dir
1152    }
1153
1154    #[test]
1155    fn explicit_binary_wins_over_sibling() {
1156        let dir = sel_dir("explicit");
1157        let fake_exe = dir.join("pite");
1158        let explicit = dir.join("custom-player");
1159        let sibling = dir.join(player_bin_name(std::env::consts::OS));
1160        for p in [&fake_exe, &explicit, &sibling] {
1161            std::fs::write(p, "x").unwrap();
1162        }
1163        let got = select_binary_from(
1164            &bin_opts(std::env::consts::OS, Some(explicit.clone())),
1165            Some(fake_exe),
1166        )
1167        .unwrap();
1168        assert_eq!(got, explicit);
1169        std::fs::remove_dir_all(&dir).ok();
1170    }
1171
1172    #[test]
1173    fn sibling_player_serves_native_export() {
1174        let dir = sel_dir("sibling");
1175        let fake_exe = dir.join("pite");
1176        let sibling = dir.join(player_bin_name(std::env::consts::OS));
1177        std::fs::write(&fake_exe, "x").unwrap();
1178        std::fs::write(&sibling, "x").unwrap();
1179        let got =
1180            select_binary_from(&bin_opts(std::env::consts::OS, None), Some(fake_exe)).unwrap();
1181        assert_eq!(got, sibling);
1182        std::fs::remove_dir_all(&dir).ok();
1183    }
1184
1185    #[test]
1186    fn missing_sibling_fails_with_build_hint() {
1187        let _guard = SERIAL.lock().unwrap();
1188        let dir = sel_dir("nosibling");
1189        let fake_exe = dir.join("pite");
1190        std::fs::write(&fake_exe, "x").unwrap();
1191        let empty_cache = dir.join("empty_cache");
1192        std::fs::create_dir_all(&empty_cache).unwrap();
1193        std::env::set_var("PITE_PLAYER_DIR", &empty_cache);
1194        std::env::set_var("PITE_PLAYER_URL", "http://127.0.0.1:9/nonexistent");
1195        let err = select_binary_from(&bin_opts(std::env::consts::OS, None), Some(fake_exe))
1196            .unwrap_err()
1197            .to_string();
1198        assert!(err.contains("pite-player"), "got: {err}");
1199        assert!(err.contains("--binary"), "got: {err}");
1200        std::env::remove_var("PITE_PLAYER_DIR");
1201        std::env::remove_var("PITE_PLAYER_URL");
1202        std::fs::remove_dir_all(&dir).ok();
1203    }
1204
1205    #[test]
1206    fn missing_sibling_uses_cached_player() {
1207        let _guard = SERIAL.lock().unwrap();
1208        let dir = sel_dir("cached_player");
1209        let fake_exe = dir.join("pite");
1210        std::fs::write(&fake_exe, "x").unwrap();
1211        let cache = dir.join("cache");
1212        let platform = std::env::consts::OS;
1213        let version = format!("v{}", env!("CARGO_PKG_VERSION"));
1214        let cached_bin = cache
1215            .join(&version)
1216            .join(platform)
1217            .join(player_bin_name(platform));
1218        std::fs::create_dir_all(cached_bin.parent().unwrap()).unwrap();
1219        std::fs::write(&cached_bin, "cached-binary-content").unwrap();
1220
1221        std::env::set_var("PITE_PLAYER_DIR", &cache);
1222        let got = select_binary_from(&bin_opts(platform, None), Some(fake_exe)).unwrap();
1223        assert_eq!(got, cached_bin);
1224        std::env::remove_var("PITE_PLAYER_DIR");
1225        std::fs::remove_dir_all(&dir).ok();
1226    }
1227
1228    #[test]
1229    fn missing_sibling_downloads_and_unpacks_player_tar_gz() {
1230        let _guard = SERIAL.lock().unwrap();
1231        let dir = sel_dir("dl_tar_gz");
1232        let fake_exe = dir.join("pite");
1233        std::fs::write(&fake_exe, "x").unwrap();
1234        let cache = dir.join("cache");
1235        let archive_src = dir.join("src");
1236        std::fs::create_dir_all(&archive_src).unwrap();
1237        let bin_src = archive_src.join("pite-player-linux");
1238        std::fs::write(&bin_src, "downloaded-player-content").unwrap();
1239        let archive_tar = dir.join("pite-player-v0.3.0-linux.tar.gz");
1240
1241        let status = std::process::Command::new("tar")
1242            .args([
1243                "-czf",
1244                archive_tar.to_str().unwrap(),
1245                "-C",
1246                archive_src.to_str().unwrap(),
1247                "pite-player-linux",
1248            ])
1249            .status()
1250            .unwrap();
1251        assert!(status.success());
1252
1253        std::env::set_var("PITE_PLAYER_DIR", &cache);
1254        std::env::set_var(
1255            "PITE_PLAYER_URL",
1256            format!("file://{}", archive_tar.display()),
1257        );
1258        let got = select_binary_from(&bin_opts("linux", None), Some(fake_exe)).unwrap();
1259        assert!(got.is_file());
1260        let content = std::fs::read_to_string(&got).unwrap();
1261        assert_eq!(content, "downloaded-player-content");
1262
1263        std::env::remove_var("PITE_PLAYER_DIR");
1264        std::env::remove_var("PITE_PLAYER_URL");
1265        std::fs::remove_dir_all(&dir).ok();
1266    }
1267
1268    #[test]
1269    fn missing_sibling_downloads_and_unpacks_player_zip() {
1270        let _guard = SERIAL.lock().unwrap();
1271        let dir = sel_dir("dl_zip");
1272        let fake_exe = dir.join("pite");
1273        std::fs::write(&fake_exe, "x").unwrap();
1274        let cache = dir.join("cache");
1275        let archive_zip = dir.join("pite-player-v0.3.0-windows.zip");
1276
1277        {
1278            let file = std::fs::File::create(&archive_zip).unwrap();
1279            let mut zip = zip::ZipWriter::new(file);
1280            let options = zip::write::SimpleFileOptions::default();
1281            zip.start_file("pite-player-windows.exe", options).unwrap();
1282            std::io::Write::write_all(&mut zip, b"windows-player-content").unwrap();
1283            zip.finish().unwrap();
1284        }
1285
1286        std::env::set_var("PITE_PLAYER_DIR", &cache);
1287        std::env::set_var(
1288            "PITE_PLAYER_URL",
1289            format!("file://{}", archive_zip.display()),
1290        );
1291        let got = select_binary_from(&bin_opts("windows", None), Some(fake_exe)).unwrap();
1292        assert!(got.is_file());
1293        let content = std::fs::read_to_string(&got).unwrap();
1294        assert_eq!(content, "windows-player-content");
1295
1296        std::env::remove_var("PITE_PLAYER_DIR");
1297        std::env::remove_var("PITE_PLAYER_URL");
1298        std::fs::remove_dir_all(&dir).ok();
1299    }
1300
1301    #[test]
1302    fn foreign_platform_uses_bundled_sibling() {
1303        // Engine bundles ship both players, so a foreign-named sibling next
1304        // to the exe is picked with no download and no compiler involved.
1305        let foreign = if std::env::consts::OS == "windows" {
1306            "linux"
1307        } else {
1308            "windows"
1309        };
1310        let dir = sel_dir("foreign");
1311        let fake_exe = dir.join("pite");
1312        let sibling = dir.join(player_bin_name(foreign));
1313        std::fs::write(&fake_exe, "x").unwrap();
1314        std::fs::write(&sibling, "x").unwrap();
1315        let got = select_binary_from(&bin_opts(foreign, None), Some(fake_exe)).unwrap();
1316        assert_eq!(got, sibling);
1317        std::fs::remove_dir_all(&dir).ok();
1318    }
1319
1320    #[test]
1321    fn foreign_macos_uses_bundled_sibling() {
1322        let dir = sel_dir("foreign-macos");
1323        let fake_exe = dir.join("pite");
1324        let sibling = dir.join("pite-player-macos");
1325        std::fs::write(&fake_exe, "x").unwrap();
1326        std::fs::write(&sibling, "x").unwrap();
1327        let got = select_binary_from(&bin_opts("macos", None), Some(fake_exe)).unwrap();
1328        assert_eq!(got, sibling);
1329        std::fs::remove_dir_all(&dir).ok();
1330    }
1331
1332    #[test]
1333    fn macos_export_layout_without_bundle() {
1334        let (dir, bin) = fixture("mac-layout");
1335        let report = export_project(
1336            &dir,
1337            &ExportOptions {
1338                platform: "macos".to_string(),
1339                out_dir: None,
1340                binary: Some(bin),
1341                skip_python_check: true,
1342                bundle_python: false,
1343            },
1344        )
1345        .unwrap();
1346        let out = &report.out_dir;
1347        assert!(out.join("bin").join("fixt").is_file());
1348        assert!(out.join("run.sh").is_file());
1349        let sh = std::fs::read_to_string(out.join("run.sh")).unwrap();
1350        assert!(sh.contains("needs system Python 3.12"));
1351        assert!(sh.contains("game/scenes/main.ptscn"));
1352        std::fs::remove_dir_all(&dir).ok();
1353        std::fs::remove_dir_all(out).ok();
1354    }
1355
1356    #[test]
1357    fn unix_launcher_sets_both_ld_and_dyld_library_paths() {
1358        let bundle = python_bundle::StagedBundle {
1359            root: PathBuf::from("/game/python"),
1360            lib: PathBuf::from("/game/python/lib/libpython3.12.dylib"),
1361            stdlib_zip: PathBuf::from("/game/python/python312.zip"),
1362        };
1363        let sh = unix_launcher(
1364            "game",
1365            "bin",
1366            "main.ptscn",
1367            Path::new("/game"),
1368            Some(&bundle),
1369        );
1370        assert!(sh.contains("LD_LIBRARY_PATH"));
1371        assert!(sh.contains("DYLD_LIBRARY_PATH"));
1372        assert!(sh.contains("PYTHONPATH"));
1373    }
1374
1375    #[test]
1376    fn unknown_platform_fails_loudly() {
1377        let (dir, bin) = fixture("plat");
1378        let err = export_project(
1379            &dir,
1380            &ExportOptions {
1381                platform: "ps5".to_string(),
1382                ..opts(&bin)
1383            },
1384        )
1385        .unwrap_err();
1386        assert!(err.to_string().contains("unsupported export platform"));
1387        std::fs::remove_dir_all(&dir).ok();
1388    }
1389}