Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/actions/rust-python-env/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -58,3 +58,4 @@ runs:
run: |
echo "PYO3_PYTHON=$(which python3.12)" >> "$GITHUB_ENV"
echo "LD_LIBRARY_PATH=$(python3.12 -c "import sysconfig; print(sysconfig.get_config_var('LIBDIR'))")" >> "$GITHUB_ENV"
echo "DYLD_LIBRARY_PATH=$(python3.12 -c "import sysconfig; print(sysconfig.get_config_var('LIBDIR'))")" >> "$GITHUB_ENV"
21 changes: 17 additions & 4 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ jobs:
- os: windows-latest
platform: windows
suffix: ".exe"
- os: macos-latest
platform: macos
suffix: ""
steps:
- name: Check out the repo
uses: actions/checkout@v7
Expand Down Expand Up @@ -61,18 +64,28 @@ jobs:
uses: actions/download-artifact@v7
with:
path: dist
- name: Assemble the engine bundles (engine + both players each)
- name: Assemble the engine bundles (engine + player binaries for all platforms)
shell: bash
run: |
mkdir -p bundle/linux bundle/windows
mkdir -p bundle/linux bundle/windows bundle/macos
cp dist/dist-linux/pite-linux bundle/linux/pite
cp dist/dist-linux/pite-player-linux bundle/linux/pite-player
cp dist/dist-windows/pite-player-windows.exe bundle/linux/pite-player.exe
cp dist/dist-macos/pite-player-macos bundle/linux/pite-player-macos

cp dist/dist-windows/pite-windows.exe bundle/windows/pite.exe
cp dist/dist-windows/pite-player-windows.exe bundle/windows/pite-player.exe
cp dist/dist-linux/pite-player-linux bundle/windows/pite-player
tar -czf "dist/pite-${{ github.ref_name }}-linux.tar.gz" -C bundle/linux pite pite-player pite-player.exe
zip -j "dist/pite-${{ github.ref_name }}-windows.zip" bundle/windows/pite.exe bundle/windows/pite-player.exe bundle/windows/pite-player
cp dist/dist-macos/pite-player-macos bundle/windows/pite-player-macos

cp dist/dist-macos/pite-macos bundle/macos/pite
cp dist/dist-macos/pite-player-macos bundle/macos/pite-player
cp dist/dist-windows/pite-player-windows.exe bundle/macos/pite-player.exe
cp dist/dist-linux/pite-player-linux bundle/macos/pite-player-linux

tar -czf "dist/pite-${{ github.ref_name }}-linux.tar.gz" -C bundle/linux pite pite-player pite-player.exe pite-player-macos
zip -j "dist/pite-${{ github.ref_name }}-windows.zip" bundle/windows/pite.exe bundle/windows/pite-player.exe bundle/windows/pite-player bundle/windows/pite-player-macos
tar -czf "dist/pite-${{ github.ref_name }}-macos.tar.gz" -C bundle/macos pite pite-player pite-player.exe pite-player-linux
- name: Publish the bundles and checksums
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ cargo run --locked -p pite-cli -- check
- Single crate: `cargo test -p <crate>` (e.g. `-p pite-scene`). Focused check: `cargo run -p pite-cli -- check --path <dir>`; `--strict` promotes warnings, `--json` gives machine report. With no `--path` outside a project it falls back to `examples/minimal-2d`.
- Build needs CPython 3.12 dev headers + ALSA headers or it fails to link: `libasound2-dev pkg-config` on Debian/Ubuntu, plus `export PYO3_PYTHON=$(which python3.12)` and `LD_LIBRARY_PATH` pointing at its `LIBDIR`.
- WSL without Wayland: `WINIT_UNIX_BACKEND=x11 cargo run -p pite-cli -- edit`.
- `pite` dogfood flow: `new --template minimal-2d` → `run --scene …` → `check` → `edit --scene …`. Export via `pite export linux|windows`; offline export needs `--no-bundle-python` (+ `PITE_PYTHON` / `--skip-python-check`). Export ships the player found via `--binary`, else a `pite-player` sibling of `pite` — from a source checkout build both: `cargo build --release -p pite-cli -p pite-player`.
- `pite` dogfood flow: `new --template minimal-2d` → `run --scene …` → `check` → `edit --scene …`. Export via `pite export linux|windows|macos`; offline export needs `--no-bundle-python` (+ `PITE_PYTHON` / `--skip-python-check`). Export ships the player found via `--binary`, else a `pite-player` sibling of `pite` — from a source checkout build both: `cargo build --release -p pite-cli -p pite-player`.

