Embed the game data so the disks are not needed at run time

build.rs unpacks the ADF images next to Cargo.toml (or in
SKIDS_EMBED_DIR) with the same ADF/LHA readers the game uses and packs
the files into the executable. At run time the built-in data loads
first and anything given with --data, or found next to the executable,
overrides it. The embed feature is on by default; builds without it
behave as before and ask for --data. CI now runs the binary from an
empty directory.
This commit is contained in:
2026-10-19 18:45:01 +00:00
parent f88ba23282
commit bfaa1a287b
7 changed files with 186 additions and 34 deletions
+6 -3
View File
@@ -31,11 +31,14 @@ jobs:
run: cargo test --release run: cargo test --release
- name: Build - name: Build
run: cargo build --release run: cargo build --release
- name: Check against the game data - name: Check the binary runs without the disks
shell: bash shell: bash
run: | run: |
./target/release/${{ matrix.bin }} info --data . mkdir -p "$RUNNER_TEMP/standalone"
./target/release/${{ matrix.bin }} bench --data . --seconds 60 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 - uses: actions/upload-artifact@v4
with: with:
name: ${{ matrix.artifact }} name: ${{ matrix.artifact }}
+4 -1
View File
@@ -8,10 +8,13 @@ readme = "README.md"
repository = "https://github.com/andydixon/skids" repository = "https://github.com/andydixon/skids"
[features] [features]
default = ["audio"] default = ["audio", "embed"]
# Procedural engine/skid sound via cpal. Disable with --no-default-features # Procedural engine/skid sound via cpal. Disable with --no-default-features
# on Linux machines without the ALSA development headers. # on Linux machines without the ALSA development headers.
audio = ["dep:cpal"] 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] [dependencies]
minifb = "0.28" minifb = "0.28"
+23 -7
View File
@@ -3,10 +3,14 @@
A cross-platform (Windows, macOS, Linux) engine written in Rust that plays A cross-platform (Windows, macOS, Linux) engine written in Rust that plays
*Super Skidmarks* (Acid Software, 1995) on the original Amiga data. *Super Skidmarks* (Acid Software, 1995) on the original Amiga data.
It reads the game's ADF disk images directly: it unpacks the LHA archives At build time it reads the game's ADF disk images from this repository:
on them, decrunches the CrunchMania-packed files, and decodes the planar it unpacks the LHA archives on them and builds the game files into the
track graphics and AGA sprite banks. No game assets are compiled into the executable. At run time it decrunches the CrunchMania-packed files and
program; you supply the disks. 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 ## What works
@@ -44,11 +48,17 @@ far.
## Running ## Running
```sh ```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 built executable (`target/release/skids`, or `skids.exe` on Windows)
the executable, so running it from this repository's root just works. 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 | | Action | Player 1 | Player 2 |
|-------------|---------------------------------------|----------| |-------------|---------------------------------------|----------|
@@ -79,12 +89,18 @@ You need a stable Rust toolchain.
without sound (no ALSA needed), use `cargo build --release --no-default-features`. without sound (no ALSA needed), use `cargo build --release --no-default-features`.
- **macOS / Windows:** `cargo build --release` works as is. - **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 Actions builds all three platforms on every push
(`.github/workflows/build.yml`). (`.github/workflows/build.yml`).
## Layout ## Layout
``` ```
build.rs unpacks the ADFs and embeds the game files at build time
src/formats/ ADF (OFS/FFS), LHA and CrunchMania readers src/formats/ ADF (OFS/FFS), LHA and CrunchMania readers
src/data/ track, vehicle and palette decoding; locating the game files src/data/ track, vehicle and palette decoding; locating the game files
src/game/ physics, AI and race rules src/game/ physics, AI and race rules
+84
View File
@@ -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<u8>)> = 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<PathBuf> = 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();
}
+25
View File
@@ -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<Vec<(String, &'static [u8])>> {
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)
}
+23 -10
View File
@@ -1,9 +1,12 @@
//! Locates and decodes the original game data. //! Locates and decodes the original game data.
//! //!
//! Data can be supplied as the original ADF images, as the LHA archives taken //! The default build embeds the data from the ADF images it was built
//! off them, or as an already-extracted directory tree containing `track/` and //! alongside (see `build.rs`), so no disks are needed at run time. Extra or
//! `vehicles/`. Nothing from the original game is embedded in this program. //! 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 palette;
pub mod track; pub mod track;
pub mod vehicle; pub mod vehicle;
@@ -25,12 +28,18 @@ pub struct FileSet {
} }
impl FileSet { impl FileSet {
pub fn scan(root: &Path) -> FileSet { pub fn embedded() -> FileSet {
let mut fs_ = FileSet::default(); let mut fs_ = FileSet::default();
fs_.scan_dir(root, 0); for (path, data) in embedded::files() {
fs_.add(&path, data.to_vec());
}
fs_ fs_
} }
pub fn scan(&mut self, root: &Path) {
self.scan_dir(root, 0);
}
fn scan_dir(&mut self, dir: &Path, depth: usize) { fn scan_dir(&mut self, dir: &Path, depth: usize) {
let Ok(rd) = fs::read_dir(dir) else { let Ok(rd) = fs::read_dir(dir) else {
self.warnings self.warnings
@@ -116,8 +125,12 @@ pub struct GameData {
} }
impl GameData { impl GameData {
pub fn load(root: &Path) -> Result<GameData, String> { /// Loads the built-in data, overlaid with whatever is under `dir`.
let files = FileSet::scan(root); pub fn load(dir: Option<&Path>) -> Result<GameData, String> {
let mut files = FileSet::embedded();
if let Some(dir) = dir {
files.scan(dir);
}
let mut warnings = files.warnings.clone(); let mut warnings = files.warnings.clone();
let mut track_files = Vec::new(); let mut track_files = Vec::new();
@@ -145,9 +158,9 @@ impl GameData {
if track_files.is_empty() || vehicles.is_empty() { if track_files.is_empty() || vehicles.is_empty() {
return Err(format!( return Err(format!(
"no usable game data under {} (found {} track files, {} vehicles).\n\ "no usable game data (found {} track files, {} vehicles).\n\
Point --data at a folder containing the Super Skidmarks ADF images.{}", This build has no data built in: point --data at a folder containing the \
root.display(), Super Skidmarks ADF images, or rebuild with the images next to Cargo.toml.{}",
track_files.len(), track_files.len(),
vehicles.len(), vehicles.len(),
if warnings.is_empty() { if warnings.is_empty() {
+21 -13
View File
@@ -30,9 +30,11 @@ USAGE:
skids trace [--data DIR] --out FILE.png [--track N] [--seconds S] skids trace [--data DIR] --out FILE.png [--track N] [--seconds S]
draw the computer cars' paths on a track draw the computer cars' paths on a track
The data directory should hold the Super Skidmarks ADF images (or the LHA The game data is built into the executable, so no disks are needed. --data
archives from them, or extracted track/ and vehicles/ folders). It defaults to adds to or overrides it with ADF images, LHA archives, or extracted track/,
the current directory, then the directory the executable is in. 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: CONTROLS:
Player 1: arrow keys (or W A S D in one-player games) Player 1: arrow keys (or W A S D in one-player games)
@@ -83,9 +85,12 @@ fn parse_args() -> Result<Args, String> {
Ok(a) Ok(a)
} }
fn find_data(explicit: Option<PathBuf>) -> PathBuf { /// An explicit --data directory, or a nearby folder that looks like it holds
if let Some(p) = explicit { /// game data (e.g. disk 1 dropped next to the executable). The built-in data
return p; /// is always loaded first, so finding nothing is fine.
fn find_data(explicit: Option<PathBuf>) -> Option<PathBuf> {
if explicit.is_some() {
return explicit;
} }
let looks_right = |d: &Path| { let looks_right = |d: &Path| {
std::fs::read_dir(d).is_ok_and(|rd| { std::fs::read_dir(d).is_ok_and(|rd| {
@@ -103,10 +108,7 @@ fn find_data(explicit: Option<PathBuf>) -> PathBuf {
candidates.push(dir); candidates.push(dir);
} }
candidates.push(PathBuf::from("data")); candidates.push(PathBuf::from("data"));
candidates candidates.into_iter().find(|d| looks_right(d))
.into_iter()
.find(|d| looks_right(d))
.unwrap_or_else(|| PathBuf::from("."))
} }
fn main() -> ExitCode { fn main() -> ExitCode {
@@ -125,7 +127,7 @@ fn main() -> ExitCode {
} }
}; };
let root = find_data(args.data.clone()); let root = find_data(args.data.clone());
let data = match GameData::load(&root) { let data = match GameData::load(root.as_deref()) {
Ok(d) => d, Ok(d) => d,
Err(e) => { Err(e) => {
eprintln!("{e}"); eprintln!("{e}");
@@ -137,7 +139,7 @@ fn main() -> ExitCode {
} }
let result = match args.cmd.as_str() { let result = match args.cmd.as_str() {
"info" => { "info" => {
info(&data); info(&data, root.as_deref());
Ok(()) Ok(())
} }
"extract" => extract(&data, args.out.as_deref()), "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:"); println!("Tracks:");
for (f, s) in data.track_list() { for (f, s) in data.track_list() {
let tf = &data.track_files[f]; let tf = &data.track_files[f];