diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..35049cb --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,2 @@ +[alias] +xtask = "run --package xtask --" diff --git a/.gitignore b/.gitignore index 5a69c42..562dfaf 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ /target /saves __pycache__/ -/.NOCOMMIT \ No newline at end of file +/.NOCOMMIT +dist diff --git a/AGENTS.md b/AGENTS.md index 9233299..2ca6bd9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -47,6 +47,8 @@ Station 13, with singleplayer and multiplayer. It is built in Rust (edition - `assets/data/`: item, recipe, building and skill definitions (`.rsn`). - `assets/scripts/`: Rune scripts (`.rn`), referenced as `"module::function"`. - `assets/maps/`, `assets/tilesets/`, `assets/sprites/`: Tiled maps and art. +- `xtask/`: project automation (`cargo xtask pack` builds and archives the + client and server into `dist/`). - `tools/gen_placeholder_assets.py`: regenerates placeholder art and the example map (generated output is committed). - `.github/workflows/`, `.gitea/workflows/`: CI for GitHub and Gitea. @@ -89,9 +91,10 @@ cargo nextest run --workspace ``` Run binaries with `cargo run` (client; `--host`, `--connect`) and -`cargo run -p game_server --bin game-server`. The Windows client can be -cross-built with `cargo build --release -p game_client --target -x86_64-pc-windows-gnu` (needs MinGW). Running the client requires a Vulkan or +`cargo run -p game_server --bin game-server`. Build a distributable archive +with `cargo xtask pack` (add `--target x86_64-pc-windows-gnu` to cross-build +for Windows; needs MinGW). Files shipped next to the binaries are listed in +`EXTRA_FILES` in `xtask/src/main.rs`. Running the client requires a Vulkan or GL capable GPU or a software driver such as lavapipe. ## Conventions diff --git a/CHANGELOG.md b/CHANGELOG.md index 933fece..380c351 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,3 +18,5 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - Crafting, construction/deconstruction, skills, hunger, health and respawning. - Windows client builds for GitHub and Gitea CI; release builds run without a console window. +- `cargo xtask pack` builds the client and server and packs them with their + assets into a distributable archive; CI publishes these archives. diff --git a/Cargo.lock b/Cargo.lock index c55da7c..509d6a2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2638,6 +2638,16 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + [[package]] name = "find-msvc-tools" version = "0.1.14" @@ -5428,6 +5438,16 @@ dependencies = [ "slotmap", ] +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", +] + [[package]] name = "termcolor" version = "1.4.1" @@ -5686,6 +5706,12 @@ version = "2.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "86a801b3cea342a06d468c8710662aa29e5e05e4f5c0d62f00bbb7f2ad7941c2" +[[package]] +name = "typed-path" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e" + [[package]] name = "typeid" version = "1.0.3" @@ -6659,6 +6685,16 @@ version = "0.8.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" +[[package]] +name = "xtask" +version = "0.1.0" +dependencies = [ + "flate2", + "serde_json", + "tar", + "zip", +] + [[package]] name = "xxhash-rust" version = "0.8.19" @@ -6782,6 +6818,20 @@ dependencies = [ "syn 3.0.9", ] +[[package]] +name = "zip" +version = "9.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e04976da629d3b04204ba8105b396a193af8ad2583b2c79bfb3ab8af580a11e" +dependencies = [ + "crc32fast", + "flate2", + "indexmap", + "memchr", + "nix", + "typed-path", +] + [[package]] name = "zlib-rs" version = "0.6.8" diff --git a/Cargo.toml b/Cargo.toml index ce16310..1e57563 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "3" -members = ["crates/game_core", "crates/game_server", "crates/game_client"] +members = ["crates/game_core", "crates/game_server", "crates/game_client", "xtask"] default-members = ["crates/game_client"] [workspace.package] @@ -25,6 +25,11 @@ opt-level = 1 [profile.dev.package."*"] opt-level = 3 +# Used by `cargo xtask pack` for distributable builds. +[profile.dist] +inherits = "release" +strip = true + [workspace.lints.clippy] # Bevy system queries are naturally verbose. type_complexity = "allow" diff --git a/README.md b/README.md index dbeac5f..ccc07be 100644 --- a/README.md +++ b/README.md @@ -185,3 +185,17 @@ cargo clippy --workspace --all-targets Tests run with [cargo-nextest](https://nexte.st) (`cargo install cargo-nextest --locked`); its configuration is in `.config/nextest.toml`. CI uses `--profile ci`, which writes a JUnit report to `target/nextest/ci/junit.xml`. + +### Packaging + +```sh +cargo xtask pack # for the host platform +cargo xtask pack --target x86_64-pc-windows-gnu # cross-compile for Windows (needs MinGW) +``` + +`pack` builds the client (`game`) and the dedicated server (`game-server`) with the +stripped `dist` profile and writes `dist/station--.zip` (Windows) or +`.tar.gz` (other platforms). The archive contains both binaries, `assets/`, the README +and the changelog. Unpack it and run the binaries from that directory. The unpacked +staging directory is kept next to the archive. Options: `--format zip|tar.gz`, +`--out-dir DIR`, `--debug`. diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml new file mode 100644 index 0000000..3f9040f --- /dev/null +++ b/xtask/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "xtask" +version.workspace = true +edition.workspace = true +publish = false + +[dependencies] +flate2 = "1" +serde_json = "1" +tar = { version = "0.4", default-features = false } +zip = { version = "9", default-features = false, features = ["deflate-flate2"] } + +[lints] +workspace = true diff --git a/xtask/src/main.rs b/xtask/src/main.rs new file mode 100644 index 0000000..1f18495 --- /dev/null +++ b/xtask/src/main.rs @@ -0,0 +1,367 @@ +//! Project automation, run with `cargo xtask `. +//! +//! ```text +//! cargo xtask pack [--target TRIPLE] [--format zip|tar.gz] [--out-dir DIR] [--debug] +//! ``` + +use std::{ + env, + error::Error, + fs::{self, File}, + io::{self, BufRead, BufReader, BufWriter, Write}, + path::{Path, PathBuf}, + process::{Command, ExitCode, Stdio}, +}; + +type Result> = std::result::Result; + +const PACKAGE_NAME: &str = "station"; +/// `(package, binary)` pairs shipped in the archive. +const BINARIES: [(&str, &str); 2] = [("game_client", "game"), ("game_server", "game-server")]; +/// Files and directories (relative to the workspace root) shipped next to the binaries. +const EXTRA_FILES: [&str; 3] = ["assets", "README.md", "CHANGELOG.md"]; + +fn main() -> ExitCode { + let mut args = env::args().skip(1); + let result = match args.next().as_deref() { + Some("pack") => PackOptions::parse(args).and_then(|opts| pack(&opts)), + Some("-h" | "--help") | None => { + print_help(); + return ExitCode::SUCCESS; + } + Some(other) => Err(format!("unknown command `{other}`, see `cargo xtask --help`").into()), + }; + match result { + Ok(()) => ExitCode::SUCCESS, + Err(err) => { + eprintln!("error: {err}"); + ExitCode::FAILURE + } + } +} + +fn print_help() { + println!( + "usage: cargo xtask \n\n\ + commands:\n \ + pack build the client and server and pack them with their assets\n\n\ + pack options:\n \ + --target TRIPLE cross-compile for TRIPLE (default: host)\n \ + --format zip|tar.gz archive format (default: zip for Windows, tar.gz otherwise)\n \ + --out-dir DIR output directory (default: dist)\n \ + --debug pack a debug build instead of the stripped `dist` profile" + ); +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Format { + Zip, + TarGz, +} + +impl Format { + fn parse(s: &str) -> Result { + match s { + "zip" => Ok(Self::Zip), + "tar.gz" | "tgz" => Ok(Self::TarGz), + _ => Err(format!("unknown archive format `{s}` (expected zip or tar.gz)").into()), + } + } + + fn default_for(target: &str) -> Self { + if target.contains("windows") { + Self::Zip + } else { + Self::TarGz + } + } + + fn extension(self) -> &'static str { + match self { + Self::Zip => "zip", + Self::TarGz => "tar.gz", + } + } +} + +#[derive(Debug, Default)] +struct PackOptions { + target: Option, + format: Option, + out_dir: Option, + debug: bool, +} + +impl PackOptions { + fn parse(mut args: impl Iterator) -> Result { + let mut opts = Self::default(); + while let Some(arg) = args.next() { + let mut value = |name: &str| args.next().ok_or_else(|| format!("{name} needs a value")); + match arg.as_str() { + "--target" => opts.target = Some(value("--target")?), + "--format" => opts.format = Some(Format::parse(&value("--format")?)?), + "--out-dir" => opts.out_dir = Some(value("--out-dir")?.into()), + "--debug" => opts.debug = true, + "-h" | "--help" => { + print_help(); + std::process::exit(0); + } + other => return Err(format!("unknown pack option `{other}`").into()), + } + } + Ok(opts) + } +} + +fn workspace_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("xtask lives inside the workspace") + .to_path_buf() +} + +fn cargo() -> Command { + Command::new(env::var_os("CARGO").unwrap_or_else(|| "cargo".into())) +} + +fn host_target() -> Result { + let output = Command::new(env::var_os("RUSTC").unwrap_or_else(|| "rustc".into())) + .arg("-vV") + .output()?; + String::from_utf8(output.stdout)? + .lines() + .find_map(|line| line.strip_prefix("host: ")) + .map(str::to_owned) + .ok_or_else(|| "could not determine the host target from `rustc -vV`".into()) +} + +fn pack(opts: &PackOptions) -> Result<()> { + let root = workspace_root(); + let target = match &opts.target { + Some(target) => target.clone(), + None => host_target()?, + }; + let format = opts.format.unwrap_or_else(|| Format::default_for(&target)); + let out_dir = root.join(opts.out_dir.as_deref().unwrap_or(Path::new("dist"))); + let name = format!("{PACKAGE_NAME}-{}-{target}", env!("CARGO_PKG_VERSION")); + + let executables = build(&root, opts.target.as_deref(), opts.debug)?; + + // The staging directory is kept so CI can upload it without double-zipping. + let stage = out_dir.join(&name); + if stage.exists() { + fs::remove_dir_all(&stage)?; + } + fs::create_dir_all(&stage)?; + for exe in &executables { + fs::copy( + exe, + stage.join(exe.file_name().expect("executable has a file name")), + )?; + } + for extra in EXTRA_FILES { + copy_recursive(&root.join(extra), &stage.join(extra))?; + } + + let archive = out_dir.join(format!("{name}.{}", format.extension())); + write_archive(&stage, &name, &archive, format)?; + println!("packed {}", archive.display()); + Ok(()) +} + +/// Builds the shipped binaries and returns their paths, as reported by cargo. +fn build(root: &Path, target: Option<&str>, debug: bool) -> Result> { + let mut cmd = cargo(); + cmd.current_dir(root).args([ + "build", + "--locked", + "--message-format=json-render-diagnostics", + ]); + cmd.args(["--profile", if debug { "dev" } else { "dist" }]); + if let Some(target) = target { + cmd.args(["--target", target]); + } + for (package, bin) in BINARIES { + cmd.args(["-p", package, "--bin", bin]); + } + let mut child = cmd.stdout(Stdio::piped()).spawn()?; + + let mut executables = Vec::new(); + for line in BufReader::new(child.stdout.take().expect("stdout is piped")).lines() { + let msg: serde_json::Value = serde_json::from_str(&line?)?; + if msg["reason"] == "compiler-artifact" + && let Some(exe) = msg["executable"].as_str() + && BINARIES + .iter() + .any(|(_, bin)| msg["target"]["name"] == *bin) + { + executables.push(PathBuf::from(exe)); + } + } + if !child.wait()?.success() { + return Err("cargo build failed".into()); + } + if executables.len() != BINARIES.len() { + return Err(format!( + "expected {} executables, cargo reported {executables:?}", + BINARIES.len() + ) + .into()); + } + Ok(executables) +} + +fn copy_recursive(from: &Path, to: &Path) -> io::Result<()> { + if from.is_dir() { + fs::create_dir_all(to)?; + for entry in fs::read_dir(from)? { + let entry = entry?; + copy_recursive(&entry.path(), &to.join(entry.file_name()))?; + } + Ok(()) + } else { + fs::copy(from, to).map(|_| ()) + } +} + +/// Files below `dir` as `(path, archive path with '/' separators)`, sorted for reproducible archives. +fn collect_files(dir: &Path, prefix: &str) -> io::Result> { + let mut files = Vec::new(); + let mut entries: Vec<_> = fs::read_dir(dir)?.collect::>()?; + entries.sort_by_key(|e| e.file_name()); + for entry in entries { + let name = format!("{prefix}/{}", entry.file_name().to_string_lossy()); + if entry.file_type()?.is_dir() { + files.extend(collect_files(&entry.path(), &name)?); + } else { + files.push((entry.path(), name)); + } + } + Ok(files) +} + +fn is_executable(archive_path: &str) -> bool { + let file = archive_path.rsplit('/').next().unwrap_or(archive_path); + BINARIES + .iter() + .any(|(_, bin)| file == *bin || file == format!("{bin}.exe")) +} + +/// Packs the contents of `dir` into `archive`, below a top-level directory `prefix`. +fn write_archive(dir: &Path, prefix: &str, archive: &Path, format: Format) -> Result<()> { + let files = collect_files(dir, prefix)?; + let out = BufWriter::new(File::create(archive)?); + match format { + Format::Zip => { + let mut zip = zip::ZipWriter::new(out); + for (path, name) in files { + let mode = if is_executable(&name) { 0o755 } else { 0o644 }; + let options = zip::write::SimpleFileOptions::default() + .compression_method(zip::CompressionMethod::Deflated) + .unix_permissions(mode); + zip.start_file(name, options)?; + io::copy(&mut File::open(path)?, &mut zip)?; + } + zip.finish()?.flush()?; + } + Format::TarGz => { + let gz = flate2::write::GzEncoder::new(out, flate2::Compression::default()); + let mut tar = tar::Builder::new(gz); + for (path, name) in files { + let mut header = tar::Header::new_gnu(); + header.set_size(fs::metadata(&path)?.len()); + header.set_mode(if is_executable(&name) { 0o755 } else { 0o644 }); + header.set_mtime(0); + header.set_cksum(); + tar.append_data(&mut header, &name, File::open(&path)?)?; + } + tar.into_inner()?.finish()?.flush()?; + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn temp_dir(name: &str) -> PathBuf { + let dir = env::temp_dir().join(format!("xtask-test-{name}-{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(dir.join("assets/data")).unwrap(); + fs::write(dir.join("game"), b"binary").unwrap(); + fs::write(dir.join("assets/data/items.rsn"), b"items: []").unwrap(); + dir + } + + #[test] + fn default_format_follows_target() { + assert_eq!(Format::default_for("x86_64-pc-windows-msvc"), Format::Zip); + assert_eq!(Format::default_for("x86_64-pc-windows-gnu"), Format::Zip); + assert_eq!( + Format::default_for("x86_64-unknown-linux-gnu"), + Format::TarGz + ); + } + + #[test] + fn parses_pack_options() { + let args = ["--target", "x", "--format", "zip", "--debug"].map(String::from); + let opts = PackOptions::parse(args.into_iter()).unwrap(); + assert_eq!(opts.target.as_deref(), Some("x")); + assert_eq!(opts.format, Some(Format::Zip)); + assert!(opts.debug); + assert!(PackOptions::parse(["--target".to_owned()].into_iter()).is_err()); + assert!(PackOptions::parse(["--bogus".to_owned()].into_iter()).is_err()); + } + + #[test] + fn zip_contains_prefixed_files_with_permissions() { + let dir = temp_dir("zip"); + let archive = dir.with_extension("zip"); + write_archive(&dir, "pkg", &archive, Format::Zip).unwrap(); + + let mut zip = zip::ZipArchive::new(File::open(&archive).unwrap()).unwrap(); + let mut names: Vec<_> = zip.file_names().map(|n| n.unwrap().into_owned()).collect(); + names.sort(); + assert_eq!(names, ["pkg/assets/data/items.rsn", "pkg/game"]); + assert_eq!( + zip.by_name("pkg/game").unwrap().unix_mode().unwrap() & 0o777, + 0o755 + ); + + fs::remove_dir_all(&dir).unwrap(); + fs::remove_file(&archive).unwrap(); + } + + #[test] + fn tar_gz_contains_prefixed_files_with_permissions() { + let dir = temp_dir("tar"); + let archive = dir.with_extension("tar.gz"); + write_archive(&dir, "pkg", &archive, Format::TarGz).unwrap(); + + let gz = flate2::read::GzDecoder::new(File::open(&archive).unwrap()); + let mut tar = tar::Archive::new(gz); + let entries: Vec<_> = tar + .entries() + .unwrap() + .map(|e| { + let e = e.unwrap(); + ( + e.path().unwrap().to_string_lossy().into_owned(), + e.header().mode().unwrap(), + ) + }) + .collect(); + assert_eq!( + entries, + [ + ("pkg/assets/data/items.rsn".to_owned(), 0o644), + ("pkg/game".to_owned(), 0o755) + ] + ); + + fs::remove_dir_all(&dir).unwrap(); + fs::remove_file(&archive).unwrap(); + } +}