## Architecture (crate → role)

Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ The `v*` tag flow automates almost nothing — bump these by hand, every release
- `Cargo.toml`: `version` + all 12 internal `pite-*` path-dep pins (nothing enforces lockstep; `cargo publish` breaks otherwise).
- `CHANGELOG.md`: move notes from `## Unreleased` to the release heading.
- MSRV proof re-runs automatically (weekly + manual); if it goes red, fix code or raise `rust-version`.
- Only when the toolchain changes: `python_bundle.rs` (`PINNED_PYTHON`, `PINNED_BUILD`, 4 filenames, 2 checksums).
- Only when the toolchain changes: `python_bundle.rs` (`PINNED_PYTHON`, `PINNED_BUILD`, filenames, checksums).
- Only when pyright changes: `pite-editor/src/lsp.rs`, `docs/lsp.md`, `ci.yml`, `AGENTS.md` (all pin the same version).
- Only when the scene format changes: `FORMAT_VERSION`, `CACHE_VERSION`, the `format_version` template literal, `docs/scene-format.md`.
- Tag rule: strict `v*.*.*` tags must equal the workspace version (the publish workflow fails otherwise); milestone tags (`v0.1-mN`) never touch the registry.
Expand Down
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,10 @@ Build games from scenes and nodes (not ECS) - edit visually, script in Python.

## Quickstart

Install the engine bundle (engine + both player binaries, so any machine
exports for either platform) — latest stable, verified on download:
Install the engine bundle (engine + player binaries for all platforms, so any machine
exports for any platform) — latest stable, verified on download:

Linux Terminal:
Linux / macOS Terminal:

```sh
curl -fsSL https://raw.githubusercontent.com/piteengine/pite/main/install.sh | sh
Expand Down Expand Up @@ -48,7 +48,7 @@ Install once, then use `pite`. `new` scaffolds a project, `run` plays a scene (d
pite export linux
```

`export linux|windows` writes a runnable directory (`dist/<name>-<platform>/`) with the lean player binary (no editor), a launcher script, a manual for how to run, and only the referenced game content. Provides `[export] include` in `pite.toml` for extra files, plus `vendor/` for third-party Python packages. The player is resolved as `--binary`, else a `pite-player` sibling of `pite` — engine bundles ship both players, so any machine exports for either platform with no download and no compiler.
`export linux|windows|macos` writes a runnable directory (`dist/<name>-<platform>/`) with the lean player binary (no editor), a launcher script, a manual for how to run, and only the referenced game content. Provides `[export] include` in `pite.toml` for extra files, plus `vendor/` for third-party Python packages. The player is resolved as `--binary`, else a `pite-player` sibling of `pite` — engine bundles ship player binaries for all platforms, so any machine exports for any platform with no download and no compiler.

Python is bundled: export fetches a pinned CPython 3.12 (checksum-verified, cached under `~/.cache/pite/toolchains`) and ships in the exported game. The exported game runs with no system Python installed. Passing `--no-bundle-python` falls back to end-user's system Python 3.12 (`PITE_PYTHON` overrides detection).

Expand Down
10 changes: 5 additions & 5 deletions docs/export.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ Ships a playable build of the project: a lean `pite-player` binary, the
resolved content, and a launcher (`run.sh` / `run.bat`).

```sh
pite export linux|windows [--path <dir>] [--out <dir>] [--binary <file>]
pite export linux|windows|macos [--path <dir>] [--out <dir>] [--binary <file>]
[--skip-python-check] [--no-bundle-python]
```

Expand All @@ -17,10 +17,10 @@ old manifests carrying it still parse with a deprecation warning.
## Player binary

Which binary an export ships: an explicit `--binary`, else a `pite-player`
sibling (`pite-player.exe` on Windows) next to the engine binary. Every
engine bundle ships both players, so cross-platform export needs no download
and no compiler. Without either, export fails and tells you to pass
`--binary`.
sibling (`pite-player.exe` on Windows, `pite-player-macos` or `pite-player` on macOS)
next to the engine binary. Engine bundles ship player binaries for all supported
platforms, so cross-platform export needs no download and no compiler. Without
either, export fails and tells you to pass `--binary`.

