diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c3f4595..16e1046 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -31,11 +31,14 @@ jobs: run: cargo test --release - name: Build run: cargo build --release - - name: Check against the game data + - name: Check the binary runs without the disks shell: bash run: | - ./target/release/${{ matrix.bin }} info --data . - ./target/release/${{ matrix.bin }} bench --data . --seconds 60 + mkdir -p "$RUNNER_TEMP/standalone" + cp target/release/${{ matrix.bin }} "$RUNNER_TEMP/standalone/" + cd "$RUNNER_TEMP/standalone" + ./${{ matrix.bin }} info + ./${{ matrix.bin }} bench --seconds 60 - uses: actions/upload-artifact@v4 with: name: ${{ matrix.artifact }} diff --git a/Cargo.toml b/Cargo.toml index 75936b0..84b0173 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,10 +8,13 @@ readme = "README.md" repository = "https://github.com/andydixon/skids" [features] -default = ["audio"] +default = ["audio", "embed"] # Procedural engine/skid sound via cpal. Disable with --no-default-features # on Linux machines without the ALSA development headers. audio = ["dep:cpal"] +# Build the game data from the ADF images next to Cargo.toml (or in +# SKIDS_EMBED_DIR) into the executable, so it runs without the disks. +embed = [] [dependencies] minifb = "0.28" diff --git a/README.md b/README.md index 8e53af7..a7b3f8c 100644 --- a/README.md +++ b/README.md @@ -3,10 +3,14 @@ A cross-platform (Windows, macOS, Linux) engine written in Rust that plays *Super Skidmarks* (Acid Software, 1995) on the original Amiga data. -It reads the game's ADF disk images directly: it unpacks the LHA archives -on them, decrunches the CrunchMania-packed files, and decodes the planar -track graphics and AGA sprite banks. No game assets are compiled into the -program; you supply the disks. +At build time it reads the game's ADF disk images from this repository: +it unpacks the LHA archives on them and builds the game files into the +executable. At run time it decrunches the CrunchMania-packed files and +decodes the planar track graphics and AGA sprite banks. The finished +program is a single self-contained file, with no disks needed to play. + +Because the binary carries the original game data, anyone you give a build +to also gets the game data. ## What works @@ -44,11 +48,17 @@ far. ## Running ```sh -cargo run --release -- --data /path/to/adfs +cargo run --release ``` -Without `--data`, the game looks in the current directory and then next to -the executable, so running it from this repository's root just works. +The built executable (`target/release/skids`, or `skids.exe` on Windows) +runs on its own from anywhere. + +`--data DIR` adds to or overrides the built-in data with more ADF images, +LHA archives, or extracted `track/`, `vehicles/` and `palettes/` folders. +Without `--data`, a folder like that in the current directory or next to +the executable is picked up automatically. That means dropping disk 1 or +real palette files next to the game just works. | Action | Player 1 | Player 2 | |-------------|---------------------------------------|----------| @@ -76,15 +86,22 @@ You need a stable Rust toolchain. - **Linux:** install the ALSA and X11 development packages, e.g. `sudo apt install libasound2-dev libx11-dev libxkbcommon-dev`. To build - without sound (no ALSA needed), use `cargo build --release --no-default-features`. + without sound (no ALSA needed), use + `cargo build --release --no-default-features --features embed`. - **macOS / Windows:** `cargo build --release` works as is. +The build embeds the data from the `*.adf` files next to `Cargo.toml`. To +use images from somewhere else, set `SKIDS_EMBED_DIR=/path/to/adfs`. To +build without embedding, so the game needs `--data` at run time, use +`--no-default-features --features audio`. + GitHub Actions builds all three platforms on every push (`.github/workflows/build.yml`). ## Layout ``` +build.rs unpacks the ADFs and embeds the game files at build time src/formats/ ADF (OFS/FFS), LHA and CrunchMania readers src/data/ track, vehicle and palette decoding; locating the game files src/game/ physics, AI and race rules diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..09ca08e --- /dev/null +++ b/build.rs @@ -0,0 +1,84 @@ +//! Embeds the game data in the executable so it runs without the disks. +//! +//! The ADF images next to Cargo.toml (or in `SKIDS_EMBED_DIR`) are unpacked +//! at build time and the files inside their LHA archives are packed into +//! `$OUT_DIR/embedded.bin`, which `src/data/embedded.rs` includes. If no +//! images are found the pack is empty and the game asks for `--data`. +//! Disable with `--no-default-features --features audio`. + +#[path = "src/formats/mod.rs"] +#[allow(dead_code)] +mod formats; + +use formats::{adf::Adf, lha}; +use std::env; +use std::fs; +use std::path::PathBuf; + +fn main() { + println!("cargo:rerun-if-changed=build.rs"); + println!("cargo:rerun-if-env-changed=SKIDS_EMBED_DIR"); + let out = PathBuf::from(env::var("OUT_DIR").unwrap()).join("embedded.bin"); + + let mut files: Vec<(String, Vec)> = Vec::new(); + if env::var_os("CARGO_FEATURE_EMBED").is_some() { + let dir = env::var_os("SKIDS_EMBED_DIR") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap())); + println!("cargo:rerun-if-changed={}", dir.display()); + let mut images: Vec = fs::read_dir(&dir) + .map(|rd| rd.flatten().map(|e| e.path()).collect()) + .unwrap_or_default(); + images.retain(|p| p.extension().is_some_and(|e| e.eq_ignore_ascii_case("adf"))); + images.sort(); + for image in images { + println!("cargo:rerun-if-changed={}", image.display()); + let name = image.display().to_string(); + let disk = match fs::read(&image) + .map_err(|e| e.to_string()) + .and_then(|d| Adf::open(d).map_err(|e| e.to_string())) + .and_then(|a| a.files().map_err(|e| e.to_string())) + { + Ok(f) => f, + Err(e) => { + println!("cargo:warning=skipping {name}: {e}"); + continue; + } + }; + for f in disk { + if !f.path.to_lowercase().ends_with(".lha") { + files.push((f.path, f.data)); + continue; + } + match lha::parse(&f.data) { + Ok(entries) => { + for e in entries { + if let Some(d) = e.data { + files.push((e.path, d)); + } + } + } + Err(e) => println!("cargo:warning=skipping {name}/{}: {e}", f.path), + } + } + } + if files.is_empty() { + println!( + "cargo:warning=no Super Skidmarks ADF images in {}; the game will need --data", + dir.display() + ); + } + } + + // Pack: "SKIDPAK1", u32 count, then per file u16 name length, name, + // u32 data length, data. All little-endian. + let mut pack = b"SKIDPAK1".to_vec(); + pack.extend_from_slice(&(files.len() as u32).to_le_bytes()); + for (name, data) in &files { + pack.extend_from_slice(&(name.len() as u16).to_le_bytes()); + pack.extend_from_slice(name.as_bytes()); + pack.extend_from_slice(&(data.len() as u32).to_le_bytes()); + pack.extend_from_slice(data); + } + fs::write(out, pack).unwrap(); +} diff --git a/src/data/embedded.rs b/src/data/embedded.rs new file mode 100644 index 0000000..d9bc212 --- /dev/null +++ b/src/data/embedded.rs @@ -0,0 +1,25 @@ +//! Game files built into the executable by `build.rs`. + +static PACK: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/embedded.bin")); + +/// Every embedded file as (archive path, contents). Empty when the build +/// found no disk images. +pub fn files() -> Vec<(String, &'static [u8])> { + parse(PACK).unwrap_or_default() +} + +fn parse(d: &'static [u8]) -> Option> { + let rest = d.strip_prefix(b"SKIDPAK1")?; + let (count, mut rest) = rest.split_at_checked(4)?; + let count = u32::from_le_bytes(count.try_into().ok()?); + let mut out = Vec::with_capacity(count as usize); + for _ in 0..count { + let (len, r) = rest.split_at_checked(2)?; + let (name, r) = r.split_at_checked(u16::from_le_bytes(len.try_into().ok()?) as usize)?; + let (len, r) = r.split_at_checked(4)?; + let (data, r) = r.split_at_checked(u32::from_le_bytes(len.try_into().ok()?) as usize)?; + out.push((String::from_utf8_lossy(name).into_owned(), data)); + rest = r; + } + Some(out) +} diff --git a/src/data/mod.rs b/src/data/mod.rs index df02583..ef05b53 100644 --- a/src/data/mod.rs +++ b/src/data/mod.rs @@ -1,9 +1,12 @@ //! Locates and decodes the original game data. //! -//! Data can be supplied as the original ADF images, as the LHA archives taken -//! off them, or as an already-extracted directory tree containing `track/` and -//! `vehicles/`. Nothing from the original game is embedded in this program. +//! The default build embeds the data from the ADF images it was built +//! alongside (see `build.rs`), so no disks are needed at run time. Extra or +//! replacement data can still be supplied as ADF images, the LHA archives +//! from them, or an extracted tree with `track/`, `vehicles/` and `palettes/`. +//! Files on disk override the built-in ones. +pub mod embedded; pub mod palette; pub mod track; pub mod vehicle; @@ -25,12 +28,18 @@ pub struct FileSet { } impl FileSet { - pub fn scan(root: &Path) -> FileSet { + pub fn embedded() -> FileSet { let mut fs_ = FileSet::default(); - fs_.scan_dir(root, 0); + for (path, data) in embedded::files() { + fs_.add(&path, data.to_vec()); + } fs_ } + pub fn scan(&mut self, root: &Path) { + self.scan_dir(root, 0); + } + fn scan_dir(&mut self, dir: &Path, depth: usize) { let Ok(rd) = fs::read_dir(dir) else { self.warnings @@ -116,8 +125,12 @@ pub struct GameData { } impl GameData { - pub fn load(root: &Path) -> Result { - let files = FileSet::scan(root); + /// Loads the built-in data, overlaid with whatever is under `dir`. + pub fn load(dir: Option<&Path>) -> Result { + let mut files = FileSet::embedded(); + if let Some(dir) = dir { + files.scan(dir); + } let mut warnings = files.warnings.clone(); let mut track_files = Vec::new(); @@ -145,9 +158,9 @@ impl GameData { if track_files.is_empty() || vehicles.is_empty() { return Err(format!( - "no usable game data under {} (found {} track files, {} vehicles).\n\ - Point --data at a folder containing the Super Skidmarks ADF images.{}", - root.display(), + "no usable game data (found {} track files, {} vehicles).\n\ + This build has no data built in: point --data at a folder containing the \ + Super Skidmarks ADF images, or rebuild with the images next to Cargo.toml.{}", track_files.len(), vehicles.len(), if warnings.is_empty() { diff --git a/src/main.rs b/src/main.rs index e1069c4..f21f0bc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -30,9 +30,11 @@ USAGE: skids trace [--data DIR] --out FILE.png [--track N] [--seconds S] draw the computer cars' paths on a track -The data directory should hold the Super Skidmarks ADF images (or the LHA -archives from them, or extracted track/ and vehicles/ folders). It defaults to -the current directory, then the directory the executable is in. +The game data is built into the executable, so no disks are needed. --data +adds to or overrides it with ADF images, LHA archives, or extracted track/, +vehicles/ and palettes/ folders (e.g. disk 1, or real palettes). Without +--data, a folder like that in the current directory or next to the +executable is picked up automatically. CONTROLS: Player 1: arrow keys (or W A S D in one-player games) @@ -83,9 +85,12 @@ fn parse_args() -> Result { Ok(a) } -fn find_data(explicit: Option) -> PathBuf { - if let Some(p) = explicit { - return p; +/// An explicit --data directory, or a nearby folder that looks like it holds +/// game data (e.g. disk 1 dropped next to the executable). The built-in data +/// is always loaded first, so finding nothing is fine. +fn find_data(explicit: Option) -> Option { + if explicit.is_some() { + return explicit; } let looks_right = |d: &Path| { std::fs::read_dir(d).is_ok_and(|rd| { @@ -103,10 +108,7 @@ fn find_data(explicit: Option) -> PathBuf { candidates.push(dir); } candidates.push(PathBuf::from("data")); - candidates - .into_iter() - .find(|d| looks_right(d)) - .unwrap_or_else(|| PathBuf::from(".")) + candidates.into_iter().find(|d| looks_right(d)) } fn main() -> ExitCode { @@ -125,7 +127,7 @@ fn main() -> ExitCode { } }; let root = find_data(args.data.clone()); - let data = match GameData::load(&root) { + let data = match GameData::load(root.as_deref()) { Ok(d) => d, Err(e) => { eprintln!("{e}"); @@ -137,7 +139,7 @@ fn main() -> ExitCode { } let result = match args.cmd.as_str() { "info" => { - info(&data); + info(&data, root.as_deref()); Ok(()) } "extract" => extract(&data, args.out.as_deref()), @@ -155,7 +157,13 @@ fn main() -> ExitCode { } } -fn info(data: &GameData) { +fn info(data: &GameData, dir: Option<&Path>) { + let built_in = data::embedded::files().len(); + match (built_in, dir) { + (0, Some(d)) => println!("Data: {}", d.display()), + (n, Some(d)) => println!("Data: built in ({n} files), plus {}", d.display()), + (n, None) => println!("Data: built in ({n} files)"), + } println!("Tracks:"); for (f, s) in data.track_list() { let tf = &data.track_files[f];