diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f380580 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,50 @@ +name: CI + +on: + push: + pull_request: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + CARGO_TERM_COLOR: always + +jobs: + linux-smoke: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + + - name: Cache cargo + uses: Swatinem/rust-cache@v2 + + - name: Cargo fmt check + run: cargo fmt --all -- --check + + - name: Cargo clippy + run: cargo clippy --all-targets -- -D warnings + + - name: Build modbuild (release) + run: cargo build --release + + - name: Show targets + run: ./target/release/modbuild list-targets + + - name: Smoke build (linux only) + run: ./target/release/modbuild build --path examples/example-mod --out dist --targets linux + + - name: Assert artifact exists + run: test -f dist/libexample_mod-linux.so + + - name: Upload dist artifacts + uses: actions/upload-artifact@v4 + with: + name: dist-linux + path: dist/* + if-no-files-found: error \ No newline at end of file diff --git a/.gitignore b/.gitignore index 6985cf1..3ce3883 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,6 @@ Cargo.lock # MSVC Windows builds of rustc generate these, which store debugging information *.pdb + +.idea/ +dist/ \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml index 95c0d0f..01d9987 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,3 +5,5 @@ edition = "2024" license = "MIT" [dependencies] +clap = { version = "4.5.45", features = ["derive"] } +serde_json = "1.0.143" diff --git a/README.md b/README.md index 4b97036..5be81e1 100644 --- a/README.md +++ b/README.md @@ -1,131 +1,139 @@ # 🛠️ modbuild - Cross-platform Mod Builder for Freven -**modbuild** is the official tool used by [Freven](https://discord.gg/zKY3Tkk837) to compile mods for **Linux**, **Windows**, and **macOS**. -It turns your Rust-based mod into shared libraries (`.so`, `.dll`, `.dylib`) with just one command - no manual setup or cross-compilation headaches. +**modbuild** is a tool used by [Freven](https://discord.gg/zKY3Tkk837) to compile Rust-based mods for **Linux**, **Windows**, and **macOS**. +It produces shared libraries (`.so`, `.dll`, `.dylib`) for multiple platforms from a single command. --- -## 💡 What is this? +## 💡 Installation -Freven uses dynamic `.so`/`.dll`/`.dylib` files to load mods at runtime. -This tool builds those files for all platforms at once - so your mod works everywhere without needing a Mac or Windows machine. - ---- - -## 📦 Installation - -Build it once: +Build once: ```bash cd modbuild/ cargo build --release ``` +Or install globally: + +```bash +cargo install --path . +``` + --- ## 🚀 Usage -Inside your mod crate (where `Cargo.toml` is), run: +Build all targets for your mod: ```bash -cargo run -p modbuild +./target/release/modbuild build --path /path/to/your/mod --out ./dist ``` -Or use the compiled binary: +Specify targets explicitly: ```bash -./target/release/modbuild +./target/release/modbuild build --path /path/to/your/mod --out ./dist --targets linux,windows-gnu,windows-msvc,mac-intel,mac-arm64 ``` -You'll see a clean report showing `.so`, `.dll`, and `.dylib` outputs. +List all supported targets: + +```bash +./target/release/modbuild list-targets +``` --- -## 📁 How to Set Up Your Mod +## 📁 Setting Up Your Mod -In your `Cargo.toml`, make sure this is set: +In your `Cargo.toml`, configure your library as a dynamic library: ```toml [lib] crate-type = ["cdylib"] ``` -This makes Rust compile your mod as a dynamic library. +This allows Rust to produce `.so`, `.dll`, or `.dylib` files. --- ## ✅ Example Output -```text -🔧 Building for linux... -✅ Built linux: target/x86_64-unknown-linux-gnu/release/libhello.so -🔧 Building for windows... -✅ Built windows: target/x86_64-pc-windows-gnu/release/libhello.dll -🔧 Building for mac... -✅ Built mac: target/x86_64-apple-darwin/release/libhello.dylib +```bash +Building for linux... +Built linux successfully. +Copied to ./dist/libmy_mod-linux.so +Building for windows-gnu... +Built windows-gnu successfully. +Copied to ./dist/my_mod-windows-gnu.dll +Building for windows-msvc... +Built windows-msvc successfully. +Copied to ./dist/my_mod-windows-msvc.dll +Building for mac-intel... +Built mac-intel successfully. +Copied to ./dist/libmy_mod-mac-intel.dylib +Building for mac-arm64... +Built mac-arm64 successfully. +Copied to ./dist/libmy_mod-mac-arm64.dylib ``` --- ## 🧠 Requirements -### Linux & Windows builds +### Linux & Windows -Install the Windows target: +Install the Windows targets: ```bash rustup target add x86_64-pc-windows-gnu +rustup target add x86_64-pc-windows-msvc ``` -### macOS builds (2 options) +### macOS builds #### Option 1: Build on macOS ```bash rustup target add x86_64-apple-darwin +rustup target add aarch64-apple-darwin ``` -#### Option 2: Build on Linux (with osxcross or zig) +#### Option 2: Cross-build on Linux -Use [osxcross](https://github.com/tpoechtrager/osxcross) and set: +Install [cargo-zigbuild](https://github.com/messense/cargo-zigbuild) or [osxcross](https://github.com/tpoechtrager/osxcross): ```bash -export PATH="$HOME/osxcross/target/bin:$PATH" +cargo install cargo-zigbuild export CC=o64-clang export CXX=o64-clang++ ``` -Or install [cargo-zigbuild](https://github.com/messense/cargo-zigbuild): - -```bash -cargo install cargo-zigbuild -``` - -`modbuild` will detect this automatically. +`modbuild` will detect the cross-compilation tools automatically. --- ## ⚙️ How It Works -- Calls `cargo build` or `cargo zigbuild` for each target -- Auto-detects macOS cross-compilation tools -- Outputs shared libraries to: - - `target/x86_64-unknown-linux-gnu/release/lib*.so` - - `target/x86_64-pc-windows-gnu/release/lib*.dll` - - `target/x86_64-apple-darwin/release/lib*.dylib` +- Uses `cargo build` or `cargo zigbuild` per target +- Detects macOS cross-compilation automatically +- Outputs shared libraries to the `--out` directory, named like: + - `lib-linux.so` + - `-windows-gnu.dll` + - `-windows-msvc.dll` + - `lib-mac-intel.dylib` + - `lib-mac-arm64.dylib` --- ## 🧩 Compatibility - Rust 1.74+ -- Freven mods using `extern "C"` and the `FrevenApi` -- Works on Linux/macOS (Windows coming soon) +- Freven mods using `extern "C"` and `FrevenApi` +- Works on Linux, Windows, and macOS --- ## 📜 License -MIT - use freely, modify freely, just include the license. - ---- \ No newline at end of file +MIT - use freely, modify freely, include the license. \ No newline at end of file diff --git a/examples/example-mod/Cargo.toml b/examples/example-mod/Cargo.toml new file mode 100644 index 0000000..08cd066 --- /dev/null +++ b/examples/example-mod/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "example_mod" +version = "0.1.0" +edition = "2024" + +[lib] +crate-type = ["cdylib"] + +[dependencies] + +[workspace] \ No newline at end of file diff --git a/examples/example-mod/src/lib.rs b/examples/example-mod/src/lib.rs new file mode 100644 index 0000000..85141f8 --- /dev/null +++ b/examples/example-mod/src/lib.rs @@ -0,0 +1,4 @@ +#[unsafe(no_mangle)] +pub extern "C" fn modbuild_smoke() -> i32 { + 52 +} \ No newline at end of file diff --git a/src/build.rs b/src/build.rs new file mode 100644 index 0000000..34e9d94 --- /dev/null +++ b/src/build.rs @@ -0,0 +1,124 @@ +use crate::utils::{has_mac_compiler, has_zigbuild, is_host_triple}; +use std::fs; +use std::path::PathBuf; +use std::process::{Command, Stdio}; + +/// Represents a build target (OS + architecture) +#[derive(Clone)] +pub struct BuildTarget { + pub name: &'static str, + pub triple: &'static str, + pub ext: &'static str, + pub needs_mac: bool, +} + +/// All supported targets +pub fn all_targets() -> Vec { + vec![ + BuildTarget { + name: "linux", + triple: "x86_64-unknown-linux-gnu", + ext: "so", + needs_mac: false, + }, + BuildTarget { + name: "windows-gnu", + triple: "x86_64-pc-windows-gnu", + ext: "dll", + needs_mac: false, + }, + BuildTarget { + name: "windows-msvc", + triple: "x86_64-pc-windows-msvc", + ext: "dll", + needs_mac: false, + }, + BuildTarget { + name: "mac-intel", + triple: "x86_64-apple-darwin", + ext: "dylib", + needs_mac: true, + }, + BuildTarget { + name: "mac-arm64", + triple: "aarch64-apple-darwin", + ext: "dylib", + needs_mac: true, + }, + ] +} + +/// Select targets based on optional filter string +pub fn select_targets(filter: Option) -> Vec { + let all = all_targets(); + if let Some(f) = filter { + f.split(',') + .filter_map(|name| all.iter().find(|t| t.name == name.trim())) + .cloned() + .collect() + } else { + all + } +} + +/// Build a crate for a specific target +pub fn build_for_target( + crate_name: &str, + out: &PathBuf, + target: &BuildTarget, + mod_path: &PathBuf, +) -> Result<(), String> { + if target.needs_mac && !has_mac_compiler() { + return Err(format!("Skipping {}: mac compiler not found", target.name)); + } + + println!("Building for {}...", target.name); + + let lib_basename = if target.ext == "dll" { + crate_name.replace('-', "_") + } else { + format!("lib{}", crate_name.replace('-', "_")) + }; + + // Choose cargo subcommand: prefer zigbuild for macOS / cross, else plain build + let use_zig = if target.needs_mac { + has_zigbuild() && has_mac_compiler() + } else if !is_host_triple(target.triple) { + has_zigbuild() + } else { + false + }; + let cargo_cmd = if use_zig { "zigbuild" } else { "build" }; + + let status = Command::new("cargo") + .arg(cargo_cmd) + .args(["--release", "--target", target.triple]) + .current_dir(mod_path) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .status() + .map_err(|e| format!("Failed to run cargo: {e}"))?; + + if !status.success() { + return Err(format!("Build failed for {}", target.name)); + } + + println!("Built {} successfully.", target.name); + + let built_path = mod_path.join(format!( + "target/{}/release/{}.{}", + target.triple, lib_basename, target.ext + )); + if !built_path.exists() { + return Err(format!("Built file not found: {}", built_path.display())); + } + + let out_name = format!("{}-{}.{}", lib_basename, target.name, target.ext); + let out_path = out.join(out_name); + + fs::create_dir_all(out).map_err(|e| format!("Failed to create output directory: {e}"))?; + fs::copy(&built_path, &out_path).map_err(|e| format!("Failed to copy file: {e}"))?; + + println!("Copied to {}", out_path.display()); + Ok(()) +} diff --git a/src/crate_info.rs b/src/crate_info.rs new file mode 100644 index 0000000..786bf87 --- /dev/null +++ b/src/crate_info.rs @@ -0,0 +1,68 @@ +use serde_json::Value; +use std::path::PathBuf; +use std::process::Command; + +/// Get crate name from Cargo metadata (workspace-safe) +pub fn get_crate_name(mod_path: &PathBuf) -> Result { + let output = Command::new("cargo") + .args(["metadata", "--format-version", "1", "--no-deps"]) + .current_dir(mod_path) + .output() + .map_err(|e| format!("Failed to run cargo metadata: {e}"))?; + + if !output.status.success() { + let err = String::from_utf8_lossy(&output.stderr); + return Err(format!("cargo metadata failed:\n{}", err)); + } + + let stdout = String::from_utf8(output.stdout) + .map_err(|e| format!("Invalid UTF-8 from cargo metadata: {e}"))?; + + let metadata: Value = serde_json::from_str(&stdout) + .map_err(|e| format!("Failed to parse cargo metadata JSON: {e}"))?; + + let manifest_path = mod_path + .join("Cargo.toml") + .canonicalize() + .unwrap_or(mod_path.join("Cargo.toml")); + let manifest_str = manifest_path.to_string_lossy(); + + let packages = metadata["packages"].as_array().ok_or("No packages")?; + let pkg = packages + .iter() + .find(|p| { + p["manifest_path"] + .as_str() + .map(|s| s == manifest_str) + .unwrap_or(false) + }) + .or_else(|| packages.first()) + .ok_or("No package found")?; + + let name = pkg["name"].as_str().ok_or("Failed to get package name")?; + Ok(name.to_string()) +} + +/// Ensure the crate is configured to build a dynamic library +pub fn ensure_cdylib(mod_path: &PathBuf) -> Result<(), String> { + let out = Command::new("cargo") + .args(["read-manifest"]) + .current_dir(mod_path) + .output() + .map_err(|e| format!("Failed to run cargo read-manifest: {e}"))?; + if !out.status.success() { + return Err("cargo read-manifest failed".into()); + } + let v: Value = serde_json::from_slice(&out.stdout).map_err(|e| e.to_string())?; + let targets = v["targets"].as_array().ok_or("No targets in manifest")?; + let has_cdylib = targets.iter().any(|t| { + t["kind"] + .as_array() + .map(|ks| ks.iter().any(|k| k == "cdylib")) + .unwrap_or(false) + }); + if !has_cdylib { + return Err("Your Cargo.toml must include:\n\n[lib]\ncrate-type = [\"cdylib\"]\n".into()); + } + Ok(()) +} diff --git a/src/main.rs b/src/main.rs index bd90891..bfe6589 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,100 +1,72 @@ -use std::env; -use std::fs; -use std::path::PathBuf; -use std::process::{Command, Stdio}; - -fn main() { - let args: Vec = env::args().collect(); - let output_dir = args.get(1).map(|p| { - let path = PathBuf::from(p); - path.canonicalize().unwrap_or(path) - }); +mod build; +mod crate_info; +mod utils; - let crate_name = get_crate_name(); - let base = crate_name.replace('-', "_"); - - let targets = vec![ - ("linux", "x86_64-unknown-linux-gnu", "so"), - ("windows", "x86_64-pc-windows-gnu", "dll"), - ("mac-intel", "x86_64-apple-darwin", "dylib"), - ("mac-arm64", "aarch64-apple-darwin", "dylib"), - ]; - - for (name, target, ext) in &targets { - if name.starts_with("mac") && !has_mac_compiler() { - println!("⚠️ Skipping {name} build: osxcross (o64-clang) or zig not found"); - continue; - } - - println!("🔧 Building for {name}..."); - - let lib_basename = if *ext == "dll" { - base.clone() // Windows: no "lib" prefix - } else { - format!("lib{}", base) - }; +use clap::{Parser, Subcommand}; +use std::path::PathBuf; - let mut cmd = Command::new("cargo"); - //cmd.arg(if name.starts_with("mac") { "zigbuild" } else { "build" }); - cmd.arg("zigbuild"); +/// Cross-platform mod builder +#[derive(Parser)] +#[command( + name = "modbuild", + version, + about = "Cross-platform mod builder for Freven" +)] +struct Cli { + #[command(subcommand)] + command: Commands, +} - let status = cmd - .args(["--release", "--target", target]) - .stdout(Stdio::inherit()) - .stderr(Stdio::inherit()) - .status() - .expect("failed to run cargo"); +#[derive(Subcommand)] +enum Commands { + /// Build mods for one or more targets + Build { + /// Path to the mod project + #[arg(short, long, default_value = ".")] + path: PathBuf, + + /// Output directory + #[arg(short, long, default_value = "dist")] + out: PathBuf, + + /// Comma-separated list of targets (linux, windows-gnu, windows-msvc, mac-intel, mac-arm64) + #[arg(short, long)] + targets: Option, + }, + + /// List available targets + ListTargets, +} - if status.success() { - println!("✅ Built {name}: target/{target}/release/{lib_basename}.{ext}"); +fn main() { + let cli = Cli::parse(); - if let Some(ref out_dir) = output_dir { - let cargo_root = PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .parent() - .unwrap() - .to_path_buf(); + match cli.command { + Commands::Build { path, out, targets } => { + let path = std::fs::canonicalize(&path).unwrap_or(path); - let built_path = cargo_root.join(format!( - "target/{target}/release/{lib_basename}.{ext}" - )); - let out_name = format!("{lib_basename}-{name}.{ext}"); - let out_path = out_dir.join(out_name); + let targets = build::select_targets(targets); + let crate_name = crate_info::get_crate_name(&path).unwrap_or_else(|e| { + eprintln!("Failed to read Cargo.toml at {}: {e}", path.display()); + std::process::exit(1); + }); - if !built_path.exists() { - eprintln!("❗ Built file not found: {}", built_path.display()); - } + if let Err(e) = crate_info::ensure_cdylib(&path) { + eprintln!("Invalid mod at {}: {e}", path.display()); + std::process::exit(1); + } - if let Err(e) = fs::copy(&built_path, &out_path) { - eprintln!("❌ Failed to copy to {}: {e}", out_path.display()); - } else { - println!("📦 Copied to {}", out_path.display()); + for target in targets { + if let Err(e) = build::build_for_target(&crate_name, &out, &target, &path) { + eprintln!("{e}"); } } - } else { - println!("❌ Failed to build for {name}"); } - } -} - - -fn get_crate_name() -> String { - let cargo_toml = std::fs::read_to_string("Cargo.toml") - .expect("Failed to read Cargo.toml"); - for line in cargo_toml.lines() { - if let Some(name) = line.strip_prefix("name = ") { - return name.trim_matches('"').to_string(); + Commands::ListTargets => { + println!("Available targets:"); + for t in build::all_targets() { + println!(" - {} ({})", t.name, t.triple); + } } } - panic!("Could not find crate name in Cargo.toml"); -} - -fn has_mac_compiler() -> bool { - if let Ok(path) = env::var("CC") { - return path.contains("o64-clang") || path.contains("zig"); - } - Command::new("which") - .arg("zig") - .output() - .map(|o| o.status.success()) - .unwrap_or(false) } diff --git a/src/utils.rs b/src/utils.rs new file mode 100644 index 0000000..24b9340 --- /dev/null +++ b/src/utils.rs @@ -0,0 +1,30 @@ +use std::env; +use std::process::Command; + +pub fn has_cmd(cmd: &str, arg: &str) -> bool { + Command::new(cmd) + .arg(arg) + .output() + .map(|o| o.status.success()) + .unwrap_or(false) +} + +/// Is `cargo zigbuild` available on this machine? +pub fn has_zigbuild() -> bool { + has_cmd("cargo", "zigbuild") +} + +/// Is the requested target equal to the host toolchain triple? +pub fn is_host_triple(triple: &str) -> bool { + let host = env::var("HOST").unwrap_or_default(); + host == triple +} + +/// Checks if a macOS compiler is available +pub fn has_mac_compiler() -> bool { + if let Ok(path) = env::var("CC") { + let p = path.to_lowercase(); + return p.contains("o64-clang") || p.contains("oa64-clang") || p.contains("zig"); + } + has_cmd("which", "zig") || has_cmd("which", "o64-clang") || has_cmd("which", "oa64-clang") +}