@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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 |
|
||||||
|-------------|---------------------------------------|----------|
|
|-------------|---------------------------------------|----------|
|
||||||
@@ -76,15 +86,22 @@ You need a stable Rust toolchain.
|
|||||||
|
|
||||||
- **Linux:** install the ALSA and X11 development packages, e.g.
|
- **Linux:** install the ALSA and X11 development packages, e.g.
|
||||||
`sudo apt install libasound2-dev libx11-dev libxkbcommon-dev`. To build
|
`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.
|
- **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
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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];
|
||||||
|
|||||||
Reference in New Issue
Block a user