## Python

Expand Down
4 changes: 2 additions & 2 deletions install.ps1
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Install Pite (engine + both players) from a release bundle, verified.
# Install Pite (engine + player binaries for all platforms) from a release bundle, verified.
# Usage: .\install.ps1 [-Tag v0.1.0] [-Prefix "$env:LOCALAPPDATA\Pite"]
param(
[string]$Tag = "",
Expand Down Expand Up @@ -32,7 +32,7 @@ try {
[Environment]::SetEnvironmentVariable("PATH", "$path;$Prefix", "User")
Write-Host "added $Prefix to the user PATH (restart the terminal to use it)"
}
Write-Host "installed $Tag to $Prefix (pite.exe, pite-player.exe, pite-player)"
Write-Host "installed $Tag to $Prefix (pite.exe, pite-player.exe, pite-player, pite-player-macos)"
} finally {
Remove-Item -Recurse -Force $work -ErrorAction SilentlyContinue
}
25 changes: 19 additions & 6 deletions install.sh
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
#!/bin/sh
# Install Pite (engine + both players) from a release bundle, verified.
# Install Pite (engine + player binaries for all platforms) from a release bundle, verified.
# Usage: ./install.sh [tag] (tag defaults to the latest release)
# Env: PREFIX (default $HOME/.local), PITE_VERSION (overrides the tag arg).
set -e
Expand All @@ -14,21 +14,34 @@ fi

PREFIX="${PREFIX:-$HOME/.local}"
BINDIR="$PREFIX/bin"
BUNDLE="pite-$TAG-linux.tar.gz"
OS="$(uname -s)"
case "$OS" in
Linux*) PLATFORM="linux";;
Darwin*) PLATFORM="macos";;
*) echo "error: unsupported operating system $OS" >&2; exit 1;;
esac
BUNDLE="pite-$TAG-$PLATFORM.tar.gz"
BASE="https://github.com/$REPO/releases/download/$TAG"

work="$(mktemp -d)"
trap 'rm -rf "$work"' EXIT INT TERM
cd "$work"
curl -fsSL -O "$BASE/$BUNDLE"
curl -fsSL -O "$BASE/SHA256SUMS"
grep -F " $BUNDLE" SHA256SUMS | sha256sum -c - >/dev/null
if command -v sha256sum >/dev/null 2>&1; then
grep -F " $BUNDLE" SHA256SUMS | sha256sum -c - >/dev/null
elif command -v shasum >/dev/null 2>&1; then
grep -F " $BUNDLE" SHA256SUMS | shasum -a 256 -c - >/dev/null
else
echo "error: neither sha256sum nor shasum found" >&2
exit 1
fi

mkdir -p "$BINDIR"
tar -xzf "$BUNDLE" -C "$BINDIR" pite pite-player pite-player.exe
chmod +x "$BINDIR/pite" "$BINDIR/pite-player"
tar -xzf "$BUNDLE" -C "$BINDIR"
chmod +x "$BINDIR/pite" "$BINDIR"/pite-player*

echo "installed $TAG to $BINDIR (pite, pite-player, pite-player.exe)"
echo "installed $TAG to $BINDIR"
case ":$PATH:" in
*":$BINDIR:"*) ;;
*) echo "note: $BINDIR is not on PATH; add: export PATH=\"$BINDIR:\$PATH\"" ;;
Expand Down
155 changes: 144 additions & 11 deletions pite-export/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use pite_render::atlas::{sheet_for_sidecar, SIDECAR_EXT};

pub const SUPPORTED_PLATFORMS: &[&str] = &["linux", "windows"];
pub const SUPPORTED_PLATFORMS: &[&str] = &["linux", "windows", "macos"];

#[derive(Debug, Clone)]
pub struct ExportOptions {
Expand Down Expand Up @@ -111,7 +111,7 @@ impl Drop for ExportDir {
pub fn export_project(root: &Path, opts: &ExportOptions) -> Result<ExportReport> {
if !SUPPORTED_PLATFORMS.contains(&opts.platform.as_str()) {
anyhow::bail!(
"unsupported export platform {:?} (supported: linux, windows)",
"unsupported export platform {:?} (supported: linux, windows, macos)",
opts.platform
);
}
Expand Down Expand Up @@ -269,8 +269,8 @@ pub fn export_project(root: &Path, opts: &ExportOptions) -> Result<ExportReport>
}

/// Bundled mode needs no Python on PATH: the loader finds `libpython` through
/// `LD_LIBRARY_PATH` (or a `$ORIGIN` RUNPATH when the binary was built with one)
/// and the stdlib comes from the zip. Proved on Linux.
/// `LD_LIBRARY_PATH` / `DYLD_LIBRARY_PATH` (or a `$ORIGIN` RUNPATH when the binary was built with one)
/// and the stdlib comes from the zip. Proved on Linux and macOS.
fn unix_launcher(
game: &str,
bin: &str,
Expand All @@ -287,6 +287,8 @@ fn unix_launcher(
HERE=\"$(cd \"$(dirname \"$0\")\" && pwd)\"\n\
LD_LIBRARY_PATH=\"$HERE/{lib_rel}\"${{LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}}\n\
export LD_LIBRARY_PATH\n\
DYLD_LIBRARY_PATH=\"$HERE/{lib_rel}\"${{DYLD_LIBRARY_PATH:+:$DYLD_LIBRARY_PATH}}\n\
export DYLD_LIBRARY_PATH\n\
PYTHONPATH=\"$HERE/{zip_rel}\"\n\
export PYTHONPATH\n\
exec \"$HERE/bin/{bin}\" --scene \"$HERE/game/{main_rel}\" \"$@\"\n",
Expand Down Expand Up @@ -368,8 +370,8 @@ fn windows_launcher(
}

/// Which binary an export ships: an explicit `--binary`, else a `pite-player`
/// sibling named for the target platform. Every engine bundle ships both
/// players, so cross-platform export needs no download and no compiler.
/// sibling named for the target platform. Engine bundles ship player binaries
/// for all platforms, so cross-platform export needs no download and no compiler.
/// The full `pite` binary is never shipped: it carries the editor.
fn select_binary(opts: &ExportOptions) -> Result<PathBuf> {
select_binary_from(opts, std::env::current_exe().ok())
Expand All @@ -384,6 +386,23 @@ fn player_bin_name(platform: &str) -> &'static str {
}
}

/// Candidate sibling player binary names for `platform` when running on `host_os`.
fn candidate_player_names(platform: &str, host_os: &str) -> Vec<String> {
if platform == host_os {
vec![player_bin_name(platform).to_string()]
} else {
match platform {
"windows" => vec![
"pite-player.exe".to_string(),
"pite-player-windows.exe".to_string(),
],
"macos" => vec!["pite-player-macos".to_string()],
"linux" => vec!["pite-player-linux".to_string(), "pite-player".to_string()],
_ => vec![format!("pite-player-{platform}")],
}
}
}

fn select_binary_from(opts: &ExportOptions, current_exe: Option<PathBuf>) -> Result<PathBuf> {
if let Some(p) = &opts.binary {
if !p.is_file() {
Expand All @@ -392,14 +411,16 @@ fn select_binary_from(opts: &ExportOptions, current_exe: Option<PathBuf>) -> Res
return Ok(p.clone());
}
if let Some(dir) = current_exe.as_ref().and_then(|e| e.parent()) {
let sibling = dir.join(player_bin_name(&opts.platform));
if sibling.is_file() {
return Ok(sibling);
for name in candidate_player_names(&opts.platform, std::env::consts::OS) {
let sibling = dir.join(name);
if sibling.is_file() {
return Ok(sibling);
}
}
}
anyhow::bail!(
"export needs a pite-player binary for {} next to {} (engine bundles ship both \
players), or pass --binary with a player binary",
"export needs a pite-player binary for {} next to {} (engine bundles ship player \
binaries for all platforms), or pass --binary with a player binary",
opts.platform,
current_exe
.as_deref()
Expand Down Expand Up @@ -844,6 +865,63 @@ def _once():
std::fs::remove_dir_all(&dir).ok();
}

#[test]
fn macos_bundling_stages_runtime_and_stdlib_next_to_the_game() {
let Some(spec) = python_bundle::pinned("macos") else {
panic!("macos must have a pinned bundle");
};
let dir = std::env::temp_dir().join(format!("pite-stage-mac-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("python/lib/python3.12/site-packages")).unwrap();
std::fs::write(
dir.join("python/lib").join("libpython3.12.dylib"),
b"fake-dylib",
)
.unwrap();
std::fs::write(
dir.join("python/lib/python3.12").join("os.py"),
b"import sys\n",
)
.unwrap();
std::fs::write(
dir.join("python/lib/python3.12/site-packages")
.join("pip.py"),
b"raise SystemExit\n",
)
.unwrap();
let archive = dir.join("archive.tar.gz");
let tar = std::process::Command::new("tar")
.arg("-czf")
.arg(&archive)
.arg("-C")
.arg(&dir)
.arg("python")
.output();
if !tar.as_ref().is_ok_and(|o| o.status.success()) {
eprintln!("skipped: tar unavailable");
return;
}
let out = dir.join("out");
let staged = python_bundle::stage(&spec, &archive, &out).unwrap();
assert!(staged.lib.ends_with("libpython3.12.dylib"), "{staged:?}");
assert!(staged.lib.is_file());
assert!(staged.stdlib_zip.is_file());
let listing = std::process::Command::new("python3")
.arg("-c")
.arg("import zipfile,sys; print(sorted(zipfile.ZipFile(sys.argv[1]).namelist()))")
.arg(&staged.stdlib_zip)
.output()
.expect("python3 lists the zip");
let listing = String::from_utf8_lossy(&listing.stdout).into_owned();
assert!(listing.contains("os.py"), "{listing}");
assert!(!listing.contains("site-packages"), "{listing}");
assert!(
!out.join(".staging").exists(),
"staging dir must be cleaned up"
);
std::fs::remove_dir_all(&dir).ok();
}

/// Real download + export layout. Gated: it fetches a pinned 60 MB archive
/// the first time, then runs from the local toolchain cache.
#[test]
Expand Down Expand Up @@ -1152,6 +1230,61 @@ def _once():
std::fs::remove_dir_all(&dir).ok();
}

#[test]
fn foreign_macos_uses_bundled_sibling() {
let dir = sel_dir("foreign-macos");
let fake_exe = dir.join("pite");
let sibling = dir.join("pite-player-macos");
std::fs::write(&fake_exe, "x").unwrap();
std::fs::write(&sibling, "x").unwrap();
let got = select_binary_from(&bin_opts("macos", None), Some(fake_exe)).unwrap();
assert_eq!(got, sibling);
std::fs::remove_dir_all(&dir).ok();
}

#[test]
fn macos_export_layout_without_bundle() {
let (dir, bin) = fixture("mac-layout");
let report = export_project(
&dir,
&ExportOptions {
platform: "macos".to_string(),
out_dir: None,
binary: Some(bin),
skip_python_check: true,
bundle_python: false,
},
)
.unwrap();
let out = &report.out_dir;
assert!(out.join("bin").join("fixt").is_file());
assert!(out.join("run.sh").is_file());
let sh = std::fs::read_to_string(out.join("run.sh")).unwrap();
assert!(sh.contains("needs system Python 3.12"));
assert!(sh.contains("--scene"));
std::fs::remove_dir_all(&dir).ok();
std::fs::remove_dir_all(out).ok();
}

#[test]
fn unix_launcher_sets_both_ld_and_dyld_library_paths() {
let bundle = python_bundle::StagedBundle {
root: PathBuf::from("/game/python"),
lib: PathBuf::from("/game/python/lib/libpython3.12.dylib"),
stdlib_zip: PathBuf::from("/game/python/python312.zip"),
};
let sh = unix_launcher(
"game",
"bin",
"main.ptscn",
Path::new("/game"),
Some(&bundle),
);
assert!(sh.contains("LD_LIBRARY_PATH"));
assert!(sh.contains("DYLD_LIBRARY_PATH"));
assert!(sh.contains("PYTHONPATH"));
}

#[test]
fn unknown_platform_fails_loudly() {
let (dir, bin) = fixture("plat");
Expand Down
Loading
Loading